diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 622ace75c..29f36a0d8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,6 +63,7 @@ jobs: needs: git-sources permissions: contents: write + packages: write # 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/release.yml@main with: diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 211ed988d..668e904d2 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -26,8 +26,8 @@ permissions: jobs: windows-arm64: - # Immutable supporting workflow revision until the new install contract lands upstream. - uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f + # 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/windows-arm64.yml@main with: app-name: designcraft app-dir: DesignCraft diff --git a/AGENTS.md b/AGENTS.md index 0be7fa87b..a02505f71 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ DesignCraft is a clean-room, open-source, Rust-native page-layout application ta `plan/` is gitignored (local only). ## Never crash -People trust DesignCraft with their layouts; a crash loses their work. **This outranks feature work**: never ship a feature through a panic path, and fix a crash before building on top of it. Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md). +People trust DesignCraft with their layouts; a crash loses their work. **This outranks feature work**: never ship a feature through a panic path, and fix a crash before building on top of it. - **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The one exception is a provably infallible literal (a constant regex, `write!` to a `String`): `#[allow(clippy::expect_used)]` + `.expect("why it cannot fail")`. - **Errors are `Result`** through the crate's error type and `?` (`ok_or(..)?`, `let … else`, `if let`, `map_err` for context). An unfinished feature returns an "unsupported" error. Don't `unwrap_or_default()` where a silent default would corrupt a document; return an error. - **Input-derived numbers are hostile** (files, commands, MCP/control params, settings): `get()` instead of `[i]`/`[a..b]`, slice strings at char boundaries, checked/saturating arithmetic for lengths and offsets, no division by zero or NaN casts, cap input-sized allocations. @@ -21,8 +21,8 @@ People trust DesignCraft with their layouts; a crash loses their work. **This ou ## Non-negotiables - **Clean-room.** InDesign is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic documents, take screenshots (by window id) stored only under `plan/indesign/screenshots/` (never committed). Never read, disassemble or copy anything inside the InDesign bundle (names/listings only), never copy Adobe icons, artwork, presets or wording beyond feature names, never commit files produced by InDesign. Never copy GPL/AGPL/LGPL code (Scribus, LibreOffice, Ghostscript…). - **Assets:** no Adobe iconography or images — ever. Every asset is original / OSI / CC0 / redistributable CC (except the ArtCraft trademarks in `docs/brand/`, under `docs/brand/LICENSE-brand.txt`) and has a row in `ASSETS.md` (`cargo xtask assets` enforces it). Prefer art generated in code. -- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo: don't commit new font files (the Latin UI/document fonts already in `assets/fonts/` stay). It is an optional build input, never a `Cargo.toml` dependency: `CRAFT_FONTS_DIR=/abs/path/to/craft-fonts cargo build` makes `crates/fonts/build.rs` embed its fonts as `designcraft_fonts::CRAFT_FONTS` (Japanese: BIZ UDPGothic for the UI, Mincho first for document text; web builds embed BIZ UDPGothic Regular only). Unset, `CRAFT_FONTS` is empty and the app uses its bundled and system fonts; code and tests must work that way (tests on its glyphs skip). Releases build with it. Standard: [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md). -- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`. +- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo: don't commit new font files (the Latin UI/document fonts already in `assets/fonts/` stay). It is an optional build input, never a `Cargo.toml` dependency: `CRAFT_FONTS_DIR=/abs/path/to/craft-fonts cargo build` makes `crates/fonts/build.rs` embed its fonts as `designcraft_fonts::CRAFT_FONTS` (Japanese: BIZ UDPGothic for the UI, Mincho first for document text; web builds embed BIZ UDPGothic Regular only). Unset, `CRAFT_FONTS` is empty and the app uses its bundled and system fonts; code and tests must work that way (tests on its glyphs skip). Releases build with it. +- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`. - **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`. @@ -39,22 +39,24 @@ People trust DesignCraft with their layouts; a crash loses their work. **This ou - Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk). ## Roadmap -`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It follows craftrules' [`standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md). Details: `docs/target-app-parity.md` (assessment), `docs/gaps.md` (ranked work list: pick from the top), `docs/roadmap.md` (milestones, current focus), `docs/parity-checklist.md` (presence checklist, `cargo xtask parity`), and the typography, file-format, UI, hardware and localization parity docs in `docs/`. When work lands, update the affected docs and their status lines, delete or shrink the closed gap, and add a progress-log line to ROADMAP.md. +`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. Details: `docs/target-app-parity.md` (assessment), `docs/gaps.md` (ranked work list: pick from the top), `docs/roadmap.md` (milestones, current focus), `docs/parity-checklist.md` (presence checklist, `cargo xtask parity`), and the typography, file-format, UI, hardware and localization parity docs in `docs/`. When work lands, update the affected docs and their status lines, delete or shrink the closed gap, and add a progress-log line to ROADMAP.md. ## Contributor credits (About window) - About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json` - (commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for + (commit stats; never hand-edit) and `contributors/people.toml` (names people chose for themselves). See `docs/contributors.md`. - **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub username has a `[people.]` entry in `contributors/people.toml`. If not, ask them once whether they want to be credited by more than their username: a real name, a display name, and/or their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry - (copy the template at the top of the file, or run - `python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`) + (copy the template at the top of the file) and include it in their PR, committed as them. If no, change nothing: they are credited as `@username` anyway. - Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or GitHub profiles). Never hand-edit `contributors.json`. -- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also - re-verifies who wrote each `people.toml` entry; `--check` only verifies). + +## CI workflows +- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. diff --git a/ASSETS.md b/ASSETS.md index 356309cee..4297ab421 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -2,12 +2,13 @@ Every non-code asset in this repository (fonts, images, example art, presets) is listed here with its author, source and licence. `cargo xtask assets` (part of `cargo xtask ci`) fails if an asset file is missing from this table. -**Policy (mandatory):** DesignCraft contains **no Adobe iconography, images, artwork, presets, swatch libraries or ICC profiles**. Every asset is original work by DesignCraft contributors or third-party material under an open licence (OSI open source, public domain / CC0, or Creative Commons that allows redistribution). Screenshots of Adobe software are never committed. Font files are not added here: shared fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), an optional build input (`CRAFT_FONTS_DIR`, see AGENTS.md and craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). The one exception is the ArtCraft brand files in `docs/brand/`: ArtCraft trademarks, not open source, used under `docs/brand/LICENSE-brand.txt`. +**Policy (mandatory):** DesignCraft contains **no Adobe iconography, images, artwork, presets, swatch libraries or ICC profiles**. Every asset is original work by DesignCraft contributors or third-party material under an open licence (OSI open source, public domain / CC0, or Creative Commons that allows redistribution). Screenshots of Adobe software are never committed. Font files are not added here: shared fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), an optional build input (`CRAFT_FONTS_DIR`, see AGENTS.md). The one exception is the ArtCraft brand files in `docs/brand/`: ArtCraft trademarks, not open source, used under `docs/brand/LICENSE-brand.txt`. Generated-in-code art is original and has no file to list: the UI icon set (`crates/ui-egui/src/icons.rs`), default swatches, and the sample magazine's imagery (`crates/engine/src/sample.rs`). | Asset | Author | Source | Licence | Notes | |---|---|---|---|---| +| German hyphenation patterns (embedded by `hypher` 0.1.8, feature `german`) | Deutschsprachige Trennmustermannschaft: Stephan Hennig, Werner Lemberg, Günter Milde, Sander van Geloven, Georg Pfeiffer, Gisbert W. Selke, Tobias Wendorf, Keno Wehr | https://github.com/typst/hypher/blob/d81cc506416bffef2a75ee2dc969d4b41bc36613/patterns/hyph-de-1996.tex | MIT (copyright 2013–2024; full text in NOTICE) | Version 2024-02-28; reformed German spelling (2006), despite the `de-1996` filename; no separate old-spelling or Swiss patterns | | `epaint_default_fonts/fonts/egui-icons.ttf` (egui dependency) | John Slegers (emoji-icon-font) | https://github.com/storytold/egui/blob/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts/egui-icons.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full text in NOTICE) | Small special-icon subset embedded by default_fonts | | `epaint_default_fonts/fonts/emoji-icon-font.ttf` (egui dependency) | John Slegers | https://github.com/storytold/egui/blob/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full text in NOTICE) | Parent face embedded by monochrome_emoji_fonts | | `crates/ui-egui/src/i18n/uk.rs` (332 common phrases) | VectorCraft contributors | https://github.com/storytold/vectorcraft/blob/01f329aa99fa0c9517311c7b6cf7233ffc207742/crates/ui-egui/src/i18n/uk.tsv | MIT OR Apache-2.0 | Adapted for DesignCraft context; remaining Ukrainian phrases are original or credited below | @@ -51,10 +52,10 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | `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`) | -| `assets/app-icon/designcraft-small.svg` | DesignCraft project owner (drawn in ArtCraft) | owner's ArtCraft drawing, vectorised with craftrules `assets/logo-options/_tools/vectorize_tile.py` | 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-small.svg` | DesignCraft project owner (drawn in ArtCraft) | owner's ArtCraft drawing | 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.icns` | 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.ico` | 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.svg` | DesignCraft project owner (drawn in ArtCraft) | owner's ArtCraft drawing, vectorised with craftrules `assets/logo-options/_tools/vectorize_tile.py` | 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.svg` | DesignCraft project owner (drawn in ArtCraft) | owner's ArtCraft drawing | 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/hicolor/128x128/apps/ai.storyteller.designcraft.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/hicolor/16x16/apps/ai.storyteller.designcraft.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/hicolor/24x24/apps/ai.storyteller.designcraft.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`) | @@ -63,7 +64,7 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | `assets/app-icon/hicolor/48x48/apps/ai.storyteller.designcraft.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/hicolor/512x512/apps/ai.storyteller.designcraft.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/hicolor/64x64/apps/ai.storyteller.designcraft.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/hicolor/scalable/apps/ai.storyteller.designcraft.svg` | DesignCraft project owner (drawn in ArtCraft) | owner's ArtCraft drawing, vectorised with craftrules `assets/logo-options/_tools/vectorize_tile.py` | 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/hicolor/scalable/apps/ai.storyteller.designcraft.svg` | DesignCraft project owner (drawn in ArtCraft) | owner's ArtCraft drawing | 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`) | | `docs/brand/artcraft-logo.svg`, `docs/brand/artcraft-logo.png` | ArtCraft / Storyteller | ArtCraft brand kit (project owner) | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | ArtCraft wordmark (README header) | | `docs/brand/artcraft-logo-white.svg`, `docs/brand/artcraft-logo-white.png` | ArtCraft / Storyteller | ArtCraft brand kit (project owner) | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Wordmark for dark backgrounds | | `docs/brand/artcraft-mark.svg`, `docs/brand/artcraft-mark.png` | ArtCraft / Storyteller | ArtCraft brand kit (project owner) | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | The mark; its outline is drawn in code by `crates/ui-egui/src/about.rs` (About splash, start screen) | diff --git a/CLAUDE.md b/CLAUDE.md index bed556afb..76e6ac6d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ DesignCraft is a clean-room, open-source, Rust-native page-layout application ta `plan/` is gitignored (local only). ## Never crash -People trust DesignCraft with their layouts; a crash loses their work. **This outranks feature work**: never ship a feature through a panic path, and fix a crash before building on top of it. Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md). +People trust DesignCraft with their layouts; a crash loses their work. **This outranks feature work**: never ship a feature through a panic path, and fix a crash before building on top of it. - **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The one exception is a provably infallible literal (a constant regex, `write!` to a `String`): `#[allow(clippy::expect_used)]` + `.expect("why it cannot fail")`. - **Errors are `Result`** through the crate's error type and `?` (`ok_or(..)?`, `let … else`, `if let`, `map_err` for context). An unfinished feature returns an "unsupported" error. Don't `unwrap_or_default()` where a silent default would corrupt a document; return an error. - **Input-derived numbers are hostile** (files, commands, MCP/control params, settings): `get()` instead of `[i]`/`[a..b]`, slice strings at char boundaries, checked/saturating arithmetic for lengths and offsets, no division by zero or NaN casts, cap input-sized allocations. @@ -21,8 +21,8 @@ People trust DesignCraft with their layouts; a crash loses their work. **This ou ## Non-negotiables - **Clean-room.** InDesign is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic documents, take screenshots (by window id) stored only under `plan/indesign/screenshots/` (never committed). Never read, disassemble or copy anything inside the InDesign bundle (names/listings only), never copy Adobe icons, artwork, presets or wording beyond feature names, never commit files produced by InDesign. Never copy GPL/AGPL/LGPL code (Scribus, LibreOffice, Ghostscript…). - **Assets:** no Adobe iconography or images — ever. Every asset is original / OSI / CC0 / redistributable CC (except the ArtCraft trademarks in `docs/brand/`, under `docs/brand/LICENSE-brand.txt`) and has a row in `ASSETS.md` (`cargo xtask assets` enforces it). Prefer art generated in code. -- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo: don't commit new font files (the Latin UI/document fonts already in `assets/fonts/` stay). It is an optional build input, never a `Cargo.toml` dependency: `CRAFT_FONTS_DIR=/abs/path/to/craft-fonts cargo build` makes `crates/fonts/build.rs` embed its fonts as `designcraft_fonts::CRAFT_FONTS` (Japanese: BIZ UDPGothic for the UI, Mincho first for document text; web builds embed BIZ UDPGothic Regular only). Unset, `CRAFT_FONTS` is empty and the app uses its bundled and system fonts; code and tests must work that way (tests on its glyphs skip). Releases build with it. Standard: [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md). -- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`. +- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo: don't commit new font files (the Latin UI/document fonts already in `assets/fonts/` stay). It is an optional build input, never a `Cargo.toml` dependency: `CRAFT_FONTS_DIR=/abs/path/to/craft-fonts cargo build` makes `crates/fonts/build.rs` embed its fonts as `designcraft_fonts::CRAFT_FONTS` (Japanese: BIZ UDPGothic for the UI, Mincho first for document text; web builds embed BIZ UDPGothic Regular only). Unset, `CRAFT_FONTS` is empty and the app uses its bundled and system fonts; code and tests must work that way (tests on its glyphs skip). Releases build with it. +- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`. - **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`. @@ -38,4 +38,9 @@ People trust DesignCraft with their layouts; a crash loses their work. **This ou - Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk). ## Roadmap -`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It follows craftrules' [`standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md). Details: `docs/target-app-parity.md` (assessment), `docs/gaps.md` (ranked work list: pick from the top), `docs/roadmap.md` (milestones, current focus), `docs/parity-checklist.md` (presence checklist, `cargo xtask parity`), and the typography, file-format, UI, hardware and localization parity docs in `docs/`. When work lands, update the affected docs and their status lines, delete or shrink the closed gap, and add a progress-log line to ROADMAP.md. +`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. Details: `docs/target-app-parity.md` (assessment), `docs/gaps.md` (ranked work list: pick from the top), `docs/roadmap.md` (milestones, current focus), `docs/parity-checklist.md` (presence checklist, `cargo xtask parity`), and the typography, file-format, UI, hardware and localization parity docs in `docs/`. When work lands, update the affected docs and their status lines, delete or shrink the closed gap, and add a progress-log line to ROADMAP.md. + +## CI workflows +- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. diff --git a/Cargo.lock b/Cargo.lock index 1c361aef2..de36f69d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1138,13 +1138,14 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "chrono", "dunce", "egui", "flate2", "getrandom 0.3.4", + "image", "log", "plist", "semver", @@ -1305,7 +1306,7 @@ dependencies = [ [[package]] name = "designcraft" -version = "0.14.0" +version = "0.17.0" dependencies = [ "arboard", "craft-update", @@ -1329,7 +1330,7 @@ dependencies = [ [[package]] name = "designcraft-cli" -version = "0.14.0" +version = "0.17.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1340,12 +1341,13 @@ dependencies = [ "designcraft-mcp", "designcraft-pdf", "designcraft-render", + "image", "serde_json", ] [[package]] name = "designcraft-color" -version = "0.14.0" +version = "0.17.0" dependencies = [ "craft-cms", "kurbo", @@ -1357,7 +1359,7 @@ dependencies = [ [[package]] name = "designcraft-compose" -version = "0.14.0" +version = "0.17.0" dependencies = [ "designcraft-color", "designcraft-doc", @@ -1377,7 +1379,7 @@ dependencies = [ [[package]] name = "designcraft-doc" -version = "0.14.0" +version = "0.17.0" dependencies = [ "chrono", "designcraft-color", @@ -1390,7 +1392,7 @@ dependencies = [ [[package]] name = "designcraft-engine" -version = "0.14.0" +version = "0.17.0" dependencies = [ "caseless", "craft-atomic", @@ -1430,7 +1432,7 @@ dependencies = [ [[package]] name = "designcraft-epub" -version = "0.14.0" +version = "0.17.0" dependencies = [ "chrono", "designcraft-color", @@ -1440,7 +1442,7 @@ dependencies = [ [[package]] name = "designcraft-fonts" -version = "0.14.0" +version = "0.17.0" dependencies = [ "core-text", "harfrust 0.13.3", @@ -1453,7 +1455,7 @@ dependencies = [ [[package]] name = "designcraft-format" -version = "0.14.0" +version = "0.17.0" dependencies = [ "designcraft-doc", "designcraft-images", @@ -1465,7 +1467,7 @@ dependencies = [ [[package]] name = "designcraft-geom" -version = "0.14.0" +version = "0.17.0" dependencies = [ "flo_curves", "kurbo", @@ -1478,7 +1480,7 @@ dependencies = [ [[package]] name = "designcraft-idml" -version = "0.14.0" +version = "0.17.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1493,7 +1495,7 @@ dependencies = [ [[package]] name = "designcraft-images" -version = "0.14.0" +version = "0.17.0" dependencies = [ "craft-cms", "designcraft-color", @@ -1503,6 +1505,7 @@ dependencies = [ "log", "quick-xml 0.41.0", "resvg", + "skrifa 0.47.0", "tiff", "zune-core", "zune-jpeg", @@ -1510,7 +1513,7 @@ dependencies = [ [[package]] name = "designcraft-mcp" -version = "0.14.0" +version = "0.17.0" dependencies = [ "designcraft-engine", "designcraft-geom", @@ -1523,7 +1526,7 @@ dependencies = [ [[package]] name = "designcraft-pdf" -version = "0.14.0" +version = "0.17.0" dependencies = [ "chrono", "craft-cms", @@ -1535,6 +1538,7 @@ dependencies = [ "designcraft-fonts", "designcraft-geom", "designcraft-images", + "flate2", "hayro-cmap", "hayro-interpret", "hayro-syntax", @@ -1548,7 +1552,7 @@ dependencies = [ [[package]] name = "designcraft-pptx" -version = "0.14.0" +version = "0.17.0" dependencies = [ "chrono", "designcraft-color", @@ -1566,7 +1570,7 @@ dependencies = [ [[package]] name = "designcraft-render" -version = "0.14.0" +version = "0.17.0" dependencies = [ "craft-pdf", "designcraft-color", @@ -1585,7 +1589,7 @@ dependencies = [ [[package]] name = "designcraft-textimport" -version = "0.14.0" +version = "0.17.0" dependencies = [ "designcraft-doc", "quick-xml 0.41.0", @@ -1594,7 +1598,7 @@ dependencies = [ [[package]] name = "designcraft-tools" -version = "0.14.0" +version = "0.17.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1606,7 +1610,7 @@ dependencies = [ [[package]] name = "designcraft-ui-egui" -version = "0.14.0" +version = "0.17.0" dependencies = [ "craft-dialogs", "craft-keymap", @@ -1638,7 +1642,7 @@ dependencies = [ [[package]] name = "designcraft-web" -version = "0.14.0" +version = "0.17.0" dependencies = [ "designcraft-engine", "designcraft-format", @@ -3135,7 +3139,7 @@ checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" [[package]] name = "indd" version = "0.1.0" -source = "git+https://github.com/storytold/indd-utils?branch=main#7f3060e5bce78d21ea70e0d5eacb96edce15dfbe" +source = "git+https://github.com/storytold/indd-utils?rev=7f3060e5bce78d21ea70e0d5eacb96edce15dfbe#7f3060e5bce78d21ea70e0d5eacb96edce15dfbe" [[package]] name = "indexmap" @@ -3334,7 +3338,7 @@ dependencies = [ "skrifa 0.42.1", "smallvec", "subsetter", - "tiny-skia-path", + "tiny-skia-path 0.12.0", "xmp-writer", "yoke", "zune-jpeg", @@ -3352,7 +3356,7 @@ dependencies = [ "resvg", "skrifa 0.42.1", "smallvec", - "tiny-skia", + "tiny-skia 0.12.0", "usvg", ] @@ -4929,7 +4933,7 @@ dependencies = [ "pico-args", "rgb", "svgtypes", - "tiny-skia", + "tiny-skia 0.12.0", "usvg", "zune-jpeg", ] @@ -5207,6 +5211,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "log", + "smithay-client-toolkit 0.19.2", + "tiny-skia 0.11.4", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -6029,6 +6044,20 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path 0.11.4", +] + [[package]] name = "tiny-skia" version = "0.12.0" @@ -6041,7 +6070,18 @@ dependencies = [ "cfg-if", "log", "png", - "tiny-skia-path", + "tiny-skia-path 0.12.0", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", ] [[package]] @@ -6465,7 +6505,7 @@ dependencies = [ "siphasher", "strict-num", "svgtypes", - "tiny-skia-path", + "tiny-skia-path 0.12.0", "ttf-parser", "unicode-bidi", "unicode-script", @@ -7358,6 +7398,7 @@ dependencies = [ "raw-window-handle", "redox_syscall 0.4.1", "rustix 0.38.44", + "sctk-adwaita", "smithay-client-toolkit 0.19.2", "smol_str", "tracing", @@ -7602,7 +7643,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.14.0" +version = "0.17.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 139d06f01..dc20f8aca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"] [workspace.package] -version = "0.14.0" +version = "0.17.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -17,7 +17,8 @@ craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff1 craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", features = ["egui", "ui"] } # Separations, ink coverage and the ink manager; same revision as craft-pdf and the krilla patch. craft-prepress = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", features = ["flatten"] } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } +# craft-update: app-specific pretend versions apply to startup and manual checks. +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] } # internal designcraft-geom = { path = "crates/geom" } designcraft-color = { path = "crates/color" } @@ -75,9 +76,12 @@ flo_curves = "0.8" qrcodegen = "1.8" 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 +# INDD/INDT → IDML conversion for File › Open: storytold/indd-utils, pinned by rev as every other +# git dependency here is. It parses untrusted INDD files, and `branch = "main"` meant any +# `cargo update` -- Dependabot runs them -- pulled whatever was on that branch into the parser +# without review (#1599). # (MIT OR Apache-2.0, no dependencies, builds for wasm). -indd = { git = "https://github.com/storytold/indd-utils", branch = "main" } +indd = { git = "https://github.com/storytold/indd-utils", rev = "7f3060e5bce78d21ea70e0d5eacb96edce15dfbe" } # Which paths stored in documents may be followed (craft-libs). craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } diff --git a/NOTICE b/NOTICE index fc46bcdf3..47a709d39 100644 --- a/NOTICE +++ b/NOTICE @@ -32,6 +32,20 @@ list (author, source, license) is in ASSETS.md. 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. +- Dutch hyphenation patterns, embedded through the hypher crate (feature "dutch"): + hyph-nl.tex 1.1 from hyph-utf8, https://github.com/hyphenation/tex-hyphen. + Copyright (C) 1996 Piet Tutelaers. MIT License: + 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. - Portuguese hyphenation patterns, embedded through the hypher crate (feature "portuguese"): hyph-pt.tex 1.4 from hyph-utf8, https://github.com/hyphenation/tex-hyphen. Copyright (C) 1987, 1994, 1996, 2015 Pedro J. de Rezende, 1996, 2015 J. Joao Dias Almeida, @@ -68,12 +82,56 @@ list (author, source, license) is in ASSETS.md. 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. +- French hyphenation patterns, embedded through the hypher crate (feature "french"): + hyph-fr.tex 2.13 from hyph-utf8, https://github.com/hyphenation/tex-hyphen. + Copyright (C) 1994-2002 Daniel Flipo, Bernard Gaulle, 2016 Arthur Reutenauer. MIT License: + 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. +- Italian hyphenation patterns, embedded through the hypher crate (feature "italian"): + hyph-it.tex 4.9 from hyph-utf8, https://github.com/hyphenation/tex-hyphen. + Copyright (C) 2008-2011 Claudio Beccari. Available under the LPPL (1.3 or later) or the MIT + License; taken here under the MIT License: 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. - Norwegian hyphenation patterns, embedded through the hypher crate (feature "norwegian"): hyph-no.tex from hyph-utf8, https://github.com/hyphenation/tex-hyphen. Copyright (C) Rune Kleveland and Ole Michael Selberg. Copying and distribution of this file, with or without modification, is permitted in any medium without royalty provided the copyright notice and this notice are preserved (additionally distributed under the LaTeX Project Public License in CTAN distributions). +- German hyphenation patterns, embedded through the hypher crate (feature "german"): + hyph-de-1996.tex 2024-02-28, German reformed orthography (2006), from hyph-utf8, + https://github.com/typst/hypher/blob/d81cc506416bffef2a75ee2dc969d4b41bc36613/patterns/hyph-de-1996.tex. + Copyright (c) 2013-2024 Stephan Hennig, Werner Lemberg, Günter Milde, Sander van Geloven, + Georg Pfeiffer, Gisbert W. Selke, Tobias Wendorf, Keno Wehr. MIT License: + 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. - Japanese fonts (craft-fonts): builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) embed the fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic, BIZ UDMincho, @@ -115,6 +173,37 @@ Assets already bundled through Hayro's default features retain their licences: Adobe and contributor names do not endorse or promote this product. Per-file hashes and sources are recorded in ASSETS.md. +Linux Wayland window decorations +------------------------------- +sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita, +commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux +for vector window controls. Its title-font features are disabled: no Cantarell +font is embedded by this dependency and no font file is added to this repo. + +sctk-adwaita's MIT licence: + +MIT License + +Copyright (c) 2022 Bartłomiej Maryńczak + +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. + Bundled egui font icons The pinned egui dependency includes egui-icons.ttf, a small subset of John Slegers' diff --git a/README.md b/README.md index cd0f56479..37e67cfa4 100644 --- a/README.md +++ b/README.md @@ -123,9 +123,7 @@ Japanese text (UI and documents) uses fonts from [storytold/craft-fonts](https://github.com/storytold/craft-fonts), an optional build input (font files are never committed here; see the public [craft-fonts integration guide](https://github.com/storytold/craft-fonts/blob/main/docs/integration.md) -for build instructions and craftrules -[`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md) -for the shared standard): +for build instructions): ```sh git clone https://github.com/storytold/craft-fonts ../craft-fonts @@ -338,7 +336,16 @@ stand on its own. | | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) | | | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) | | | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) | -| | **DesignCraft** | **Page layout and publishing · you are here** | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) | +| | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) | +| | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) | +| | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) | +| | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) | +| | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) | +| | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) | +| | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) | +| | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) | +| | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) | +| | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | | And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control. diff --git a/ROADMAP.md b/ROADMAP.md index bbe3ab926..ed70e8ea2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ - 2026-10-23: placed-artwork layer options survive IDML and native round trips per placement, independently of shared assets. Unsupported visibility overrides are explicit import/preflight/PDF warnings; format 4 prevents older native readers from silently discarding them. Rendering parity is still open; see [graphic-layer-options.md](docs/graphic-layer-options.md). - FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Linear/radial, midpoint, archival and print policies have synthetic regression coverage. -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (placed graphic layer-option preservation and explicit rendering warnings; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-01 · **Change:** minor (placed graphic layer-option preservation and explicit rendering warnings; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026 - AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it. - AR1: Arabic IDML controls now retain character direction, kashida switches, diacritic offsets/forms, paragraph policy values and independent story/table directions. Shaping preserves joining context and script/language selection; paragraph-level bidi, contextual digit conversion, mark-safe elongation, RTL columns/tables and physical column insertion have regression coverage. Vendor-specific justification and diacritic presets remain explicit preflight warnings; see docs/arabic-typography.md. @@ -12,6 +12,10 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. +- **2026-10-28** · Inline table rows leave room for column and spanning footnotes, continuing in + the next column or frame when needed (#1901). Cell-local footnotes contribute to row height + and sit inside the cell rather than outside its measuring frame. Vertical justification keeps + paragraphs and table fragments above the footnote area. No readiness percentage change. - **2026-10-25** · The application bar uses craft-window's shared title-bar code. A drag that starts on a floating panel over the bar moves the panel, not the window, and in full screen the bar no longer zooms or moves the window. No readiness percentage change. @@ -42,7 +46,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope default layer queries find a placed PDF anywhere in mixed selections. Regression tests cover IDML export after rejection and both selection orders. -## Status (2026-10-01) +## Status (2026-10-21) **Working today** - Document model: facing/non-facing spreads, parent pages with page-number markers, sections, layers, frames (text / graphic / unassigned), threaded stories, paragraph/character/object styles with based-on, swatches (process/spot/tints/gradients), corner options, text wrap, drop shadow, opacity/blend. @@ -127,7 +131,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Linked stories (Edit ▸ Place and Link, `story.placeAndLink/links/updateLink/unlink`): child stories copy a parent, show out of date in the Links panel and update. - Edit ▸ Menus (`window.hideMenuItem`): hide menu items; menus with hidden items end in Show All Menu Items. - View ▸ Separations Preview (`view.separations`, Window ▸ Output ▸ Separations Preview, `ink.separations`): the plates the print PDF puts on film, spot plates included. Each visible spread is exported as Export PDF writes it and separated with craft-prepress, so the Ink Manager's aliases and spots to process, overprint and the flattener show as printed. All inks as printed, one plate as film, or an ink limit in red over gray; the panel lists the plates with each ink's percentage under the pointer. -- Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Spanish is hyphenated with Javier Bezos's Liang patterns (`hyph-es.tex` 5.0, MIT) through the `hypher` crate; hyphenation for other languages isn't bundled yet. +- Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). English, Spanish, Portuguese, Norwegian, Greek and German (2006 reform) hyphenate. German uses `hyph-de-1996.tex` 2024-02-28 (MIT) through `hypher`; the filename denotes the reformed 2006 rules. Other languages have no bundled patterns. - Books (`book.new/open/add/remove/list/styleSource/paginate/syncStyles/exportPdf`, Book panel): `.dcbook` files, continuous page numbering, style sync from a style source, one merged PDF. - Track Changes (`changes.track/list/acceptAll/rejectAll`, Type ▸ Track Changes): typing is marked added (highlighted on screen), deletions are kept but hidden from layout and exports until accepted or rejected. Per-change accept/reject and authors aren't there yet. - Track Changes: per-change Accept / Reject (`changes.accept`, `changes.reject`) and a Track Changes panel listing every change. @@ -350,7 +354,7 @@ A string without a translation shows in English and fails no check; `cargo xtask | Vietnamese | vi | none | 0% | Also shipped: Czech (99%), Italian (85%), Ukrainian (83%), Polish (99%). -Also shipped: Italian (85%), Ukrainian (83%), Georgian (100%). +Also shipped: Italian (85%), Ukrainian (83%), Georgian (100%), Swedish (99%). ## Upcoming @@ -378,13 +382,37 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log +- **2026-11-02** · EPUB Export Options names a cover image file (PNG or JPEG, typed or chosen with Choose…) for the reflowable export; left empty, the first page stays the cover (#1934). No readiness percentage change. +- **2026-11-02** · The Pen shows the segment a dragged smooth point shapes while it is dragged, and its rubber band curves out of a smooth point's direction line (#1438, first item). No readiness percentage change. +- **2026-11-02** · A Custom text variable defined with a number (a typed `2026` reaches the command as one) shows its digits instead of nothing (#2093, first item). No readiness percentage change. +- **2026-11-02** · Windows: the custom title bar window stays borderless, so the content is no longer drawn offset by the caption and border (black bars at the top and left, clicks landing off target). The workaround goes when winit carries the fix (storytold/winit#17). No readiness percentage change. +- **2026-10-30** · The Color Picker takes an eyedropper: dragged from the picker onto the page, it takes the colour the canvas shows (proofed under Proof Colors). No readiness percentage change. +- **2026-10-30** · The Color Picker is laid out as the reference app's: a field and slider for the component chosen, HSB, Lab, RGB and CMYK values, the old colour, and Add CMYK Swatch; Enter and Esc close it once. No readiness percentage change. +- **2026-10-18** · Table of Contents gains Create PDF Bookmarks: included headings become nested page destinations, regenerated in their existing outline position on update without replacing or moving surrounding manual bookmarks. The setting survives native and IDML files. No readiness percentage change. + Newest first. +- **2026-11-03** · `type.char` says when the font it applies isn't available (#2189): it returns `{"warnings": ["Missing font: Courier Prime (using Source Sans 3)"]}`, or `Missing font style: …` for a style the family doesn't have, in preflight's words. The font is still applied, as before, so Find Font and preflight list it. A script that asked for a font the machine doesn't have exported in the substitute with no word about it. No readiness percentage change. +- **2026-11-01** · Parent page resizing (#1620): Properties, the Page Tool Control panel and on-page handles resize the selected parent page independently of document pages. Stable parent IDs, repeated resizing, anchors and liquid rules are preserved on the current command modules. Inline parent margin editing targets only the selected parent page and preserves opposite-page margins and document overrides; document commands retain their first-page default before the canvas is initialized. No readiness percentage change. +- **2026-10-31** · `font.list` reports the embedding licence of the face that sets each font's text: its OS/2 `fsType` and what the bits mean (`restrictedLicence`, `bitmapOnly`, `noSubsetting`, `editable`, `previewPrint`; null when the font is missing or has no OS/2 table). A script or host that makes PDFs can check every face, bundled and installed ones included, without reading font files. Export behaviour is unchanged. No readiness percentage change. +- **2026-10-31** · A `.designcraft` file opened from bytes (`file.openBytes` with its file name, as a browser or host passes it) is titled without the extension, as an IDML file opened that way is. Saving it in the browser downloaded `Brochure.designcraft.designcraft`, and the File Name variable showed the doubled name. Browser Open and Drop pass the full file name, so only the engine removes the extension and a repeated suffix is kept in the title. Native and IDML content takes priority over a misleading `.indd` or `.indt` extension. No readiness percentage change. +- **2026-10-30** · Import warnings about a story have a Show button: it closes the warnings (the status line keeps them) and selects the story's first frame, fitted in the window. `file.open` returns `warningItems`, the frame each IDML or InDesign warning is about (or null), matching the converter's INDD story numbers (`story 30892`, written to IDML as `u78ac`) and IDML `Self` names to the imported stories. No readiness percentage change. +- **2026-10-30** · Parent pages: a running-header variable shows its name in angle brackets on the parent itself (it was blank), as InDesign shows it; on the document pages, and in text with no page, it shows its text as before. No readiness percentage change. +- **2026-10-30** · Without facing pages, a page of a multi-page spread shows the parent page in its place, as in InDesign: the second page of a two-page spread shows the parent's second page. Every single-sided page showed the parent's first page, so an InDesign book set up without facing pages but laid out in two-page spreads repeated the left parent page's items (a black heading bar) on its right pages and lost the right parent page's. No readiness percentage change. +- **2026-10-30** · File > Package opens a dialog, as the reference app's: a Summary of fonts, links and images and inks, pages for each, a Printing Instructions step, and a PDF made with the options of a preset (#1514, #1515). +- **2026-10-30** · IDML import reads TOC separators and entry styles written with the `$ID/` prefix or as `Properties` children, and the TOC separator notation (`^t`, `^y`, `^m`, `^>`) is shared by the dialog and IDML, with a special-character menu on the separator field (#1249, #1255). +- **2026-10-29** · Inserting text with many returns no longer takes time that grows with their square (part of #1588): each return copied its paragraph's format with its own insert, moving every format after it. Splitting 20,000 returns into paragraphs took 15.8 s; placing a text file of them now takes 0.2 s, and one of a million returns 5.5 s. No readiness percentage change. +- **2026-10-28** · A placed image whose pixels would decode past 1 GiB of RGBA (256 Mpx) is refused from its header, before decoding (#1586). A 288 KB 23,000 px gray PNG used to hang the first draw for 9 s and take the app to 4.5 GB; it now draws as an empty frame, and PDF export skips it with a warning. A 300 ppi A0 scan still fits. No readiness percentage change. +- **2026-10-28** · Discretionary hyphens (U+00AD) break in the single-line composer with automatic hyphenation on or off, matching the paragraph composer. Unbroken soft hyphens remain invisible and zero-width, and words containing them keep only their discretionary break points (#1955). No readiness percentage change. +- **2026-10-28** · Autoflow no longer sizes the rest of a story by a frame that a Start Paragraph option (next column, frame, page, or odd or even page) ends early (#1209). With a title before a chapter that starts on the next odd page, it estimated a page per few characters, added up to 2000 pages (the autoflow limit) and removed them again: placing 2000 words took 36 s in the app, now 0.4 s, with the same pages. It looks past tracked-deleted paragraphs for the option, as composing does, and asks for every frame that start options and breaks force in one round: 200 one-line chapters on their own pages take 9 ms (58 ms before). A break and the start option it meets count once, and breaks that tracked changes delete or a hidden condition hides don't count. No readiness percentage change. +- **2026-10-28** · IDML export writes the document's default `StoryPreference` in `Resources/Preferences.xml`, as InDesign does. Only each story carried one, and IDML readers that take the spread reading order from the document's (Okapi's IDML filter) failed on DesignCraft's files. No readiness percentage change. - **2026-10-27** · An iconic panel's flyout stays open while you work in the canvas or a text field: Auto-Collapse Iconic Panels is off by default, and preferences saved with it on start with it off. Preferences › Interface › Panels holds the setting next to the column bar's menu item. No readiness percentage change. +- **2026-10-26** · Inner Shadow, Inner Glow (from the edges) and Bevel and Emboss on a frame holding an image follow what the image paints: a transparent part is outside the object, so the effect lines the image's opaque edge, not the frame (#1622). Satin and a centred Inner Glow still follow the frame. No readiness percentage change. - **2026-10-24** · Column rules run from the top line's ascent to the bottom line's descent, as InDesign draws them, instead of from the top to the bottom of the columns, on screen, in PDF and in PowerPoint export. The top and bottom insets shorten the rule from those lines. Existing documents draw their column rules at the new length. No readiness percentage change. - **2026-10-20** · Edit › Keyboard Shortcuts is craft-keymap's shared dialog: commands grouped by menu with Show and Filter, recording that asks before taking a key from another command, undo and redo, named shortcut sets (edits of Default become Custom; an imported set becomes a set of its own), an on-screen US keyboard, InDesign Show Set files reviewed before they apply, JSON export and an HTML summary. ui.json keeps the `shortcuts` map earlier versions read, for the active set. No readiness percentage change. - **2026-10-19** · Import InDesign Set… runs on craft-keymap's importer: a set's Window menu panel items (Layers F7, Pages F12, Swatches F5) and Tools items set the panel and tool shortcuts, all of an item's Default keys apply, and the report adds numeric keypad keys read as main keys and likely commands for unmatched items (`window.importShortcuts`). On InDesign's Windows defaults it matches 366 of 1,629 items (was 233) and changes 23 commands. No readiness percentage change. - **2026-10-19** · Keyboard shortcuts run on the shared craft-keymap crate. Panels take shortcuts, with InDesign's defaults where the key is free (Swatches F5, Color F6, Layers F7, Info F8, Stroke F10, Paragraph Styles F11, Character Styles Shift+F11, Pages F12, Align Shift+F7); tool keys are editable in Edit › Keyboard Shortcuts and `window.setShortcut`, and tools sharing a key take turns (B). Saved shortcuts load unchanged. No readiness percentage change. +- **2026-10-17** · HU1: initial Hungarian (`hu` / Magyar) interface localization (updated 2026-11-01): 1,906 catalog entries, four context captions and all 37 tool labels. AI-assisted; the initial catalog was personally reviewed in the app by Veress Róbert, a native Hungarian speaker with over 30 years of DTP experience. Uncatalogued strings keep their English fallback. Hungarian spelling and hyphenation are separate work in #1484. 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-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. @@ -396,6 +424,7 @@ Newest first. - **2026-10-24** · Renaming a paragraph or character style, or moving it into a style group, keeps its Export Tagging under the new name, and undo puts it back under the old one. Deleting a tagged style drops its tagging and leaves the replacement style's own tagging as it was. No readiness percentage change. - **2026-10-24** · Quick Apply and the application bar's search leave out the interface commands that failed or reset a setting when chosen there: those that need a workspace, a command and shortcut, a menu item, a panel, a window edge, an app page, a recent file or a swatch they can't give them, a Contextual Task Bar place, and Interface Color Theme, UI Scaling, Reset Shortcuts, the flattener preset, Screen Mode and Zoom To, which without a value switched to the dark theme, set 100%, cleared every custom shortcut or the preset, or went to Normal mode or 100% zoom. Each stays in the menu, panel or dialog that gives it the value. No readiness percentage change. - **2026-10-24** · EPUB Export Options › Split Document, a dialog before File › Export EPUB… saves (`document.epubSplit`, stored with the document): off, a single paragraph style, or based on paragraph style export tags, with Paragraph Style Options › Export Tagging's Split Document flag marking those styles. A reflowable EPUB is one content file unless Split Document is on; the split before each top-level h1 from #791 is gone. A split before a list item or preformatted line no longer leaves an unclosed list or block in a content file. IDML import and export keep the setting and the flag, and Load Styles the flag. No readiness percentage change. +- **2026-10-24** · Linux file managers show a `.designcraft` file's first page instead of a blank icon: the packages install a freedesktop thumbnailer (`share/thumbnailers/ai.storyteller.designcraft.thumbnailer`) that runs `designcraft-cli thumbnail FILE OUT.png SIZE`, which writes the `preview.png` saving already puts in the file, fitted to the size asked for and never enlarged. A file without a preview fails, so the file manager keeps the file type's icon (#1782). No readiness percentage change. - **2026-10-18** · Repeated placed-PDF layer variants are prepared once per asset and hidden-name set, with recovery warnings retained. All-visible named layers keep their original source bytes, including PDFs whose cross-reference tables the reader repairs, in plain, PDF/X-4, booklet and book-merge output. Layer changes that require writing still reject an unusable cross-reference table with a file-named damage error; the first placement error is retained (#906). No readiness percentage change. - **2026-10-18** · Placed PDF optional-content defaults and per-frame overrides survive plain and PDF/X-4 vector export, including repeated placements, booklet output and book merging (#906). Source usage-application arrays are accepted; groups without a catalog configuration stay visible. Missing or unreadable default configurations and unsupported base states use all-ON defaults with a file-named export warning, retaining explicit frame overrides. Readable groups omitted from the catalog list are added with a distinct file-named warning, preserving their default ON/OFF states. Concurrent recovery warnings are combined. Malformed ON data is recoverable when a known ON base and empty OFF list prove it cannot hide artwork; missing display names are generated without changing known visibility when no override needs name matching. Contradictory states, unresolved references and ambiguous name overrides still fail export. Print-usage adjustments and IDML layer overrides remain follow-ups. No readiness percentage change. @@ -411,8 +440,10 @@ Newest first. - **2026-10-24** · Optical Margin Alignment per story with a size (`story.options {opticalMargin, opticalMarginSize}`, off by default): when it is on, the story's paragraphs hang punctuation and letter edges by amounts set for that type size, except paragraphs with Ignore Optical Margin (`ignoreOpticalMargin` in `type.para` and paragraph styles). Table cells and footnotes don't take the story setting yet. When it is off, the per-paragraph switch still hangs at the text's own size. IDML imports and exports both (`StoryPreference/@OpticalMarginAlignment`, `@OpticalMarginSize`, `IgnoreEdgeAlignment`) (#947). No readiness percentage change. - **2026-10-24** · Text frames: a custom baseline grid's Relative To (Text Frame Options › Baseline Options, `object.textFrameOptions {baselineGrid: {relativeTo}}`, IDML `BaselineFrameGridRelativeOption`) measures the grid's start from the top of the page, the top margin, the top of the frame or its top inset. Text Frame Options had no custom baseline grid controls, imports dropped the setting and every frame grid started from the page top, so grid-aligned text in a frame measured from its own top sat on the wrong lines. No readiness percentage change. - **2026-10-24** · Paragraph Style Options › Indents and Spacing has an Align to Grid menu: None, All Lines or First Line Only. It shows the style's current setting. A First Line Only style could be set only in the CJK Grid Settings section, which shows only with CJK features on, or through commands. No readiness percentage change. +- **2026-10-24** · A guide pulled out of a ruler keeps the resize cursor across it until it is dropped; the tool's cursor took over as soon as the pointer left the ruler, so the drag looked as if it had stopped. No readiness percentage change. - **2026-10-24** · Anchored text frames, such as marginal notes, compose where their host story places them: they align to the baseline grid at that position, and page-number markers and spine-relative alignment inside them read the host's page. They composed against a grid measured from their stored position and on no page, so a note set to Only Align First Line to Grid could sit a fraction of a line off the body text's grid line. No readiness percentage change. - **2026-10-24** · Tables: a gradient swatch in a cell fill draws as a gradient on the canvas and in PDF, in one gradient space for the whole table, so it runs across cells without restarting. Cells showed the gradient's first stop as a solid colour. No readiness percentage change. +- **2026-10-22** · Paragraph Style Options › Paragraph Shading and Paragraph Border are laid out as InDesign's: headed with the page's name; Tint is a spinner with InDesign's presets and has Overprint beside it (shading, border and gap; a new attribute each, kept through IDML; PDF export warns it doesn't overprint yet); Corner Size and Shape is InDesign's two-by-two grid of corner icons, sizes and shape menus showing each shape's glyph, with the link in the middle; offsets and weights have spinners. Found in the Learn Skills Daily course, chapter 41. No readiness percentage change. - **2026-10-24** · New documents start with Hyphenate Capitalized Words, Hyphenate Last Word and Hyphenate Across Column off in [Basic Paragraph], as in InDesign. Opened and imported files keep the values they carry. No readiness percentage change. - **2026-10-24** · The baseline grid's Relative To setting applies: with Top Margin, the grid starts at each page's own top margin in text composition, on the canvas and for snapping. It started at the page top, so grid-aligned text sat on the wrong lines and a page could fit one line fewer. No readiness percentage change. - **2026-10-24** · Custom-positioned anchored objects honour Relative to Spine (`anchored.options {spineRelative}`, IDML `SpineRelative`): the settings describe a left-hand page and mirror on a right-hand page, so marginal notes stay in the outside margin on both pages of a spread. On right-hand pages they took the left-hand placement and landed in the inside margin, across the spine or over the body text. No readiness percentage change. @@ -420,17 +451,28 @@ Newest first. - **2026-10-23** · File › File Info… edits the document's Document Title, Author, Description, Keywords, Copyright Notice and Copyright Info URL (`file.info`), and the document saves them. PDF export (PDF/X-4 and Print Booklet included) writes the title, author, description and keywords into the PDF's document information and XMP. EPUB export takes the title and author from File Info, and HTML export takes the title. A title or author given to the export replaces File Info's, and an empty Document Title leaves the document's name as the title. No readiness percentage change. - **2026-10-23** · Save and export dialogs (Save As, Save a Copy, Export PDF and the other exports, Package, libraries and books) start in the active document's folder once it has been saved, and for a document never saved in the folder of the last file saved or exported, remembered across sessions (#1653). A folder deleted since falls back to the system's choice. No readiness percentage change. - **2026-10-22** · Autoflow (Shift-click with the loaded text cursor, autoflow on Place, `text.thread {autoflow}`) fills the pages that follow before it adds any, as InDesign does: one frame per column on each following page, whatever else the page holds, then new pages at the end of the document. It inserted pages right after the story's last frame, in front of the document's own pages. Frames it set past the end of the text go again; a page the document had stays, and a page autoflow added only for such a frame goes with it. Smart Text Reflow of the primary text frame is unchanged. No readiness percentage change. +- **2026-10-18** · German text hyphenates using the MIT-licensed 2006 reform patterns (`hyph-de-1996.tex`) through `hypher`. Explicit 2006 names and plain/2006 locale codes reach both composers; traditional and unknown spelling variants stay unhyphenated. Tests cover this distinction, umlauts, ß, compounds, reformed ck/st breaks and paragraph limits. Six of InDesign's ~40 hyphenation languages. 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-23** · Translations: a missing string shows in English and fails no test; `cargo xtask i18n-report`, run by `cargo xtask ci`, prints the missing strings per language. Tests keep duplicate-key and placeholder checks and the language mechanics (lookup, context captions, plural captions, right-to-left). No readiness percentage change. +- **2026-10-23** · Show Hidden Characters marks table cells as InDesign does: every cell shows its own spaces, tabs and paragraph returns and an end-of-story # after its text (an empty cell shows just the #), and the table paragraph's ¶ sits after the table's bottom right corner. Cells showed no marks. No readiness percentage change. +- **2026-10-23** · Tables: with the Type tool in a cell, Shift+Tab goes back a cell (it went on to the next one) and Esc toggles between the text and the cell, as in InDesign: the insertion point selects its cell, and selected cells select the text of the first one (`table.toggleSelection`). Esc left the table for the frame. No readiness percentage change. +- **2026-10-23** · Direct Selection shows a path's direction handles as InDesign: both handles of each selected anchor and, on its neighbours, the handle shaping the segment to it; no handles with no anchor selected, and handles that don't show can't be grabbed. The tool draws the path and its points without the bounding box. Found in the Learn Skills Daily course, chapter 43. No readiness percentage change. +- **2026-10-23** · Tables: Convert Text to Table… and Convert Table to Text… open InDesign's dialogs (they converted at once, with tabs and paragraphs only). Column and Row Separator take Tab, Comma, Paragraph or any characters typed; Number of Columns, available when both separators are the same, fills rows of that many cells (a list of paragraphs into two columns); Convert Text to Table also applies a Table Style. `table.convertFromText` takes `rowSeparator`, `columns` and `tableStyle`, and `table.convertToText` its separators. Convert Text to Table is available only with text selected outside a table. No readiness percentage change. +- **2026-10-23** · Tables: the Table menu follows InDesign's order (Cell Options before Insert, Delete and Select; Merge, Unmerge and Split before Convert Cell Type and Convert Rows; Distribute Rows Evenly before Distribute Columns Evenly), and its first item is Insert Table… with InDesign's Cmd+Alt+Shift+T, which opens the dialog (the shortcut ran `table.insert` with its defaults, behind the dialog). The Table panel stays in Window › Type & Tables (Shift+F9). The Insert Table dialog is laid out as InDesign's: Body Rows and Columns, then Header and Footer Rows, as numbers with spinners and right-aligned labels, and a Table Style applied to the new table. No readiness percentage change. - **2026-10-22** · Hyphenation Settings, as in InDesign: the Paragraph panel menu's Hyphenation… (`app.hyphenationDialog`) sets the selected paragraphs' Hyphenate, Words with at Least, After First, Before Last, Hyphen Limit, Hyphenation Zone, the Better Spacing ↔ Fewer Hyphens slider (InDesign's 0–10 steps), Hyphenate Capitalized Words, Last Word and Across Column, with Preview, as one undo step; Paragraph Style Options › Hyphenation has the same form. The panel menu lists its items in InDesign's order. Replicated from a hyphenation tutorial: the same text in Minion Pro breaks into the same lines as InDesign's with hyphenation on, off, and at Fewer Hyphens. No readiness percentage change. - **2026-10-22** · Hyphenate Across Column (Hyphenation Settings; IDML HyphenateAcrossColumns) is applied: with it off, a paragraph whose line ending in an automatic hyphen would end a column or frame is re-composed without that hyphenation point, so the word moves on whole, as in InDesign. Opened and imported files keep their own setting, so their layouts don't change. No readiness percentage change. - **2026-10-22** · The Paragraph panel is laid out as InDesign's: the nine alignment buttons in groups; indent, spacing and drop cap fields by their icons in two columns, with Space Between Paragraphs Using Same Style (Ignore or a space); Shading and Border with the Paragraph Borders and Shading button and colour; Hyphenate with Do Not Align / Align to Baseline Grid. Its panel menu follows InDesign's order: Paragraph and Single-line Composer, Only Align First Line to Grid, Balance Ragged Lines, Optical Margin Alignment, Justification…, Keep Options…, Span Columns…, Hyphenation…, Drop Caps and Nested Styles…, GREP Styles… (these three now open for the selected paragraphs), Paragraph Rules…, Paragraph Borders and Shading…, Bullets and Numbering…, Restart/Continue Numbering, Define Lists… and the paragraph direction. Convert Bullets and Numbering to Text is listed but not available yet. No readiness percentage change. +- **2026-10-22** · Preferences › Units & Increments with no document open, as in InDesign: the ruler units (horizontal and vertical), the ruler origin and the cursor-key and kerning increments set there are what new documents start with, and New Document opens in those units. They are kept with the application preferences (`prefs.set {defaultUnits}`); a document keeps its own once made. Reported by a reader who works in inches and had to change every file. No readiness percentage change. +- **2026-10-22** · French and Italian text is hyphenated, with hyph-utf8's patterns through hypher (hyph-fr 2.13 and hyph-it 4.9, MIT): for the Language set in the Character panel (French, French: Canadian, French: Swiss, Italian, or fr / it locale codes). A word with an elision hyphenates after its apostrophe (l'im-pri-me-rie, dell'im-pa-gi-na-zio-ne), in French and Italian only; other languages leave apostrophe words unbroken. Gap 13 shrinks. 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-23** · Type › Text Variables › Convert Variable to Text (`variables.convertToText`): the variable instances in the selected text, in a story, a table cell or a footnote, become plain text of the value each shows on its own page (a running header on page 3 becomes page 3's heading). `variables.convertAllToText {name}` does the same for every instance of one variable in the document, as the Text Variables dialog's Convert to Text button. The variable stays defined; one undo step. 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-21** · Properties for points selected with Direct Selection, as InDesign's: the title is "Anchor Point(s)", and the panel shows Transform, Appearance, Convert Points (Plain, Corner, Smooth, Symmetrical) and the Delete Point(s) quick action (`path.deletePoints`, one undo step). Found in Zero2Graphic's *How to Page Layout design in Adobe InDesign 2022 CC*. No readiness percentage change. +- **2026-10-20** · Direct Selection selects anchor points: a marquee selects the points inside it on every path it reaches, a click selects one point and Shift+click adds or removes one, selected points show solid, and dragging one (Shift: at 45° steps) or pressing the arrow keys moves them all in one step (`selection.set {anchors}`, `path.moveAnchors {points}`). No readiness percentage change. +- **2026-10-19** · A PDF export can repeat its bytes: it recorded the second it was made in, so exporting one document twice gave different files though the pages matched. `SOURCE_DATE_EPOCH` stands in for the clock when the export is given no date, as the reproducible-builds specification defines it; a date passed in still wins, and a malformed value is ignored rather than refusing the export. The timestamp was the only thing that differed, so the files are now identical byte for byte. No readiness percentage change. - **2026-10-17** · PDF/X-4 export warns about fonts drawing visible text without an embedded font program, including placed PDF forms; `validate-pdf` reports the font and page (#909). Invisible OCR text is exempt. Detection only: fonts are not embedded or outlined automatically. @@ -444,11 +486,59 @@ Newest first. - **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-21** · An empty graphic frame's X joins its corners, so a frame slanted with Direct Selection keeps its X corner to corner, as InDesign draws it; it used to stay across the old rectangle. Found in Zero2Graphic's *How to Page Layout design in Adobe InDesign 2022 CC*. No readiness percentage change. +- **2026-10-21** · Paragraph Border and Paragraph Shading link their four sides as InDesign's: the stroke weights, offsets and corners have the "Make all settings the same" link, on when the four are the same, so a side typed in sets all four; with the link off a side is set alone (a border with only a thick left side). Found in the Learn Skills Daily course, chapter 40. No readiness percentage change. +- **2026-10-21** · Paragraph rules have InDesign's Type (solid, thick/thin stripes, dashed, dotted, straight hash, wavy, or a stroke style of the document), Gap Color and Gap Tint, and Keep In Frame (rule above); the Paragraph Rules page is laid out as InDesign's (Weight and Type, Color and Tint, Gap Color and Gap Tint, Width and Offset, Left and Right Indent). IDML reads and writes them (`RuleAboveType`, `RuleBelowGapColor`, `KeepRuleAboveInFrame`…). Found in the Learn Skills Daily course, chapter 20. No readiness percentage change. +- **2026-10-21** · New Character Style with text selected starts from what the text has of its own over its paragraph style, as InDesign's ("[None] + Bold + color"), and Apply Style to Selection gives the text the new style, with no override left. A new style's dialog is titled New Paragraph Style / New Character Style. `type.selectionAttrs` reports the text's own character attributes (`ownChars`). Found in the Learn Skills Daily course, chapter 22. No readiness percentage change. +- **2026-10-21** · New Paragraph Style with text selected starts from it, as InDesign's (Based On [No Paragraph Style], every setting of the text that differs) and Apply Style to Selection gives the paragraph the new style; General has Next Style and Reset To Base; Basic Character Formats is in two columns (Size | Leading, Kerning | Tracking, Case | Position). `style.paragraph.edit` honours `nextStyle` (it was ignored) and takes `resetToBase`. Found in the Learn Skills Daily course, chapter 20. No readiness percentage change. +- **2026-10-21** · Find/Change has InDesign's Change and Change/Find: Change changes the text Find Next found and keeps it selected, Change/Find also finds the next match, and the search keeps its domain (a selection or story) across them. The Search list reads Document, Story and Selection. `find.change` takes `found`. Found in the Learn Skills Daily course, chapter 22. No readiness percentage change. +- **2026-10-21** · Find/Change has InDesign's Find Format: Specify Attributes opens Find Format Settings (the same Style Options and character sections as Change Format Settings), and only text with the format is found. With nothing in Find what, each stretch of text with the format is found, so Change All can restyle every word in a character style. `find.find`, `find.next` and `find.change` take `findFormat {charStyle?, paraStyle?, chars?}`. Found in the Learn Skills Daily course, chapter 22. No readiness percentage change. +- **2026-10-21** · Find/Change has InDesign's Change Format. Specify Attributes opens Change Format Settings: Style Options (the character and paragraph style to apply, [Any Style] for none), then Basic and Advanced Character Formats, Character Color, OpenType Features, Underline and Strikethrough Options. The box shows the format ("Character Style: eggs"), and Change All gives it to the changed text. With a Change Format and nothing in Change to, the found text stays and is only formatted, as InDesign's (an ⓘ beside Change to says so). `find.change` takes `charStyle` and `paraStyle`. Found in the Learn Skills Daily course, chapter 22. No readiness percentage change. +- **2026-10-21** · Preferences › Interface follows InDesign's: Appearance has a Color Theme swatch per interface theme (a click shows it at once, Cancel goes back) and Panels sets the Tools panel to a single or double column; UI Size moves to a UI Scaling section of its own. Interface held only UI Size, and the theme could be changed only from the Window menu. Found in the Learn Skills Daily course, chapter 26. No readiness percentage change. +- **2026-10-21** · Preferences › File Handling shows Number of Recent Items to Display as a whole number ("20"; it read "20.0"). Found in the Learn Skills Daily course, chapter 26. No readiness percentage change. +- **2026-10-21** · Insert Pages… opens InDesign's dialog: Pages, Insert (After Page, Before Page, At Start of Document, At End of Document) with the page by name, and Parent ([None] or a parent), set to the page in view and its parent. It was a form asking for a page index and a parent id, and pages could not go before the first page; `layout.pages.insert` takes `before` (0: at the start). Found in the Learn Skills Daily course, chapter 25. No readiness percentage change. +- **2026-10-21** · Spelling (Check Spelling and Dynamic Spelling) no longer flags everyday words the bundled hyphenation word list lacks: "we", "us", "if", titles such as "Mr", and words such as email, online, website, database, desktop and download, with their plurals and other forms. Contractions typed with a typographer's apostrophe (don’t, we’re), which Use Typographer's Quotes produces by default, count like their straight-apostrophe forms. Dynamic Spelling used to underline most words of an ordinary sentence. Found in the Learn Skills Daily course, chapter 26. No readiness percentage change. +- **2026-10-21** · While a parent is shown, the status bar's page box and Properties › Page name it ("A-Parent") instead of page 1, as InDesign's; their lists have the parents after the pages, and choosing one shows that parent (a page leaves the parents). Found in the Learn Skills Daily course, chapter 25. No readiness percentage change. +- **2026-10-21** · Effects dialog: the Angle field of the shadows, Bevel and Emboss, Satin and Gradient Feather has InDesign's dial beside it, its radius pointing where the light comes from; a click or drag on it sets the angle. Found in the Learn Skills Daily course, chapter 14. No readiness percentage change. +- **2026-10-21** · A double-click on a Fill or Stroke chip of Properties › Appearance opens the Color Picker with its colour, as in InDesign (a click still opens the swatches); for selected text, its own chips open it for the text. Found in the Learn Skills Daily course, chapter 23. No readiness percentage change. +- **2026-10-20** · Properties › Appearance: with several text frames selected, the Fill and Stroke popups offer Apply To Frame or Text, as with one frame, so their text can be coloured at once, as in InDesign. No readiness percentage change. +- **2026-10-20** · Properties › Appearance: the arrow beside Opacity opens a slider under the field, as in InDesign, instead of the Effects panel; a drag shows the opacity live and is one undo step (the Opacity link still opens Effects). No readiness percentage change. +- **2026-10-20** · Properties › Frame Fitting has InDesign's Auto-Fit checkbox and Options button (Frame Fitting Options); the app's checkboxes are now named and ticked-or-not for screen readers. No readiness percentage change. +- **2026-10-20** · Properties › Text Wrap shows the four wrap offsets under the wrap buttons, with the link that makes them the same, as InDesign's Properties panel does (greyed with no wrap). No readiness percentage change. +- **2026-10-21** · Properties › Bullets and Numbering › Options opens the Bullets and Numbering dialog for the selected paragraphs, as InDesign's, instead of the Paragraph panel. Found in the Learn Skills Daily course, chapter 21. No readiness percentage change. +- **2026-10-20** · Properties › Appearance: the corner shape list shows each shape's glyph beside its name, drawn as InDesign draws them (Fancy has its own glyph now), and the stroke type list shows each type's line; both dropdowns are named for screen readers. No readiness percentage change. +- **2026-10-21** · Properties › Align: the Align To button shows what the objects align to, one glyph each for the selection, the key object, the margins, the page and the spread, as InDesign's. Found in the Learn Skills Daily course, chapter 12. No readiness percentage change. +- **2026-10-20** · Object › Corner Options opens a Corner Options dialog laid out as InDesign's: each corner's size and shape, the link button that makes all four the same, and Preview. The Corner link in Properties › Appearance opens it too (it opened a Rectangle size dialog). No readiness percentage change. +- **2026-10-20** · The Object menu is in InDesign's order and groups; Paths has Join, Open Path and Close Path (Convert Shape lists only shapes, as InDesign's does) and a Convert Point submenu (Plain, Corner, Smooth, Symmetrical) is added. No readiness percentage change. +- **2026-10-20** · Layout › Create Guides puts its guides on both pages of the spread in view, as InDesign does; "This page" in Apply to narrows them to one page. No readiness percentage change. +- **2026-10-21** · Object › Fitting › Clear Frame Fitting Options (`object.clearFittingOptions`): the frames' auto-fit, crop amounts and reference point go back to their defaults, and the picture stays where it is. The Fitting submenu is grouped as InDesign's. Found in the Learn Skills Daily course, chapter 11. No readiness percentage change. +- **2026-10-21** · Swatches panel rows show InDesign's columns, drawn in code: a crossed pencil on the swatches that can't be edited ([None], [Registration], [Black]), the colour type (process or spot) and the colour mode (CMYK, RGB, Gray, or [None]'s and [Registration]'s own glyphs), in place of a lock and "CMYK" text. Tint reads 100 with nothing selected. No readiness percentage change. +- **2026-10-21** · With a gradient fill or stroke, the Color panel shows the stop selected in the Gradient panel (the stop before a selected midpoint) with its values, and its C/M/Y/K or R/G/B sliders and spectrum colour that stop, keeping the others, as in InDesign. It showed "[None] — choose a colour on the ramp", and a spectrum click replaced the whole gradient with a flat colour. Found in the Learn Skills Daily course, chapter 24. No readiness percentage change. +- **2026-10-20** · A new document's Swatches panel lists InDesign's defaults: [None], [Registration], [Paper], [Black] and six process colours (C=100, M=100, Y=100, C=15 M=100 Y=100, C=75 M=5 Y=100, C=100 M=90 Y=10); the extra M=100 Y=100, C=100 Y=100 and C=100 M=100 swatches are gone. No readiness percentage change. +- **2026-10-21** · The readout beside the cursor while drawing or measuring ("W: 3.13 cm") is InDesign's light grey box with white text, measured from the tutorials' frames; High Contrast keeps it black. It was a dark translucent box. No readiness percentage change. +- **2026-10-20** · Drawing a frame or shape shows its width and height in the document's ruler units, to two decimals and on two lines ("W: 6.08 cm", "H: 6.08 cm"), as InDesign does; it was always whole points. No readiness percentage change. +- **2026-10-20** · Type tool: a drag that starts in an empty frame draws a new text frame over it, as in InDesign; a click still turns the frame into a text frame. No readiness percentage change. +- **2026-10-20** · Measures in centimetres show four decimals, as InDesign's fields do (a 1p gutter reads 0.4233 cm, not 0.423 cm). No readiness percentage change. +- **2026-10-21** · Text Frame Options is laid out as InDesign's, with Preview: General, Column Rules, Baseline Options, Auto-Size; framed Columns (Number and Width, Gutter and Maximum, Balance Columns), Inset Spacing (Top/Bottom, the link, Left/Right) and Vertical Justification (Align, Paragraph Spacing Limit) with spinner fields; Fixed Number shows each column's width as the fields change. Preview shows the change on the page until OK (one undo step) or Cancel. Justify honours the Paragraph Spacing Limit (`paragraphSpacingLimit` on `object.textFrameOptions`): the space goes between paragraphs up to the limit, the rest between lines. Found in the Learn Skills Daily course, chapter 18. No readiness percentage change. +- **2026-10-21** · Swatches panel: a swatch is renamed in place, as in InDesign: click it, then click its name again (a slow double-click), type, and Enter (Escape keeps the name). Everything that uses it follows; a double-click still opens Swatch Options, and [None], [Registration], [Paper] and [Black] keep their names. Found in the Learn Skills Daily course, chapter 23. No readiness percentage change. +- **2026-10-20** · The loaded place cursor shows a thumbnail of the graphic, as InDesign's does; dragging with it draws the frame at the graphic's proportions from where the drag started, with its scale beside the pointer, and places exactly that frame. No readiness percentage change. +- **2026-10-21** · Drop Shadow, Inner Shadow and Outer Glow have a blend mode, as InDesign's (Effects dialog › Blending › Mode; `mode` on `object.dropShadow`, `object.innerShadow`, `object.outerGlow`; IDML `BlendMode`). New shadows start Multiply and new outer glows Screen in white ([Paper]); effects saved before keep Normal, so documents look as they did. Found in the Learn Skills Daily course, chapter 14. No readiness percentage change. +- **2026-10-14** · The Pages panel labels spreads as InDesign does ("2–3", "2, 1" across a section start, "8, A"), marks each section's first page with a triangle, and scrolls to the page in view. No readiness percentage change. +- **2026-10-18** · Double-clicking a section triangle in the Pages panel opens that page’s numbering options, including from parent-page editing. No readiness percentage change. +- **2026-10-18** · TOC separators preserve right-indent tabs through the dialog and IDML, including localized `$ID/^y` values. A separator picker exposes Tab Character, Right Indent Tab, Em Space and En Space. Existing paragraph tab leaders now work with the tutorial's TOC workflow. No readiness percentage change. +- **2026-10-21** · The Package dialog's Include PDF (Print) has a PDF Preset (the Export PDF presets, the last one used first), as InDesign's Select PDF Preset; `file.package` takes `pdf: {…file.exportPdf options}`, refusing bad options before writing anything. The packaged PDF always had the default settings. Found in the Learn Skills Daily course, chapter 27. No readiness percentage change. +- **2026-10-21** · File › Package… opens InDesign's Package dialog: Summary counts the fonts, links and images and inks the package carries, with a warning where some are missing or modified, and Fonts, Links and Images and Colors and Inks list them (Show Problems Only). Create Printing Instructions asks for the contact details and instructions that go into the package's Instructions.txt, and Include IDML / Include PDF (Print) choose the copies it carries. Package… went straight to the folder picker with fixed options. Found in the Learn Skills Daily course, chapter 27. No readiness percentage change. - **2026-10-21** · Properties › Character and Paragraph: ••• (More Options) expands the section in place, as InDesign's: Character adds vertical and horizontal scale, baseline shift, skew, language and OpenType, and All Caps, Small Caps, Superscript, Subscript, Underline and Strikethrough; Paragraph adds the left, right, first line and last line indents, space before and after, the drop cap's lines and characters, Align to Baseline Grid and Hyphenate. It used to open the Character or Paragraph panel; the language field moved under ••• as in InDesign. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change. - **2026-10-21** · The text Control panel has InDesign's two modes: A (Character Formatting Controls: font and style, size and leading, All Caps/Superscript/Underline and Small Caps/Subscript/Strikethrough, kerning and tracking, vertical and horizontal scale, baseline shift and skew, character style and language) and ¶ (Paragraph Formatting Controls: the nine alignments, left/first line and right/last line indents, space before and after, the drop cap's lines and characters, paragraph style, Hyphenate and Align to Baseline Grid). It used to show a fixed mix of both. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change. - **2026-10-20** · With the Type tool, the Control panel's font size, leading and tracking fields have a spinner and a list of common values, as in InDesign (auto leading in parentheses). No readiness percentage change. - **2026-10-21** · The ¶ (Paragraph Formatting Controls) mode of the text Control panel has InDesign's Bulleted List and Numbered List buttons (with the default hanging indent) and Shading and Border: a checkbox, a button opening Paragraph Borders and Shading on that tab, and the colour. Found in the Learn Skills Daily course, chapter 21. No readiness percentage change. - **2026-10-21** · The Bulleted and Numbered List buttons (Properties, and Type › Bulleted & Numbered Lists › Apply Bullets / Apply Numbers) work as InDesign's: a paragraph with no indents gets the default hanging indent (0.25 in, −0.25 in), so the text lines up after the bullet instead of at the next default tab stop, and pressing the button again removes the list and that indent (`list.bullets`, `list.numbers`). Found in the Learn Skills Daily course, chapter 21. No readiness percentage change. +- **2026-10-18** · Table of Contents offers More Options / Fewer Options, preserving page-number, separator, sorting and level settings while their controls are hidden. No readiness percentage change. +- **2026-10-18** · Table of Contents reserves the title field's width so Contents is readable next to its style picker; the grid previously squeezed it to a few characters. No readiness percentage change. +- **2026-10-17** · Inline and anchored objects paint under their frame's text, on screen and in PDF, as InDesign draws them: a picture whose frame reaches into the next line (a negative Y offset) no longer hides the top of that line. No readiness percentage change. +- **2026-10-17** · Running headers: *first/last on page* read a page in text order (story order, then column by column), not by height — a multi-column page's right header showed the lowest entry of column 1 instead of the page's last entry. No readiness percentage change. +- **2026-10-17** · Cross-references keep text typed over their generated text (IDML import and export), with the page number still live, as InDesign shows them until updated; a cross-reference whose destination is gone shows its saved text and page instead of "??"; and a cross-reference's text breaks at its spaces instead of inside words. No readiness percentage change. +- **2026-10-17** · Parent pages: while parents are edited, the document pages' hidden characters, spelling squiggles and other overlays no longer pile onto the right parent page. No readiness percentage change. +- **2026-10-16** · A Direct Selection click on an image whose frame is already selected selects the image, so Delete removes only the image and leaves the empty frame, as in InDesign. 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. @@ -458,9 +548,14 @@ Newest first. - **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** · 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-15** · Swedish interface (`sv`, Preferences ▸ General ▸ Interface Language ▸ Svenska): menus, panels, + dialogs and messages, 2,857 of the 2,891 interface strings (99%) in the missing-translations report; the other 34 + show in English. AI-drafted with Claude Code from the English source; spot-checked by a native speaker, not every + string reviewed. 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. +- **2026-10-15** · Anchored objects wrap their own story as their anchor lines are set, as in InDesign: the layout no longer depends on where an earlier composition put them (a long story whose pictures moved between frames from pass to pass never settled). No readiness percentage change. - **2026-10-15** · Color Theme: the toolbar tool previews up to five colours from a placed raster and adds them as one undoable swatch group. Local deterministic extraction; transparent padding is ignored. No readiness percentage change. - **2026-10-15** · Applying text styles removes matching local formatting so capture sources inherit later edits; real overrides and named character styles remain. @@ -520,6 +615,8 @@ Newest first. - **2026-10-15** · Flex Layout (InDesign 2026): Object › Flex Layout (Add to, Convert to, Remove) and a Flex Layout panel laid out as InDesign's, also in Properties. A container lays out its objects by direction, wrap, justification, the alignment grid, Fixed/Auto width and height, padding and spacing, after every edit; Add detects aligned objects (direction, Auto size, Space Between at their spacing); resizing a container reflows its objects instead of scaling them; dragging an object in one reorders it. Montilla Design's tutorial replays step for step. No readiness percentage change. - **2026-10-15** · IDML: the CJK leading model InDesign writes on [No paragraph style] of every document (Aki Below) only applies when the document uses a Japanese composer, as in InDesign; a Western document's paragraphs after a size change no longer sit lower than in InDesign (Montilla Design's Flex Layout file: its captions came out overset). No readiness percentage change. - **2026-10-15** · IDML: linked images in a subfolder beside the package load when the package is opened on another machine (a link to `/images/logo.png` finds `images/logo.png` next to the .idml); they showed as missing. No readiness percentage change. +- **2026-10-14** · Numbering & Section Options opened on a page that doesn't start a section is New Section, as in InDesign: Start Section on, 1 offered, and the start numbers stay editable (typing one chooses it). No readiness percentage change. +- **2026-10-14** · The Direct Selection tool pressed on a corner or anchor of an object that isn't selected selects it and pulls just that point, as in InDesign (it moved the whole object). No readiness percentage change. - **2026-10-14** · Overset text is marked on every frame that ends an overset story, selected or not and inside groups too (the red mark at the out port, with frame edges shown); a selected group shows the in/out ports of the text frames inside it. InDesign shows it only on the selected frame, which hides overset labels in grouped objects until Preflight. No readiness percentage change. - **2026-10-14** · Load Swatches adds the colors of an `.ase` file that repeat a name or have none: a repeated name gets a number (`Red 2`), and a color without a name is named by its value (`C=0 M=0 Y=0 K=55`). Before, a repeated name kept one of its colors, and only the first unnamed color was added, with an empty name. No readiness percentage change. - **2026-10-14** · Objects snap to the bleed guide's four sides while moving, resizing or drawing; snapping and the canvas share one bleed rectangle (`Spread::bleed_rect`). No readiness percentage change. @@ -692,14 +789,14 @@ Newest first. - **2026-10-10 (UI.4–UI.5):** Panel tabs can move into other groups, split the dock, float, resize and return to their saved locations through shared docking transactions. Layouts persist in custom workspaces; UI and control commands share the same validated action path. Shared pane geometry and widget-state theme application retain all five palettes and local fonts. Split panes send keyboard input only to the focused canvas and scope contextual toolbar IDs per pane. No readiness percentage change. - **2026-10-10** · Version 0.6.0; releases add a Linux riscv64 tar.gz (cross-compiled, CLI smoke-tested under QEMU). -- **2026-10-10** · Readiness per audience: full ~47% (method aligned with the standard, was +- **2026-10-10** · Readiness per audience: full ~47% (method corrected, was 42%), mainstream practitioner ~42%, essentials user ~54%, each with hours to ~95%. -- **2026-10-10** · Stage re-normalized to **pre-alpha** under craftrules' core-workflow gate: text +- **2026-10-10** · Stage re-normalized to **pre-alpha** under the core-workflow gate: text can't be threaded by hand (no out-port click, no thread command). - **2026-10-10** · Full re-measure against InDesign 2026 21.6 (menu dump, bundle formats and localizations, 124 issues): breadth ~80%, ready ~42% (down from the ~83% "including depth" of - 2026-10-04, on user evidence and the INDD gap); progress docs restructured to the craftrules - standard (`docs/parity.md` → `docs/parity-checklist.md`; new target-app-parity, gaps, roadmap, + 2026-10-04, on user evidence and the INDD gap); progress docs restructured + (`docs/parity.md` → `docs/parity-checklist.md`; new target-app-parity, gaps, roadmap, architecture, typography, file-format, UI, hardware and localization docs). Landed today: split columns (#295), PDF export options dialog (#196), column rules (#274), Paragraph Rules dialog (#263), Character Style Options (#144), PDF/X-4 RGB handling (#223), Spanish @@ -728,10 +825,10 @@ Newest first. |---|---|---| | 2026-10-12 | minor | Manual text threading landed; alpha distance 15–25 h → 5–7 h (Primary Text Frame page fixes, #165) | | 2026-10-11 | minor | Localized Japanese print, import and table controls; readiness estimates unchanged | -| 2026-10-10 | minor | Full number 42% → 47% (method aligned with the standard: plain weighted sum, no new evidence); readiness table with hours per audience | +| 2026-10-10 | minor | Full number 42% → 47% (plain weighted sum, no new evidence); readiness table with hours per audience | | 2026-10-10 | minor | Headline adds mainstream practitioner (~42%) and essentials user (~54%); method in docs/target-app-parity.md | | 2026-10-10 | minor | Stage alpha → pre-alpha under the new core-workflow gate: manual text threading is missing; alpha gate table added to docs/roadmap.md | -| 2026-10-10 | major | Restructured to the progress-docs standard; full re-measure; the "Working today" inventory moved to docs/target-app-parity.md, milestones to docs/roadmap.md, the CLI/MCP section left to docs/agents.md (which already covered it) | +| 2026-10-10 | major | Progress docs restructured; full re-measure; the "Working today" inventory moved to docs/target-app-parity.md, milestones to docs/roadmap.md, the CLI/MCP section left to docs/agents.md (which already covered it) | | 2026-10-04 | major | Parity estimate: breadth 99%, depth ~83%, ~170 h | | 2026-10-01 | major | First roadmap: status, milestones | diff --git a/apps/designcraft-cli/Cargo.toml b/apps/designcraft-cli/Cargo.toml index 7aaa454cb..ab2464147 100644 --- a/apps/designcraft-cli/Cargo.toml +++ b/apps/designcraft-cli/Cargo.toml @@ -22,4 +22,5 @@ designcraft-compose = { workspace = true } designcraft-format = { workspace = true } designcraft-pdf = { workspace = true } designcraft-mcp = { workspace = true } +image = { workspace = true } serde_json = { workspace = true } diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index 9ce15674c..8f704d806 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -16,6 +16,8 @@ //! designcraft-cli bench FILE [--runs N] # the same measurements on one document //! designcraft-cli validate-pdf FILE.pdf [--standard x4|x1a] # the built-in PDF/X-4 or PDF/X-1a checks (default: the //! # file's own GTS_PDFXVersion) +//! designcraft-cli thumbnail FILE.designcraft OUT.png [SIZE] # the file's first-page preview, fitted to SIZE +//! # (the freedesktop thumbnailer for Linux file managers) //! designcraft-cli links # Discord, website, app page and GitHub links //! designcraft-cli --version # print the version //! ``` @@ -40,7 +42,7 @@ use designcraft_engine::Session; use serde_json::{Value, json}; /// The usage block. Shared by `--help` (stdout, success) and an unknown command (stderr, failure). -const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR]... [--automation-write-root DIR]...\n the roots confine every file the agent's commands read (open, placed images, data sources, libraries) and\n write (save, export, package) to those folders, links followed. Each may be given more than once; a kind\n left out grants none of its access.\n They run the in-process server, so they conflict with --connect (a running app is not confined by them).\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli validate-pdf FILE.pdf # built-in PDF/X-4 checks (including visible unembedded fonts)\n designcraft-cli links\n designcraft-cli --version"; +const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR]... [--automation-write-root DIR]...\n the roots confine every file the agent's commands read (open, placed images, data sources, libraries) and\n write (save, export, package) to those folders, links followed. Each may be given more than once; a kind\n left out grants none of its access.\n They run the in-process server, so they conflict with --connect (a running app is not confined by them).\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli validate-pdf FILE.pdf # built-in PDF/X-4 checks (including visible unembedded fonts)\n designcraft-cli thumbnail FILE.designcraft OUT.png [SIZE] # the saved first-page preview, at most SIZE px\n designcraft-cli links\n designcraft-cli --version"; /// `script --help`: the script syntax, otherwise only in `describe script.run` and docs/agents.md. const SCRIPT_HELP: &str = r#"usage: designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going] @@ -124,6 +126,7 @@ fn main() -> ExitCode { Some("perf") => report(perf::perf(&args[1..])), Some("bench") => report(perf::bench(&args[1..])), Some("validate-pdf") => report(validate_pdf(args.get(1..).unwrap_or_default())), + Some("thumbnail") => report(thumbnail(&args[1..])), Some("links") => { use designcraft_engine::links::{APP_PAGE, DISCORD, GITHUB, ISSUES, WEBSITE}; outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}\nIssues {ISSUES}"); @@ -156,7 +159,6 @@ fn report(r: Result<(), String>) -> ExitCode { fn validate_pdf(args: &[String]) -> Result<(), String> { const USAGE: &str = "usage: designcraft-cli validate-pdf FILE.pdf [--standard x4|x1a]"; - const X1A: &[u8] = b"/GTS_PDFXVersion(PDF/X-1:"; let mut path = None; let mut standard = None; let mut it = args.iter(); @@ -172,7 +174,7 @@ fn validate_pdf(args: &[String]) -> Result<(), String> { let standard = match standard.map(designcraft_pdf::Standard::parse) { Some(Some(s)) => s, Some(None) => return Err(USAGE.into()), - None if bytes.windows(X1A.len()).any(|w| w == X1A) => designcraft_pdf::Standard::PdfX1a, + None if designcraft_pdf::pdfx_version(&bytes).is_some_and(|v| v.starts_with("PDF/X-1:")) => designcraft_pdf::Standard::PdfX1a, None => designcraft_pdf::Standard::PdfX4, }; let (name, issues) = match standard { @@ -188,6 +190,61 @@ fn validate_pdf(args: &[String]) -> Result<(), String> { if valid { Ok(()) } else { Err(format!("{path} failed {name} validation")) } } +/// `thumbnail FILE OUT [SIZE]`: the first-page preview saved in a `.designcraft` file, as a PNG +/// whose longer side is at most SIZE pixels (never enlarged). It is the freedesktop thumbnailer +/// (`packaging/linux/ai.storyteller.designcraft.thumbnailer`), so it only reads the preview: a file +/// without one fails, and the file manager keeps the file type's icon. +fn thumbnail(args: &[String]) -> Result<(), String> { + const USAGE: &str = "usage: designcraft-cli thumbnail FILE.designcraft OUT.png [SIZE]"; + let [input, out, rest @ ..] = args else { return Err(USAGE.into()) }; + let size = match rest { + [] => None, + [s] => Some(s.parse::().ok().filter(|s| *s > 0).ok_or_else(|| format!("bad SIZE `{s}`: expected a positive number of pixels"))?), + _ => return Err(USAGE.into()), + }; + if names_the_same_file(input.as_ref(), out.as_ref()) { + return Err(format!("{out} is the document {input}: the thumbnail would replace it")); + } + let file = std::fs::File::open(input).map_err(|e| format!("{input}: {e}"))?; + let preview = designcraft_format::read_preview(std::io::BufReader::new(file)) + .ok_or_else(|| format!("{input} has no preview (a DesignCraft file saved before they had one, or not a DesignCraft file)"))?; + let png = fit_png(preview, size).map_err(|e| format!("{input}: the preview can't be read: {e}"))?; + designcraft_engine::atomic::write_atomic(std::path::Path::new(out), &png).map_err(|e| format!("{out}: {e}")) +} + +/// Whether `out` is the file `input` names: the same path, a symlink to it or, on Unix, a hard +/// link (the same device and inode). An `out` that doesn't exist yet can't be the input. +fn names_the_same_file(input: &std::path::Path, out: &std::path::Path) -> bool { + let (Ok(a), Ok(b)) = (std::fs::canonicalize(input), std::fs::canonicalize(out)) else { return false }; + if a == b { + return true; + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if let (Ok(a), Ok(b)) = (std::fs::metadata(&a), std::fs::metadata(&b)) { + return (a.dev(), a.ino()) == (b.dev(), b.ino()); + } + } + false +} + +/// `png` with its longer side brought down to `size`. A picture already that small is returned +/// as it is; it is decoded anyway, so a broken one is an error rather than an unreadable thumbnail. +fn fit_png(png: Vec, size: Option) -> Result, String> { + // The preview comes from the file: it may claim any size. + let mut limits = image::Limits::default(); + limits.max_image_width = Some(4096); + limits.max_image_height = Some(4096); + let mut reader = image::ImageReader::with_format(std::io::Cursor::new(&png), image::ImageFormat::Png); + reader.limits(limits); + let img = reader.decode().map_err(|e| e.to_string())?; + let Some(size) = size.filter(|s| img.width().max(img.height()) > *s) else { return Ok(png) }; + let mut out = std::io::Cursor::new(Vec::new()); + img.resize(size, size, image::imageops::FilterType::Triangle).write_to(&mut out, image::ImageFormat::Png).map_err(|e| e.to_string())?; + Ok(out.into_inner()) +} + /// `mcp` (headless, in-process engine) or `mcp --connect PORT|HOST:PORT` (drive a running app /// started with `designcraft --control PORT`). JSON-RPC on stdin/stdout; logs on stderr. fn mcp(args: &[String]) -> Result<(), String> { diff --git a/apps/designcraft-cli/tests/it/cli.rs b/apps/designcraft-cli/tests/it/cli.rs index 861cb514f..f19551a71 100644 --- a/apps/designcraft-cli/tests/it/cli.rs +++ b/apps/designcraft-cli/tests/it/cli.rs @@ -556,3 +556,110 @@ fn usage_lists_the_automation_root_flags() { let usage = String::from_utf8_lossy(&out.stdout); assert!(usage.contains("--automation-read-root DIR") && usage.contains("--automation-write-root DIR"), "{usage}"); } + +/// Width and height from a PNG's IHDR chunk. +fn png_size(png: &[u8]) -> (u32, u32) { + assert!(png.starts_with(b"\x89PNG\r\n\x1a\n"), "not a PNG"); + let be = |at: usize| u32::from_be_bytes(png[at..at + 4].try_into().unwrap()); + (be(16), be(20)) +} + +/// Linux file managers (Dolphin, Nautilus, Thunar) draw a `.designcraft` file's icon through the +/// freedesktop thumbnailer, which runs `designcraft-cli thumbnail %i %o %s` (#1782). The picture is +/// the first-page preview the file already holds, fitted to the size asked for and never enlarged. +#[test] +fn thumbnail_writes_the_saved_preview_fitted_to_the_size() { + let dir = std::env::temp_dir().join(format!("dc-cli-thumb-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let doc = dir.join("a.designcraft"); + make_document(&doc); + let preview = designcraft_format::read_preview(std::fs::File::open(&doc).unwrap()).unwrap(); + let (pw, ph) = png_size(&preview); + assert!(pw.max(ph) > 128, "the saved preview is {pw}×{ph}"); + + let small = dir.join("small.png"); + let out = cli().arg("thumbnail").arg(&doc).arg(&small).arg("128").output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let (w, h) = png_size(&std::fs::read(&small).unwrap()); + assert_eq!(w.max(h), 128, "{w}×{h} from a {pw}×{ph} preview"); + // The page's proportions stay. + let ratio = |a: u32, b: u32| f64::from(a) / f64::from(b); + assert!((ratio(w, h) - ratio(pw, ph)).abs() < 0.02, "{w}×{h} from a {pw}×{ph} preview"); + + let large = dir.join("large.png"); + let out = cli().arg("thumbnail").arg(&doc).arg(&large).arg("1024").output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + assert_eq!(std::fs::read(&large).unwrap(), preview, "a preview smaller than the size is written as saved"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A file saved without a preview (by an older version, or not by DesignCraft) has no picture: the +/// thumbnailer fails and writes nothing, so the file manager keeps the file type's icon. +#[test] +fn thumbnail_of_a_file_without_a_preview_fails_and_writes_nothing() { + let dir = std::env::temp_dir().join(format!("dc-cli-thumb-none-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let with = dir.join("with.designcraft"); + make_document(&with); + let doc = designcraft_format::load(&std::fs::read(&with).unwrap()).unwrap(); + let without = dir.join("without.designcraft"); + std::fs::write(&without, designcraft_format::save(&doc).unwrap()).unwrap(); + let text = dir.join("notes.designcraft"); + std::fs::write(&text, "not a document").unwrap(); + for input in [&without, &text] { + let png = dir.join("out.png"); + let out = cli().arg("thumbnail").arg(input).arg(&png).arg("256").output().unwrap(); + let err = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success(), "{}", input.display()); + assert!(err.contains("no preview"), "{}: {err}", input.display()); + assert!(!png.exists(), "{}", input.display()); + } + let _ = std::fs::remove_dir_all(&dir); +} + +/// `thumbnail a.designcraft a.designcraft` would replace the document with a PNG. An output that +/// names the input, by the same path, through a symlink or (on Unix) as a hard link, is refused +/// before anything is read or written, and the document keeps its bytes. +#[test] +fn thumbnail_refuses_an_output_that_names_the_input() { + let dir = std::env::temp_dir().join(format!("dc-cli-thumb-self-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let doc = dir.join("a.designcraft"); + make_document(&doc); + let saved = std::fs::read(&doc).unwrap(); + let mut outs = vec![doc.clone(), dir.join(".").join("a.designcraft")]; + #[cfg(unix)] + { + let link = dir.join("link.png"); + std::os::unix::fs::symlink(&doc, &link).unwrap(); + outs.push(link); + let hard = dir.join("hard.png"); + std::fs::hard_link(&doc, &hard).unwrap(); + outs.push(hard); + } + for out_path in &outs { + let out = cli().arg("thumbnail").arg(&doc).arg(out_path).arg("128").output().unwrap(); + let err = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code(), Some(1), "{}: {err}", out_path.display()); + assert!(err.contains("is the document"), "{}: {err}", out_path.display()); + assert!(std::fs::read(&doc).unwrap() == saved, "{} changed the document", out_path.display()); + assert!(std::fs::read(out_path).unwrap() == saved, "{} was written", out_path.display()); + } + let _ = std::fs::remove_dir_all(&dir); +} + +/// Another existing file at the output path is still replaced by the thumbnail. +#[test] +fn thumbnail_replaces_another_existing_file() { + let dir = std::env::temp_dir().join(format!("dc-cli-thumb-other-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let doc = dir.join("a.designcraft"); + make_document(&doc); + let png = dir.join("old.png"); + std::fs::write(&png, b"an older thumbnail").unwrap(); + let out = cli().arg("thumbnail").arg(&doc).arg(&png).arg("128").output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let (w, h) = png_size(&std::fs::read(&png).unwrap()); + assert_eq!(w.max(h), 128, "{w}×{h}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/apps/designcraft/Cargo.toml b/apps/designcraft/Cargo.toml index d706374c7..518c89874 100644 --- a/apps/designcraft/Cargo.toml +++ b/apps/designcraft/Cargo.toml @@ -37,7 +37,8 @@ winit = { version = "0.30", default-features = false, features = ["x11"] } craft-update = { workspace = true } # EnumDisplayDevices (src/gpu.rs): which GPU drives the display, so the window draws on the GPU # that shows it; drawing on a GPU without a monitor reset the display driver. Safe wrappers over -# Win32 (MIT); the `user` feature is User32.dll only. +# Win32 (MIT); the `user` feature is User32.dll only. Also the window frame for the custom title +# bar (src/window_frame.rs, window styles). winsafe = { version = "0.0.29", default-features = false, features = ["user"] } [target.'cfg(target_os = "macos")'.dependencies] @@ -55,3 +56,8 @@ craft-window = { workspace = true } # PdfCraft and WordCraft) registers the handler behind a safe API (Objective-C interop needs unsafe, # which the workspace forbids). Pinned exactly: review the diff before bumping. fmv-macos-events = "=0.1.0" + +[target.'cfg(target_os = "linux")'.dependencies] +# Native GNOME/Wayland controls without a bundled title font. +winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita-notitle"] } + diff --git a/apps/designcraft/src/gpu.rs b/apps/designcraft/src/gpu.rs index 907117b0c..93586e585 100644 --- a/apps/designcraft/src/gpu.rs +++ b/apps/designcraft/src/gpu.rs @@ -678,7 +678,12 @@ fn restart_without<'a>(graphics_failed: bool, frames: u64, picked: Option<&'a st /// ended it. pub fn finish(outcome: Result, startup: &Startup) -> Result<(), String> { let (why, graphics_failed) = match outcome { - Ok(Ok(())) => return Ok(()), + // The window closed normally, perhaps before its first frame or before the app was + // created (so `on_exit` never ran): no driver fault took this start down (#2116). + Ok(Ok(())) => { + startup.presented(); + return Ok(()); + } Ok(Err(e @ eframe::Error::Wgpu(_))) => (e.to_string(), true), Ok(Err(e)) => (e.to_string(), false), Err(panic) => (panic, GRAPHICS_PANIC.load(Ordering::Relaxed)), diff --git a/apps/designcraft/src/gpu/backend.rs b/apps/designcraft/src/gpu/backend.rs index 6aa250dd4..06c477064 100644 --- a/apps/designcraft/src/gpu/backend.rs +++ b/apps/designcraft/src/gpu/backend.rs @@ -14,8 +14,9 @@ //! fallback off. //! //! The fallback: before the window is created, `gpu.json` in the settings directory records the -//! backend being tried; once a frame has been presented, or the graphics failed with an error the -//! app caught (see `gpu::finish`), that entry is cleared. An entry still there at the next start +//! backend being tried; once a frame has been presented, the window closed normally (even before +//! its first frame), or the graphics failed with an error the app caught (see `gpu::finish`), that +//! entry is cleared. An entry still there at the next start //! means the previous start never got that far — the driver crashed, or the app was killed //! meanwhile — so that backend joins `failed`, the next candidate is tried and the status bar says //! so. Backends a restart left out (`--gpu-skip=`, see `gpu`) aren't chosen for that start but @@ -538,6 +539,23 @@ mod tests { let _ = std::fs::remove_dir_all(path.parent().expect("dir")); } + /// A window closed before its first frame (#2116): the app was created slowly or not at all, + /// so neither a frame nor `on_exit` cleared the record, but eframe ended cleanly. No driver + /// fault took that start down, so the next start keeps the backend. + #[test] + fn a_start_that_ends_cleanly_before_its_first_frame_does_not_fail_its_backend() { + let path = temp_path("clean-end"); + let fallback = Fallback::begin_unforced(Some(path.clone()), V, &[]).expect("candidates"); + let first = fallback.backend; + assert_eq!(read(&path).trying, Some(first)); + let startup = super::super::Startup::new(Some(fallback), Vec::new()); + assert_eq!(super::super::finish(Ok(Ok(())), &startup), Ok(())); + assert_eq!(read(&path).trying, None, "a clean end leaves no backend being tried"); + let c = choose(candidates(), &read(&path), V, &[]).expect("candidates"); + assert_eq!((c.backend, c.record.failed, c.switched_from), (first, Vec::new(), None)); + let _ = std::fs::remove_dir_all(path.parent().expect("dir")); + } + #[test] fn without_a_path_nothing_is_written_and_a_bad_path_is_not_fatal() { let mut s = Fallback::begin_unforced(None, V, &[]).expect("candidates"); diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index 5f056aab3..ea0747a46 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -22,6 +22,7 @@ mod logging; #[cfg(target_os = "macos")] mod native_menu; mod profile; +mod window_frame; use designcraft_engine::Session; use designcraft_ui_egui::{DesignApp, Services}; @@ -255,7 +256,7 @@ fn control_port_from(source: &str, value: Option) -> Option { fn main() -> std::process::ExitCode { // Windows: the update helper is a copy of this program started with `--finish-update`. #[cfg(windows)] - if let Some(result) = craft_update::self_update::finish(&craft_update::suite::DESIGNCRAFT, &std::env::args_os().skip(1).collect::>()) { + if let Some(result) = craft_update::self_update::finish(&designcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::>()) { std::process::exit(i32::from(result.is_err())); } // First, so every start-up record (and the engine's panic hook, installed with the first @@ -428,6 +429,9 @@ fn main() -> std::process::ExitCode { } app.integrated_titlebar = cfg!(target_os = "macos"); app.custom_titlebar = cfg!(target_os = "windows"); + // The custom title bar's undecorated window must stay borderless, or its caption + // and border offset the content and the pointer on some Windows systems (#1295). + app.services.window_frame = window_frame::service(cc, app.custom_titlebar); if let Some(port) = control_port { let rx = control_server::start(port, cc.egui_ctx.clone()); app = app.with_control(rx); diff --git a/apps/designcraft/src/native_menu.rs b/apps/designcraft/src/native_menu.rs index e127776ba..ba92e0031 100644 --- a/apps/designcraft/src/native_menu.rs +++ b/apps/designcraft/src/native_menu.rs @@ -230,10 +230,12 @@ fn build( path: &str, recent: &mut Option, ) { - for e in entries { + for (i, e) in entries.iter().enumerate() { match e { Item::Sep => { - let _ = parent.append(&PredefinedMenuItem::separator()); + if menus::has_shown_item(app, &entries[i + 1..]) { + let _ = parent.append(&PredefinedMenuItem::separator()); + } } Item::Sub(label, children) => { let sub = Submenu::new(menu_title(app, label), true); diff --git a/apps/designcraft/src/window_frame.rs b/apps/designcraft/src/window_frame.rs new file mode 100644 index 000000000..c50e1870c --- /dev/null +++ b/apps/designcraft/src/window_frame.rs @@ -0,0 +1,352 @@ +//! Keeps the custom (undecorated) title bar window truly borderless (#1295). +//! +//! winit 0.30 gives an undecorated window the `WS_CAPTION | WS_BORDER` styles and hides the +//! non-client area by spreading the client rect over it (`WM_NCCALCSIZE`). On some systems +//! (reported: Windows 10 LTSB, Intel HD Graphics 4600, 1366 × 768) the desktop compositor still +//! reserves that frame for the window surface: the content is drawn offset by the caption and +//! border, leaving black bars at the window's top-left, while the pointer keeps the un-offset +//! client coordinates, so it no longer points at what is drawn under it. +//! +//! winit 0.27 — before rust-windowing/winit#2419 — simply omitted those styles for undecorated +//! windows, and there was no offset. [`service`] restores that: it strips `WS_CAPTION` and +//! `WS_BORDER` and forces Windows to recalculate the frame. winit puts them back whenever it +//! changes the window flags (maximize, restore, …), so the returned guard checks every frame. +//! +//! Windows sizes a maximize by the frame its styles describe, so a window maximized while its +//! caption is gone is sized without one: its rect covers the whole monitor, taskbar included. +//! The shell reads a caption-less window covering its monitor as a full-screen app and drops the +//! taskbar's backdrop behind it — a black band on stock Windows 11, a glitched unresponsive one +//! under ExplorerPatcher (photocraft#3144). The guard therefore also trims a parked maximize's rect back to +//! the work area plus its own frame, clear of the taskbar. winit pins the client area to the work +//! area either way (`WM_NCCALCSIZE`), so the app's pixels do not move. +//! +//! An auto-hidden taskbar reserves no work area, so that trim leaves the maximize covering the +//! whole monitor, and the shell, taking it for a full-screen app, no longer reveals the taskbar +//! when the pointer reaches its edge (photocraft#4845). On each edge with an auto-hidden taskbar the guard +//! instead stops the window one pixel short of the monitor, as other custom-frame apps do. +//! +//! The long-term fix is in winit itself (storytold/winit#17); this module goes when that lands. +//! +//! The Windows calls go through safe `winsafe` wrappers; the workspace forbids `unsafe`, and +//! winsafe can't wrap a raw handle without it, so the window is found by its handle instead. + +pub use platform::service; + +#[cfg(target_os = "windows")] +mod platform { + use designcraft_ui_egui::WindowFrameFn; + use eframe::wgpu::rwh::{HasWindowHandle, RawWindowHandle}; + use winsafe::prelude::Handle; + use winsafe::{AtomStr, HWND, HwndPlace, POINT, SIZE, co}; + + /// A rect in desktop coordinates: `(left, top, right, bottom)`, right and bottom exclusive. + type Rect = (i32, i32, i32, i32); + + /// A monitor edge. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum Edge { + Left, + Top, + Right, + Bottom, + } + + /// A guard that keeps the window borderless each frame, or `None` when the app uses the + /// system title bar (the window is decorated) or its handle can't be found. + pub fn service(cc: &eframe::CreationContext<'_>, custom_titlebar: bool) -> Option { + if !custom_titlebar { + return None; + } + let target = match cc.window_handle().ok()?.as_raw() { + RawWindowHandle::Win32(h) => h.hwnd.get(), + _ => return None, + }; + let hwnd = find_window(target)?; + reapply(&hwnd); + Some(Box::new(move || reapply(&hwnd))) + } + + /// The top-level window with the given raw handle. `EnumWindows` hands out real `HWND`s, so + /// this matches winit's own window without wrapping a raw handle (which needs `unsafe`). + fn find_window(target: isize) -> Option { + let mut found = None; + let _ = winsafe::EnumWindows(|hwnd| { + if hwnd.ptr() as isize == target { + found = Some(hwnd); + false + } else { + true + } + }); + found + } + + /// One pass of the per-frame guard: the styles winit put back come off again, and a parked + /// maximize stays clear of the taskbar. + fn reapply(hwnd: &HWND) { + strip(hwnd); + keep_clear_of_the_taskbar(hwnd); + } + + /// Removes `WS_CAPTION`/`WS_BORDER` and has Windows recalculate the frame. A no-op once they + /// are gone, so it is cheap to call every frame. + fn strip(hwnd: &HWND) { + let style = hwnd.style(); + let without = borderless(style); + if without.raw() == style.raw() { + return; + } + hwnd.set_style(without); + let _ = hwnd.SetWindowPos( + HwndPlace::None, + POINT::new(), + SIZE::new(), + co::SWP::FRAMECHANGED | co::SWP::NOCOPYBITS | co::SWP::NOMOVE | co::SWP::NOSIZE | co::SWP::NOZORDER | co::SWP::NOACTIVATE, + ); + } + + /// How far a parked maximize reaches past a monitor/work-area edge: the resize + /// border (about 8 pt at 100 %, more with DPI). Past that, a maximized window's rect is not + /// parked but being dragged or snapped, and is left alone. + const FRAME: i32 = 32; + + /// Keep a parked maximize inside all four work-area edges, including a top taskbar. + fn keep_clear_of_the_taskbar(hwnd: &HWND) { + if !hwnd.IsZoomed() { + return; + } + let (Ok(r), Ok(mi)) = (hwnd.GetWindowRect(), hwnd.MonitorFromWindow(co::MONITOR::DEFAULTTONEAREST).GetMonitorInfo()) else { + return; + }; + let (m, w) = (mi.rcMonitor, mi.rcWork); + let (rect, monitor, work) = ((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom)); + // The taskbars (a couple of `FindWindowEx` calls) are only looked up while the window + // covers the whole monitor, which a trimmed maximize no longer does. + let Some((left, top, right, bottom)) = clamped_rect(rect, monitor, work) + .or_else(|| covers(rect, monitor).then(|| short_of_autohide_taskbars(rect, monitor, work, &taskbar_edges(monitor))).flatten()) + else { + return; + }; + let (Some(width), Some(height)) = (right.checked_sub(left), bottom.checked_sub(top)) else { + return; + }; + // Moving the outer frame while winit pins the client rect to the work area must + // not copy the old frame's pixels into the custom title bar. + let _ = hwnd.SetWindowPos( + HwndPlace::None, + POINT { x: left, y: top }, + SIZE { cx: width, cy: height }, + co::SWP::FRAMECHANGED | co::SWP::NOCOPYBITS | co::SWP::NOZORDER | co::SWP::NOACTIVATE, + ); + let _ = hwnd.InvalidateRect(None, false); + } + + /// Return the corrected outer rect in desktop coordinates, preserving the resize border. + /// Each edge must be parked just outside either the monitor or work-area edge. Accepting + /// both makes the guard idempotent and handles Windows already correcting some edges. + /// Dragged/snapped windows are left alone; no monitor-origin or DPI assumptions are needed. + fn clamped_rect(rect: (i32, i32, i32, i32), monitor: (i32, i32, i32, i32), work: (i32, i32, i32, i32)) -> Option<(i32, i32, i32, i32)> { + let (l, t, r, b) = rect; + let (ml, mt, mr, mb) = monitor; + let (wl, wt, wr, wb) = work; + if ml >= mr || mt >= mb || wl < ml || wt < mt || wr > mr || wb > mb || wl >= wr || wt >= wb { + return None; + } + // Infer the frame from an edge without a taskbar. On an obstructed edge, the + // monitor and work-area offsets can both look like valid borders (e.g. auto-hide). + let border = [ + (ml == wl).then(|| ml.checked_sub(l)).flatten(), + (mt == wt).then(|| mt.checked_sub(t)).flatten(), + (mr == wr).then(|| r.checked_sub(mr)).flatten(), + (mb == wb).then(|| b.checked_sub(mb)).flatten(), + ] + .into_iter() + .flatten() + .filter(|n| (0..=FRAME).contains(n)) + .min()?; + let parked = |monitor: Option, work: Option| monitor == Some(border) || work == Some(border); + if !parked(ml.checked_sub(l), wl.checked_sub(l)) + || !parked(mt.checked_sub(t), wt.checked_sub(t)) + || !parked(r.checked_sub(mr), r.checked_sub(wr)) + || !parked(b.checked_sub(mb), b.checked_sub(wb)) + { + return None; + } + let corrected = (wl.checked_sub(border)?, wt.checked_sub(border)?, wr.checked_add(border)?, wb.checked_add(border)?); + (corrected != rect).then_some(corrected) + } + + /// Whether `rect` reaches every edge of `monitor`: what the shell takes for a full-screen app. + fn covers(rect: Rect, monitor: Rect) -> bool { + rect.0 <= monitor.0 && rect.1 <= monitor.1 && rect.2 >= monitor.2 && rect.3 >= monitor.3 + } + + /// The edges of `monitor` that have a taskbar (the primary `Shell_TrayWnd` or a secondary + /// monitor's `Shell_SecondaryTrayWnd`), shown or hidden. + fn taskbar_edges(monitor: Rect) -> Vec { + let mut edges = Vec::new(); + for class in ["Shell_TrayWnd", "Shell_SecondaryTrayWnd"] { + let mut after: Option = None; + // One taskbar per monitor; the bound only guards against a misbehaving shell. + for _ in 0..64 { + let Ok(Some(tray)) = HWND::NULL.FindWindowEx(after.as_ref(), AtomStr::from_str(class), None) else { + break; + }; + if let Ok(r) = tray.GetWindowRect() { + edges.extend(taskbar_edge((r.left, r.top, r.right, r.bottom), monitor)); + } + after = Some(tray); + } + } + edges + } + + /// The edge of `monitor` a taskbar with rect `taskbar` sits on: the one nearest its centre, + /// or `None` when it is on another monitor. An auto-hidden taskbar is parked mostly past its + /// edge with a sliver left on screen, so its centre may lie outside the monitor. + fn taskbar_edge(taskbar: Rect, monitor: Rect) -> Option { + let (tl, tt, tr, tb) = (i64::from(taskbar.0), i64::from(taskbar.1), i64::from(taskbar.2), i64::from(taskbar.3)); + let (ml, mt, mr, mb) = (i64::from(monitor.0), i64::from(monitor.1), i64::from(monitor.2), i64::from(monitor.3)); + if tl >= tr || tt >= tb || ml >= mr || mt >= mb || tr <= ml || tl >= mr || tb <= mt || tt >= mb { + return None; + } + // Doubled centre and distances, so nothing rounds. + let (cx, cy) = (tl + tr, tt + tb); + [(Edge::Left, cx - 2 * ml), (Edge::Top, cy - 2 * mt), (Edge::Right, 2 * mr - cx), (Edge::Bottom, 2 * mb - cy)] + .into_iter() + .min_by_key(|&(_, distance)| distance) + .map(|(edge, _)| edge) + } + + /// A maximize covering its monitor, trimmed to stop one pixel short of each edge where a + /// taskbar reserves no work area (it auto-hides), so the shell still reveals it there. + /// `None` when there is nothing to trim. + fn short_of_autohide_taskbars(rect: Rect, monitor: Rect, work: Rect, taskbars: &[Edge]) -> Option { + if !covers(rect, monitor) { + return None; + } + let (mut l, mut t, mut r, mut b) = rect; + for edge in taskbars { + match edge { + Edge::Left if work.0 == monitor.0 => l = monitor.0.checked_add(1)?, + Edge::Top if work.1 == monitor.1 => t = monitor.1.checked_add(1)?, + Edge::Right if work.2 == monitor.2 => r = monitor.2.checked_sub(1)?, + Edge::Bottom if work.3 == monitor.3 => b = monitor.3.checked_sub(1)?, + _ => {} + } + } + let trimmed = (l, t, r, b); + (l < r && t < b && trimmed != rect).then_some(trimmed) + } + + /// `style` without the OS title bar and frame (`WS_CAPTION`, which already includes + /// `WS_BORDER`). The sizing border (`WS_THICKFRAME`) stays, so edge resizing and Aero snap + /// keep working, as they did with winit 0.27's undecorated windows. + fn borderless(style: co::WS) -> co::WS { + style & !(co::WS::CAPTION | co::WS::BORDER) + } + + #[cfg(test)] + mod tests { + use super::{Edge, borderless, clamped_rect, short_of_autohide_taskbars, taskbar_edge}; + use winsafe::co; + + #[test] + fn the_borderless_style_drops_the_caption_and_border_only() { + // `WS_CAPTION` is 0x00c0_0000 and already contains `WS_BORDER` (0x0080_0000). + let decorated = co::WS::CAPTION | co::WS::BORDER | co::WS::SYSMENU | co::WS::THICKFRAME | co::WS::MAXIMIZEBOX | co::WS::MINIMIZEBOX; + let bare = borderless(decorated); + assert!(!bare.has(co::WS::CAPTION), "caption gone"); + assert!(!bare.has(co::WS::BORDER), "border gone"); + assert!(bare.has(co::WS::THICKFRAME), "edge resizing kept"); + assert!(bare.has(co::WS::MAXIMIZEBOX) && bare.has(co::WS::SYSMENU), "window buttons kept"); + // Already borderless: unchanged, so the per-frame call does nothing. + assert_eq!(bare.raw(), borderless(bare).raw()); + } + + #[test] + fn a_maximize_respects_taskbars_on_every_edge() { + let monitor = (0, 0, 1920, 1080); + let rect = (-8, -8, 1928, 1088); + for (work, expected) in [ + ((0, 0, 1920, 1046), (-8, -8, 1928, 1054)), + ((0, 48, 1920, 1080), (-8, 40, 1928, 1088)), + ((0, 2, 1920, 1080), (-8, -6, 1928, 1088)), + ((0, 0, 1920, 1078), (-8, -8, 1928, 1086)), + ((48, 0, 1920, 1080), (40, -8, 1928, 1088)), + ((0, 0, 1872, 1080), (-8, -8, 1880, 1088)), + ] { + assert_eq!(clamped_rect(rect, monitor, work), Some(expected)); + assert_eq!(clamped_rect(expected, monitor, work), None, "already corrected"); + } + } + + #[test] + fn a_top_taskbar_keeps_its_offset_at_other_origins_and_frame_sizes() { + let monitor = (-1920, -1080, 0, 0); + let work = (-1920, -1032, 0, 0); + for border in [0, 8, 16, 32] { + let rect = (-1920 - border, -1080 - border, border, border); + let expected = (-1920 - border, -1032 - border, border, border); + assert_eq!(clamped_rect(rect, monitor, work), Some(expected)); + assert_eq!(clamped_rect(expected, monitor, work), None); + } + } + + #[test] + fn a_maximize_without_a_reserved_taskbar_is_left_alone() { + let monitor = (0, 0, 1920, 1080); + assert_eq!(clamped_rect((-8, -8, 1928, 1088), monitor, monitor), None); + } + + #[test] + fn a_maximize_being_dragged_or_snapped_is_left_alone() { + let monitor = (0, 0, 1920, 1080); + let work = (0, 48, 1920, 1080); + assert_eq!(clamped_rect((100, 200, 1100, 900), monitor, work), None, "moved"); + assert_eq!(clamped_rect((-500, -8, 1428, 1088), monitor, work), None, "dragged"); + assert_eq!(clamped_rect((0, 48, 960, 1080), monitor, work), None, "half-screen"); + } + + #[test] + fn a_maximize_leaves_a_pixel_strip_on_an_auto_hidden_taskbar_edge() { + // photocraft#4845: an auto-hidden taskbar reserves no work area. + let monitor = (0, 0, 1920, 1080); + let rect = (-8, -8, 1928, 1088); + for (edge, expected) in [ + (Edge::Bottom, (-8, -8, 1928, 1079)), + (Edge::Top, (-8, 1, 1928, 1088)), + (Edge::Left, (1, -8, 1928, 1088)), + (Edge::Right, (-8, -8, 1919, 1088)), + ] { + assert_eq!(short_of_autohide_taskbars(rect, monitor, monitor, &[edge]), Some(expected)); + assert_eq!(short_of_autohide_taskbars(expected, monitor, monitor, &[edge]), None, "already trimmed"); + } + assert_eq!(short_of_autohide_taskbars(rect, monitor, monitor, &[]), None, "no taskbar"); + // A taskbar that reserves its work area is the clamp's job. + assert_eq!(short_of_autohide_taskbars(rect, monitor, (0, 0, 1920, 1032), &[Edge::Bottom]), None); + assert_eq!(short_of_autohide_taskbars((0, 0, 960, 1080), monitor, monitor, &[Edge::Bottom]), None, "half-screen"); + } + + #[test] + fn a_taskbar_sits_on_the_monitor_edge_nearest_its_centre() { + let monitor = (0, 0, 1920, 1080); + assert_eq!(taskbar_edge((0, 1032, 1920, 1080), monitor), Some(Edge::Bottom), "shown"); + assert_eq!(taskbar_edge((0, 1078, 1920, 1126), monitor), Some(Edge::Bottom), "auto-hidden"); + assert_eq!(taskbar_edge((0, -46, 1920, 2), monitor), Some(Edge::Top)); + assert_eq!(taskbar_edge((-60, 0, 2, 1080), monitor), Some(Edge::Left)); + assert_eq!(taskbar_edge((1918, 0, 1980, 1080), monitor), Some(Edge::Right)); + assert_eq!(taskbar_edge((1920, 1032, 3840, 1080), monitor), None, "other monitor"); + assert!(taskbar_edge((i32::MIN, i32::MIN, i32::MAX, i32::MAX), monitor).is_some(), "no overflow"); + } + } +} + +#[cfg(not(target_os = "windows"))] +mod platform { + use designcraft_ui_egui::WindowFrameFn; + + pub fn service(_cc: &eframe::CreationContext<'_>, _custom_titlebar: bool) -> Option { + None + } +} diff --git a/assets/app-icon/README.md b/assets/app-icon/README.md index e0ee414a0..a1086577a 100644 --- a/assets/app-icon/README.md +++ b/assets/app-icon/README.md @@ -22,10 +22,8 @@ transparent margin. ## Provenance -The owner drew the cat in ArtCraft. The 2880 px drawing, keyed to the three-colour palette, lives -outside this repo in craftrules at `assets/app-icons/designcraft/source.png`. It was vectorised with -craftrules' `assets/logo-options/_tools/vectorize_tile.py`: `designcraft.svg` is traced at 2048 px and -`designcraft-small.svg` at 1024 px. Design rules: craftrules `standards/icon-design.md`. +The owner drew the cat in ArtCraft. `designcraft.svg` is traced from the drawing at 2048 px and +`designcraft-small.svg` at 1024 px. ## Files diff --git a/contributors/people.toml b/contributors/people.toml index 79acd86b0..5049c9493 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -1,13 +1,10 @@ -# Contributor names for the About window (craftrules standards/contributors.md). +# Contributor names for the About window. # # Everyone who contributes is credited by GitHub username automatically. Anything more is up to # you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a # maintainer may add a person only when that person asked, recording where in consent_source. # -# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \ -# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name] -# -# Or copy this block, replacing your-github-username (every field is optional): +# Copy this block, replacing your-github-username (every field is optional): # # [people.your-github-username] # real_name = "Your Name" # shown when the viewer picks "Real name" @@ -66,8 +63,27 @@ real_name = "Marco Arnulfo" display_name = "Marco Arnulfo" sync_github_name = false +[people.macrobi] +real_name = "Veress Róbert" +display_name = "Veress Róbert" +sync_github_name = false + [people.4waymedia] real_name = "Paul Gemignani" [people.skylartaylor] display_name = "skylartaylor" + +[people.y-az] +real_name = "Youssef Azriouil" +display_name = "youssef" + +[people.dylanmtaylor] +sync_github_name = true + +[people.Tom-R-Main] +sync_github_name = true + +[people.JonRC] +real_name = "Jonathan Cardoso" +display_name = "Jonathan Cardoso" diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index d085c5c48..c83d93f0c 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -118,6 +118,8 @@ impl Color { } else { 60.0 * ((r - g) / d + 4.0) }; + // A tiny negative `rem_euclid` input can round up to exactly 6.0 (hue 360). + let h = if h >= 360.0 { 0.0 } else { h }; let s = if max <= 0.0 { 0.0 } else { d / max }; [h, s, max] } @@ -251,6 +253,12 @@ impl Paint { mod tests { use super::*; + #[test] + fn hsb_hue_stays_below_360() { + let h = Color::rgb(1.0, 0.0, 1e-8).to_hsb()[0]; + assert!((0.0..360.0).contains(&h), "hue {h}"); + } + #[test] fn hex_roundtrip() { let c = Color::from_hex("#ff8000").unwrap(); diff --git a/crates/color/src/swatch.rs b/crates/color/src/swatch.rs index e47bf4eef..1847a4134 100644 --- a/crates/color/src/swatch.rs +++ b/crates/color/src/swatch.rs @@ -96,7 +96,9 @@ pub fn value_name(c: Color) -> String { Color::Cmyk { c, m, y, k } => cmyk_name(c, m, y, k), other => { let [r, g, b] = other.to_rgb(); - format!("R={} G={} B={}", (r * 255.0).round(), (g * 255.0).round(), (b * 255.0).round()) + // `+ 0.0` turns -0.0 into 0.0 so it prints as "0", not "-0". + let p = |v: f32| (v * 255.0).round() + 0.0; + format!("R={} G={} B={}", p(r), p(g), p(b)) } } } @@ -143,7 +145,9 @@ pub fn apply_tint(c: Color, tint: f32) -> Color { } } -/// A new document's swatches, in panel order. +/// A new document's swatches, in panel order: InDesign's four specials, then its six process +/// colours (cyan, magenta, yellow, red, green and blue as C=15 M=100 Y=100, C=75 M=5 Y=100 and +/// C=100 M=90 Y=10). pub fn default_swatches() -> Vec { let special = |name: &str, value: SwatchValue| Swatch { name: name.into(), value, locked: true, named: true, hidden: false }; let mut v = vec![ @@ -152,17 +156,9 @@ pub fn default_swatches() -> Vec { special(PAPER, SwatchValue::Paper { color: Color::WHITE }), special(BLACK, SwatchValue::Color { color: Color::cmyk(0.0, 0.0, 0.0, 1.0), color_type: ColorType::Process }), ]; - for (c, m, y, k) in [ - (1.0, 0.0, 0.0, 0.0), - (0.0, 1.0, 0.0, 0.0), - (0.0, 0.0, 1.0, 0.0), - (0.0, 1.0, 1.0, 0.0), - (1.0, 0.0, 1.0, 0.0), - (1.0, 1.0, 0.0, 0.0), - (0.15, 1.0, 1.0, 0.0), - (0.75, 0.05, 1.0, 0.0), - (1.0, 0.9, 0.1, 0.0), - ] { + for (c, m, y, k) in + [(1.0, 0.0, 0.0, 0.0), (0.0, 1.0, 0.0, 0.0), (0.0, 0.0, 1.0, 0.0), (0.15, 1.0, 1.0, 0.0), (0.75, 0.05, 1.0, 0.0), (1.0, 0.9, 0.1, 0.0)] + { v.push(Swatch::cmyk(c, m, y, k)); } v @@ -172,6 +168,11 @@ pub fn default_swatches() -> Vec { mod tests { use super::*; + #[test] + fn value_name_has_no_negative_zero() { + assert_eq!(value_name(Color::rgb(-0.001, 0.5, 1.0)), "R=0 G=128 B=255"); + } + #[test] fn defaults_start_with_specials() { let s = default_swatches(); @@ -181,6 +182,27 @@ mod tests { assert!(s.iter().any(|s| s.name == "C=100 M=0 Y=0 K=0")); } + /// The Swatches panel of a new InDesign document (Zero2Graphic's page layout tutorial, 3:22). + #[test] + fn defaults_are_indesigns_ten() { + let names: Vec = default_swatches().into_iter().map(|s| s.name).collect(); + assert_eq!( + names, + [ + "[None]", + "[Registration]", + "[Paper]", + "[Black]", + "C=100 M=0 Y=0 K=0", + "C=0 M=100 Y=0 K=0", + "C=0 M=0 Y=100 K=0", + "C=15 M=100 Y=100 K=0", + "C=75 M=5 Y=100 K=0", + "C=100 M=90 Y=10 K=0", + ] + ); + } + #[test] fn resolves_tints_and_specials() { let mut s = default_swatches(); diff --git a/crates/compose/Cargo.toml b/crates/compose/Cargo.toml index b7017f80f..37092adb8 100644 --- a/crates/compose/Cargo.toml +++ b/crates/compose/Cargo.toml @@ -21,7 +21,7 @@ log = { workspace = true } regex = { workspace = true } fancy-regex = { workspace = true } miniz_oxide = "0.8" -hypher = { version = "0.1.8", default-features = false, features = ["alloc", "greek", "portuguese", "spanish", "norwegian"] } +hypher = { version = "0.1.8", default-features = false, features = ["alloc", "dutch", "french", "german", "greek", "italian", "portuguese", "spanish", "norwegian"] } [dev-dependencies] designcraft-fonts = { workspace = true, features = ["testing"] } diff --git a/crates/compose/src/breaker.rs b/crates/compose/src/breaker.rs index 9b453ff0a..6f62542d7 100644 --- a/crates/compose/src/breaker.rs +++ b/crates/compose/src/breaker.rs @@ -972,7 +972,15 @@ pub fn greedy( x += adv; shrink += sp.box_elastic(g).1.iter().sum::(); if i + 1 < n && !glyphs[i + 1].is_space() && !g.no_break && g.break_after != Some(false) { - if matches!(g.ch, '-' | '\u{2010}' | '\u{2013}' | '\u{2014}' | '/') + if g.ch == SOFT_HYPHEN { + if may_hyphenate(hyphens) { + let hw = hyphen_width(g, &mut hy_cache); + let hy = (hw - hyphen_hang(g, hw, sp)).max(0.0); + if x + hy <= w + shrink + FIT_EPS { + last_ok = Some((i + 1, true)); + } + } + } else if matches!(g.ch, '-' | '\u{2010}' | '\u{2013}' | '\u{2014}' | '/') || g.break_after.unwrap_or_else(|| cjk_break(g, &glyphs[i + 1], sp)) { last_ok = Some((i + 1, false)); diff --git a/crates/compose/src/cache.rs b/crates/compose/src/cache.rs index 66122ccf4..5e4cb0911 100644 --- a/crates/compose/src/cache.rs +++ b/crates/compose/src/cache.rs @@ -7,7 +7,7 @@ //! story's composition places it. Entries hold clones of those `Arc`s, so the pointers //! can't be reused by other allocations while the entry lives. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex, PoisonError}; use designcraft_doc::{Document, Item, Story, StoryId, Styles}; @@ -39,6 +39,12 @@ impl From> for ShownOn { /// (`true`) or not. type Key = (StoryId, ShownOn, bool); +/// The hosts and wrap neighbours already resolved while answering one [`Cache::get_on`], by +/// story, whether other stories wrap it, and depth. A story reached from many others (frames of +/// many stories anchored in it, at several levels) is resolved once instead of once per path to +/// it. +type Resolved = HashMap<(StoryId, bool, usize), Arc>; + struct Entry { sig: Vec, _keep: (Arc, Arc, Vec>, Vec>), @@ -58,6 +64,14 @@ pub struct Cache { previous: Mutex, Arc)>>, } +/// A signature's hash, standing in for it in another story's signature. +fn hash_of(sig: &[usize]) -> usize { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + sig.hash(&mut h); + h.finish() as usize +} + /// Identity of everything a running header can depend on (stories, spreads, styles, variables). fn doc_signature(doc: &Document) -> u64 { use std::hash::{Hash, Hasher}; @@ -100,29 +114,44 @@ impl Cache { /// [`Cache::get`] for any [`ShownOn`]. pub fn get_on(&self, doc: &Document, sid: StoryId, on: ShownOn) -> Arc { - self.get_with(doc, sid, on, true, 0) + self.get_with(doc, sid, on, true, 0, &mut Resolved::new()) + } + + /// [`Cache::get_with`] for a host or a wrap neighbour (shown in its own frames), once per + /// traversal. + fn resolve(&self, doc: &Document, sid: StoryId, cross: bool, depth: usize, resolved: &mut Resolved) -> Arc { + if let Some(c) = resolved.get(&(sid, cross, depth)) { + return c.clone(); + } + let c = self.get_with(doc, sid, ShownOn::Own, cross, depth, resolved); + resolved.insert((sid, cross, depth), c.clone()); + c } /// [`Cache::get_on`], wrapped by other stories' anchored objects only when `cross` is set, for /// a story `depth` hosts down from the story asked for. - fn get_with(&self, doc: &Document, sid: StoryId, on: ShownOn, cross: bool, depth: usize) -> Arc { + fn get_with(&self, doc: &Document, sid: StoryId, on: ShownOn, cross: bool, depth: usize, resolved: &mut Resolved) -> Arc { let Some(story) = doc.stories.get(&sid) else { return Arc::new(ComposedStory { story: sid, ..Default::default() }) }; let (mut sig, mut keep_items) = signature(doc, story); let neighbours = if cross { crate::wrap::neighbours(doc, sid) } else { Vec::new() }; let mut keep_stories = Vec::new(); + // A neighbour's signature goes in as its hash, and its items only once each: stories on + // one spread share its wrapping items, so copying every neighbour's whole signature and + // items made each entry as large as all of theirs together. + let mut kept: HashSet<*const Item> = keep_items.iter().map(Arc::as_ptr).collect(); for n in &neighbours { if let Some(ns) = doc.stories.get(n) { let (s, k) = signature(doc, ns); sig.push(usize::MAX - 1); - sig.extend(s); - keep_items.extend(k); + sig.push(hash_of(&s)); + keep_items.extend(k.into_iter().filter(|it| kept.insert(Arc::as_ptr(it)))); keep_stories.push(ns.clone()); } } // Anchored frames compose where their host story places them, so the host is composed // first and the placements are part of the signature. let placed = if depth < crate::placed::MAX_DEPTH { - crate::placed::placements(doc, sid, &mut |host| self.get_with(doc, host, ShownOn::Own, true, depth + 1)) + crate::placed::placements(doc, sid, &mut |host| self.resolve(doc, host, true, depth + 1, resolved)) } else { Vec::new() }; @@ -229,7 +258,7 @@ impl Cache { // Parent-page items are composed per page: markers show its name, variables need its index. let page = page.filter(|_| with_vars); let running = running.then(|| self.running_index(doc)); - let others: Vec> = neighbours.iter().map(|n| self.neighbour(doc, *n, depth)).collect(); + let others: Vec> = neighbours.iter().map(|n| self.neighbour(doc, *n, depth, resolved)).collect(); let out = Arc::new(crate::wrap::compose_among( doc, sid, @@ -315,13 +344,13 @@ impl Cache { /// Story `sid` composed for the wrap of its anchored objects in other stories: without other /// stories' anchored objects (no cycles), and outside the cache when it needs the document-wide /// indexes (they compose every story). - fn neighbour(&self, doc: &Document, sid: StoryId, depth: usize) -> Arc { + fn neighbour(&self, doc: &Document, sid: StoryId, depth: usize, resolved: &mut Resolved) -> Arc { let indexed = doc.stories.get(&sid).is_some_and(|st| crate::xref::has_xrefs(st) || (crate::vars::has_vars(st) && crate::vars::has_running(doc, st))); if indexed { Arc::new(crate::wrap::compose_among(doc, sid, &ComposeOptions::default(), &[])) } else { - self.get_with(doc, sid, ShownOn::Own, false, depth) + self.resolve(doc, sid, false, depth, resolved) } } diff --git a/crates/compose/src/grep.rs b/crates/compose/src/grep.rs index 6f9e8f9e3..61546d162 100644 --- a/crates/compose/src/grep.rs +++ b/crates/compose/src/grep.rs @@ -385,6 +385,16 @@ impl Grep { self.re.find_iter(text).take(limit).map(|m| m.map(|m| m.range()).map_err(run_err)).collect() } + /// The first match starting at or after byte `start`, with its replacement: `template` + /// expanded as [`Self::replacements`] does. + pub fn replacement_at(&self, text: &str, start: usize, template: &str) -> Result, String)>, GrepError> { + if start > text.len() || !text.is_char_boundary(start) { + return Ok(None); + } + let Some(caps) = self.re.captures_from_pos(text, start).map_err(run_err)? else { return Ok(None) }; + Ok(caps.get(0).map(|m| (m.range(), fancy_regex::Expander::default().expansion(template, &caps)))) + } + /// Non-empty matches in `text`, at most `limit` of them: an empty match styles nothing, so it /// doesn't count toward a GREP style's cap. pub fn find_nonempty(&self, text: &str, limit: usize) -> Result>, GrepError> { diff --git a/crates/compose/src/hyphen.rs b/crates/compose/src/hyphen.rs index 3f07f4b40..898b426fa 100644 --- a/crates/compose/src/hyphen.rs +++ b/crates/compose/src/hyphen.rs @@ -3,9 +3,16 @@ //! `hyph-es.tex` 5.0 from hyph-utf8, MIT; Portuguese is `hyph-pt.tex` 1.4 from hyph-utf8 (Pedro J. //! de Rezende, J. Joao Dias Almeida, Leonardo Araujo, Aline Benevides), BSD-3-Clause; Norwegian is //! `hyph-no.tex` from hyph-utf8 (Rune Kleveland, Ole Michael Selberg), permissive (see NOTICE); -//! Greek is `hyph-el-monoton.tex` 5.0 from hyph-utf8 (Dimitrios Filippou), LPPL or MIT, taken +//! Dutch is Piet Tutelaers's `hyph-nl.tex` 1.1 from hyph-utf8, MIT; Greek is `hyph-el-monoton.tex` 5.0 from hyph-utf8 (Dimitrios Filippou), LPPL or MIT, taken //! under MIT. Those are the **monotonic** patterns, which is what `hypher` maps `el` to, so -//! polytonic text hyphenates by them as well. +//! polytonic text hyphenates by them as well. French is `hyph-fr.tex` 2.13 from hyph-utf8 (Daniel +//! Flipo, Bernard Gaulle, Arthur Reutenauer), MIT; Italian is `hyph-it.tex` 4.9 from hyph-utf8 +//! (Claudio Beccari), LPPL or MIT, taken under MIT. A word with an elision (`l'imprimerie`) +//! hyphenates after its apostrophe. +//! German uses `hyph-de-1996.tex` (2024-02-28, Deutschsprachige Trennmustermannschaft, MIT): +//! despite the filename, these are the reformed 2006 spelling patterns. Only the two 2006 reform +//! names and German locale codes without a reform year or with 2006 use this set. Traditional, +//! 1901 and unknown German variants have no automatic hyphenation; no old-spelling set is bundled. //! //! English lookup order for each word: //! 1. the **dictionary** ([`Dictionary::en_us`]) — the public-domain Moby Hyphenator II word list @@ -84,6 +91,11 @@ impl Lang { } let tag = designcraft_doc::language_tag(language)?; let sub = designcraft_doc::language_subtag(tag); + // language_tag deliberately drops spelling variants for shaping and quotes. Hyphenation + // must retain that distinction: the 2006 patterns are wrong for traditional ck/st breaks. + if sub == "de" && !german_2006_language(language) { + return None; + } if sub == "en" { return Some(Lang::English); } @@ -95,6 +107,15 @@ impl Lang { } } +/// Accept explicit reform names and unqualified German locales (our modern default), but never +/// silently substitute reform patterns for another spelling variant imported from IDML. +fn german_2006_language(language: &str) -> bool { + let language = language.trim(); + let normalized = language.strip_prefix("$ID/").unwrap_or(language).to_lowercase(); + let words: Vec<&str> = normalized.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect(); + matches!(words.as_slice(), ["german", "2006", "reform"] | ["german", "swiss", "2006", "reform"] | ["de"] | ["de", _] | ["de", _, "2006"]) +} + /// Where a word's break points came from. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Source { @@ -119,8 +140,14 @@ fn core_breaks(core: &[char], lang: Lang) -> (Vec, Source) { return (vec![], Source::None); } if let Lang::Patterns(l) = lang { - if core.contains(&'\'') { - return (vec![], Source::None); + if let Some(ap) = core.iter().rposition(|&c| c == '\'') { + // An elision (French "l'", Italian "dell'"): the word after the apostrophe hyphenates, + // with no break up to it. Elsewhere (German "Rock'n'Roll") an apostrophe word gets none. + if !matches!(l, hypher::Lang::French | hypher::Lang::Italian) { + return (vec![], Source::None); + } + let (pts, src) = if ap + 1 < n { core_breaks(&core[ap + 1..], lang) } else { (vec![], Source::None) }; + return (pts.into_iter().map(|p| p + ap + 1).collect(), src); } let s: String = core.iter().collect(); let mut out = Vec::new(); @@ -211,6 +238,7 @@ pub fn hyphen_points_in(word: &str, lim: &Limits, lang: Lang) -> Vec { } let min_before = lim.after_first.max(1); let min_after = lim.before_last.max(1); + let elides = matches!(lang, Lang::Patterns(hypher::Lang::French | hypher::Lang::Italian)); // Run bounds of each point's letter run (trimmed of apostrophes). let run_of = |p: usize| { let mut a = p; @@ -227,6 +255,10 @@ pub fn hyphen_points_in(word: &str, lim: &Limits, lang: Lang) -> Vec { while b > a && is_apostrophe(chars[b - 1]) { b -= 1; } + // After an elision the word is the part after its last apostrophe: the limits count that. + if elides && let Some(ap) = chars[a..b].iter().rposition(|&c| is_apostrophe(c)) { + a += ap + 1; + } (a, b) }; pts.into_iter() @@ -287,6 +319,25 @@ fn heuristic_points(c: &[char]) -> Vec { return vec![]; } let v: Vec = c.iter().map(|&x| is_vowel(x)).collect(); + // A trailing silent `ed`, `es` or `e` comes off the right part. Which one depends only on how + // the word ends, so the right part's core is `c[i..n - silent]` (nothing comes off a right + // part shorter than the ending). Prefix sums of vowels and UTF-8 lengths then answer "does the + // core have a vowel" and "is it longer than one byte" without building each suffix, which was + // quadratic in the word's length. + let ends = |s: &str| c.ends_with(&s.chars().collect::>()); + let silent = if ends("ed") || ends("es") { + 2 + } else if ends("e") { + 1 + } else { + 0 + }; + let mut vowels = vec![0_usize; n + 1]; + let mut bytes = vec![0_usize; n + 1]; + for (k, ch) in c.iter().enumerate() { + vowels[k + 1] = vowels[k] + usize::from(v[k]); + bytes[k + 1] = bytes[k] + ch.len_utf8(); + } let mut out = vec![]; for i in 1..n { let ok = if v[i - 1] && !v[i] { @@ -297,9 +348,8 @@ fn heuristic_points(c: &[char]) -> Vec { false }; // Both parts need a sounded vowel: a trailing silent `e`, `ed` or `es` doesn't count. - let right: String = c[i..].iter().collect(); - let core = right.strip_suffix("ed").or_else(|| right.strip_suffix("es")).or_else(|| right.strip_suffix('e')).unwrap_or(&right); - if ok && core.chars().any(is_vowel) && core.len() > 1 && v[..i].iter().any(|x| *x) { + let core_end = if n - i >= silent { n - silent } else { n }; + if ok && vowels[core_end] > vowels[i] && bytes[core_end] - bytes[i] > 1 && vowels[i] > 0 { out.push(i); } } @@ -379,6 +429,82 @@ mod tests { assert!(hyphen_points("typography", &lim).is_empty()); } + /// Only French and Italian elide at an apostrophe; other languages leave such words unbroken. + #[test] + fn apostrophe_words_hyphenate_only_in_french_and_italian() { + let de = Lang::for_language("German: 2006 Reform").unwrap(); + assert!(hyphen_points_in("Rock'n'Roll", &Limits::default(), de).is_empty()); + let fr = Lang::for_language("French").unwrap(); + assert!(!hyphen_points_in("l'information", &Limits::default(), fr).is_empty()); + } + + #[test] + fn german_2006_uses_reformed_patterns() { + let de = Lang::for_language("German: 2006 Reform").unwrap(); + assert_eq!(de, Lang::Patterns(hypher::Lang::German)); + for name in [ + "$ID/German: 2006 Reform", + " german: 2006 reform ", + "de", + "de-DE", + "de_AT", + "de_CH", + "de_DE_2006", + "German: Swiss 2006 Reform", + "$ID/German: Swiss 2006 Reform", + "de_CH_2006", + ] { + assert_eq!(Lang::for_language(name), Some(de), "{name}"); + } + for (word, expected) in [ + ("Silbentrennung", "Sil-ben-tren-nung"), + ("Zucker", "Zu-cker"), + ("Bäcker", "Bä-cker"), + ("Fenster", "Fens-ter"), + ("Straße", "Stra-ße"), + ("Fußgänger", "Fuß-gän-ger"), + ("Donaudampfschifffahrt", "Do-nau-dampf-schiff-fahrt"), + ("SILBENTRENNUNG", "SIL-BEN-TREN-NUNG"), + ] { + let pts = hyphen_points_in(word, &Limits::default(), de); + let actual: String = word.chars().enumerate().map(|(i, c)| if pts.contains(&i) { format!("-{c}") } else { c.to_string() }).collect(); + assert_eq!(actual, expected, "{word}"); + } + } + + #[test] + fn non_2006_german_has_no_bundled_hyphenation() { + for name in [ + "German", + "German: Traditional", + "$ID/German: Traditional", + "German: Swiss", + "German: 1901", + "German: 1996 Reform", + "German: Unknown", + "German: Traditional 2006 Reform", + "de_DE_1901", + "de-CH-1901", + "de_AT_1996", + "de_DE_2099", + ] { + assert_eq!(designcraft_doc::language_subtag(designcraft_doc::language_tag(name).unwrap()), "de", "{name}"); + assert_eq!(Lang::for_language(name), None, "{name}: do not use 2006 ck/st breaks for unsupported spelling variants"); + } + } + + #[test] + fn german_2006_respects_paragraph_limits() { + let de = Lang::for_language("de_DE_2006").unwrap(); + let lim = Limits { after_first: 4, before_last: 5, ..Limits::default() }; + assert_eq!(hyphen_points_in("Silbentrennung", &lim, de), vec![6]); + let lim = Limits { min_word: 15, ..Limits::default() }; + assert!(hyphen_points_in("Silbentrennung", &lim, de).is_empty()); + let lim = Limits { capitalized: false, ..Limits::default() }; + assert!(hyphen_points_in("Silbentrennung", &lim, de).is_empty()); + assert!(!hyphen_points_in("silbentrennung", &lim, de).is_empty()); + } + #[test] fn spanish_uses_the_bezos_patterns() { let es = Lang::for_language("Spanish").unwrap(); @@ -393,7 +519,7 @@ mod tests { assert_eq!(h("desesperación"), "de-ses-pe-ra-ción"); assert_eq!(h("teatro"), "tea-tro", "a vowel run stays whole"); assert_eq!(h("calle"), "ca-lle", "ll stays whole"); - assert!(Lang::for_language("French").is_none(), "only languages built in"); + assert!(Lang::for_language("Swedish").is_none(), "only languages built in"); } #[test] @@ -435,9 +561,149 @@ mod tests { assert_eq!(h("beveger"), "be-ve-ger"); } + /// The paragraph limits count the letters of the word after an elision, not the elided prefix. + #[test] + fn elision_limits_apply_to_the_word_after_it() { + let fr = Lang::for_language("French").unwrap(); + let lim = Limits { min_word: 5, after_first: 3, before_last: 2, capitalized: true }; + assert_eq!(hyphen_points_in("l'imprimerie", &lim, fr), vec![7, 9]); + let lim = Limits { min_word: 11, ..Limits::default() }; + assert!(hyphen_points_in("l'imprimerie", &lim, fr).is_empty()); + } + + /// A word with an elision ("l'", "dell'") hyphenates as the word after it. + fn marked(w: &str, l: Lang) -> String { + let pts = hyphen_points_in(w, &Limits::default(), l); + w.chars().enumerate().map(|(i, c)| if pts.contains(&i) { format!("-{c}") } else { c.to_string() }).collect() + } + + #[test] + fn french_uses_the_hyph_fr_patterns() { + let fr = Lang::for_language("French").unwrap(); + for name in ["fr", "fr_FR", "French: Canadian", "French: Swiss"] { + assert_eq!(Lang::for_language(name), Some(fr), "{name}"); + } + assert_eq!(marked("hyphénation", fr), "hy-phé-na-tion"); + assert_eq!(marked("typographie", fr), "ty-po-gra-phie"); + assert_eq!(marked("Développement", fr), "Dé-ve-lop-pe-ment"); + assert_eq!(marked("anticonstitutionnellement", fr), "an-ti-cons-ti-tu-tion-nel-le-ment"); + assert_eq!(marked("paragraphe", fr), "pa-ra-graphe", "no break before a final mute syllable of two letters"); + assert_eq!(marked("l'imprimerie", fr), "l'im-pri-me-rie", "after an elision"); + assert_eq!(marked("l’imprimerie", fr), "l’im-pri-me-rie", "a typographer's apostrophe too"); + } + + #[test] + fn italian_uses_the_hyph_it_patterns() { + let it = Lang::for_language("Italian").unwrap(); + for name in ["it", "it_IT"] { + assert_eq!(Lang::for_language(name), Some(it), "{name}"); + } + assert_ne!(it, Lang::for_language("French").unwrap()); + assert_eq!(marked("sillabazione", it), "sil-la-ba-zio-ne"); + assert_eq!(marked("tipografia", it), "ti-po-gra-fia"); + assert_eq!(marked("Repubblica", it), "Re-pub-bli-ca"); + assert_eq!(marked("impaginazione", it), "im-pa-gi-na-zio-ne"); + assert_eq!(marked("dell'impaginazione", it), "dell'im-pa-gi-na-zio-ne", "after an elision"); + } + + #[test] + fn dutch_uses_the_hyph_nl_patterns() { + let nl = Lang::for_language("Dutch").unwrap(); + // InDesign writes the 2005 spelling as `nl_NL_2005`. + assert_eq!(nl, Lang::for_language("nl_NL_2005").unwrap()); + assert_ne!(nl, Lang::for_language("Spanish").unwrap()); + let h = |w: &str| { + let pts = hyphen_points_in(w, &Limits::default(), nl); + w.chars().enumerate().map(|(i, c)| if pts.contains(&i) { format!("-{c}") } else { c.to_string() }).collect::() + }; + assert_eq!(h("uitspraak"), "uit-spraak"); + assert_eq!(h("kinderen"), "kin-de-ren"); + assert_eq!(h("leesvaardigheid"), "lees-vaar-dig-heid"); + assert_eq!(h("zelfvertrouwen"), "zelf-ver-trou-wen"); + assert_eq!(h("Ontwikkelen"), "Ont-wik-ke-len"); + // Long compounds break at their parts and at the syllables inside them. + assert_eq!(h("arbeidsongeschiktheidsverzekering"), "ar-beids-on-ge-schikt-heids-ver-ze-ke-ring"); + assert_eq!(h("ziekenhuisopname"), "zie-ken-huis-op-na-me"); + assert_eq!(h("gemeenteraadsverkiezingen"), "ge-meen-te-raads-ver-kie-zin-gen"); + assert_eq!(h("kinderdagverblijf"), "kin-der-dag-ver-blijf"); + } + #[test] fn heuristic_fallback() { let c: Vec = "happen".chars().collect(); assert_eq!(heuristic_points(&c), vec![3]); } + + /// The heuristic as it was written before the prefix sums, building each right part. + fn heuristic_by_suffixes(c: &[char]) -> Vec { + let n = c.len(); + if n < 4 || !c.iter().all(|c| c.is_alphabetic()) { + return vec![]; + } + let v: Vec = c.iter().map(|&x| is_vowel(x)).collect(); + let mut out = vec![]; + for i in 1..n { + let ok = if v[i - 1] && !v[i] { + (i + 1 < n && v[i + 1]) || (i + 2 < n && onset_pair(c[i], c[i + 1]) && v[i + 2]) + } else if !v[i - 1] && !v[i] { + i >= 2 && v[i - 2] && i + 1 < n && v[i + 1] && !onset_pair(c[i - 1], c[i]) && c[i - 1] != c[i] + || (c[i - 1] == c[i] && i >= 2 && v[i - 2]) + } else { + false + }; + let right: String = c[i..].iter().collect(); + let core = right.strip_suffix("ed").or_else(|| right.strip_suffix("es")).or_else(|| right.strip_suffix('e')).unwrap_or(&right); + if ok && core.chars().any(is_vowel) && core.len() > 1 && v[..i].iter().any(|x| *x) { + out.push(i); + } + } + out + } + + #[test] + fn heuristic_matches_building_each_suffix() { + for w in [ + "happen", + "typography", + "zzzzed", + "bakes", + "baked", + "flute", + "strengths", + "aeiou", + "rhythms", + "bookkeeper", + "naïveté", + "çabcde", + "ABCDE", + "plopped", + "staples", + "queued", + "ed", + "eded", + "abed", + "abes", + "cafés", + "aed", + "xaxed", + "xaxes", + "xaxe", + "ŝtupoe", + "ŝtupoed", + "ababababed", + ] { + let c: Vec = w.chars().collect(); + assert_eq!(heuristic_points(&c), heuristic_by_suffixes(&c), "{w}"); + } + } + + /// A very long word (no dictionary or pattern entry) takes time linear in its length. + #[test] + fn heuristic_on_a_long_word_is_linear() { + let c: Vec = "taco".repeat(100_000).chars().collect(); + let t = std::time::Instant::now(); + let pts = heuristic_points(&c); + assert!(!pts.is_empty()); + assert!(t.elapsed().as_secs() < 5, "{:?}", t.elapsed()); + } } diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 88becd85f..abac72985 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -268,6 +268,8 @@ pub struct Deco { pub path: Option, /// Shown on screen only (not printed or exported). pub nonprinting: bool, + /// Set to overprint (paragraph shading or border); PDF export doesn't overprint yet. + pub overprint: bool, } #[derive(Clone, Debug, Default)] @@ -579,8 +581,10 @@ pub struct FrameSpec { pub page_name: Option, /// Marker text of the section the frame's page is in (None on parent pages). pub section_marker: Option, - /// Absolute document page the frame is on (None on parent pages). + /// Absolute document page the frame is on (None on parent pages and the pasteboard). pub page: Option, + /// The frame is on a parent spread. + pub parent: bool, /// Baseline grid in inner space: (first grid line y, increment). pub grid: Option<(f64, f64)>, /// The frame is on a left page (for towards/away-from-spine alignment). @@ -815,6 +819,7 @@ pub(crate) fn frame_specs_placed(doc: &Document, sid: StoryId, placed: &[(ItemId page_name, section_marker, page, + parent: matches!(spread_ref, Some(designcraft_doc::SpreadRef::Parent(_))), grid, left_page, right_page, @@ -828,16 +833,31 @@ pub(crate) fn frame_specs_placed(doc: &Document, sid: StoryId, placed: &[(ItemId /// objects ([`wrap`]). /// Anchored frames are composed where their host story places them (unless `opts` places them). pub fn compose_story(doc: &Document, sid: StoryId, opts: &ComposeOptions) -> ComposedStory { - compose_story_at(doc, sid, opts, 0) + compose_story_at(doc, sid, opts, 0, &mut HashMap::new()) } -/// [`compose_story`] for a story `depth` hosts down from the story asked for. -fn compose_story_at(doc: &Document, sid: StoryId, opts: &ComposeOptions, depth: usize) -> ComposedStory { +/// [`compose_story`] for a story `depth` hosts down from the story asked for. `hosts` holds the +/// hosts already composed (by story and depth) while composing the story asked for, so a host +/// that many anchored frames lead to is composed once. +fn compose_story_at( + doc: &Document, + sid: StoryId, + opts: &ComposeOptions, + depth: usize, + hosts: &mut HashMap<(StoryId, usize), Arc>, +) -> ComposedStory { let others: Vec = wrap::neighbours(doc, sid).into_iter().map(|o| wrap::compose_among(doc, o, &ComposeOptions::default(), &[])).collect(); let others: Vec<&ComposedStory> = others.iter().collect(); if opts.placed.is_empty() && depth < placed::MAX_DEPTH { - let placed = placed::placements(doc, sid, &mut |host| Arc::new(compose_story_at(doc, host, &ComposeOptions::default(), depth + 1))); + let placed = placed::placements(doc, sid, &mut |host| { + if let Some(c) = hosts.get(&(host, depth + 1)) { + return c.clone(); + } + let c = Arc::new(compose_story_at(doc, host, &ComposeOptions::default(), depth + 1, hosts)); + hosts.insert((host, depth + 1), c.clone()); + c + }); if !placed.is_empty() { return wrap::compose_among(doc, sid, &ComposeOptions { placed, ..opts.clone() }, &others); } @@ -860,7 +880,7 @@ struct SubstitutionInput<'a> { frame: Option<&'a FrameSpec>, opts: &'a ComposeOptions, var_story: bool, - var_cache: &'a mut HashMap, Arc>>, + var_cache: &'a mut HashMap<(Option, bool), Arc>>, notes: &'a mut Notes, } @@ -868,7 +888,9 @@ fn paragraph_substitution(input: SubstitutionInput<'_>) -> SubstCtx { let SubstitutionInput { doc, story, range: prange, props: pp, frame: cur_frame, opts, var_story, var_cache, notes } = input; let var_values = if var_story { let page = opts.page.or_else(|| cur_frame.and_then(|f| f.page)); - var_cache.entry(page).or_insert_with(|| std::sync::Arc::new(vars::values(doc, page, opts.running.as_deref()))).clone() + // A parent page itself (not composed for one of the pages it applies to). + let parent = page.is_none() && cur_frame.is_some_and(|f| f.parent); + var_cache.entry((page, parent)).or_insert_with(|| std::sync::Arc::new(vars::values_shown(doc, page, parent, opts.running.as_deref()))).clone() } else { Default::default() }; @@ -930,18 +952,25 @@ fn line_has_wrapping_exclusion(frame: &FrameSpec, range: Range, column: R }) } -fn previous_visible_break(doc: &Document, story: &Story, ranges: &[Range], deleted: &[bool], pi: usize) -> Option { +/// The visible break ending the last placed paragraph before `pi`, which sends `pi` on. +pub fn previous_visible_break(doc: &Document, story: &Story, ranges: &[Range], deleted: &[bool], pi: usize) -> Option { (0..pi).rev().find(|&p| !deleted.get(p).copied().unwrap_or(false)).and_then(|p| { let range = ranges.get(p)?; - let (index, character) = story.text.get(range.clone())?.char_indices().next_back()?; - let start = break_start(character)?; - let (_, chars) = doc.styles.resolve_para(story.paras.get(p)?); - let props = doc.styles.resolve_char(&chars, story.char_format_at(range.start + index + character.len_utf8())); - let gone = props.change == designcraft_doc::ChangeMark::Deleted || doc.conditions_hide(&props.conditions); - (!gone).then_some(start) + let (index, _) = story.text.get(range.clone())?.char_indices().next_back()?; + visible_break(doc, story, p, range.start + index) }) } +/// The start option of the break at byte `at` in paragraph `p`, unless deleted or hidden. +pub fn visible_break(doc: &Document, story: &Story, p: usize, at: usize) -> Option { + let character = story.text.get(at..)?.chars().next()?; + let start = break_start(character)?; + let (_, chars) = doc.styles.resolve_para(story.paras.get(p)?); + let props = doc.styles.resolve_char(&chars, story.char_format_at(at + character.len_utf8())); + let gone = props.change == designcraft_doc::ChangeMark::Deleted || doc.conditions_hide(&props.conditions); + (!gone).then_some(start) +} + fn named_list_labels(doc: &Document, story: &Story, ranges: &[Range], deleted: &[bool]) -> Vec> { let mut counters: std::collections::HashMap = std::collections::HashMap::new(); story @@ -1099,7 +1128,7 @@ fn place_table_paragraph(input: TableParagraphInput<'_>) -> TableParagraphResult } let at_top = cursor.last_baseline.is_none(); let start_at = (cursor.fi, cursor.col); - if !table::place_table(doc, table, anchor, paragraph, props, frames, columns, cursor, out, opts) { + if !table::place_table(doc, table, anchor, paragraph, props, frames, columns, cursor, out, notes, opts) { if let Some(j) = trial_failed(trial, limits, *balance_runs < balance_budget) { *balance_runs += 1; rewind(j, snaps, note_snaps, notes, out, cursor, list_counter, force_col, line_cap, no_hyph); @@ -1803,6 +1832,8 @@ fn place_sub_objects( } } +/// Sets one paragraph's lines, column after column and frame after frame. It stops when the +/// paragraph ends, when the frames run out, or when an earlier paragraph has to be set again. fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLines<'_>) -> LinePlacement { let PlacementShared { doc, @@ -1835,7 +1866,7 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi let ParagraphLines { paragraph: pi, range: prange, - props: pp, + props: para_props, glyphs, hyph_after, drop_cap, @@ -1864,7 +1895,10 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi let mut hyphen_at: Option = None; let has_tabs = glyphs.iter().any(|g| g.ch == '\t'); let mut first_line_rect: Option<(usize, f64, f64, f64)> = None; // frame, baseline, ascent, x-span - let rtl = pp.direction == designcraft_doc::TextDirection::RightToLeft; + let rtl = para_props.direction == designcraft_doc::TextDirection::RightToLeft; + // The paragraph's settings with the start-side indent an Indent to Here character set for + // the lines after it (InDesign's ^i): those lines start where the character sits. + let mut indented: Option = None; // Paragraph gyoudori: the (frame, column) of the block and the grid line after it. let mut para_block: Option<((usize, usize), f64)> = None; // Segments of a line already placed (beside an object with text on both sides) that the @@ -1898,6 +1932,10 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi no_hyph, }); } + let pp = indented.as_ref().unwrap_or(para_props); + // The Indent to Here character sits past the drop cap, so the lines that start at it + // aren't indented by the drop cap again. + let beside_cap = drop_cap.filter(|_| indented.is_none()); let f = &frames[cur.fi]; let base = cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)]; let col = cur.clip(base); @@ -1923,7 +1961,7 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi props: pp, slots: &slots, column: col, - drop_cap, + drop_cap: beside_cap, line_no, col_first_line, rtl, @@ -1931,6 +1969,8 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi spacing, }); let mut moved = false; + // Set by a line with an Indent to Here character, for the lines broken again after it. + let mut reindented = None; for (k, b) in breaks.iter().enumerate() { let (s, e) = (g0 + b.start, g0 + b.end); let line_glyphs = &glyphs[s..e.max(s)]; @@ -2046,7 +2086,7 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi let col_w = notes.area(&cols[cur.fi], cur.col).map_or(0.0, |r| r.width()); let note_column = NoteColumn { frame_index: cur.fi, column_index: cur.col, width: col_w, frame: f }; // A drop cap sits at the start-side indent; the first-line indent doesn't move it. - let beside = drop_cap.filter(|dc| line_no < dc.lines && col_first_line == 0).map(|dc| dc.indent()); + let beside = beside_cap.filter(|dc| line_no < dc.lines && col_first_line == 0).map(|dc| dc.indent()); let first_line = if line_no == 0 { pp.first_line_indent } else { 0.0 }; let (ind_l, ind_r) = match beside { Some(w) if rtl => (pp.left_indent, pp.right_indent + w), @@ -2098,6 +2138,7 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi } ruby::annotate(db, styles_tab, &story.text, &mut placed, doc.settings.glyph_fallback, f.vertical); kenten::annotate(db, styles_tab, &mut placed, doc.settings.glyph_fallback); + let here_props = indent_here(&placed, pp, x0, x1, rtl); let range_end = if last { prange.end } else { glyphs.get(g0 + b.next).map(|g| g.byte).unwrap_or(prange.end) }; let range_start = glyphs.get(s).map(|g| g.byte).unwrap_or(prange.start).min(range_end); let range_start = if line_no == 0 { prange.start } else { range_start }; @@ -2156,6 +2197,10 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi hyphen_at = e.checked_sub(1).filter(|&i| b.hyphen && hyph_after.get(i) == Some(&true)).and_then(|i| glyphs.get(i)).map(|g| g.byte + g.len); line_no += 1; + // An Indent to Here character in this line moves the start of the lines after it: + // break them again with that indent (it holds into the next columns too). + let reindent = here_props.is_some(); + reindented = here_props.or(reindented); // A column / frame / page break inside the paragraph: the rest of it continues in the // new column/frame/page, re-broken there. (A break that ends the paragraph moves the // next paragraph instead.) @@ -2169,12 +2214,13 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi } // An own-story anchored object anchored in this line wraps the lines after it, // which were broken without it: break them again. - if k + 1 < breaks.len() && line_has_wrapping_exclusion(f, range_start..range_end, col, baseline) { + if k + 1 < breaks.len() && (reindent || line_has_wrapping_exclusion(f, range_start..range_end, col, baseline)) { g0 += b.next; moved = true; break; } } + indented = reindented.or(indented); if !moved { break; } @@ -2562,7 +2608,12 @@ fn finish_paragraph(input: ParagraphFinishInput<'_>) -> ParagraphFinish { .map(|l| if r.column_width { (l.x0, l.x1) } else { (l.x0, l.end_x) }) .unwrap_or((0.0, 0.0)); let (color, tint) = rule_color(r, above); - ft.decos.push(Deco { rect: Rect::new(col.0 + r.left_indent, y - r.weight, col.1 - r.right_indent, y), color, tint, ..Deco::default() }); + // Keep In Frame: not above the frame, as InDesign's (its top, not the text area's + // below the top inset). + let top = frames.get(fi).map_or(f64::NEG_INFINITY, |f| f.area.y0 - if f.vertical { 0.0 } else { f.opts.inset[0] }); + let y = if r.keep_in_frame && y - r.weight < top { top + r.weight } else { y }; + let rect = Rect::new(col.0 + r.left_indent, y - r.weight, col.1 - r.right_indent, y); + ft.decos.extend(rule_decos(doc, r, rect, (color, tint), above)); } } if pp.rule_below.on @@ -2573,7 +2624,7 @@ fn finish_paragraph(input: ParagraphFinishInput<'_>) -> ParagraphFinish { let y = l.baseline + r.offset; let (x0, x1) = if r.column_width { (l.x0, l.x1) } else { (l.x0, l.end_x) }; let (color, tint) = rule_color(r, below); - ft.decos.push(Deco { rect: Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), color, tint, ..Deco::default() }); + ft.decos.extend(rule_decos(doc, r, Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), (color, tint), below)); } // The end of a split block: text after it continues below its deepest sub-column, which // are balanced first. A block that ends the story fills its sub-columns in turn. @@ -2667,7 +2718,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let var_story = vars::has_vars(story); let mut notes = Notes::new(doc, story); let mut note_snaps: Vec<(u32, Option, usize)> = Vec::with_capacity(np); - let mut var_cache: std::collections::HashMap, std::sync::Arc>> = Default::default(); + let mut var_cache: std::collections::HashMap<(Option, bool), std::sync::Arc>> = Default::default(); // Column balancing above spanning paragraphs: settled and trial bottoms by band, the search // in progress, and a budget for the re-lays. let mut limits: Rc> = Rc::default(); @@ -3108,7 +3159,7 @@ fn finish_composition(input: FinishInput<'_>) -> ComposedStory { let before: Vec = ft.tables.iter().map(|t| ft.lines.get(t.line).map_or(0.0, |l| l.baseline)).collect(); let spanned = ft.columns.len() > 1 && ft.lines.iter().any(|l| span_paras.get(l.para) == Some(&true)) || ft.lines.iter().any(|l| split_paras.get(l.para) == Some(&true)); - vertical_justify(ft, f, spanned); + vertical_justify(ft, f, spanned, &doc.footnote_options); for (t, b) in ft.tables.iter_mut().zip(before) { let now = ft.lines.get(t.line).map_or(b, |l| l.baseline); t.shift(now - b); @@ -3289,15 +3340,22 @@ pub fn column_rule_rects(columns: &[Rect], lines: &[Line], opts: &TextFrameOptio continue; } // Vertical bands of the paragraphs whose lines cross this gutter. + // Grouped by paragraph through an index, in first-seen order: searching the bands for + // each line was quadratic in the number of paragraphs. let mut spans: Vec<(usize, f64, f64)> = Vec::new(); + let mut band_of: HashMap = HashMap::new(); for l in lines.iter().filter(|l| l.x0 < cx && l.x1 > cx) { let (t, z) = (l.baseline - l.ascent, l.baseline + l.descent); - match spans.iter_mut().find(|s| s.0 == l.para) { - Some(s) => { + match band_of.get(&l.para) { + Some(&k) => { + let s = &mut spans[k]; s.1 = s.1.min(t); s.2 = s.2.max(z); } - None => spans.push((l.para, t, z)), + None => { + band_of.insert(l.para, spans.len()); + spans.push((l.para, t, z)); + } } } spans.sort_by(|p, q| p.1.total_cmp(&q.1)); @@ -3483,8 +3541,8 @@ fn rewind( } /// Paragraphs that tracked changes delete: all their text is deleted, and so is their return or -/// the return before them (then they join a paragraph and add nothing to it). -fn deleted_paras(story: &Story, ranges: &[Range]) -> Vec { +/// the return before them (then they join a paragraph and add nothing to it). Composing skips them. +pub fn deleted_paras(story: &Story, ranges: &[Range]) -> Vec { let mut spans: Vec> = Vec::new(); for (r, f) in story.runs() { if f.over.change != Some(designcraft_doc::ChangeMark::Deleted) { @@ -4930,6 +4988,34 @@ fn distribute(line: &[Glyph], spaces: &[usize], extra: f64, sp: &Spacing, add: & } } +/// The paragraph settings for the lines after the `placed` line when it holds an Indent to Here +/// character (the last one, if several): the start-side indent reaches the character, measured +/// from the start edge of the line's segment `x0..x1`. Like the paragraph's own indents it then +/// applies from each later line's segment, so beside a text wrap it starts at the wrap's edge. +/// `None` when there is none or the indent is unchanged. +fn indent_here(placed: &[PlacedGlyph], pp: &designcraft_doc::ParaProps, x0: f64, x1: f64, rtl: bool) -> Option { + // The placed glyphs are in the line's order (bidi only moves them): the drop cap's, then a + // list label's, which is set beside the drop cap, then the text's. A character in the drop + // cap or the label counts too, and one in the label comes after one in the drop cap. + let x = placed.iter().rev().find(|g| g.rendered_char == story::INDENT_HERE)?.x; + let indent = if rtl { x1 - x } else { x - x0 }; + if !indent.is_finite() { + return None; + } + let indent = indent.max(0.0); + let current = if rtl { pp.right_indent } else { pp.left_indent }; + if (indent - current).abs() < 0.01 { + return None; + } + let mut props = pp.clone(); + if rtl { + props.right_indent = indent; + } else { + props.left_indent = indent; + } + Some(props) +} + fn place(g: &Glyph, x: f64) -> PlacedGlyph { let visible = !(g.ch == '\t' || g.ch == '\n' @@ -5240,6 +5326,40 @@ fn suppress_hyphens(hy: &mut [bool], glyphs: &[Glyph], at: &[usize]) { } } +/// Hyphenation points of `word`, remembered per set of limits: words repeat a lot. The points of +/// a word not seen yet are worked out without holding the cache's lock, so a long word in one +/// paragraph doesn't hold up hyphenation in others. +fn cached_hyphen_points(lim: &hyphen::Limits, lang: hyphen::Lang, word: &str) -> Arc<[usize]> { + type Words = HashMap<(hyphen::Lang, Box), Arc<[usize]>>; + static CACHE: Mutex> = Mutex::new(Vec::new()); + let key = (lim.min_word, lim.after_first, lim.before_last, lim.capitalized); + let word_key = (lang, Box::::from(word)); + { + let guard = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(pts) = guard.iter().find(|e| e.0 == key).and_then(|e| e.1.get(&word_key)) { + return pts.clone(); + } + } + let pts: Arc<[usize]> = hyphen::hyphen_points_in(word, lim, lang).into(); + let mut guard = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let idx = match guard.iter().position(|e| e.0 == key) { + Some(i) => i, + None => { + if guard.len() > 32 { + guard.clear(); + } + guard.push((key, HashMap::new())); + guard.len() - 1 + } + }; + let cache = &mut guard[idx].1; + if cache.len() > 100_000 { + cache.clear(); + } + cache.insert(word_key, pts.clone()); + pts +} + fn hyphenation_points( text: &str, glyphs: &[Glyph], @@ -5257,24 +5377,6 @@ fn hyphenation_points( before_last: pp.hyph_before_last as usize, capitalized: pp.hyph_capitalized, }; - // Words repeat a lot: remember their points per set of limits. - static CACHE: Mutex), Box<[usize]>>)>> = Mutex::new(Vec::new()); - let key = (lim.min_word, lim.after_first, lim.before_last, lim.capitalized); - let mut guard = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - let idx = match guard.iter().position(|e| e.0 == key) { - Some(i) => i, - None => { - if guard.len() > 32 { - guard.clear(); - } - guard.push((key, HashMap::new())); - guard.len() - 1 - } - }; - let cache = &mut guard[idx].1; - if cache.len() > 100_000 { - cache.clear(); - } let mut i = 0; while i < glyphs.len() { if !glyphs[i].is_letter() || glyphs[i].len == 0 { @@ -5300,16 +5402,29 @@ fn hyphenation_points( let lang = foreign.iter().find(|(r, _)| r.contains(&a)).map_or(Some(hyphen::Lang::English), |(_, l)| *l); if !is_last_word && let Some(lang) = lang { let user = (!exceptions.is_empty()).then(|| exceptions.get(&word.to_lowercase())).flatten(); + let cached; let pts: &[usize] = match user { // User dictionary exceptions win over the patterns and the built-in list. Some(v) => v, - None => cache.entry((lang, word.into())).or_insert_with(|| hyphen::hyphen_points_in(word, &lim, lang).into_boxed_slice()), + None => { + cached = cached_hyphen_points(&lim, lang, word); + &cached + } }; - for &p in pts.iter() { - // char index p → byte → glyph whose cluster ends at that byte. - let byte = a + word.char_indices().nth(p).map(|(bi, _)| bi).unwrap_or(word.len()); - if let Some(k) = (i..j).find(|&k| glyphs[k].byte + glyphs[k].len == byte && glyphs[k].len > 0) { - out[k] = true; + if !pts.is_empty() { + // char index p → byte → the first glyph whose cluster ends at that byte. Both + // indexed once per word: looking each point up by scanning was quadratic in + // a long word's length. + let char_bytes: Vec = word.char_indices().map(|(bi, _)| bi).collect(); + let mut glyph_ending_at: HashMap = HashMap::new(); + for k in (i..j).filter(|&k| glyphs[k].len > 0) { + glyph_ending_at.entry(glyphs[k].byte + glyphs[k].len).or_insert(k); + } + for &p in pts.iter() { + let byte = a + char_bytes.get(p).copied().unwrap_or(word.len()); + if let Some(&k) = glyph_ending_at.get(&byte) { + out[k] = true; + } } } } @@ -5319,8 +5434,57 @@ fn hyphenation_points( out } +/// A paragraph rule drawn in `rect` (its length and weight) with its Type: the gap colour under +/// it, then the line, as dashes, dots, stripes, waves or hash marks. A pattern that can't be laid +/// out along the rule draws solid. +/// `(color, tint)` is the rule's (Text Color already resolved); `text`, the colour of the text +/// the rule goes with, for a gap in Text Color. +fn rule_decos(doc: &Document, r: &designcraft_doc::Rule, rect: Rect, (color, tint): (String, f32), text: &(String, f32)) -> Vec { + use designcraft_doc::StrokeType; + let kind = doc.stroke_kind(&r.kind); + let (w, len) = (rect.height(), rect.width()); + let mut out = Vec::new(); + if !matches!(kind, StrokeType::Solid) && r.gap_color != designcraft_color::swatch::NONE && w > 0.0 && len > 0.0 { + let gap = if r.gap_color == designcraft_doc::TEXT_COLOR { text.0.clone() } else { r.gap_color.clone() }; + out.push(Deco { rect, color: gap, tint: r.gap_tint, ..Deco::default() }); + } + let y = rect.center().y; + let mut line = designcraft_geom::BezPath::new(); + line.move_to((rect.x0, y)); + line.line_to((rect.x1, y)); + let path = match &kind { + StrokeType::Dashed { pattern } if StrokeType::expandable_dashes(pattern, len) => { + let mut p = designcraft_geom::BezPath::new(); + let (mut x, mut k, mut on) = (rect.x0, 0, true); + while x < rect.x1 { + let step = pattern[k % pattern.len()]; + if on && step > 0.0 { + let r = Rect::new(x, rect.y0, (x + step).min(rect.x1), rect.y1); + p.extend(designcraft_geom::Shape::path_elements(&r, 0.1)); + } + x += step; + k += 1; + on = !on; + } + Some(p) + } + StrokeType::Dotted if StrokeType::expandable_dashes(&[w, w], len) => { + let mut p = designcraft_geom::BezPath::new(); + let mut x = rect.x0 + w / 2.0; + while x <= rect.x1 - w / 2.0 + 1e-9 { + p.extend(designcraft_geom::Shape::path_elements(&designcraft_geom::kurbo::Circle::new((x, y), w / 2.0), 0.1)); + x += 2.0 * w; + } + Some(p) + } + k => k.outline(&line, w, 0.05), + }; + out.push(Deco { rect, color, tint, path, ..Deco::default() }); + out +} + /// `spanned`: a paragraph spans the frame's columns, so the lines move together. -fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool) { +fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool, notes: &designcraft_doc::FootnoteOptions) { if ft.lines.is_empty() || f.opts.vertical_justification == VerticalJustification::Top { return; } @@ -5333,11 +5497,19 @@ fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool) { let idx: Vec = ft.lines.iter().enumerate().filter(|(_, l)| spanned || l.column == c).map(|(i, _)| i).collect(); let (Some(&a), Some(_)) = (idx.first(), idx.last()) else { continue }; let bottom = idx.iter().map(|&i| ft.lines[i].baseline + ft.lines[i].descent).fold(f64::NEG_INFINITY, f64::max); - let space = f.area.y1 - bottom; + let bottom = ft.tables.iter().filter(|t| spanned || t.column == c).map(|t| t.rect.y1).fold(bottom, f64::max); + let limit = ft + .notes + .iter() + .filter(|n| spanned || notes.span_columns || n.column == c) + .map(|n| n.rect.y0 - notes.space_before) + .fold(f.area.y1, f64::min); + let space = limit - bottom; if space <= 0.0 { continue; } let _ = a; + let before: Vec = idx.iter().map(|&i| ft.lines[i].baseline).collect(); match f.opts.vertical_justification { VerticalJustification::Center => idx.iter().for_each(|&i| ft.lines[i].baseline += space / 2.0), VerticalJustification::Bottom => idx.iter().for_each(|&i| ft.lines[i].baseline += space), @@ -5356,14 +5528,39 @@ fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool) { .collect(); let n = rows.last().copied().unwrap_or(0); if n > 0 { - let per = space / n as f64; + // Paragraph Spacing Limit: the space goes between paragraphs, up to the limit + // each; what is left spreads over every line (all of it with no limit). + let mut starts = vec![false; n + 1]; for (&i, &k) in idx.iter().zip(&rows) { - ft.lines[i].baseline += per * k as f64; + if k > 0 && ft.lines[i].first_in_para { + starts[k] = true; + } + } + let gaps = starts.iter().filter(|s| **s).count(); + let limit = f.opts.vj_paragraph_spacing_limit.max(0.0); + let para = if gaps > 0 && limit > 0.0 { (space / gaps as f64).min(limit) } else { 0.0 }; + let per = (space - para * gaps as f64) / n as f64; + let mut shift = vec![0.0; n + 1]; + for k in 1..=n { + shift[k] = shift[k - 1] + per + if starts[k] { para } else { 0.0 }; + } + for (&i, &k) in idx.iter().zip(&rows) { + ft.lines[i].baseline += shift[k]; } } } _ => {} } + // A drop cap moves with its line. + for (&i, b) in idx.iter().zip(before) { + let line = &mut ft.lines[i]; + let dy = line.baseline - b; + if let Some(dc) = &mut line.drop_cap { + dc.baseline += dy; + dc.rect.y0 += dy; + dc.rect.y1 += dy; + } + } } } @@ -5881,6 +6078,7 @@ mod column_allocation_safety_tests { page_name: None, section_marker: None, page: None, + parent: false, grid: None, left_page: false, right_page: false, diff --git a/crates/compose/src/notes.rs b/crates/compose/src/notes.rs index a385a9093..67f922637 100644 --- a/crates/compose/src/notes.rs +++ b/crates/compose/src/notes.rs @@ -14,7 +14,7 @@ //! With Span Footnotes Across Columns the notes of a frame share one area across all its columns, //! composed at the frame's text width: every column of the frame makes room for it. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::ops::Range; use std::sync::Arc; @@ -146,11 +146,13 @@ impl Notes { } /// Footnote indices referenced by a line's glyphs. + /// In the order they first appear; a set keeps a line with many references linear. pub fn refs_in(&self, glyphs: &[Glyph]) -> Vec { let mut v: Vec = Vec::new(); + let mut seen = HashSet::new(); for g in glyphs.iter().filter(|g| g.ch == FOOTNOTE_REF && g.len > 0) { if let Ok(k) = self.refs.binary_search(&g.byte) - && !v.contains(&k) + && seen.insert(k) { v.push(k); } @@ -176,6 +178,7 @@ impl Notes { page_name: f.page_name.clone(), section_marker: f.section_marker.clone(), page: f.page, + parent: f.parent, grid: None, left_page: f.left_page, right_page: f.right_page, diff --git a/crates/compose/src/overlay.rs b/crates/compose/src/overlay.rs index b09ef908e..6cd3df72f 100644 --- a/crates/compose/src/overlay.rs +++ b/crates/compose/src/overlay.rs @@ -2,7 +2,7 @@ //! through or up to a delimiter) and GREP styles (every match of a pattern). use std::cell::RefCell; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::ops::Range; use designcraft_doc::{GrepStyle, NestedStyle, NestedUntil}; @@ -47,7 +47,9 @@ fn with_pattern(pattern: &str, f: impl FnOnce(&mut Option) -> R) -> R { }) } -fn is_event(u: &NestedUntil, c: char, prev: Option, next: Option) -> bool { +/// Whether `c` ends one unit of `u`. `delims` is the character set of a [`NestedUntil::Chars`] +/// delimiter, indexed once per nested style. +fn is_event(u: &NestedUntil, delims: &HashSet, c: char, prev: Option, next: Option) -> bool { match u { NestedUntil::Sentences => matches!(c, '.' | '!' | '?') && next.is_none_or(char::is_whitespace), NestedUntil::Words => c.is_whitespace() && prev.is_some_and(|p| !p.is_whitespace()), @@ -60,7 +62,7 @@ fn is_event(u: &NestedUntil, c: char, prev: Option, next: Option) -> NestedUntil::EnSpace => c == '\u{2002}', // Counted by position, not by character (see `overlays`). NestedUntil::Dropcap => false, - NestedUntil::Chars(s) => s.contains(c), + NestedUntil::Chars(_) => delims.contains(&c), } } @@ -81,18 +83,25 @@ pub(crate) fn overlays(text: &str, range: Range, nested: &[NestedStyle], // Through the drop cap runs to its end; up to it (it starts the paragraph) is nothing. stop = if ns.through { drop_end.max(pos).min(end) } else { pos }; } else { + // Streamed up to the delimiter, with one character of lookahead: copying the rest of + // the paragraph for every nested style multiplied its length by the number of styles. + let delims: HashSet = match &ns.until { + NestedUntil::Chars(s) => s.chars().collect(), + _ => HashSet::new(), + }; let mut count = 0; - let chars: Vec<(usize, char)> = text[pos..end].char_indices().map(|(i, c)| (pos + i, c)).collect(); - for (k, &(i, c)) in chars.iter().enumerate() { - let prev = if k > 0 { Some(chars[k - 1].1) } else { None }; - let next = chars.get(k + 1).map(|x| x.1); - if is_event(&ns.until, c, prev, next) { + let mut prev = None; + let mut chars = text[pos..end].char_indices().map(|(i, c)| (pos + i, c)).peekable(); + while let Some((i, c)) = chars.next() { + let next = chars.peek().map(|x| x.1); + if is_event(&ns.until, &delims, c, prev, next) { count += 1; if count >= ns.count.max(1) { stop = if ns.through { i + c.len_utf8() } else { i }; break; } } + prev = Some(c); } } if stop > pos && !ns.style.is_empty() { @@ -161,6 +170,19 @@ mod tests { assert!(has_last, "the most recently used pattern is still cached"); } + /// Nested styles that stop where they start (up to a delimiter the paragraph starts with) + /// read one character each, not the rest of the paragraph. + #[test] + fn nested_styles_stopping_at_once_do_not_rescan_the_paragraph() { + let text = format!("x{}\n", "y".repeat(200_000)); + let delim: String = ('a'..='z').chain('A'..='Z').filter(|c| *c != 'y').collect(); + let rule = NestedStyle { style: "S".into(), through: false, count: 1, until: NestedUntil::Chars(delim) }; + let nested = vec![rule; 5000]; + let t = std::time::Instant::now(); + assert!(overlays(&text, 0..text.len(), &nested, &[], 0).is_empty(), "each style styles nothing"); + assert!(t.elapsed().as_secs() < 5, "{:?}", t.elapsed()); + } + #[test] fn nested_and_grep_ranges() { let text = "Q: what is 42? A drop of 7 words.\n".to_string(); diff --git a/crates/compose/src/parabox.rs b/crates/compose/src/parabox.rs index f6b90e127..4a5b0e8d1 100644 --- a/crates/compose/src/parabox.rs +++ b/crates/compose/src/parabox.rs @@ -32,6 +32,7 @@ struct Shading { bottom: BoxBottom, clip: bool, nonprinting: bool, + overprint: bool, } #[derive(Clone, Debug, PartialEq)] @@ -51,6 +52,8 @@ struct Border { display_if_splits: bool, gap_color: String, gap_tint: f32, + overprint: bool, + gap_overprint: bool, } fn finite(v: f64) -> f64 { @@ -72,6 +75,7 @@ impl ParaBox { bottom: pp.shading_bottom, clip: pp.shading_clip, nonprinting: pp.shading_nonprinting, + overprint: pp.shading_overprint, }); let border = pp.border_on.then(|| Border { color: pp.border_color.clone(), @@ -88,6 +92,8 @@ impl ParaBox { display_if_splits: pp.border_display_if_splits, gap_color: pp.border_gap_color.clone(), gap_tint: pp.border_gap_tint, + overprint: pp.border_overprint, + gap_overprint: pp.border_gap_overprint, }); Some(ParaBox { shading, border, merge: pp.border_merge }) } @@ -136,7 +142,7 @@ fn shading_deco(s: &Shading, part: &Part<'_>, frames: &[FrameSpec], first_part: // Corners stay square where the box is cut by a split. let corners = cut_corners(s.corners, !first_part, !last_part); let path = (!corners.iter().all(Corner::is_none)).then(|| box_outline(rect, &corners, [true; 4])); - return Some(Deco { rect, color: s.color.clone(), tint: s.tint, path, nonprinting: s.nonprinting }); + return Some(Deco { rect, color: s.color.clone(), tint: s.tint, path, nonprinting: s.nonprinting, overprint: s.overprint }); } None } @@ -149,15 +155,14 @@ fn border_decos(bd: &Border, part: &Part<'_>, first_part: bool, last_part: bool, let open_top = !first_part && !bd.display_if_splits; let open_bottom = !last_part && !bd.display_if_splits; for (path, gap) in border_paths(bd, o, open_top, open_bottom) { - let (color, tint) = if gap { (bd.gap_color.clone(), bd.gap_tint) } else { (bd.color.clone(), bd.tint) }; - decos.push((part.fi, false, Deco { rect: path.bounding_box(), color, tint, path: Some(path), nonprinting: false })); + let (color, tint, overprint) = + if gap { (bd.gap_color.clone(), bd.gap_tint, bd.gap_overprint) } else { (bd.color.clone(), bd.tint, bd.overprint) }; + decos.push((part.fi, false, Deco { rect: path.bounding_box(), color, tint, path: Some(path), nonprinting: false, overprint })); } if is_plain(bd, open_top, open_bottom) { - decos.extend( - plain_edges(bd, o, open_top, open_bottom) - .into_iter() - .map(|rect| (part.fi, false, Deco { rect, color: bd.color.clone(), tint: bd.tint, path: None, nonprinting: false })), - ); + decos.extend(plain_edges(bd, o, open_top, open_bottom).into_iter().map(|rect| { + (part.fi, false, Deco { rect, color: bd.color.clone(), tint: bd.tint, path: None, nonprinting: false, overprint: bd.overprint }) + })); } } @@ -353,10 +358,10 @@ fn stroke_outline(path: &BezPath, w: f64, kind: &StrokeType, cap: Cap, join: Joi let p: Vec = pattern.iter().copied().filter(|v| v.is_finite() && *v >= 0.0).collect(); Some(if p.iter().sum::() > 1e-3 { p } else { vec![3.0 * w, 2.0 * w] }) } - StrokeType::Dotted => { - st = st.with_caps(kurbo::Cap::Round); - Some(vec![0.0, 2.0 * w]) - } + StrokeType::Dotted => match dots(path, w) { + Some(d) => return d, + None => None, + }, _ => None, }; if let Some(d) = dashes { @@ -367,3 +372,142 @@ fn stroke_outline(path: &BezPath, w: f64, kind: &StrokeType, cap: Cap, join: Joi } kurbo::stroke(path.iter(), &st, &kurbo::StrokeOpts::default(), TOL) } + +/// Round dots `w` wide along `path`, about `2w` apart and evenly spaced by arc length: around a +/// closed subpath the spacing divides its length exactly, an open one has a dot at each end. +/// `None` (stroke solid) when there would be more than [`MAX_DASHES`] dots. +fn dots(path: &BezPath, w: f64) -> Option { + use kurbo::{ParamCurve, ParamCurveArclen, PathEl, PathSeg}; + // Subpaths as (segments, closed). + let mut subs: Vec<(Vec, bool)> = Vec::new(); + let (mut start, mut cur) = (kurbo::Point::ZERO, kurbo::Point::ZERO); + for el in path.elements() { + let seg = match *el { + PathEl::MoveTo(p) => { + subs.push((Vec::new(), false)); + (start, cur) = (p, p); + continue; + } + PathEl::LineTo(p) => PathSeg::Line(kurbo::Line::new(cur, p)), + PathEl::QuadTo(a, p) => PathSeg::Quad(kurbo::QuadBez::new(cur, a, p)), + PathEl::CurveTo(a, b, p) => PathSeg::Cubic(kurbo::CubicBez::new(cur, a, b, p)), + PathEl::ClosePath => { + if let Some(s) = subs.last_mut() { + s.1 = true; + if cur.distance(start) > 1e-9 { + s.0.push(PathSeg::Line(kurbo::Line::new(cur, start))); + } + } + cur = start; + continue; + } + }; + cur = seg.end(); + if let Some(s) = subs.last_mut() { + s.0.push(seg); + } + } + const ACC: f64 = 1e-4; + let mut out = BezPath::new(); + let mut count = 0.0; + for (segs, closed) in subs { + let lens: Vec = segs.iter().map(|s| s.arclen(ACC)).collect(); + let total: f64 = lens.iter().sum(); + if !(total.is_finite() && total > 1e-9) { + continue; + } + let n = (total / (2.0 * w)).round().max(1.0); + count += n + 1.0; + if count > MAX_DASHES { + return None; + } + let step = total / n; + let dots = if closed { n as usize } else { n as usize + 1 }; + let (mut k, mut before) = (0, 0.0); + for i in 0..dots { + let s = i as f64 * step; + while k + 1 < segs.len() && before + lens.get(k).copied().unwrap_or(0.0) < s { + before += lens.get(k).copied().unwrap_or(0.0); + k += 1; + } + let (Some(seg), Some(&l)) = (segs.get(k), lens.get(k)) else { continue }; + let t = if l > 1e-12 { seg.inv_arclen((s - before).clamp(0.0, l), ACC) } else { 0.0 }; + out.extend(kurbo::Circle::new(seg.eval(t), w / 2.0).path_elements(TOL)); + } + } + Some(out) +} + +#[cfg(test)] +mod dotted_tests { + use super::*; + + /// The centres of the dots (one subpath each) of a dotted outline. + fn dot_centres(p: &BezPath) -> Vec { + let mut out = Vec::new(); + let mut cur = BezPath::new(); + for el in p.elements() { + if matches!(el, kurbo::PathEl::MoveTo(_)) && !cur.elements().is_empty() { + out.push(cur.bounding_box().center()); + cur = BezPath::new(); + } + cur.push(*el); + } + if !cur.elements().is_empty() { + out.push(cur.bounding_box().center()); + } + out + } + + /// Distance along the outline of `r`, clockwise from its top left corner, of point `q` on it. + fn along(r: Rect, q: kurbo::Point) -> f64 { + let (w, h) = (r.width(), r.height()); + if (q.y - r.y0).abs() < 1e-6 { + q.x - r.x0 + } else if (q.x - r.x1).abs() < 1e-6 { + w + q.y - r.y0 + } else if (q.y - r.y1).abs() < 1e-6 { + w + h + r.x1 - q.x + } else { + 2.0 * w + h + r.y1 - q.y + } + } + + fn assert_even(gaps: &[f64], expected: f64) { + for g in gaps { + assert!((g - expected).abs() < 0.05, "uneven dot gaps {gaps:?}, expected {expected}"); + } + } + + #[test] + fn dotted_box_border_dots_are_evenly_spaced() { + let r = Rect::new(0.0, 0.0, 100.3, 37.7); + let path = box_outline(r, &[Corner::default(); 4], [true; 4]); + let len = 2.0 * (r.width() + r.height()); + let out = stroke_outline(&path, 1.0, &StrokeType::Dotted, Cap::Butt, Join::Miter, len); + let mut s: Vec = dot_centres(&out).into_iter().map(|q| along(r, q)).collect(); + s.sort_by(f64::total_cmp); + let n = (len / 2.0).round() as usize; + assert_eq!(s.len(), n, "dot count"); + let mut gaps: Vec = s.windows(2).map(|p| p[1] - p[0]).collect(); + gaps.push(s[0] + len - s[n - 1]); + assert_even(&gaps, len / n as f64); + } + + #[test] + fn dotted_edge_dots_are_evenly_spaced_and_reach_both_ends() { + for (w, l) in [(1.0, 50.3), (2.5, 37.7), (0.7, 100.3)] { + let mut p = BezPath::new(); + p.move_to((0.0, 0.0)); + p.line_to((l, 0.0)); + let out = stroke_outline(&p, w, &StrokeType::Dotted, Cap::Butt, Join::Miter, l); + let mut xs: Vec = dot_centres(&out).into_iter().map(|q| q.x).collect(); + xs.sort_by(f64::total_cmp); + let n = (l / (2.0 * w)).round() as usize; + assert_eq!(xs.len(), n + 1, "dot count for w {w}, length {l}"); + assert!(xs[0].abs() < 0.05 && (xs[n] - l).abs() < 0.05, "ends {xs:?}"); + let gaps: Vec = xs.windows(2).map(|p| p[1] - p[0]).collect(); + assert_even(&gaps, l / n as f64); + } + } +} diff --git a/crates/compose/src/placed.rs b/crates/compose/src/placed.rs index 200ff6e3c..33e4ff73c 100644 --- a/crates/compose/src/placed.rs +++ b/crates/compose/src/placed.rs @@ -4,6 +4,7 @@ //! own story is composed against that placement (page, page side, page rect, baseline grid), so //! the host story is composed first. +use std::collections::HashMap; use std::sync::Arc; use designcraft_doc::{Document, Item, ItemId, SpreadRef, StoryId}; @@ -23,10 +24,12 @@ pub struct Placement { pub xf: Affine, } -/// The placements of `sid`'s anchored frames, from their host stories composed by `host`. +/// The placements of `sid`'s anchored frames, from their host stories composed by `host`. Each +/// host is asked for once, however many of the frames it holds. pub(crate) fn placements(doc: &Document, sid: StoryId, host: &mut dyn FnMut(StoryId) -> Arc) -> Vec<(ItemId, Placement)> { let Some(st) = doc.story(sid) else { return Vec::new() }; let mut anchored = None; + let mut hosts: HashMap> = HashMap::new(); let mut out = Vec::new(); for &fid in &st.frames { if doc.find(fid).is_some() { @@ -35,7 +38,8 @@ pub(crate) fn placements(doc: &Document, sid: StoryId, host: &mut dyn FnMut(Stor let items = anchored.get_or_insert_with(|| doc.anchored_items()); // A frame anchored in its own story would need its own composition to place it. let Some(owner) = items.iter().find_map(|(owner, it)| (it.id == fid).then_some(*owner)).filter(|o| *o != sid) else { continue }; - if let Some(p) = locate(doc, &host(owner), fid) { + let composed = hosts.entry(owner).or_insert_with(|| host(owner)); + if let Some(p) = locate(doc, composed, fid) { out.push((fid, p)); } } diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index d972cad30..4ce7247d5 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -385,6 +385,7 @@ fn resolve_pieces(input: &ShapeInput<'_, '_>, table: &mut StyleTable<'_>) -> (Ve overlays.push((range.start..drop_cap.0, s.to_string())); } // Each run, cut where nested / GREP styles start and end. + let bands = overlay_bands(&overlays); let mut segments: Vec<(usize, usize, Option<&str>, &designcraft_doc::CharFormat)> = Vec::new(); for (rr, fmt) in story.runs() { let a = rr.start.max(range.start); @@ -396,14 +397,16 @@ fn resolve_pieces(input: &ShapeInput<'_, '_>, table: &mut StyleTable<'_>) -> (Ve segments.push((a, b, None, fmt)); continue; } - let mut cuts: Vec = overlays.iter().flat_map(|(r, _)| [r.start, r.end]).filter(|x| *x > a && *x < b).collect(); - cuts.push(a); - cuts.push(b); - cuts.sort_unstable(); - cuts.dedup(); - for w in cuts.windows(2) { - let top = overlays.iter().rev().find(|(r, _)| r.start <= w[0] && w[0] < r.end).map(|(_, s)| s.as_str()); - segments.push((w[0], w[1], top, fmt)); + // The band holding `a`, then each following band up to `b`. Bands start at 0, so one + // holds `a`, and each ends where the next starts (the last one at `b`). + let mut k = bands.partition_point(|(at, _)| *at <= a).saturating_sub(1); + let mut at = a; + while at < b { + let to = bands.get(k + 1).map_or(b, |(next, _)| (*next).min(b)); + let top = bands.get(k).and_then(|(_, top)| *top).map(|i| overlays[i].1.as_str()); + segments.push((at, to, top, fmt)); + at = to; + k += 1; } } // Resolve every segment, then shape runs of segments that differ only in how they are @@ -1201,7 +1204,12 @@ fn shape_segment( for (gi, sg) in shaped.iter().enumerate() { let up = upright.get(gi).copied().unwrap_or(false); let (byte, len, ch) = if replacement.is_some() { - (range.start, if gi == 0 { range.len() } else { 0 }, text[range.start..].chars().next().unwrap_or(' ')) + let marker = text[range.start..].chars().next().unwrap_or(' '); + // A cross-reference's text is real text in InDesign: its spaces are word spaces the + // line may break at (text variables and page numbers stay whole). The first glyph + // keeps the marker, as it owns the marker's bytes. + let space = gi > 0 && marker == designcraft_doc::XREF_MARK && src.get(sg.cluster..).is_some_and(|t| t.starts_with(' ')); + (range.start, if gi == 0 { range.len() } else { 0 }, if space { ' ' } else { marker }) } else { let cl = range.start + sg.cluster; let end = shaped[gi + 1..].iter().map(|r| range.start + r.cluster).find(|&c| c > cl).unwrap_or(range.end); @@ -1421,3 +1429,69 @@ fn digit_mode(p: &designcraft_doc::CharProps, arabic_context: Option) -> d _ => Digits::Native, } } + +/// The stretches between `overlays`' boundaries, as `(start, overlay on top)` sorted by start: +/// a stretch runs to the next one's start, and the one on top is the last overlay covering it +/// (later ones win). One sweep over the boundaries; picking the top overlay by scanning them all +/// for each stretch was quadratic when a GREP style matched every character. +fn overlay_bands(overlays: &[(std::ops::Range, String)]) -> Vec<(usize, Option)> { + let mut starts: Vec<(usize, usize)> = Vec::new(); + let mut ends: Vec<(usize, usize)> = Vec::new(); + for (i, (r, _)) in overlays.iter().enumerate().filter(|(_, (r, _))| r.start < r.end) { + starts.push((r.start, i)); + ends.push((r.end, i)); + } + starts.sort_unstable(); + ends.sort_unstable(); + let mut cuts: Vec = starts.iter().chain(&ends).map(|x| x.0).collect(); + cuts.push(0); + cuts.sort_unstable(); + cuts.dedup(); + let mut active = std::collections::BTreeSet::new(); + let (mut s, mut e) = (0, 0); + let mut out = Vec::with_capacity(cuts.len()); + for at in cuts { + while e < ends.len() && ends[e].0 <= at { + active.remove(&ends[e].1); + e += 1; + } + while s < starts.len() && starts[s].0 <= at { + active.insert(starts[s].1); + s += 1; + } + out.push((at, active.last().copied())); + } + out +} + +#[cfg(test)] +mod tests { + use super::overlay_bands; + + /// The band sweep picks the same top overlay as checking every overlay at each boundary. + #[test] + fn overlay_bands_match_the_last_covering_overlay() { + let overlays: Vec<(std::ops::Range, String)> = [(2..9, "a"), (0..4, "b"), (4..4, "empty"), (3..6, "c"), (8..12, "d"), (5..7, "e")] + .into_iter() + .map(|(r, s)| (r, s.to_string())) + .collect(); + let bands = overlay_bands(&overlays); + for at in 0..14 { + let k = bands.partition_point(|(start, _)| *start <= at) - 1; + let want = overlays.iter().rposition(|(r, _)| r.start <= at && at < r.end); + assert_eq!(bands[k].1, want, "at {at}"); + } + } + + /// One overlay per character (a GREP style matching every character) sweeps in one pass. + #[test] + fn many_overlays_are_banded_quickly() { + let n = 200_000; + let overlays: Vec<(std::ops::Range, String)> = (0..n).map(|i| (i..i + 1, "s".to_string())).collect(); + let t = std::time::Instant::now(); + let bands = overlay_bands(&overlays); + assert_eq!(bands.len(), n + 1); + assert_eq!(bands[n / 2], (n / 2, Some(n / 2))); + assert!(t.elapsed().as_secs() < 5); + } +} diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs index c620622a3..5811887ae 100644 --- a/crates/compose/src/table.rs +++ b/crates/compose/src/table.rs @@ -5,6 +5,7 @@ //! cell (or its fixed height); rows that don't fit continue in the next column / frame, repeating //! header (and footer) rows. Rows tied together by row spans never split. +use std::collections::HashMap; use std::sync::Arc; use designcraft_doc::{Align, Cell, CellStroke, Document, ItemId, ParaProps, RowHeightMode, Table, TextFrameOptions, VerticalJustification}; @@ -122,7 +123,7 @@ impl CellLineExtent { /// Compose a cell story at width `w` (content space, top-left at the origin). fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, (left_page, right_page): (bool, bool)) -> CellLayout { - let spec = FrameSpec { + let mut spec = FrameSpec { id: ItemId(0), area: Rect::new(0.0, 0.0, w.max(1.0), 1.0e6), opts: TextFrameOptions { first_baseline: cell.first_baseline, first_baseline_min: cell.first_baseline_min, ..TextFrameOptions::default() }, @@ -132,12 +133,21 @@ fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, (lef page_name: opts.page_name.clone(), section_marker: opts.section_marker.clone(), page: opts.page, + parent: false, grid: None, left_page, right_page, page_rect: None, }; - let cs = crate::compose(doc, &cell.text, std::slice::from_ref(&spec), opts); + let mut cs = crate::compose(doc, &cell.text, std::slice::from_ref(&spec), opts); + // The measuring frame is deliberately tall. Recompose at the actual content height so + // cell-local footnotes sit inside the row rather than at the measuring frame's bottom. + if let Some(frame) = cs.frames.first().filter(|f| !f.notes.is_empty()) { + let body = frame.lines.iter().map(|l| l.baseline + l.descent).fold(0.0, f64::max); + let note_top = frame.notes.first().map_or(spec.area.y1, |n| n.rect.y0); + spec.area.y1 = body + doc.footnote_options.space_before + (spec.area.y1 - note_top); + cs = crate::compose(doc, &cell.text, std::slice::from_ref(&spec), opts); + } let line_heights: Vec = cs .frames .first() @@ -157,7 +167,8 @@ fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, (lef .unwrap_or_default(); // Check all lines: a large descender or lowered inline object on an earlier line can // extend below the final baseline when leading is fixed. - let content_h = line_heights.iter().copied().fold(0.0, f64::max); + let note_bottom = cs.frames.first().map_or(0.0, |f| f.notes.iter().map(|n| n.rect.y1).fold(0.0, f64::max)); + let content_h = line_heights.iter().copied().fold(note_bottom, f64::max); CellLayout { text: Arc::new(cs), content_h, line_heights } } @@ -174,6 +185,7 @@ pub(crate) fn place_table( cols: &[Vec], cur: &mut Cursor, out: &mut ComposedStory, + notes: &mut crate::notes::Notes, opts: &ComposeOptions, ) -> bool { if cur.fi >= frames.len() || table.nrows() == 0 || table.ncols() == 0 { @@ -292,6 +304,13 @@ pub(crate) fn place_table( } }; place_rows(0..header, &mut y, false, &mut frag_rows); + // Table rows do not add story footnotes; cell-local notes are composed separately. + let column_reserve = |notes: &mut crate::notes::Notes, cur: &Cursor, col: Rect| { + let note_w = notes.area(&cols[cur.fi], cur.col).map_or(col.width(), |a| a.width()); + let column = crate::notes::NoteColumn { frame_index: cur.fi, column_index: cur.col, width: note_w, frame: &frames[cur.fi] }; + notes.reserve(doc, column, &[], opts) + }; + let mut reserve = column_reserve(notes, cur, col); let mut r = header; loop { if r >= body_end { @@ -299,9 +318,10 @@ pub(crate) fn place_table( } let ge = group_end[r].min(body_end).max(r + 1); let gh = sum(r, ge); + let bottom = col.y1 - reserve; let at_col_top = cur.last_baseline.is_none() && (top - col.y0).abs() < 1e-6; - if y + gh + footer_h <= col.y1 + 0.01 || (body_placed == 0 && at_col_top) { - if y + gh + footer_h > col.y1 + 0.01 && body_placed == 0 && at_col_top && y + gh > col.y1 + 0.01 { + if y + gh + footer_h <= bottom + 0.01 || (body_placed == 0 && at_col_top && reserve == 0.0) { + if y + gh + footer_h > bottom + 0.01 && body_placed == 0 && at_col_top && y + gh > bottom + 0.01 { // A row group taller than an empty column can never fit. return false; } @@ -324,6 +344,7 @@ pub(crate) fn place_table( return false; } col = col_rect(cur); + reserve = column_reserve(notes, cur, col); y = col.y0; top = y; frag_rows.clear(); @@ -384,7 +405,13 @@ fn emit( out: &mut ComposedStory, ) { let nc = table.ncols(); - let ry = |r: usize| rows.iter().find(|x| x.0 == r).map(|x| x.1); + // Each row's top (its first occurrence in the fragment), indexed once: looking it up per cell + // by scanning the rows was quadratic in a fragment's row count. + let mut row_top: HashMap = HashMap::with_capacity(rows.len()); + for &(r, y, _) in rows { + row_top.entry(r).or_insert(y); + } + let ry = |r: usize| row_top.get(&r).copied(); let header = table.header_rows(); let body = table.body_rows(); let nbody = body.len(); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 3083261fb..92f24682b 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1103,21 +1103,95 @@ fn hyphenate_across_column_off_keeps_nested_line_styles_on_their_lines() { } #[test] -fn discretionary_hyphen_replaces_automatic_points() { - let text = "aaaa bbbb cccc extraordi\u{AD}narily dddd eeee"; - let para = ParaAttrs { align: Some(Align::Left), ..Default::default() }; - let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 120.0, 1000.0), para); - let cs = compose_story(&d, sid, &ComposeOptions::default()); - let soft = text.find('\u{AD}').unwrap(); - for l in all_lines(&cs) { - if l.hyphenated { - let last = l.glyphs.iter().rfind(|g| g.len > 0).unwrap(); - assert!(last.byte + last.len >= soft && last.byte <= soft + 2, "{:?}", &text[l.range.clone()]); +fn discretionary_hyphen_breaks_with_both_composers_and_hyphenation_settings() { + let text = "Wir möchten uns ganz besonders herzlich bei folgenden Personen bedanken, die auf unterschied\u{ad}lichste Weise zur Entstehung dieses Lehrbuches beigetragen haben:"; + let soft_end = text.find('\u{ad}').unwrap() + '\u{ad}'.len_utf8(); + for composer in [Composer::SingleLine, Composer::Paragraph] { + for hyphenate in [false, true] { + let para = ParaAttrs { composer: Some(composer), hyphenate: Some(hyphenate), ..Default::default() }; + let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 430.0, 1000.0), para); + d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.size = Some(9.5)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let first = all_lines(&cs)[0]; + assert_eq!(first.range.end, soft_end, "{composer:?}, hyphenate={hyphenate}: {:?}", &text[first.range.clone()]); + assert!(first.hyphenated); + let hyphen = first.glyphs.last().unwrap(); + assert!(hyphen.visible && hyphen.len == 0 && hyphen.adv > 0.0); } } - // Hyphenation points of the word itself: none besides the discretionary one. - let lines = all_lines(&cs); - assert!(lines.iter().filter(|l| l.hyphenated).count() <= 1); +} + +#[test] +fn discretionary_hyphen_before_cjk_single_line() { + discretionary_hyphen_before_cjk(Composer::SingleLine); +} + +#[test] +fn discretionary_hyphen_before_cjk_paragraph() { + discretionary_hyphen_before_cjk(Composer::Paragraph); +} + +fn discretionary_hyphen_before_cjk(composer: Composer) { + let text = "abc\u{ad}\u{65e5}\u{672c}\u{8a9e}\u{65e5}\u{672c}\u{8a9e}"; + let soft_end = text.find('\u{ad}').unwrap() + '\u{ad}'.len_utf8(); + for hyphenate in [false, true] { + let para = ParaAttrs { composer: Some(composer), hyphenate: Some(hyphenate), ..Default::default() }; + let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 24.0, 1000.0), para); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let first = all_lines(&cs)[0]; + assert_eq!(first.range.end, soft_end, "{composer:?}, hyphenate={hyphenate}: {:?}", &text[first.range.clone()]); + assert!(first.hyphenated, "{composer:?}, hyphenate={hyphenate}"); + let hyphen = first.glyphs.last().unwrap(); + assert!(hyphen.visible && hyphen.len == 0 && hyphen.adv > 0.0); + } +} + +#[test] +fn discretionary_hyphen_is_invisible_and_zero_width_when_unbroken() { + for composer in [Composer::SingleLine, Composer::Paragraph] { + let mut widths = Vec::new(); + for text in ["unterschiedlichste", "unterschied\u{ad}lichste"] { + let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 1000.0, 100.0), ParaAttrs { composer: Some(composer), ..Default::default() }); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + assert_eq!(lines.len(), 1); + assert!(!lines[0].hyphenated); + if let Some(soft) = text.find('\u{ad}') { + let glyph = lines[0].glyphs.iter().find(|g| g.byte == soft).unwrap(); + assert!(!glyph.visible && glyph.adv == 0.0); + } + widths.push(lines[0].end_x); + } + assert!((widths[0] - widths[1]).abs() < 1e-6, "{composer:?}: {widths:?}"); + } +} + +#[test] +fn discretionary_hyphen_replaces_automatic_points() { + let text = "aaaa bbbb cccc extraordi\u{AD}narily dddd eeee"; + let soft = text.find('\u{AD}').unwrap(); + for composer in [Composer::SingleLine, Composer::Paragraph] { + let mut discretionary_breaks = 0; + let mut automatic_breaks = 0; + for width in (90..181).step_by(10) { + for discretionary in [false, true] { + let source = if discretionary { text.to_string() } else { text.replace('\u{AD}', "") }; + let para = ParaAttrs { composer: Some(composer), hyphenate: Some(true), align: Some(Align::Left), ..Default::default() }; + let (d, sid, _) = doc_with(&source, Rect::new(0.0, 0.0, f64::from(width), 1000.0), para); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + for l in all_lines(&cs).into_iter().filter(|l| l.hyphenated) { + if discretionary { + assert_eq!(l.range.end, soft + '\u{AD}'.len_utf8(), "{composer:?}: {:?}", &source[l.range.clone()]); + discretionary_breaks += 1; + } else { + automatic_breaks += 1; + } + } + } + } + assert!(discretionary_breaks > 0, "{composer:?}: discretionary breaks exercised"); + assert!(automatic_breaks > 0, "{composer:?}: pattern breaks exercised without U+00AD"); + } } #[test] @@ -1832,6 +1906,141 @@ fn tab_leaders_fill_the_gap() { assert!(last_dot < num_x, "leaders stop before the page number"); } +#[test] +fn indent_to_here_starts_the_paragraphs_later_lines_at_the_character() { + // "1.", Indent to Here, then text long enough to wrap: every line after the first starts + // where the character sits, in this frame and the next one (#2080). + let text = format!("1.\u{e005}{}", [LOREM; 3].join(" ")); + let para = ParaAttrs { left_indent: Some(6.0), ..Default::default() }; + let (mut d, sid, a) = doc_with(&text, Rect::new(72.0, 36.0, 272.0, 96.0), para.clone()); + let lid = d.default_layer(); + let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(72.0, 200.0, 272.0, 800.0), lid, "", ParaFormat::default()).unwrap(); + d.thread(a, b).unwrap(); + let cs = compose_story(&d, sid, &Default::default()); + assert!(!cs.is_overset()); + assert!(cs.frames[0].lines.len() > 1 && !cs.frames[1].lines.is_empty(), "the text wraps into the second frame"); + let lines = all_lines(&cs); + let here = lines[0].glyphs.iter().find(|g| g.byte == 2 && g.len > 0).unwrap().x; + assert!(here > 72.0 + 6.0 + 1.0, "the character follows the number"); + for (i, line) in lines.iter().enumerate().skip(1) { + let start = line.glyphs.iter().find(|g| g.visible).unwrap().x; + assert!((start - here).abs() < 0.01, "line {i} starts at {start}, not at the indent {here}"); + assert!(line.end_x <= 272.0 + 0.01, "line {i} fits the narrower measure"); + } + // Without the character the later lines keep the paragraph's own indent. + let (plain, psid, _) = doc_with(&text.replace('\u{e005}', " "), Rect::new(72.0, 36.0, 272.0, 400.0), para); + let pcs = compose_story(&plain, psid, &Default::default()); + let start = pcs.frames[0].lines[1].glyphs.iter().find(|g| g.visible).unwrap().x; + assert!((start - 78.0).abs() < 0.01, "plain second line starts at {start}"); +} + +/// Where each line after the first starts, and the Indent to Here character's x on the first. +fn indent_to_here_starts(text: &str, para: ParaAttrs) -> (f64, Vec) { + let (d, sid, _) = doc_with(text, Rect::new(72.0, 36.0, 272.0, 600.0), para); + let cs = compose_story(&d, sid, &Default::default()); + let lines = all_lines(&cs); + assert!(lines.len() > 4, "the text wraps"); + let byte = text.find('\u{e005}').unwrap(); + let here = lines[0].glyphs.iter().find(|g| g.byte == byte && g.len > 0).unwrap().x; + let starts = lines.iter().skip(1).map(|l| l.glyphs.iter().find(|g| g.visible).unwrap().x).collect(); + (here, starts) +} + +#[test] +fn indent_to_here_after_a_tab_hangs_the_text_after_the_number() { + // The usual hanging number: "1.", a tab to the next stop, then Indent to Here. + let tabs = vec![designcraft_doc::TabStop { position: 24.0, align: TabAlign::Left, leader: String::new(), align_on: String::new() }]; + let (here, starts) = indent_to_here_starts(&format!("1.\t\u{e005}{LOREM}"), ParaAttrs { tabs: Some(tabs), ..Default::default() }); + assert!((here - (72.0 + 24.0)).abs() < 0.01, "the character sits at the tab stop: {here}"); + for (i, start) in starts.iter().enumerate() { + assert!((start - here).abs() < 0.01, "line {} starts at {start}, not at {here}", i + 1); + } +} + +#[test] +fn indent_to_here_beside_a_drop_cap_is_not_indented_by_the_drop_cap_again() { + // A three-line drop cap, then Indent to Here on the first line: the lines beside the drop + // cap and the lines below it all start at the character, none further in. + let (here, starts) = indent_to_here_starts(&format!("Drop \u{e005}{LOREM}"), drop_cap(3, 1)); + for (i, start) in starts.iter().enumerate() { + assert!((start - here).abs() < 0.01, "line {} starts at {start}, not at {here}", i + 1); + } +} + +#[test] +fn indent_to_here_inside_the_drop_cap_indents_the_lines_below_it() { + // A two-character drop cap whose second character is Indent to Here: the lines beside the + // drop cap and the ones below it all start at the character, at the drop cap's edge. + let (here, starts) = indent_to_here_starts(&format!("A\u{e005}{LOREM}"), drop_cap(3, 2)); + assert!(here > 72.0 + 10.0, "the character sits past the drop cap: {here}"); + assert!(starts.len() > 3, "lines below the drop cap"); + for (i, start) in starts.iter().enumerate() { + assert!((start - here).abs() < 0.01, "line {} starts at {start}, not at {here}", i + 1); + } +} + +#[test] +fn indent_to_here_in_a_list_number_indents_to_the_generated_character() { + // "^#.^i^t": the paragraph's later lines start where the number's Indent to Here sits, + // right after "1.", not at the text after the tab. + let mut para = numbered(designcraft_doc::NumberStyle::Arabic, "^#.^i^t"); + para.list_tab = Some(Some(36.0)); + let (d, sid, _) = doc_with(LOREM, Rect::new(72.0, 36.0, 272.0, 600.0), para); + let cs = compose_story(&d, sid, &Default::default()); + let lines = all_lines(&cs); + assert!(lines.len() > 3, "the text wraps"); + let label = |c: char| lines[0].glyphs.iter().find(|g| g.len == 0 && g.rendered_char == c).unwrap(); + let (dot, here) = (label('.'), label('\u{e005}').x); + assert!((here - (dot.x + dot.adv)).abs() < 0.01, "the character follows the number: {here}"); + let text = lines[0].glyphs.iter().find(|g| g.len > 0).unwrap().x; + assert!((text - (72.0 + 36.0)).abs() < 0.01 && here < text - 1.0, "the text after the tab is further in: {text}"); + for (i, line) in lines.iter().enumerate().skip(1) { + let start = line.glyphs.iter().find(|g| g.visible).unwrap().x; + assert!((start - here).abs() < 0.01, "line {i} starts at {start}, not at {here}"); + } +} + +#[test] +fn indent_to_here_in_a_list_number_after_one_in_the_drop_cap_wins() { + // The drop cap "A^i" sits at the indent and the number "1.^i" follows it on the first line, + // so the number's character is the line's last one: the later lines start there, not at + // the drop cap's. + let mut para = ParaAttrs { drop_cap_lines: Some(3), drop_cap_chars: Some(2), ..numbered(designcraft_doc::NumberStyle::Arabic, "^#.^i^t") }; + para.list_tab = Some(Some(36.0)); + let (d, sid, _) = doc_with(&format!("A\u{e005}{LOREM}"), Rect::new(72.0, 36.0, 272.0, 600.0), para); + let cs = compose_story(&d, sid, &Default::default()); + let lines = all_lines(&cs); + assert!(lines.len() > 4, "the text wraps below the drop cap"); + let marker = |label: bool| lines[0].glyphs.iter().find(|g| g.rendered_char == '\u{e005}' && (g.len == 0) == label).unwrap().x; + let (in_cap, in_label) = (marker(false), marker(true)); + assert!(in_label > in_cap + 1.0, "the number follows the drop cap: {in_cap} then {in_label}"); + for (i, line) in lines.iter().enumerate().skip(1) { + let start = line.glyphs.iter().find(|g| g.visible).unwrap().x; + assert!((start - in_label).abs() < 0.01, "line {i} starts at {start}, not at {in_label}"); + } +} + +#[test] +fn indent_to_here_in_a_right_to_left_paragraph_indents_from_the_right() { + // Hebrew words (the bidi classes decide the order; no Hebrew font has to be present): the + // lines after the first end where the character sits, measured from the right edge. + let words = "שלום עולם ".repeat(40); + let text = format!("שלום \u{e005}{words}"); + let para = ParaAttrs { direction: Some(designcraft_doc::TextDirection::RightToLeft), align: Some(Align::Right), ..Default::default() }; + let (d, sid, _) = doc_with(&text, Rect::new(72.0, 36.0, 272.0, 600.0), para); + let cs = compose_story(&d, sid, &Default::default()); + let lines = all_lines(&cs); + assert!(lines.len() > 3, "the text wraps"); + let byte = text.find('\u{e005}').unwrap(); + let here = lines[0].glyphs.iter().find(|g| g.byte == byte && g.len > 0).unwrap().x; + assert!(here < 272.0 - 1.0, "the character follows the first word: {here}"); + let right_edge = |l: &Line| l.glyphs.iter().filter(|g| g.visible).map(|g| g.x + g.adv).fold(f64::MIN, f64::max); + for (i, line) in lines.iter().enumerate().skip(1) { + let end = right_edge(line); + assert!((end - here).abs() < 0.01, "line {i} ends at {end}, not at {here}"); + } +} + #[test] fn right_indent_tab_leaders_follow_stop_precedence_without_moving_text() { let text = "Intro\u{e006}12"; @@ -1994,6 +2203,178 @@ fn footnotes_sit_at_the_column_bottom_and_push_text() { assert!(note_caret(&cs, n.id, 0).is_some()); } +fn justified_content_clears_footnotes(mode: designcraft_doc::VerticalJustification, table: bool) { + for span in [false, true] { + let (mut d, sid, fid) = doc_with("First paragraph.\nSecond paragraph.", Rect::new(0.0, 0.0, 420.0, 240.0), ParaAttrs::default()); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2; + d.footnote_options.span_columns = span; + d.footnote_options.space_before = 60.0; + let st = d.story_mut(sid).unwrap(); + st.insert_note(5, "Synthetic footnote.", ParaFormat::default()); + if table { + let mut t = designcraft_doc::Table::new(77, 1, 1, 0, 0, 180.0); + t.rows[0].mode = designcraft_doc::RowHeightMode::Exactly; + t.rows[0].height = 90.0; + st.insert_table(st.len(), t); + } + let top = compose_story(&d, sid, &ComposeOptions::default()); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.vertical_justification = mode; + + let cs = compose_story(&d, sid, &ComposeOptions::default()); + + assert!(!cs.is_overset()); + let ft = cs.frame(fid).unwrap(); + let original = top.frame(fid).unwrap(); + assert_eq!(ft.notes.len(), 1); + assert_eq!(ft.notes[0].rect, original.notes[0].rect, "footnotes must not move"); + let limit = ft.notes[0].rect.y0 - d.footnote_options.space_before; + assert!(ft.lines.iter().all(|l| l.baseline + l.descent <= limit + 0.01), "lines overlap footnotes in {mode:?}, span {span}"); + assert!(ft.tables.iter().flat_map(|t| &t.cells).all(|c| c.rect.y1 <= limit + 0.01), "cells overlap footnotes in {mode:?}, span {span}"); + assert!(ft.lines.last().unwrap().baseline > original.lines.last().unwrap().baseline + 0.01, "content must still move down"); + if table { + assert_eq!(ft.tables.len(), 1); + assert!(ft.tables[0].rect.y1 > original.tables[0].rect.y1 + 0.01, "table must still move down"); + } + } +} + +#[test] +fn bottom_justified_paragraphs_clear_footnotes() { + justified_content_clears_footnotes(designcraft_doc::VerticalJustification::Bottom, false); +} + +#[test] +fn bottom_justified_tables_clear_footnotes() { + justified_content_clears_footnotes(designcraft_doc::VerticalJustification::Bottom, true); +} + +#[test] +fn center_justified_tables_clear_footnotes() { + justified_content_clears_footnotes(designcraft_doc::VerticalJustification::Center, true); +} + +#[test] +fn vertically_justified_tables_clear_footnotes() { + justified_content_clears_footnotes(designcraft_doc::VerticalJustification::Justify, true); +} + +#[test] +fn vertical_justification_counts_table_fragment_bottoms() { + use designcraft_doc::VerticalJustification::{Bottom, Center, Justify}; + let (mut d, sid, fid) = doc_with("Reference.", Rect::new(0.0, 0.0, 200.0, 240.0), ParaAttrs::default()); + let st = d.story_mut(sid).unwrap(); + st.insert_note(5, "Synthetic footnote.", ParaFormat::default()); + let mut table = designcraft_doc::Table::new(77, 1, 1, 0, 0, 180.0); + table.rows[0].mode = designcraft_doc::RowHeightMode::Exactly; + table.rows[0].height = 120.0; + st.insert_table(st.len(), table); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + for mode in [Bottom, Center, Justify] { + let mut ft = cs.frame(fid).unwrap().clone(); + let t = &ft.tables[0]; + let (anchor, bottom, baseline) = (t.line, t.rect.y1, t.rect.y0); + // Exercise a fragment extending below its anchor, without a later text line hiding it. + ft.lines.truncate(anchor + 1); + ft.lines[anchor].baseline = baseline; + let mut spec = frame_specs(&d, sid).remove(0); + spec.opts.vertical_justification = mode; + + vertical_justify(&mut ft, &spec, false, &d.footnote_options); + + let moved = ft.lines[anchor].baseline - baseline; + let limit = ft.notes[0].rect.y0 - d.footnote_options.space_before; + assert!(moved > 0.01); + assert!(bottom + moved <= limit + 0.01, "fragment overlaps footnotes in {mode:?}"); + } +} + +#[test] +fn table_rows_clear_footnotes_and_continue_in_the_next_column() { + for span in [false, true] { + for row_height in [24.0, 150.0] { + let (mut d, sid, fid) = doc_with("First paragraph.\nSecond paragraph.", Rect::new(0.0, 0.0, 420.0, 200.0), ParaAttrs::default()); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2; + d.footnote_options.span_columns = span; + let st = d.story_mut(sid).unwrap(); + st.insert_note(5, "First synthetic footnote.", ParaFormat::default()); + st.insert_note(25, "Second synthetic footnote.", ParaFormat::default()); + let mut table = designcraft_doc::Table::new(77, 8, 1, 0, 0, 180.0); + for row in &mut table.rows { + row.mode = designcraft_doc::RowHeightMode::Exactly; + row.height = row_height; + } + st.insert_table(st.len(), table); + let (next, _) = + d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 220.0, 420.0, 1720.0), d.default_layer(), "", ParaFormat::default()).unwrap(); + d.thread(fid, next).unwrap(); + + let cs = compose_story(&d, sid, &ComposeOptions::default()); + + assert!(!cs.is_overset()); + let ft = cs.frame(fid).unwrap(); + assert_eq!(ft.notes.len(), 2); + let top = ft.notes[0].rect.y0 - d.footnote_options.space_before; + for frag in &ft.tables { + if span || frag.column == 0 { + assert!(frag.cells.iter().all(|c| c.rect.y1 <= top + 0.01), "rows overlap footnotes: {:?}, top {top}", frag.rect); + } + } + assert!(cs.frames.iter().skip(1).any(|f| !f.tables.is_empty()) || ft.tables.iter().any(|t| t.column == 1)); + let rows: Vec<_> = cs.frames.iter().flat_map(|f| &f.tables).flat_map(|t| &t.cells).filter(|c| !c.repeated).map(|c| c.row).collect(); + assert_eq!(rows, (0..8).collect::>()); + if row_height == 150.0 { + assert!(!ft.tables.iter().any(|t| t.column == 0), "the whole table moves when no row fits"); + } + } + } +} + +#[test] +fn table_reports_overset_when_every_remaining_column_has_footnotes() { + let (mut d, sid, fid) = doc_with("Reference.", Rect::new(0.0, 0.0, 420.0, 200.0), ParaAttrs::default()); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 3; + d.footnote_options.span_columns = true; + d.footnote_options.space_before = 40.0; + let st = d.story_mut(sid).unwrap(); + st.insert_note(5, "Synthetic spanning footnote.", ParaFormat::default()); + let mut table = designcraft_doc::Table::new(77, 2, 1, 0, 0, 100.0); + for row in &mut table.rows { + row.mode = designcraft_doc::RowHeightMode::Exactly; + row.height = 90.0; + } + table.cells[0].row_span = 2; + let anchor = st.insert_table(st.len(), table); + + let cs = compose_story(&d, sid, &ComposeOptions::default()); + + assert!(cs.is_overset()); + assert_eq!(cs.overset_at, Some(anchor)); + let ft = cs.frame(fid).unwrap(); + assert_eq!(ft.notes.len(), 1); + assert!(ft.notes[0].rect.y0 - d.footnote_options.space_before < 180.0); + assert!(cs.frames.iter().all(|f| f.tables.is_empty()), "the unplaced row group must remain overset"); +} + +#[test] +fn table_cell_footnotes_fit_inside_the_row() { + let (mut d, sid, _) = doc_with("", Rect::new(0.0, 0.0, 200.0, 200.0), ParaAttrs::default()); + let mut table = designcraft_doc::Table::new(77, 1, 1, 0, 0, 180.0); + let cell = &mut table.cells[0]; + cell.text.insert(0, "Cell reference."); + cell.text.insert_note(5, "Synthetic cell footnote.", ParaFormat::default()); + d.story_mut(sid).unwrap().insert_table(0, table); + + let cs = compose_story(&d, sid, &ComposeOptions::default()); + + let cell = &cs.frames[0].tables[0].cells[0]; + assert!(!cell.overset); + let frame = &cell.text.frames[0]; + assert_eq!(frame.notes.len(), 1); + let note = &frame.notes[0]; + assert!(cell.origin.y + note.rect.y1 <= cell.clip.y1 + 0.01, "cell footnote must remain within the row"); + assert!(frame.lines.iter().all(|l| l.baseline + l.descent <= note.rect.y0 - d.footnote_options.space_before + 0.01)); +} + #[test] fn footnotes_stack_baseline_to_baseline() { // Single-line footnotes read as one text: consecutive baselines sit one leading (plus @@ -2836,11 +3217,18 @@ fn only_english_text_gets_english_hyphenation() { let hyphenated = |d: &Document, sid| all_lines(&compose_story(d, sid, &ComposeOptions::default())).iter().filter(|l| l.hyphenated).count(); let (d, sid, _) = doc_with(text, narrow, ParaAttrs::default()); assert!(hyphenated(&d, sid) > 0, "English hyphenates"); - let mut d2 = d.clone(); - let st = d2.story_mut(sid).unwrap(); - let n = st.len(); - st.format_chars(0..n, |f| f.over.language = Some("French".into())); - assert_eq!(hyphenated(&d2, sid), 0, "French isn't hyphenated with English rules"); + // A language without bundled patterns isn't hyphenated with English rules. + let in_language = |lang: &str| { + let mut d2 = d.clone(); + let st = d2.story_mut(sid).unwrap(); + let n = st.len(); + st.format_chars(0..n, |f| f.over.language = Some(lang.into())); + hyphenated(&d2, sid) + }; + assert_eq!(in_language("Swedish"), 0, "Swedish isn't hyphenated with English rules"); + // French and Italian hyphenate with their own patterns. + assert!(in_language("French") > 0, "French hyphenates (hyph-fr)"); + assert!(in_language("Italian") > 0, "Italian hyphenates (hyph-it)"); } // A text frame's bottom inset constrains its last baseline. The full font descent @@ -3492,6 +3880,21 @@ fn text_x(l: &Line, from: usize) -> f64 { l.glyphs.iter().find(|g| g.len > 0 && g.byte >= from).map(|g| g.x).unwrap() } +/// Vertical justification moves the drop cap's box with its line. +#[test] +fn drop_cap_box_follows_vertical_justification() { + let (mut d, sid, fid) = drop_doc_in(&[LOREM; 2].join(" "), Rect::new(0.0, 0.0, 300.0, 400.0), drop_cap(3, 1)); + let top = compose_story(&d, sid, &ComposeOptions::default()); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.vertical_justification = designcraft_doc::VerticalJustification::Bottom; + let bottom = compose_story(&d, sid, &ComposeOptions::default()); + let (a, b) = (&top.frame(fid).unwrap().lines[0], &bottom.frame(fid).unwrap().lines[0]); + let dy = b.baseline - a.baseline; + assert!(dy > 1.0); + let (da, db) = (a.drop_cap.unwrap(), b.drop_cap.unwrap()); + assert!((db.baseline - da.baseline - dy).abs() < 1e-6); + assert!((db.rect.y0 - da.rect.y0 - dy).abs() < 1e-6 && (db.rect.y1 - da.rect.y1 - dy).abs() < 1e-6); +} + #[test] fn drop_cap_spans_lines_and_indents_them() { let text = [LOREM; 2].join(" "); @@ -3889,6 +4292,56 @@ fn drop_cap_scales_with_its_point_size() { assert!(b2 - fitted * 1.5 < b0 - base[0].ascent, "a 15 pt cap rises above the first line"); } +/// Paragraph Rules › Type: a wavy, dashed or dotted rule is drawn as its shape (filled paths in +/// the rule's box), a striped one as its bands; Gap Color goes under it. Keep In Frame keeps a rule +/// above the first line inside the frame. +#[test] +fn paragraph_rules_have_a_type_a_gap_colour_and_keep_in_frame() { + use designcraft_doc::{Rule, StrokeType}; + let rule = |kind: StrokeType| Rule { on: true, weight: 4.0, kind, ..Default::default() }; + let decos = |r: Rule| { + let para = ParaAttrs { rule_below: Some(r), ..Default::default() }; + let (d, sid, fid) = doc_with("Recipe Categories", Rect::new(0.0, 0.0, 300.0, 200.0), para); + compose_story(&d, sid, &ComposeOptions::default()).frame(fid).unwrap().decos.clone() + }; + let solid = decos(rule(StrokeType::Solid)); + assert!(solid.len() == 1 && solid[0].path.is_none()); + let wavy = decos(rule(StrokeType::Wavy)); + let p = wavy[0].path.as_ref().expect("a wavy rule is a shape"); + assert!(designcraft_geom::Shape::area(p).abs() > 0.0 && (wavy[0].rect.height() - 4.0).abs() < 1e-9); + let subpaths = |p: &designcraft_geom::BezPath| p.elements().iter().filter(|e| matches!(e, designcraft_geom::PathEl::MoveTo(_))).count(); + let dashed = decos(rule(StrokeType::Dashed { pattern: vec![12.0, 4.0] })); + assert!(subpaths(dashed[0].path.as_ref().unwrap()) > 5, "one box per dash"); + let dotted = decos(rule(StrokeType::Dotted)); + assert!(subpaths(dotted[0].path.as_ref().unwrap()) > 10, "one dot per 2 weights"); + let gap = decos(Rule { gap_color: "[Black]".into(), gap_tint: 0.3, ..rule(StrokeType::Dotted) }); + assert_eq!((gap.len(), gap[0].color.as_str(), gap[0].tint, gap[0].path.is_none()), (2, "[Black]", 0.3, true), "the gap colour under the dots"); + assert_eq!(decos(Rule { gap_color: "[Black]".into(), ..rule(StrokeType::Solid) }).len(), 1, "a solid rule has no gaps"); + // A gap in Text Color takes the text's colour, whatever swatch the rule itself has. + let gap = decos(Rule { color: "[Paper]".into(), gap_color: designcraft_doc::TEXT_COLOR.into(), ..rule(StrokeType::Dotted) }); + assert_eq!((gap[0].color.as_str(), gap[1].color.as_str()), ("[Black]", "[Paper]")); + // Keep In Frame: a rule above raised out of the frame comes back to its top. + let above = |keep: bool| { + let r = Rule { on: true, weight: 2.0, offset: 30.0, keep_in_frame: keep, ..Default::default() }; + let para = ParaAttrs { rule_above: Some(r), ..Default::default() }; + let (d, sid, fid) = doc_with("Title", Rect::new(0.0, 50.0, 300.0, 200.0), para); + compose_story(&d, sid, &ComposeOptions::default()).frame(fid).unwrap().decos[0].rect + }; + assert!(above(false).y0 < 50.0, "{:?}", above(false)); + assert!((above(true).y0 - 50.0).abs() < 1e-6, "{:?}", above(true)); + // With a top inset the rule may still rise into it: raised out of the frame, it comes back + // to the frame's edge, not to the text area's below the inset. + let r = Rule { on: true, weight: 2.0, offset: 60.0, keep_in_frame: true, ..Default::default() }; + let para = ParaAttrs { rule_above: Some(r), ..Default::default() }; + let (mut d, sid, fid) = doc_with("Title", Rect::new(0.0, 50.0, 300.0, 200.0), para); + if let Some(tf) = d.item_mut(fid).and_then(|it| it.text_frame_mut()) { + tf.options.inset = [20.0, 0.0, 0.0, 0.0]; + } + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let rule = cs.frame(fid).unwrap().decos[0].rect; + assert!((rule.y0 - 50.0).abs() < 1e-6, "kept at the frame's top, in the inset: {rule:?}"); +} + /// A rule in Text Color takes the colour of the paragraph's text: the first character's for the /// rule above, the last character's for the rule below. A swatch colour is used as is. #[test] @@ -4076,6 +4529,36 @@ fn empty_paragraph_uses_its_own_mark_not_the_previous_one() { assert!((lines[1].baseline - lines[0].baseline - 8.0).abs() < 1e-6, "{} {}", lines[0].baseline, lines[1].baseline); } +#[test] +fn german_2006_text_gets_german_hyphenation() { + let text = "Silbentrennung Fußgänger Bäcker Fenster Donaudampfschifffahrt Silbentrennung"; + let de = hyphen::Lang::for_language("German: 2006 Reform").unwrap(); + for composer in [Composer::Paragraph, Composer::SingleLine] { + for language in ["German: 2006 Reform", "de_DE_2006"] { + let para = ParaAttrs { composer: Some(composer), ..Default::default() }; + let (mut d, sid, _) = doc_with(text, Rect::new(36.0, 36.0, 96.0, 700.0), para); + d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.language = Some(language.into())); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset(), "{composer:?}, {language}"); + let lines = all_lines(&cs); + assert!(lines.iter().any(|l| l.hyphenated), "{composer:?}, {language}: German text hyphenates"); + for line in lines.iter().filter(|l| l.hyphenated) { + // Check the actual character offset in the original word, including when the + // word spans more than two lines; UTF-8 byte offsets must not become char counts. + let end = line.range.end; + let start = text[..end].rfind(' ').map_or(0, |at| at + 1); + let word = text[start..].split_whitespace().next().unwrap(); + let point = text[start..end].chars().count(); + assert!(hyphen::hyphen_points_in(word, &hyphen::Limits::default(), de).contains(&point), "{word} at {point}"); + } + // The existing paragraph switch must still disable automatic German hyphenation. + d.story_mut(sid).unwrap().format_paras(0..text.len(), |f| f.para.hyphenate = Some(false)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(all_lines(&cs).iter().all(|l| !l.hyphenated)); + } + } +} + #[test] fn spanish_text_gets_spanish_hyphenation() { let text = "La transición democrática fue una construcción colectiva extraordinariamente compleja y desesperadamente necesaria."; @@ -7155,6 +7638,98 @@ fn anchored_object_wraps_its_own_storys_later_lines() { assert!((o.origin.y - top).abs() < 1e-6); } +/// Vertical Justification › Justify with a Paragraph Spacing Limit: the space goes between the +/// paragraphs (up to the limit each), the lines of a paragraph keep their leading; past the limit +/// the rest spreads over every line. Without a limit it spreads over every line. +#[test] +fn justify_paragraph_spacing_limit() { + let text = "One line. +Two +Three"; + let baselines = |limit: f64| { + let (mut d, sid, fid) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 400.0), ParaAttrs::default()); + let o = &mut d.item_mut(fid).unwrap().text_frame_mut().unwrap().options; + o.vertical_justification = designcraft_doc::VerticalJustification::Justify; + o.vj_paragraph_spacing_limit = limit; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + all_lines(&cs).iter().map(|l| l.baseline).collect::>() + }; + let (none, big, small) = (baselines(0.0), baselines(1000.0), baselines(20.0)); + assert_eq!(none.len(), 3); + let gaps = |b: &[f64]| (b[1] - b[0], b[2] - b[1]); + // No limit: equal steps. A big limit: all of it between paragraphs (here every line is one). + assert!((gaps(&none).0 - gaps(&none).1).abs() < 1e-6, "{none:?}"); + assert!((gaps(&big).0 - gaps(&big).1).abs() < 1e-6, "{big:?}"); + // A small limit: every paragraph gap takes the whole 20 pt, and the rest spreads over lines, + // so each gap is wider than the top-aligned one by at least 20 pt and narrower than with a big limit. + let top = { + let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 400.0), ParaAttrs::default()); + all_lines(&compose_story(&d, sid, &ComposeOptions::default())).iter().map(|l| l.baseline).collect::>() + }; + let (natural, small_gaps, big_gaps) = (gaps(&top), gaps(&small), gaps(&big)); + assert!(small_gaps.0 >= natural.0 + 20.0 - 1e-6 && small_gaps.1 >= natural.1 + 20.0 - 1e-6, "{top:?} {small:?}"); + assert!(small_gaps.0 <= big_gaps.0 + 1e-6, "{small:?} {big:?}"); + assert!((none[2] - big[2]).abs() < 1e-6 && (none[2] - small[2]).abs() < 1e-6, "the last line stays at the bottom"); + // A paragraph of two lines keeps its leading when the limit absorbs the space. + let (mut d, sid, fid) = doc_with( + &[LOREM, "End."].join( + " +", + ), + Rect::new(0.0, 0.0, 300.0, 600.0), + ParaAttrs::default(), + ); + let o = &mut d.item_mut(fid).unwrap().text_frame_mut().unwrap().options; + o.vertical_justification = designcraft_doc::VerticalJustification::Justify; + o.vj_paragraph_spacing_limit = 10_000.0; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + let first: Vec = lines.iter().filter(|l| l.para == 0).map(|l| l.baseline).collect(); + assert!(first.len() > 2, "LOREM runs over several lines"); + let lead = first[1] - first[0]; + assert!(first.windows(2).all(|w| (w[1] - w[0] - lead).abs() < 1e-6), "the paragraph's lines keep their leading: {first:?}"); + let last = lines.iter().find(|l| l.para == 1).unwrap().baseline; + assert!(last - first[first.len() - 1] > lead + 50.0, "the space went between the paragraphs"); +} + +/// Each object's wrap pushes the anchors after it down, so where each object lands depends on the +/// wraps of all those before it; every object still ends up wrapping the lines it hangs over. +#[test] +fn chained_anchored_objects_all_wrap_their_own_storys_lines() { + const OBJECTS: usize = 8; + let text = [LOREM; 12].join(" "); + let (mut d, sid, _) = doc_with(&text, Rect::new(0.0, 0.0, 300.0, 3000.0), ParaAttrs::default()); + let plain = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&plain); + assert!(lines.len() > 2 + 6 * OBJECTS); + let left = lines[0].x0; + // One object every six lines, each hanging from its anchor's baseline over the lines below; + // inserted last first so the earlier offsets still hold. + let ats: Vec = (0..OBJECTS).map(|k| lines[2 + 6 * k].range.start + 3).collect(); + for &at in ats.iter().rev() { + anchor_box(&mut d, sid, at, 0.0, 0.0, true, WrapMode::BoundingBox); + } + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset()); + let lines = all_lines(&cs); + let anchors: Vec = d.story(sid).unwrap().text.match_indices(designcraft_doc::OBJECT_MARK).map(|(i, _)| i).collect(); + let objects = &cs.frames[0].objects; + assert_eq!(objects.len(), OBJECTS); + for o in objects { + let anchor_line = lines.iter().find(|l| l.range.contains(&anchors[o.index])).unwrap(); + assert!((o.origin.y - anchor_line.baseline).abs() < 1e-6, "object {} at {}, anchor line at {}", o.index, o.origin.y, anchor_line.baseline); + let under = lines_in_band(&cs, o.origin.y, o.origin.y + 60.0); + assert!(under.len() >= 2); + assert!( + under.iter().all(|l| l.x0 >= left + 100.0 - 1e-6), + "object {} at {}: {:?}", + o.index, + o.origin.y, + under.iter().map(|l| (l.baseline, l.x0)).collect::>() + ); + } +} + #[test] fn anchored_object_without_wrap_leaves_layout_unchanged() { let long = [LOREM; 3].join(" "); @@ -7751,6 +8326,43 @@ fn greek_text_gets_greek_hyphenation() { assert_eq!(hyphen::hyphen_points_in("ανθρωπος", &hyphen::Limits::default(), el), vec![2, 5]); } +/// An object's wrap pushes the text so that a later object's anchor goes on into the next frame +/// (Learn Skills Daily's long-document exercise: pictures anchored in headings through a 79-page +/// story). Each object is placed when its anchor line is set and wraps only the lines after it, +/// in the frame its anchor lands in: the layout doesn't depend on where an earlier composition put +/// the objects, and composing again gives the same lines. +#[test] +fn an_object_wraps_the_frame_its_anchor_lands_in_after_an_earlier_object_pushed_it_on() { + let text = [LOREM; 6].join(" "); + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let (a, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 300.0, 300.0), lid, &text, ParaFormat::default()).unwrap(); + let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(320.0, 0.0, 620.0, 300.0), lid, "", ParaFormat::default()).unwrap(); + d.thread(a, b).unwrap(); + let plain = compose_story(&d, sid, &ComposeOptions::default()); + // The second object's anchor sits near the end of the first frame without wrap. + let late = plain.frames[0].lines.iter().rev().nth(2).unwrap().range.start + 2; + anchor_box(&mut d, sid, late, 0.0, 0.0, true, WrapMode::BoundingBox); + // The first, early in the first frame: 60 pt over its whole width pushes the later anchor on. + let early = plain.frames[0].lines[1].range.start + 2; + anchor_box(&mut d, sid, early, 0.0, 0.0, true, WrapMode::JumpObject); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let late = late + OBJECT_LEN; + let anchor_frame = cs.frames.iter().position(|f| f.range.contains(&late) || f.range.end == late).unwrap(); + assert_eq!(anchor_frame, 1, "the later anchor went on into the second frame"); + // The first frame's lines under the first object's band keep the full measure (the second + // object is in the other frame); in the second frame, the lines under its object move right. + let left = cs.frames[0].lines[0].x0; + assert!(cs.frames[0].lines.iter().all(|l| (l.x0 - left).abs() < 1e-6)); + let f1 = &cs.frames[1]; + let (li, line) = f1.lines.iter().enumerate().find(|(_, l)| l.range.contains(&late)).unwrap(); + let under: Vec<_> = f1.lines[li + 1..].iter().filter(|l| l.baseline - l.ascent < line.baseline + 60.0).collect(); + assert!(!under.is_empty() && under.iter().all(|l| l.x0 >= f1.lines[0].x0 + 100.0 - 1e-6)); + assert_eq!(f1.objects.len(), 1); + // Deterministic: the same lines again. + assert_eq!(line_layout(&compose_story(&d, sid, &ComposeOptions::default())), line_layout(&cs)); +} + /// 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). @@ -7845,6 +8457,41 @@ fn an_rtl_first_line_indent_is_at_the_right_edge() { assert!((left_edge(lines[1]) - (rect.x0 + 10.0 * MM)).abs() < 1.0, "left to right, wrapped line indented: {}", left_edge(lines[1])); } +/// A story threaded through several frames anchored in one host story asks for the host's +/// composition once, not once per frame. +#[test] +fn anchored_frames_in_one_host_compose_it_once() { + let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() }); + let lid = d.default_layer(); + let body = "One.\nTwo.\nThree.\nFour."; + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 400.0, 400.0), lid, body, ParaFormat::default()).unwrap(); + let nsid = StoryId(d.alloc()); + let ids: Vec = (0..3).map(|_| ItemId(d.alloc())).collect(); + let mut story = Story::with_text(nsid, "Note", ParaFormat::default()); + story.frames.extend(&ids); + d.stories.insert(nsid, Arc::new(story)); + for (k, &nid) in ids.iter().enumerate() { + let mut note = designcraft_doc::Item::new( + nid, + lid, + designcraft_doc::Shape::Rectangle, + designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 60.0, 40.0)), + ); + note.content = Content::Text(designcraft_doc::TextFrame { story: nsid, options: Default::default() }); + let at = body.find(["Two", "Three", "Four"][k]).unwrap(); + d.story_mut(sid).unwrap().insert_object(at, designcraft_doc::AnchoredObject::new(note, Default::default())); + } + let mut asked = 0; + let placed = placed::placements(&d, nsid, &mut |h| { + asked += 1; + Arc::new(compose_story(&d, h, &ComposeOptions::default())) + }); + assert_eq!(asked, 1, "the host is composed once"); + assert_eq!(placed.len(), 3, "{placed:?}"); + // The cache and the uncached path agree on where the frames go. + assert_eq!(Cache::new().get(&d, nsid, None).frames.len(), compose_story(&d, nsid, &ComposeOptions::default()).frames.len()); +} + /// A marginal note (an anchored text frame, Only Align First Line to Grid, 9/10) hangs 15 pt /// above its anchor's baseline on a right-hand page. It composes against the document grid where /// it is placed: its first line moves down onto the grid line, the second follows 10 pt below, @@ -7932,3 +8579,34 @@ fn anchored_text_frames_compose_against_the_grid_where_they_are_placed() { Arc::make_mut(&mut d.spreads[1]).pages[1].side = designcraft_doc::PageSide::Left; assert!(!Arc::ptr_eq(&before, &cache.get(&d, nsid, None))); } + +/// A cross-reference's text breaks at its spaces like ordinary text (it is real text in +/// InDesign), not inside words; edited text whose destination is gone shows its saved page. +#[test] +fn cross_reference_text_breaks_at_its_spaces() { + let (mut d, sid, _) = doc_with("See X.", Rect::new(36.0, 36.0, 110.0, 400.0), ParaAttrs::default()); + let at = d.story(sid).unwrap().text.find('X').unwrap(); + let st = d.story_mut(sid).unwrap(); + st.delete(at..at + 1); + st.insert_xref( + at, + designcraft_doc::CrossRef { + text: Some("Page : Bove, Vinny. \"Advent Horizon.\" 1996. Volume 6, issue 5.".into()), + page: Some("7".into()), + ..Default::default() + }, + ); + let x = d.story(sid).unwrap().xrefs[0].clone(); + let shown = crate::xref::xref_text(&d, &x, None); + assert_eq!(shown, "Page 7: Bove, Vinny. \"Advent Horizon.\" 1996. Volume 6, issue 5."); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines: Vec = + cs.frames[0].lines.iter().map(|l| l.glyphs.iter().filter(|g| g.visible).map(|g| g.rendered_char).collect::()).collect(); + assert!(lines.len() > 3, "{lines:?}"); + let words: Vec<&str> = format!("See {shown}.").leak().split(' ').collect(); + for l in &lines { + for w in l.split(' ').filter(|w| !w.is_empty()) { + assert!(words.contains(&w), "a line breaks inside a word: {w:?} in {lines:?}"); + } + } +} diff --git a/crates/compose/src/vars.rs b/crates/compose/src/vars.rs index 945d8bd04..bd9e32ccd 100644 --- a/crates/compose/src/vars.rs +++ b/crates/compose/src/vars.rs @@ -53,6 +53,32 @@ pub(crate) fn place(doc: &Document, cs: &ComposedStory, pos: usize) -> Option<(u Some((doc.first_page_of_spread(si) + pi, ((b.y0 + baseline) * 10.0) as i64, ((b.x0 + x) * 10.0) as i64)) } +/// A paragraph or run a running header may show: where it starts ([`place`]), its story and byte +/// there, and its text. +struct Hit { + at: (usize, i64, i64), + story: StoryId, + pos: usize, + text: String, +} + +/// (page, text) in the order running headers read a page: text order within a story (a story's +/// second column comes after its first, however high its entries sit), and stories by where their +/// first entry on the page is (top to bottom, then left to right). +fn in_page_order(hits: Vec) -> Vec<(usize, String)> { + // Each story on a page is placed by its first hit there in text order (not its highest hit, + // which in a later column could put the whole story ahead of another). + let mut first: HashMap<(usize, StoryId), (usize, (i64, i64))> = HashMap::new(); + for h in &hits { + let (page, y, x) = h.at; + first.entry((page, h.story)).and_modify(|f| *f = (*f).min((h.pos, (y, x)))).or_insert((h.pos, (y, x))); + } + let mut hits: Vec<_> = + hits.into_iter().map(|h| (h.at.0, first.get(&(h.at.0, h.story)).map_or((h.at.1, h.at.2), |f| f.1), h.story, h.pos, h.text)).collect(); + hits.sort_by_key(|a| (a.0, a.1, a.2.0, a.3)); + hits.into_iter().map(|(page, _, _, _, text)| (page, text)).collect() +} + impl RunningIndex { /// Index the paragraphs / runs that running-header variables refer to. `get` composes a story /// (normally through the cache); stories that themselves contain running headers are skipped. @@ -65,7 +91,8 @@ impl RunningIndex { _ => None, }) .collect(); - let mut hits: HashMap<(String, bool), Vec<((usize, i64, i64), String)>> = HashMap::new(); + // (place, story, byte) of each paragraph or run, with its text. + let mut hits: HashMap<(String, bool), Vec> = HashMap::new(); if wanted.is_empty() { return RunningIndex::default(); } @@ -90,7 +117,7 @@ impl RunningIndex { continue; } if let Some(at) = place(doc, &cs, r.start) { - hits.entry(key).or_default().push((at, text)); + hits.entry(key).or_default().push(Hit { at, story: *sid, pos: r.start, text }); } } } @@ -105,16 +132,15 @@ impl RunningIndex { continue; } if let Some(at) = place(doc, &cs, r.start) { - hits.entry(key).or_default().push((at, text)); + hits.entry(key).or_default().push(Hit { at, story: *sid, pos: r.start, text }); } } } } let mut map = HashMap::new(); - for (key, mut v) in hits { - v.sort_by_key(|(at, _)| *at); + for (key, v) in hits { let mut pages: BTreeMap = BTreeMap::new(); - for ((page, ..), text) in v { + for (page, text) in in_page_order(v) { pages.entry(page).and_modify(|e| e.1 = text.clone()).or_insert((text.clone(), text)); } map.insert(key, pages); @@ -134,10 +160,18 @@ impl RunningIndex { /// Values of every variable on `page`. pub fn values(doc: &Document, page: Option, running: Option<&RunningIndex>) -> Vec { + values_shown(doc, page, false, running) +} + +/// [`values`] as text shows them: on a parent page itself (`parent`, with no `page`), a running +/// header has no entries of its own and shows which variable sits there, ``, as InDesign +/// does. Elsewhere without a page (the pasteboard), it is its text before and after. +pub fn values_shown(doc: &Document, page: Option, parent: bool, running: Option<&RunningIndex>) -> Vec { doc.text_variables .iter() .enumerate() .map(|(i, v)| match &v.kind { + VarKind::RunningHeader { .. } if parent && page.is_none() => format!("<{}>", v.name), VarKind::RunningHeader { style, use_, character } => { let body = match (running, page) { (Some(r), Some(p)) => r.value(style, *character, *use_, p), diff --git a/crates/compose/src/wrap.rs b/crates/compose/src/wrap.rs index e1cdc5fa7..f28bba424 100644 --- a/crates/compose/src/wrap.rs +++ b/crates/compose/src/wrap.rs @@ -12,16 +12,27 @@ //! that come after the line holding its anchor; that line and the lines before it are never //! wrapped by it (an inline object sits in its line and wraps none of its own story). The story //! is composed again with the objects where the previous pass placed them until they stop -//! moving, at most [`MAX_PASSES`] more times. +//! moving. An object's wrap can push the anchors of the objects after it, so a chain of n such +//! objects needs n + 1 more compositions: the k-th pass places the (k + 1)-th object where the +//! wraps before it leave its anchor, and the last pass wraps around the last object. The story +//! is composed at most that many more times (at least [`MIN_PASSES`]), and never more than +//! [`MAX_PASSES`]. use designcraft_doc::{AnchorPosition, AnchoredObject, Document, Item, OBJECT_MARK, PageSide, SpreadRef, Story, StoryId, WrapMode}; use designcraft_geom::Affine; use crate::{ComposeOptions, ComposedStory, Exclusion, FrameSpec}; -/// Extra compositions of a story with its own anchored objects' wrap; a layout that keeps moving -/// its objects (keep options, vertical justification) stops here. -pub(crate) const MAX_PASSES: usize = 4; +/// Most extra compositions of a story with its own anchored objects' wrap, however many objects +/// it has; a layout that keeps moving its objects (keep options, vertical justification) stops +/// sooner, after one per object and one more (but never fewer than [`MIN_PASSES`]). +pub(crate) const MAX_PASSES: usize = 64; + +/// Fewest extra compositions allowed before giving up on a layout that keeps moving: a single +/// object that keep options or vertical justification move still gets the room it had before. +const MIN_PASSES: usize = 4; +// `clamp` needs min <= max. +const _: () = assert!(MIN_PASSES <= MAX_PASSES); /// How far an obstacle may move between passes and still count as settled (points). const SETTLED: f64 = 0.01; @@ -199,8 +210,9 @@ pub(crate) fn compose_among(doc: &Document, sid: StoryId, opts: &ComposeOptions, if !has_wrapping_objects(st) { return out; } + let own = st.objects.iter().filter(|o| wraps(o) && !matches!(o.position, AnchorPosition::Inline { .. })).count(); let mut prev = vec![Vec::new(); specs.len()]; - for _ in 0..MAX_PASSES { + for _ in 0..(own + 1).clamp(MIN_PASSES, MAX_PASSES) { let added = exclusions(doc, &specs, &obstacles(doc, st, &out, true)); if settled(&added, &prev) { break; diff --git a/crates/compose/src/xref.rs b/crates/compose/src/xref.rs index b33714203..1d72f1ee1 100644 --- a/crates/compose/src/xref.rs +++ b/crates/compose/src/xref.rs @@ -52,8 +52,14 @@ impl XrefIndex { } /// The text a cross-reference shows ("??" when its destination is gone, like an unresolved -/// reference in typesetting). +/// reference in typesetting, unless the text was edited). pub fn xref_text(doc: &Document, x: &xref::CrossRef, index: Option<&XrefIndex>) -> String { + // Edited text stays as typed; its page numbers still follow the destination, or show the + // page last shown when the destination is gone (as in InDesign). + if let Some(t) = &x.text { + let live = index.filter(|_| doc.xref_values(x.target).is_some()).and_then(|i| i.page(x.target)); + return xref::expand_edited(t, live.or(x.page.as_deref()).unwrap_or("?")); + } let Some(mut v) = doc.xref_values(x.target) else { return "??".into() }; v.page = index.and_then(|i| i.page(x.target)).unwrap_or("?").to_string(); let def = doc.xref_format(&x.format).map_or("", |f| f.definition.as_str()); diff --git a/crates/doc/src/assets.rs b/crates/doc/src/assets.rs index 1533b8b4b..6ddd52b1e 100644 --- a/crates/doc/src/assets.rs +++ b/crates/doc/src/assets.rs @@ -112,8 +112,13 @@ pub fn find_same_asset(assets: &BTreeMap>, asset: &Asset) -> /// Add `asset` to `assets` unless they already hold the same file; returns the id placements /// should reference. A new asset with the bytes of an existing one shares its buffer. +/// +/// This scans `assets`; an importer adding many files uses an [`AssetIndex`] instead. pub fn insert_asset(assets: &mut BTreeMap>, mut asset: Asset) -> AssetId { - if let Some(id) = find_same_asset(assets, &asset) { + // An empty, unlinked asset never holds the same file as another: nothing to search for. + if (!asset.data.is_empty() || asset.link.is_some()) + && let Some(id) = find_same_asset(assets, &asset) + { return id; } if !asset.data.is_empty() @@ -126,6 +131,51 @@ pub fn insert_asset(assets: &mut BTreeMap>, mut asset: Asset id } +/// A lookup of the assets added through it, by content, so that adding many files (an import) +/// costs time in proportion to their number rather than its square. It only knows the assets +/// it inserted: use one index per asset map, and add every asset through it. +#[derive(Debug, Default)] +pub struct AssetIndex { + fingerprint: std::collections::hash_map::RandomState, + /// Candidates for [`Asset::same_file`]: (bytes fingerprint, type, page, link). + files: HashMap<(u64, String, u32, Option), Vec>, + /// One asset per distinct non-empty byte content, for buffer sharing. + bytes: HashMap>, +} + +impl AssetIndex { + pub fn new() -> Self { + Self::default() + } + + /// [`insert_asset`], looking up candidates by fingerprint instead of scanning `assets`. + /// A fingerprint only selects candidates; equality still compares the actual files. + pub fn insert_asset(&mut self, assets: &mut BTreeMap>, mut asset: Asset) -> AssetId { + // Empty, unlinked assets never compare equal: don't search for or index them. + if asset.data.is_empty() && asset.link.is_none() { + let id = asset.id; + assets.insert(id, Arc::new(asset)); + return id; + } + let hash = self.fingerprint.hash_one(asset.data.as_slice()); + let key = (hash, asset.mime.clone(), asset.page, asset.link.clone()); + if let Some(id) = self.files.get(&key).and_then(|ids| ids.iter().copied().find(|id| assets.get(id).is_some_and(|a| a.same_file(&asset)))) { + return id; + } + if !asset.data.is_empty() { + let bucket = self.bytes.entry(hash).or_default(); + match bucket.iter().find_map(|id| assets.get(id).filter(|a| a.same_bytes(&asset))) { + Some(a) => asset.data = Arc::clone(&a.data), + None => bucket.push(asset.id), + } + } + let id = asset.id; + self.files.entry(key).or_default().push(id); + assets.insert(id, Arc::new(asset)); + id + } +} + /// Calls `f` with every asset reference in `it` and its children; false when nesting exceeded /// [`MAX_DEPTH`] (some references weren't visited). fn item_refs(it: &Item, f: &mut impl FnMut(AssetId), depth: usize) -> bool { @@ -463,6 +513,54 @@ mod tests { assert_ne!(m3, m4); } + /// The index dedupes exactly as the scan does, and skips empty unlinked assets. + #[test] + fn indexed_insert_matches_scanning_insert() { + let linked = |id, path: &str, data: &[u8]| Asset { link: Some(path.into()), ..asset(id, data) }; + let cases = vec![ + asset(1, b"same"), + asset(2, b"same"), + asset(3, b"diff"), + Asset { page: 1, ..asset(4, b"same") }, + linked(5, "/p/a.png", b"same"), + linked(6, "/q/b.png", b"same"), + linked(7, "/p/a.png", b"same"), + linked(8, "/m/1.png", b""), + linked(9, "/m/1.png", b""), + asset(10, b""), + asset(11, b""), + ]; + let mut scanned = BTreeMap::new(); + let mut indexed = BTreeMap::new(); + let mut index = AssetIndex::new(); + for a in cases { + let s = insert_asset(&mut scanned, a.clone()); + let i = index.insert_asset(&mut indexed, a); + assert_eq!(s, i); + } + assert_eq!(scanned.keys().collect::>(), indexed.keys().collect::>()); + assert!(Arc::ptr_eq(&indexed[&AssetId(5)].data, &indexed[&AssetId(6)].data), "identical bytes share one buffer"); + } + + /// Many empty unlinked placements (graphics with neither bytes nor a link) insert without + /// comparing against each other. + #[test] + fn many_empty_unlinked_assets_insert_quickly() { + let mut assets = BTreeMap::new(); + let mut index = AssetIndex::new(); + for id in 1..=200_000u64 { + assert_eq!(insert_asset(&mut assets, asset(id, b"")), AssetId(id)); + } + assert_eq!(assets.len(), 200_000); + let mut indexed = BTreeMap::new(); + for id in 1..=20_000u64 { + let mut bytes = vec![7; 64]; + bytes[56..].copy_from_slice(&id.to_le_bytes()); + assert_eq!(index.insert_asset(&mut indexed, asset(id, &bytes)), AssetId(id)); + } + assert_eq!(indexed.len(), 20_000); + } + #[test] fn files_at_different_paths_stay_separate_assets() { let mut d = Document::new(&Default::default()); diff --git a/crates/doc/src/attrs/char_attrs.rs b/crates/doc/src/attrs/char_attrs.rs index 4f4cb9d52..154dbf741 100644 --- a/crates/doc/src/attrs/char_attrs.rs +++ b/crates/doc/src/attrs/char_attrs.rs @@ -86,7 +86,7 @@ attr_set! { kenten_font: String = String::new(), kenten_font_style: String = String::new(), /// Mark size in points; `None` is half the text size. - kenten_size: Option = None, + kenten_size: Option = None => "deserialize_explicit_value", /// Horizontal / vertical scale of the mark, 1.0 = 100%. kenten_x_scale: f64 = 1.0, kenten_y_scale: f64 = 1.0, @@ -99,10 +99,10 @@ attr_set! { /// Mark fill and stroke swatches (empty = the text's), tints and stroke weight (`None` = /// the text's). kenten_fill: String = String::new(), - kenten_fill_tint: Option = None, + kenten_fill_tint: Option = None => "deserialize_explicit_value", kenten_stroke: String = String::new(), - kenten_stroke_tint: Option = None, - kenten_stroke_weight: Option = None, + kenten_stroke_tint: Option = None => "deserialize_explicit_value", + kenten_stroke_weight: Option = None => "deserialize_explicit_value", kenten_overprint_fill: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto, kenten_overprint_stroke: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto, // Shatai (斜体): the character is compressed across the direction at `shatai_angle`. @@ -126,7 +126,7 @@ attr_set! { ruby_font: String = String::new(), ruby_font_style: String = String::new(), /// Points; `None` is half the parent size. - ruby_font_size: Option = None, + ruby_font_size: Option = None => "deserialize_explicit_value", /// 1.0 = 100%. ruby_x_scale: f64 = 1.0, ruby_y_scale: f64 = 1.0, @@ -147,10 +147,10 @@ attr_set! { ruby_scaling_min: f64 = 0.66, /// Ruby colour: empty is the text's colour; `None` tint and weight follow the text. ruby_fill: String = String::new(), - ruby_fill_tint: Option = None, + ruby_fill_tint: Option = None => "deserialize_explicit_value", ruby_stroke: String = String::new(), - ruby_stroke_tint: Option = None, - ruby_stroke_weight: Option = None, + ruby_stroke_tint: Option = None => "deserialize_explicit_value", + ruby_stroke_weight: Option = None => "deserialize_explicit_value", ruby_overprint_fill: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto, ruby_overprint_stroke: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto, /// Digits (World-Ready): how 0–9 are drawn. diff --git a/crates/doc/src/attrs/para_attrs.rs b/crates/doc/src/attrs/para_attrs.rs index 17edc8cc1..0077c8e12 100644 --- a/crates/doc/src/attrs/para_attrs.rs +++ b/crates/doc/src/attrs/para_attrs.rs @@ -141,6 +141,9 @@ attr_set! { shading_on: bool = false, shading_color: String = "[Black]".into(), shading_tint: f32 = 0.2, + /// Overprint (InDesign's Paragraph Shading › Overprint): stored and kept through IDML; + /// PDF export doesn't overprint yet. + shading_overprint: bool = false, shading_offsets: [f64; 4] = [0.0; 4], /// Paragraph border: stroke weights (top, left, bottom, right), colour, and offsets past the /// text (top, left, bottom, right). @@ -148,6 +151,8 @@ attr_set! { border_weights: [f64; 4] = [1.0; 4], border_color: String = "[Black]".into(), border_tint: f32 = 1.0, + /// Overprint the border's stroke (stored and kept through IDML, as `ParagraphBorderOverprint`). + border_overprint: bool = false, border_offsets: [f64; 4] = [0.0; 4], /// The border's stroke type (a document stroke style by name, or a built-in type), cap and /// join. @@ -167,6 +172,8 @@ attr_set! { /// The colour between the dashes, dots or stripes of a border that isn't solid. border_gap_color: String = "[None]".into(), border_gap_tint: f32 = 1.0, + /// Overprint the border's gap colour (stored and kept through IDML). + border_gap_overprint: bool = false, /// Shading corners: top left, top right, bottom right, bottom left. shading_corners: [designcraft_geom::corners::Corner; 4] = [designcraft_geom::corners::Corner::default(); 4], shading_width: BoxWidth = BoxWidth::Column, @@ -188,7 +195,7 @@ attr_set! { bullet_align: ListAlign = ListAlign::Left, number_align: ListAlign = ListAlign::Left, /// A tab stop for the text after the bullet or number, from the column's left edge. - list_tab: Option = None, + list_tab: Option = None => "deserialize_explicit_value", /// Restart Numbers at This Level After: a paragraph at a higher level (0 = any; otherwise /// that level) starts this level's numbering again. restart_numbers: bool = true, @@ -222,8 +229,11 @@ impl ParaAttrs { let serde_json::Value::Object(patch) = value else { return self.set_json(key, value) }; let mut merged = serde_json::to_value(rule).map_err(|e| format!("{key}: {e}"))?; let serde_json::Value::Object(fields) = &mut merged else { return Err(format!("{key}: not an object")) }; + // Every field, those at their defaults (left out of the JSON) included. + const RULE_FIELDS: [&str; 12] = + ["on", "weight", "color", "tint", "columnWidth", "offset", "leftIndent", "rightIndent", "type", "gapColor", "gapTint", "keepInFrame"]; for (k, v) in patch { - if !fields.contains_key(k) { + if !RULE_FIELDS.contains(&k.as_str()) { return Err(format!("{key}: unknown rule field `{k}`")); } fields.insert(k.clone(), v.clone()); diff --git a/crates/doc/src/attrs/rules_lists.rs b/crates/doc/src/attrs/rules_lists.rs index f4ef952e4..4b36c3baf 100644 --- a/crates/doc/src/attrs/rules_lists.rs +++ b/crates/doc/src/attrs/rules_lists.rs @@ -17,11 +17,46 @@ pub struct Rule { pub offset: f64, pub left_indent: f64, pub right_indent: f64, + /// Type: the rule's stroke type (solid, dashed, wavy…, or a stroke style of the document). + #[serde(rename = "type", skip_serializing_if = "rule_solid")] + pub kind: crate::item::StrokeType, + /// Gap Color (a swatch; [None]: none) and Gap Tint: under the gaps of a type that has them. + #[serde(skip_serializing_if = "rule_no_gap")] + pub gap_color: String, + #[serde(skip_serializing_if = "rule_full_tint")] + pub gap_tint: f32, + /// Keep In Frame: a rule above the first line of a frame stays inside the frame. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub keep_in_frame: bool, +} + +// A rule's settings at their defaults are left out of the document (and of the attributes). +fn rule_solid(k: &crate::item::StrokeType) -> bool { + *k == crate::item::StrokeType::Solid +} +fn rule_no_gap(c: &str) -> bool { + c == designcraft_color::swatch::NONE +} +fn rule_full_tint(t: &f32) -> bool { + *t == 1.0 } impl Default for Rule { fn default() -> Self { - Rule { on: false, weight: 1.0, color: TEXT_COLOR.into(), tint: 1.0, column_width: true, offset: 0.0, left_indent: 0.0, right_indent: 0.0 } + Rule { + on: false, + weight: 1.0, + color: TEXT_COLOR.into(), + tint: 1.0, + column_width: true, + offset: 0.0, + left_indent: 0.0, + right_indent: 0.0, + kind: crate::item::StrokeType::Solid, + gap_color: designcraft_color::swatch::NONE.into(), + gap_tint: 1.0, + keep_in_frame: false, + } } } diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index c7259f317..1789092ff 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -661,11 +661,18 @@ impl Document { } /// Parent page (spread index, page index) applied to `page`: the page on its side of its - /// parent spread. + /// parent spread, or in its place for single-sided pages ([`Spread::parent_page_at`]). pub fn parent_page_of(&self, page: &Page) -> Option<(usize, usize)> { let pi = self.parent_index(page.parent?)?; // A parent spread can come without pages from a file. - Some((pi, self.parents.get(pi)?.parent_page_for_side(page.side)?)) + let parent = self.parents.get(pi)?; + // Only a single-sided page's position matters: look for it only then. + let index = if page.side == PageSide::Single && parent.pages.len() >= 2 { + self.spreads.iter().find_map(|s| s.pages.iter().position(|p| p.id == page.id)).unwrap_or(0) + } else { + 0 + }; + Some((pi, parent.parent_page_at(page.side, index)?)) } /// The parent page applied to `page`. @@ -777,7 +784,7 @@ impl Document { c } }; - chain.into_iter().filter_map(|i| Some((i, self.parents.get(i)?.parent_page_for_side(pg.side)?))).collect() + chain.into_iter().filter_map(|i| Some((i, self.parents.get(i)?.parent_page_at(pg.side, page)?))).collect() } /// Whether basing parent `pi` on `base` would make a `based_on` cycle (or names no parent). diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 1d2feb6a8..4c930c878 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -385,6 +385,23 @@ mod tests { use super::*; use designcraft_geom::shapes; + /// Shadows and glows saved before they had a blend mode read Normal, as they were drawn; + /// new ones start as InDesign's: Multiply for shadows, Screen for the outer glow. + #[test] + fn effect_blend_modes_default_as_indesign_and_keep_old_documents() { + let old: DropShadow = + serde_json::from_str(r#"{"on": true, "color": "[Black]", "opacity": 0.75, "angle": 120, "distance": 7, "size": 5, "spread": 0}"#) + .unwrap(); + assert_eq!(old.mode, BlendMode::Normal); + let old: OuterGlow = serde_json::from_str(r#"{"on": true, "color": "[Black]", "opacity": 0.75, "size": 7, "spread": 0}"#).unwrap(); + assert_eq!(old.mode, BlendMode::Normal); + assert_eq!(DropShadow::default().mode, BlendMode::Multiply); + assert_eq!(InnerShadow::default().mode, BlendMode::Multiply); + assert_eq!(OuterGlow::default().mode, BlendMode::Screen); + let saved = serde_json::to_value(DropShadow { mode: BlendMode::Screen, ..Default::default() }).unwrap(); + assert_eq!(serde_json::from_value::(saved).unwrap().mode, BlendMode::Screen, "round trip"); + } + #[test] fn graphics_without_a_clipping_path_load_and_save_as_before() { let g = Graphic { diff --git a/crates/doc/src/item/effects.rs b/crates/doc/src/item/effects.rs index fa416643b..46aac2e95 100644 --- a/crates/doc/src/item/effects.rs +++ b/crates/doc/src/item/effects.rs @@ -13,12 +13,22 @@ pub struct DropShadow { pub distance: f64, pub size: f64, pub spread: f64, + /// How the shadow blends with what's behind it (InDesign: Multiply). A document saved before + /// effects had modes reads Normal, as they were drawn then. + #[serde(default = "normal_blend")] + pub mode: BlendMode, +} + +/// The blend of an effect saved before effects had one. +fn normal_blend() -> BlendMode { + BlendMode::Normal } impl Default for DropShadow { fn default() -> Self { DropShadow { on: false, + mode: BlendMode::Multiply, color: designcraft_color::swatch::BLACK.into(), opacity: 0.75, angle: 120.0, @@ -45,12 +55,16 @@ pub struct InnerShadow { pub size: f64, /// Percentage (0–100) of `size` that hardens the edge instead of blurring it. pub choke: f64, + /// How the shadow blends with the object (InDesign: Multiply; Normal when saved before). + #[serde(default = "normal_blend")] + pub mode: BlendMode, } impl Default for InnerShadow { fn default() -> Self { InnerShadow { on: false, + mode: BlendMode::Multiply, color: designcraft_color::swatch::BLACK.into(), opacity: 0.75, angle: 120.0, @@ -72,11 +86,15 @@ pub struct OuterGlow { pub size: f64, /// Percentage (0–100) of `size` that grows the silhouette instead of blurring it. pub spread: f64, + /// How the glow blends with what's behind it (InDesign: Screen; Normal when saved before). + #[serde(default = "normal_blend")] + pub mode: BlendMode, } impl Default for OuterGlow { fn default() -> Self { - OuterGlow { on: false, color: designcraft_color::swatch::BLACK.into(), opacity: 0.75, size: 7.0, spread: 0.0 } + // InDesign starts a glow screened in white. + OuterGlow { on: false, color: designcraft_color::swatch::PAPER.into(), opacity: 0.75, size: 7.0, spread: 0.0, mode: BlendMode::Screen } } } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index bf9998209..cb0e4a020 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -44,7 +44,7 @@ use std::collections::BTreeMap; use std::sync::Arc; pub use anchored::{AnchorPosition, AnchoredObject, OBJECT_MARK}; -pub use assets::{find_same_asset, insert_asset, remap_story_assets, story_asset_refs}; +pub use assets::{AssetIndex, find_same_asset, insert_asset, remap_story_assets, story_asset_refs}; pub use attrs::*; pub use datamerge::{ DataField, DataFieldKind, DataMerge, DataSource, Delimiter, Fingerprint, MergeOptions, Placeholder, PlaceholderAnchor, PlaceholderRole, @@ -831,6 +831,9 @@ pub struct Toc { /// Paragraph style of the title; `None` is the generated "TOC Title" style. #[serde(default, skip_serializing_if = "Option::is_none")] pub title_style: Option, + /// Generate a nested PDF outline from the included paragraphs when the TOC is updated. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub create_bookmarks: bool, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -841,6 +844,16 @@ pub struct Bookmark { pub page: usize, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub children: Vec, + /// Owned by the document's generated TOC, so updating it preserves manual bookmarks. + /// + /// Stored only in native files. Bookmarks (and so this flag) are not written to or read + /// from IDML yet, so after an IDML round trip the next TOC update builds the outline fresh. + /// If IDML bookmark import is added, imported bookmarks arrive with this flag false and the + /// next update would keep them as manual and add a second, duplicate outline. That import + /// must set this flag on the bookmarks the TOC generated (for example by matching them to + /// the TOC entries) when the TOC has `create_bookmarks` on. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub generated_by_toc: bool, } impl Document { @@ -955,6 +968,91 @@ impl Document { designcraft_color::swatch::resolve(&self.swatches, swatch, tint) } + /// Give items that reuse an earlier item's id (spreads, then parents, then items anchored in + /// stories) fresh ids, so a file holding such duplicates opens. The first item keeps the id and + /// what refers to it by id; a renumbered text frame takes its place in its story's thread. + pub fn renumber_duplicate_items(&mut self) { + fn walk_mut(it: &mut Item, f: &mut impl FnMut(&mut Item)) { + f(it); + for c in it.children_mut().into_iter().flatten() { + walk_mut(Arc::make_mut(c), f); + } + } + fn story_items(st: &mut Story, f: &mut impl FnMut(&mut Item)) { + for o in &mut st.objects { + walk_mut(&mut Arc::make_mut(o).item, f); + } + for t in st.tables.values_mut() { + for c in &mut Arc::make_mut(t).cells { + story_items(&mut c.text, f); + } + } + for n in st.notes.iter_mut().chain(st.endnotes.iter_mut()) { + story_items(&mut Arc::make_mut(n).text, f); + } + } + let mut seen = std::collections::HashSet::new(); + let mut dups = std::collections::HashSet::new(); + let mut next = self.next_id; + let mut note = |id: ItemId| { + next = next.max(id.0); + if !seen.insert(id) { + dups.insert(id); + } + }; + for sp in self.spreads.iter().chain(self.parents.iter()) { + for it in &sp.items { + it.walk(&mut |i| note(i.id)); + } + } + for (_, i) in self.anchored_items() { + note(i.id); + } + if dups.is_empty() { + return; + } + let mut seen = std::collections::HashSet::new(); + // (story, id in the file) → the ids its frames carry now, in walk order. + let mut threads: std::collections::HashMap<(StoryId, ItemId), Vec> = std::collections::HashMap::new(); + let mut fix = |it: &mut Item| { + let old = it.id; + if !dups.contains(&old) { + return; + } + if !seen.insert(old) { + // An id at the top of the range has no successor: leave the duplicate for + // `check()` to refuse rather than overflow. + let Some(n) = next.checked_add(1) else { return }; + next = n; + it.id = ItemId(next); + } + if let Some(t) = it.text_frame() { + threads.entry((t.story, old)).or_default().push(it.id); + } + }; + for sp in self.spreads.iter_mut().chain(self.parents.iter_mut()) { + for it in &mut Arc::make_mut(sp).items { + walk_mut(Arc::make_mut(it), &mut fix); + } + } + for st in self.stories.values_mut() { + story_items(Arc::make_mut(st), &mut fix); + } + self.next_id = next; + for ((sid, old), ids) in threads { + let Some(st) = self.stories.get_mut(&sid) else { continue }; + let st = Arc::make_mut(st); + let mut ids = ids.into_iter(); + for f in st.frames.iter_mut().filter(|f| **f == old) { + match ids.next() { + Some(id) => *f = id, + None => break, + } + } + st.frames.extend(ids); + } + } + /// Validate structural invariants (ids unique, threads consistent, stories well-formed). pub fn check(&self) -> Result<()> { for f in &self.styles.composite_fonts { diff --git a/crates/doc/src/page.rs b/crates/doc/src/page.rs index 7041f7ed8..caf1c102e 100644 --- a/crates/doc/src/page.rs +++ b/crates/doc/src/page.rs @@ -452,6 +452,16 @@ impl Spread { PageSide::Single => Some(0), } } + /// The parent page shown behind page `index` of a document spread, on `side`: by side for + /// facing pages ([`Self::parent_page_for_side`]); by position for single-sided pages in a + /// spread of several, as InDesign does, so the second page of a two-page spread shows the + /// parent's second page (the last one, for spreads longer than the parent). + pub fn parent_page_at(&self, side: PageSide, index: usize) -> Option { + match side { + PageSide::Single if self.pages.len() >= 2 => Some(index.min(self.pages.len() - 1)), + _ => self.parent_page_for_side(side), + } + } /// Index of the page under spread x (nearest page for the pasteboard). pub fn page_at_x(&self, x: f64) -> Option { if self.pages.is_empty() { diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index 11c4e2ab1..a2dcf29da 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use crate::edit::SpreadRef; -use crate::ids::{ItemId, StoryId}; +use crate::ids::{ItemId, SpreadId, StoryId}; use crate::table::CellRange; /// A selected ruler guide: index `index` in the guides of page `page` of spread `spread`. @@ -93,6 +93,9 @@ pub struct Selection { /// The page selected with the Page tool (absolute index). #[serde(default, skip_serializing_if = "Option::is_none")] pub page: Option, + /// Parent selected with the Page tool: stable spread id and page index within it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_page: Option<(SpreadId, usize)>, /// Selected ruler guides. Never together with items, text or cells: selecting one clears /// the other. #[serde(default, skip_serializing_if = "Vec::is_empty")] diff --git a/crates/doc/src/story.rs b/crates/doc/src/story.rs index 05adcb35f..bbdbc6409 100644 --- a/crates/doc/src/story.rs +++ b/crates/doc/src/story.rs @@ -39,6 +39,35 @@ pub const FORCED_LINE_BREAK: char = '\u{2028}'; pub const ODD_PAGE_BREAK: char = '\u{E011}'; pub const EVEN_PAGE_BREAK: char = '\u{E012}'; +/// Encode a table-of-contents separator using InDesign's special-character notation. +pub fn toc_separator_notation(text: &str) -> String { + text.replace('^', "^^").replace('\t', "^t").replace(RIGHT_INDENT_TAB, "^y").replace('\u{2003}', "^m").replace('\u{2002}', "^>") +} + +/// Decode TOC notation, preserving unknown codes and an escaped literal caret. +pub fn toc_separator_text(notation: &str) -> String { + let mut text = String::new(); + let mut chars = notation.chars(); + while let Some(ch) = chars.next() { + if ch != '^' { + text.push(ch); + continue; + } + match chars.next() { + Some('t') => text.push('\t'), + Some('y') => text.push(RIGHT_INDENT_TAB), + Some('m') => text.push('\u{2003}'), + Some('>') => text.push('\u{2002}'), + Some('^') | None => text.push('^'), + Some(other) => { + text.push('^'); + text.push(other); + } + } + } + text +} + /// A column, frame or page break character. It ends its paragraph: the story stores it as the /// paragraph's last character, followed by the `\n` separator (also at the end of the story), and /// the next paragraph starts in the next column, frame or page. A break inside a paragraph (from @@ -286,6 +315,24 @@ impl Story { v } + /// The paragraphs `r` touches (at least the one at `r.start`), as (index, byte range without + /// its return), found from `r` itself rather than by listing every paragraph of the story. + pub fn paras_touching(&self, r: std::ops::Range) -> Vec<(usize, std::ops::Range)> { + let start = floor_char_boundary(&self.text, r.start.min(self.text.len())); + let mut pi = self.para_at(start); + let mut a = self.text[..start].rfind('\n').map_or(0, |i| i + 1); + let mut out = Vec::new(); + loop { + let b = self.text[a..].find('\n').map_or(self.text.len(), |i| a + i); + out.push((pi, a..b)); + // The next paragraph starts after this one's return: is it still inside `r`? + if b >= self.text.len() || b + 1 >= r.end { + return out; + } + (a, pi) = (b + 1, pi + 1); + } + } + /// Index of the paragraph containing byte `pos`. pub fn para_at(&self, pos: usize) -> usize { self.text[..floor_char_boundary(&self.text, pos)].bytes().filter(|b| *b == b'\n').count() @@ -385,11 +432,11 @@ impl Story { // Paragraph formats: each new '\n' splits the current paragraph; the new paragraphs copy it. let pi = self.para_at(pos); let newlines = text.bytes().filter(|b| *b == b'\n').count(); - if newlines > 0 { - let f = self.paras[pi].clone(); - for _ in 0..newlines { - self.paras.insert(pi + 1, f.clone()); - } + if newlines > 0 + && let Some(f) = self.paras.get(pi).cloned() + { + // All at once: an insert per return made many returns quadratic (#1588). + self.paras.splice(pi + 1..pi + 1, std::iter::repeat_n(f, newlines)); } self.text.insert_str(pos, text); // Character runs. @@ -639,6 +686,27 @@ mod tests { Story::with_text(StoryId(1), t, ParaFormat::default()) } + /// `paras_touching` gives the paragraphs a range reaches into (returns included), as listing + /// every paragraph would, for every range of a story with empty and multi-byte paragraphs. + #[test] + fn paras_touching_matches_the_full_listing() { + let s = st("ab + +çé +last"); + let all = s.para_ranges(); + for a in 0..=s.len() { + for b in a..=s.len() { + if !s.text.is_char_boundary(a) || !s.text.is_char_boundary(b) { + continue; + } + let want: Vec<(usize, std::ops::Range)> = + all.iter().cloned().enumerate().filter(|(i, p)| *i == s.para_at(a) || (a < b && p.start < b && a <= p.end)).collect(); + assert_eq!(s.paras_touching(a..b), want, "{a}..{b}"); + } + } + } + #[test] fn optical_margin_is_optional_in_saved_stories() { let plain = serde_json::to_value(st("Text")).unwrap(); @@ -669,6 +737,19 @@ mod tests { assert_eq!(s.para_ranges(), vec![0..6]); } + /// Each return inserted its paragraph format on its own, quadratic in the returns (#1588). + #[test] + fn many_returns_split_a_paragraph_at_once() { + let para = ParaFormat { style: "Body".into(), ..ParaFormat::default() }; + let started = std::time::Instant::now(); + let s = Story::with_text(StoryId(1), &"\n".repeat(20_000), para.clone()); + let took = started.elapsed(); + assert!(took < std::time::Duration::from_secs(1), "took {took:?}"); + assert_eq!(s.paras.len(), 20_001); + assert!(s.paras.iter().all(|p| *p == para)); + s.check().unwrap(); + } + #[test] fn char_formats_split_and_merge() { let mut s = st("hello world"); diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index d5342b5c9..3782c0181 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -25,6 +25,32 @@ mod story; pub use cell::{AltFills, AltStrokes, Cell, CellStroke, Column, Row, RowHeightMode, RowKind, TableOptions}; pub use grid::CellRange; +/// Most rows a table holds, as InDesign allows. +pub const MAX_TABLE_ROWS: usize = 10_000; +/// Most columns a table holds, as InDesign allows. +pub const MAX_TABLE_COLS: usize = 1_000; +/// Most cells a table holds, whatever its shape. +/// +/// A [`Cell`] is about 1.4 KB (it carries a [`Story`]), so this is roughly 340 MB of cells at the +/// limit. The row and column limits alone are not enough: 10,000 × 1,000 would be 13.6 GB, and an +/// IDML story holding one `` with 5,000 `` and 5,000 `` -- about 1 KB +/// zipped -- asked for 25M cells, some 34 GB. That allocation does not fail politely: it aborts +/// the process through `handle_alloc_error`, which no `catch_unwind` can save, so every open +/// document's unsaved work goes with it (#1583). +pub const MAX_TABLE_CELLS: usize = 250_000; + +/// The rows and columns a table of `rows × cols` is actually given, inside the limits above. +/// +/// Columns are clamped first and rows take what is left of the cell budget, because a table is +/// far more often long than wide: truncating a 5,000-column table to 1,000 columns and then its +/// rows loses less of it than the other way round. Callers that read a file report the +/// difference; the counts are a document's claim, not something to trust. +pub fn clamped_size(rows: usize, cols: usize) -> (usize, usize) { + let cols = cols.clamp(1, MAX_TABLE_COLS); + let rows = rows.clamp(1, MAX_TABLE_ROWS.min(MAX_TABLE_CELLS / cols).max(1)); + (rows, cols) +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Table { @@ -48,6 +74,48 @@ mod tests { Story::with_text(StoryId(1), t, ParaFormat::default()) } + /// A table's shape comes out of IDML and DOCX files unchecked, and `Cell` is about 1.4 KB, so + /// a file claiming 5,000 x 5,000 asked for 25M cells -- some 34 GB. That allocation aborts the + /// process rather than failing, taking every open document's unsaved work with it (#1583). + #[test] + fn a_table_larger_than_the_limits_is_clamped_not_allocated() { + // The reported shape: 5,000 rows and 5,000 columns. + let t = Table::new(1, 5_000, 5_000, 0, 0, 500.0); + assert_eq!(t.columns.len(), MAX_TABLE_COLS, "columns are clamped to InDesign's limit"); + assert!(t.cells.len() <= MAX_TABLE_CELLS, "{} cells is within the budget", t.cells.len()); + assert_eq!(t.rows.len() * t.columns.len(), t.cells.len(), "the grid stays rectangular"); + // What it would have been: 25,000,000 cells at 1,360 bytes each. + assert!(25_000_000 * std::mem::size_of::() > 30_000_000_000, "the size this test is about"); + // Freed before the next large table, so the peak stays at one of them. + drop(t); + + // A long table keeps its rows while it fits the budget (the budget itself is checked by + // `clamped_size`, which allocates nothing). + let long = Table::new(2, 10_000, 5, 0, 0, 500.0); + assert_eq!((long.rows.len(), long.columns.len()), (10_000, 5), "a long thin table is untouched"); + assert_eq!(clamped_size(10_000, 25), (10_000, 25), "10,000 x 25 is inside the budget"); + drop(long); + + // Past the row limit the rows are clamped too. + let longer = Table::new(3, 50_000, 2, 0, 0, 100.0); + assert_eq!(longer.rows.len(), MAX_TABLE_ROWS, "rows are clamped to InDesign's limit"); + drop(longer); + + // Header, body and footer share the budget, and the table still has a body. + let with_parts = Table::new(4, 5_000, 5_000, 3, 2, 500.0); + assert!(with_parts.rows.len() <= MAX_TABLE_ROWS, "{} rows", with_parts.rows.len()); + assert!(with_parts.cells.len() <= MAX_TABLE_CELLS, "{} cells", with_parts.cells.len()); + assert!(with_parts.rows.iter().any(|r| r.kind == RowKind::Header), "the header survives"); + assert!(with_parts.rows.iter().any(|r| r.kind == RowKind::Footer), "the footer survives"); + assert!(with_parts.rows.iter().any(|r| r.kind == RowKind::Body), "and so does a body row"); + drop(with_parts); + + // A header and footer bigger than the whole budget still leave a body row. + let all_header = Table::new(6, 1, 1, MAX_TABLE_ROWS * 2, MAX_TABLE_ROWS * 2, 100.0); + assert!(all_header.rows.len() <= MAX_TABLE_ROWS, "{} rows", all_header.rows.len()); + assert!(all_header.rows.iter().any(|r| r.kind == RowKind::Body), "a body row survives"); + } + #[test] fn move_row_keeps_header_and_footer_rows_in_place() { let mut t = Table::new(1, 3, 2, 1, 1, 120.0); @@ -88,6 +156,19 @@ mod tests { assert_eq!(right.selected_owners(CellRange::new(1, 0, 1, 1)), vec![(0, 1), (1, 0)], "owner order, not encounter order"); } + /// A short last row's padding cells take the converted text's paragraph and character format, + /// like the cells with text. + #[test] + fn from_strings_formats_the_padding_cells_too() { + let data = vec![vec!["a".to_string(), "b".to_string()], vec!["c".to_string()]]; + let para = ParaFormat { style: "Body".into(), ..Default::default() }; + let fmt = CharFormat { style: "Strong".into(), ..Default::default() }; + let t = Table::from_strings(1, &data, 200.0, ¶, &fmt); + let pad = &t.cell(1, 1).unwrap().text; + assert_eq!((pad.paras[0].style.as_str(), pad.chars[0].format.style.as_str()), ("Body", "Strong")); + assert_eq!(pad.text, ""); + } + #[test] fn insert_table_gets_own_paragraph() { let mut s = story("before after"); diff --git a/crates/doc/src/table/grid.rs b/crates/doc/src/table/grid.rs index a64f2cd99..6a11ffcd1 100644 --- a/crates/doc/src/table/grid.rs +++ b/crates/doc/src/table/grid.rs @@ -25,8 +25,16 @@ impl CellRange { impl Table { /// A `rows × cols` table (`header` + body + `footer` rows) whose columns share `width`. + /// + /// The shape is clamped to [`clamped_size`](super::clamped_size): the counts reach this from + /// IDML and DOCX files, where they are whatever the file says (#1583). pub fn new(id: u64, body_rows: usize, cols: usize, header: usize, footer: usize, width: f64) -> Table { - let cols = cols.max(1); + // Header, body and footer rows share one budget: the total is clamped first, then the + // header and footer take what they can of it, and the body keeps at least one row. + let (total, cols) = super::clamped_size(body_rows.max(1).saturating_add(header).saturating_add(footer), cols); + let header = header.min(total.saturating_sub(1)); + let footer = footer.min(total.saturating_sub(header).saturating_sub(1)); + let body_rows = total.saturating_sub(header).saturating_sub(footer).max(1); let mut rows = Vec::new(); rows.extend((0..header).map(|_| Row { kind: RowKind::Header, ..Default::default() })); rows.extend((0..body_rows.max(1)).map(|_| Row::default())); @@ -47,12 +55,15 @@ impl Table { pub fn from_strings(id: u64, data: &[Vec], width: f64, para: &ParaFormat, fmt: &CharFormat) -> Table { let cols = data.iter().map(Vec::len).max().unwrap_or(1).max(1); let mut t = Table::new(id, data.len().max(1), cols, 0, 0, width); - for (r, row) in data.iter().enumerate() { - for (c, s) in row.iter().enumerate() { + // Every cell takes the text's format, the padding of a short row too. + for r in 0..t.nrows() { + for c in 0..t.ncols() { let Some(cell) = t.cell_mut(r, c) else { continue }; cell.text = Story::with_text(StoryId(0), "", ParaFormat { table: None, ..para.clone() }); cell.text.chars[0].format = fmt.clone(); - cell.text.insert(0, s); + if let Some(s) = data.get(r).and_then(|row| row.get(c)) { + cell.text.insert(0, s); + } } } t @@ -172,14 +183,19 @@ impl Table { /// Cell texts, rows separated by `\n`, cells by `\t` (Convert Table to Text, search, agents). pub fn plain_text(&self) -> String { + self.text_with("\t", "\n") + } + + /// The cells' text, columns separated by `col` and rows by `row` (Convert Table to Text). + pub fn text_with(&self, col: &str, row: &str) -> String { let mut out = String::new(); for r in 0..self.nrows() { if r > 0 { - out.push('\n'); + out.push_str(row); } for c in 0..self.ncols() { if c > 0 { - out.push('\t'); + out.push_str(col); } if let Some(cell) = self.cell(r, c) { out.push_str(&cell.text.text.replace('\n', " ")); diff --git a/crates/doc/src/vars.rs b/crates/doc/src/vars.rs index c53eedb1a..4cbf73605 100644 --- a/crates/doc/src/vars.rs +++ b/crates/doc/src/vars.rs @@ -175,6 +175,9 @@ pub fn parse_iso_date(s: &str) -> Option { let t = time.get(a..b)?; t.bytes().all(|c| c.is_ascii_digit()).then(|| t.parse().ok()).flatten() }; + if time.get(2..3) != Some(":") { + return None; + } let (h, mi) = (tnum(0, 2)?, tnum(3, 5)?); let mut i = 5; let mut sec = 0; @@ -290,6 +293,12 @@ impl Document { mod tests { use super::*; + #[test] + fn iso_date_requires_colon_separator() { + assert_eq!(parse_iso_date("2019-12-16T09:48:00Z"), Some(1_576_489_680)); + assert_eq!(parse_iso_date("2019-12-16T09x48:00Z"), None); + } + #[test] fn dates() { // 2026-10-01 14:05 UTC diff --git a/crates/doc/src/xref.rs b/crates/doc/src/xref.rs index 96e584862..080112c2f 100644 --- a/crates/doc/src/xref.rs +++ b/crates/doc/src/xref.rs @@ -29,13 +29,28 @@ pub struct TextAnchor { pub name: String, } -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CrossRef { /// The destination anchor (0 = unresolved, e.g. pasted without its destination). pub target: u64, /// Name of the format in [`Document::xref_formats`]. pub format: String, + /// Text typed over the generated text (InDesign keeps a cross-reference's edited text until + /// it is updated): shown as is, except that each `` shows the destination's page. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub text: Option, + /// The page last shown (for edited text whose destination is gone). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub page: Option, +} + +/// The `` building block, as it appears in [`CrossRef::text`]. +pub const PAGE_NUM_BLOCK: &str = ""; + +/// Edited cross-reference text with its page numbers filled in (quotes kept as typed). +pub fn expand_edited(text: &str, page: &str) -> String { + text.replace(PAGE_NUM_BLOCK, page) } /// A cross-reference format: building blocks between literal text. @@ -84,7 +99,7 @@ pub fn expand(definition: &str, v: &XrefValues) -> String { let mut rest = definition; while let Some(i) = rest.find('<') { out.push_str(&rest[..i]); - let Some(j) = rest[i..].find('>') else { + let Some(j) = tag_end(&rest[i..]) else { out.push_str(&rest[i..]); return out; }; @@ -120,6 +135,20 @@ pub fn expand(definition: &str, v: &XrefValues) -> String { typographic_quotes(&out) } +/// Byte index of the `>` that ends the tag starting at `s`, skipping quoted attribute values. +/// An unterminated quote falls back to the first `>`, so only that malformed tag is affected. +fn tag_end(s: &str) -> Option { + let mut in_quote = false; + for (k, c) in s.char_indices() { + match c { + '"' => in_quote = !in_quote, + '>' if !in_quote => return Some(k), + _ => {} + } + } + s.find('>') +} + /// Straight double quotes in format text become typographer's quotes. fn typographic_quotes(s: &str) -> String { let mut out = String::with_capacity(s.len()); @@ -198,7 +227,7 @@ impl Story { if n > 0 { let k = self.marks_before(XREF_MARK, pos); for i in 0..n { - self.xrefs.insert(k + i, Arc::new(CrossRef { target: 0, format: String::new() })); + self.xrefs.insert(k + i, Arc::new(CrossRef::default())); } } let n = text.matches(crate::index::INDEX_MARK).count(); @@ -268,7 +297,7 @@ impl Story { let n = self.text.matches(XREF_MARK).count(); self.xrefs.truncate(n); while self.xrefs.len() < n { - self.xrefs.push(Arc::new(CrossRef { target: 0, format: String::new() })); + self.xrefs.push(Arc::new(CrossRef::default())); } let n = self.text.matches(crate::index::INDEX_MARK).count(); self.index_refs.truncate(n); @@ -310,6 +339,35 @@ impl Story { } impl Document { + /// The text a cross-reference to `target` in `format` generates, with each page number as + /// [`PAGE_NUM_BLOCK`] (the form saved text takes); `None` when the destination is gone. + pub fn generated_xref_text(&self, target: u64, format: &str) -> Option { + let mut v = self.xref_values(target)?; + v.page = PAGE_NUM_BLOCK.into(); + Some(expand(self.xref_format(format).map_or("", |f| f.definition.as_str()), &v)) + } + + /// Forget saved cross-reference text that is exactly what its format generates for its + /// destination, so it is generated again (and follows edits to the destination). Any other + /// saved text was typed over the generated text, or its destination is gone: it is kept as + /// shown. + pub fn drop_generated_xref_text(&mut self) { + let generated: Vec<(StoryId, usize)> = self + .stories + .iter() + .flat_map(|(sid, st)| st.xrefs.iter().enumerate().map(move |(k, x)| (*sid, k, x))) + .filter(|(_, _, x)| x.text.as_ref().is_some_and(|t| self.generated_xref_text(x.target, &x.format).as_ref() == Some(t))) + .map(|(sid, k, _)| (sid, k)) + .collect(); + for (sid, k) in generated { + if let Some(x) = self.story_mut(sid).and_then(|st| st.xrefs.get_mut(k)) { + let x = Arc::make_mut(x); + x.text = None; + x.page = None; + } + } + } + /// (story, byte) of anchor `id`. pub fn find_anchor(&self, id: u64) -> Option<(StoryId, usize)> { if id == 0 { @@ -388,6 +446,24 @@ pub fn clean(s: &str) -> String { mod tests { use super::*; + #[test] + fn gt_inside_quoted_attribute_does_not_end_tag() { + let v = XrefValues { para: "Intro > the start".into(), ..Default::default() }; + assert_eq!(expand("\" includeDelim=\"false\" />|", &v), "Intro |"); + } + + #[test] + fn unterminated_quote_only_affects_its_own_tag() { + let v = XrefValues { para: "Intro > the start".into(), ..Default::default() }; + let out = expand(" ", &v); + assert!(out.contains("Intro > the start"), "{out}"); + } + + #[test] + fn edited_text_fills_in_its_page_numbers() { + assert_eq!(expand_edited("p. and ", "4"), "p. 4 and 4"); + } + #[test] fn formats_expand() { let v = @@ -407,7 +483,7 @@ mod tests { let mut s = Story::with_text(StoryId(3), "Heading\nBody text.", Default::default()); let a = s.insert_anchor(0, "Heading"); assert_eq!(a >> 24, 3); - s.insert_xref(13, CrossRef { target: a, format: "Page Number".into() }); + s.insert_xref(13, CrossRef { target: a, format: "Page Number".into(), ..Default::default() }); s.check().unwrap(); assert_eq!(s.anchor_pos(a), Some(0)); s.insert(0, "New "); diff --git a/crates/engine/src/cmd/anchors.rs b/crates/engine/src/cmd/anchors.rs index fec81de4b..4ccbeec7e 100644 --- a/crates/engine/src/cmd/anchors.rs +++ b/crates/engine/src/cmd/anchors.rs @@ -66,6 +66,15 @@ pub fn specs() -> Vec { has_selection, join_paths ), + cmd!( + "path.deletePoints", + "Delete Points", + [], + None, + "{points?: [[id, subpath, index]]} — the Direct Selection tool's selected points by default, in one undo step (Properties › Quick Actions › Delete Point(s)); a subpath left with one point goes, and an object with none is removed → {deleted, removed: [id]}", + has_doc, + delete_points + ), cmd!( "path.convertPoints", "Convert Point", @@ -162,6 +171,66 @@ fn join_paths(s: &mut Session, p: &Value) -> Result { }) } +/// Delete Point(s): removes the given points, or the ones selected with Direct Selection. +fn delete_points(s: &mut Session, p: &Value) -> Result { + const ID: &str = "path.deletePoints"; + let points: Vec<(ItemId, usize, usize)> = match p.get("points") { + Some(v) => serde_json::from_value::>(v.clone()) + .map_err(|e| bad(ID, format!("points: [[id, subpath, index], …] ({e})")))? + .into_iter() + .map(|(id, si, ai)| (ItemId(id), si, ai)) + .collect(), + None => s.doc()?.selection.anchors.clone(), + }; + if points.is_empty() { + return Err(bad(ID, "no points selected")); + } + // Each subpath's points, worked from the last subpath and the last point back, so the indices + // still to delete keep their place. + let mut groups: Vec<((ItemId, usize), Vec)> = Vec::new(); + for (id, si, ai) in points { + match groups.iter_mut().find(|(k, _)| *k == (id, si)) { + Some((_, v)) => v.push(ai), + None => groups.push(((id, si), vec![ai])), + } + } + groups.sort_by_key(|((id, si), _)| std::cmp::Reverse((id.0, *si))); + s.edit(|d, sel| { + let (mut deleted, mut removed) = (0usize, Vec::new()); + for ((id, si), mut ais) in groups { + if d.item(id).is_none() { + continue; + } + path_editable(d, id, ID)?; + let Some(it) = d.item_mut(id) else { continue }; + let Some(sp) = it.path.subpaths.get_mut(si) else { continue }; + ais.sort_unstable(); + ais.dedup(); + for ai in ais.into_iter().rev() { + if ai < sp.anchors.len() { + sp.anchors.remove(ai); + deleted += 1; + } + } + // A subpath needs two points (three to stay closed). + let (left, closed) = (sp.anchors.len(), sp.closed); + if left < 2 { + it.path.subpaths.remove(si); + } else if closed && left < 3 { + sp.closed = false; + } + as_path(&mut it.shape); + if it.path.subpaths.is_empty() { + d.remove_item(id)?; + sel.items.retain(|i| *i != id); + removed.push(id.0); + } + } + sel.anchors.clear(); + Ok(json!({"deleted": deleted, "removed": removed})) + }) +} + /// Convert Point: the selected points (or every point of the selected paths) to a point type. fn convert_points(s: &mut Session, p: &Value) -> Result { let kind = super::str_param(p, "kind").unwrap_or("plain").to_string(); @@ -232,7 +301,7 @@ fn erase_or_smooth(s: &mut Session, p: &Value, smooth: bool) -> Result { .and_then(Value::as_array) .map(|a| a.iter().filter_map(|v| Some(Point::new(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?))).collect()) .unwrap_or_default(); - s.edit(|d, _| { + s.edit(|d, sel| { path_editable(d, id, cmd)?; let inv = to_inner(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?; let scale = inv.determinant().abs().sqrt().max(1e-9); @@ -257,6 +326,8 @@ fn erase_or_smooth(s: &mut Session, p: &Value, smooth: bool) -> Result { return Err(bad(cmd, "the drag didn't touch the path")); }; let sp = it.path.subpaths[si].clone(); + // The subpath list changes under the stored point indices: they would name other points. + sel.anchors.retain(|a| a.0 != id); if smooth { it.path.subpaths[si] = designcraft_geom::edit_path::smooth(&sp, a, b, tol * scale); } else { @@ -299,7 +370,7 @@ fn add_anchor(s: &mut Session, p: &Value) -> Result { let id = super::id_param(p, "id").ok_or_else(|| bad("path.addAnchor", "missing id"))?; let at = point_param(p, "at").ok_or_else(|| bad("path.addAnchor", "missing at"))?; let tol = p.get("tolerance").and_then(Value::as_f64).unwrap_or(4.0); - s.edit(|d, _| { + s.edit(|d, sel| { path_editable(d, id, "path.addAnchor")?; let inv = to_inner(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?; let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; @@ -313,6 +384,10 @@ fn add_anchor(s: &mut Session, p: &Value) -> Result { let t = t.clamp(1e-4, 1.0 - 1e-4); let ai = it.path.subpaths[si].insert_anchor(seg, t); as_path(&mut it.shape); + // Selected points after the new one move up an index, so they stay the same points. + for a in sel.anchors.iter_mut().filter(|a| a.0 == id && a.1 == si && a.2 >= ai) { + a.2 += 1; + } Ok(json!({"subpath": si, "anchor": ai})) }) } @@ -328,15 +403,27 @@ fn delete_anchor(s: &mut Session, p: &Value) -> Result { } sp.anchors.remove(ai); // A subpath needs two points (three to stay closed). - if sp.anchors.len() < 2 { + let removed_subpath = sp.anchors.len() < 2; + if removed_subpath { it.path.subpaths.remove(si); } else if sp.closed && sp.anchors.len() < 3 { sp.closed = false; } as_path(&mut it.shape); + // The deleted point leaves the selection; the selected points after it (or the subpaths + // after a removed one) move down an index. + sel.anchors.retain(|a| !(a.0 == id && a.1 == si && (a.2 == ai || removed_subpath))); + for a in sel.anchors.iter_mut().filter(|a| a.0 == id) { + if removed_subpath && a.1 > si { + a.1 -= 1; + } else if !removed_subpath && a.1 == si && a.2 > ai { + a.2 -= 1; + } + } if it.path.subpaths.is_empty() { d.remove_item(id)?; sel.items.retain(|i| *i != id); + sel.anchors.retain(|a| a.0 != id); return Ok(json!({"deleted": true})); } Ok(json!({"deleted": false})) @@ -426,6 +513,27 @@ mod tests { } /// Join Path closes one open path, and joins two open paths at their nearest ends. + /// `path.moveAnchors` points may each carry their own movement (Direct Selection sends each + /// point's in its own spread); without one a point takes the shared `dx`, `dy`. + #[test] + fn move_anchors_takes_a_movement_per_point() { + let mut s = crate::Session::new(); + s.execute("file.new", &serde_json::json!({})).unwrap(); + let id = s.execute("frame.create", &serde_json::json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + let at = |s: &crate::Session, i: usize| { + let d = &s.doc().unwrap().doc; + let it = d.item(designcraft_doc::ItemId(id)).unwrap(); + it.xf * it.path.subpaths[0].anchors[i].p + }; + let (a0, a1) = (at(&s, 0), at(&s, 1)); + s.execute("path.moveAnchors", &serde_json::json!({"points": [[id, 0, 0, 5, 0], [id, 0, 1]], "dx": 0, "dy": 7})).unwrap(); + assert_eq!(at(&s, 0) - a0, designcraft_geom::Vec2::new(5.0, 0.0), "its own"); + assert_eq!(at(&s, 1) - a1, designcraft_geom::Vec2::new(0.0, 7.0), "the shared one"); + for bad in [serde_json::json!([[id, 0, 0, "x", 1]]), serde_json::json!([[id, 0, 0, 5]])] { + assert!(s.execute("path.moveAnchors", &serde_json::json!({"points": bad})).is_err(), "{bad}"); + } + } + #[test] fn join_path_closes_one_path_and_joins_two() { let mut s = Session::new(); @@ -454,6 +562,63 @@ mod tests { } /// Convert Point turns the selected points (or a whole path's) plain, corner, smooth or symmetrical. + /// Delete Point(s) removes the selected points of several paths in one step; a triangle left + /// with two points opens, and Undo brings every point back. + #[test] + fn delete_points_removes_the_selected_points_of_several_paths() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [72, 72, 172, 172]})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("frame.create", &json!({"rect": [200, 72, 300, 172], "shape": "polygon", "sides": 3})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"anchors": [[a, 0, 0], [a, 0, 2], [b, 0, 1]]})).unwrap(); + let r = s.execute("path.deletePoints", &json!({})).unwrap(); + assert_eq!(r["deleted"], 3); + let d = &s.doc().unwrap().doc; + let pa = &d.item(ItemId(a)).unwrap().path.subpaths[0]; + assert_eq!(pa.anchors.len(), 2, "two of the square's four points are left"); + assert!(!pa.closed, "two points can't stay closed"); + assert_eq!(d.item(ItemId(b)).unwrap().path.subpaths[0].anchors.len(), 2); + assert!(s.doc().unwrap().selection.anchors.is_empty()); + s.execute("edit.undo", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.item(ItemId(a)).unwrap().path.subpaths[0].anchors.len(), 4, "one undo step"); + assert_eq!(d.item(ItemId(b)).unwrap().path.subpaths[0].anchors.len(), 3); + // A line's two points: the line goes. + let l = s.execute("path.create", &json!({"anchors": [{"p": [72, 300]}, {"p": [172, 300]}]})).unwrap()["id"].as_u64().unwrap(); + let r = s.execute("path.deletePoints", &json!({"points": [[l, 0, 0]]})).unwrap(); + assert_eq!(r["removed"], json!([l])); + assert!(s.doc().unwrap().doc.item(ItemId(l)).is_none()); + assert!(s.execute("path.deletePoints", &json!({"points": []})).is_err()); + // Named points need no selected object. + s.execute("selection.set", &json!({"ids": []})).unwrap(); + assert!(s.doc().unwrap().selection.items.is_empty()); + let r = s.execute("path.deletePoints", &json!({"points": [[b, 0, 0]]})).unwrap(); + assert_eq!(r["deleted"], 1); + // Edit › Clear with points selected deletes the points, not their paths. + s.execute("selection.set", &json!({"anchors": [[a, 0, 0]]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.item(ItemId(a)).unwrap().path.subpaths[0].anchors.len(), 3, "one point gone, the frame stays"); + // Cut copies whole objects, so it removes whole objects. + s.execute("selection.set", &json!({"anchors": [[a, 0, 0]]})).unwrap(); + s.execute("edit.cut", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.item(ItemId(a)).is_none(), "the cut object is gone"); + } + + /// Shift-adding an object to a point selection drops the points, so Clear deletes the objects. + #[test] + fn clear_after_adding_an_object_to_a_point_selection_deletes_objects() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [72, 72, 172, 172]})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("frame.create", &json!({"rect": [200, 72, 300, 172]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"anchors": [[a, 0, 0]]})).unwrap(); + s.execute("selection.set", &json!({"ids": [b], "add": true})).unwrap(); + assert!(s.doc().unwrap().selection.anchors.is_empty()); + s.execute("edit.clear", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.item(ItemId(a)).is_none()); + assert!(s.doc().unwrap().doc.item(ItemId(b)).is_none()); + } + #[test] fn convert_point_kinds() { let mut s = Session::new(); @@ -486,16 +651,45 @@ mod tests { let pts: Vec = (0..=20).map(|i| json!({"p": [100.0 + i as f64 * 20.0, if i % 2 == 0 { 300.0 } else { 306.0 }]})).collect(); let id = s.execute("path.create", &json!({"anchors": pts})).unwrap()["id"].as_u64().unwrap(); let n = s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.anchor_count(); + s.execute("selection.set", &json!({"anchors": [[id, 0, 20]]})).unwrap(); let drag: Vec = (0..=12).map(|i| json!([150.0 + i as f64 * 20.0, 303.0])).collect(); s.execute("path.smooth", &json!({"id": id, "points": drag})).unwrap(); let after = s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.anchor_count(); assert!(after < n, "{after} < {n}"); + assert!(s.doc().unwrap().selection.anchors.is_empty(), "stored point indices are stale after the path is rebuilt"); let drag: Vec = (0..=5).map(|i| json!([200.0 + i as f64 * 20.0, 303.0])).collect(); let r = s.execute("path.erase", &json!({"id": id, "points": drag})).unwrap(); assert_eq!(r["subpaths"], 2, "a gap in the middle"); assert!(s.execute("path.erase", &json!({"id": id, "points": [[100, 600]]})).is_err()); } + /// The Direct Selection's selected points stay the same points when the path changes under + /// them: a point added before them, one of them deleted, the path reversed. + #[test] + fn selected_anchors_follow_added_deleted_and_reversed_points() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + let point = |s: &Session, a: (usize, usize)| s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.subpaths[a.0].anchors[a.1].p; + s.execute("selection.set", &json!({"anchors": [[id, 0, 2], [id, 0, 3]]})).unwrap(); + let picked = |s: &Session| s.doc().unwrap().selection.anchors.iter().map(|a| (a.1, a.2)).collect::>(); + let before: Vec<_> = picked(&s).into_iter().map(|a| point(&s, a)).collect(); + // A point added on the first side, before both. + let first = point(&s, (0, 0)); + let second = point(&s, (0, 1)); + let mid = [(first.x + second.x) / 2.0, (first.y + second.y) / 2.0]; + s.execute("path.addAnchor", &json!({"id": id, "at": mid})).unwrap(); + assert_eq!(picked(&s).into_iter().map(|a| point(&s, a)).collect::>(), before, "after an added point"); + // One of them deleted: the other stays selected, the same point. + let (si, ai) = picked(&s)[0]; + s.execute("path.deleteAnchor", &json!({"id": id, "subpath": si, "anchor": ai})).unwrap(); + assert_eq!(picked(&s).len(), 1); + assert_eq!(point(&s, picked(&s)[0]), before[1], "after a deleted point"); + // Reversed: still the same point. + s.execute("path.reverse", &json!({"ids": [id]})).unwrap(); + assert_eq!(point(&s, picked(&s)[0]), before[1], "after reversing"); + } + #[test] fn add_convert_delete_anchor_points() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/datamerge.rs b/crates/engine/src/cmd/datamerge.rs index e96feb9fb..4d3cd5dd6 100644 --- a/crates/engine/src/cmd/datamerge.rs +++ b/crates/engine/src/cmd/datamerge.rs @@ -18,7 +18,7 @@ use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_doc, id_param, ok, str_param}; use crate::{DocState, Result, Session}; -use fill::{FillReport, Maps, MissingImage, Sources, copy_item, fill_row, identity_map}; +use fill::{FillReport, ImageCache, Maps, MissingImage, Sources, copy_item, fill_row, identity_map}; use parse::{ Table, TileInput, extension, fingerprint_of, relative_between, select_records, status_of, table_from_bytes, table_from_grid, table_from_objects, tile, @@ -210,7 +210,8 @@ fn preview_cmd(s: &mut Session, p: &Value) -> Result { let row = source.rows.get((record as usize).saturating_sub(1)).cloned().unwrap_or_default(); let data_dir = source_file(&source, st.path.as_deref().map(Path::new)).as_deref().and_then(Path::parent).map(Path::to_path_buf); let doc_dir = st.path.as_deref().and_then(parent_dir); - let sources = Sources { options: &options, data_dir: data_dir.as_deref(), doc_dir: doc_dir.as_deref() }; + let images = ImageCache::default(); + let sources = Sources { options: &options, data_dir: data_dir.as_deref(), doc_dir: doc_dir.as_deref(), images: &images }; fill_row(&mut doc, &source.fields, &row, record as u32, &maps, &sources); st.doc = Arc::new(doc); st.preview_record = Some(record as u32); @@ -246,7 +247,8 @@ fn merge_cmd(s: &mut Session, p: &Value) -> Result { warnings.extend(table.warnings.iter().cloned()); let mut merged = src.clone(); let mut missing = Vec::new(); - let sources = Sources { options: &options, data_dir: data_dir.as_deref(), doc_dir: doc_dir.as_deref() }; + let images = ImageCache::default(); + let sources = Sources { options: &options, data_dir: data_dir.as_deref(), doc_dir: doc_dir.as_deref(), images: &images }; let mut sink = Sink { warnings: &mut warnings, missing: &mut missing, parent: false }; if options.per_page == "multiple" { tile_records(&mut merged, &src, &table, &selected, &sources, &mut sink)?; diff --git a/crates/engine/src/cmd/datamerge/fill.rs b/crates/engine/src/cmd/datamerge/fill.rs index 629258d6d..53fd36fd5 100644 --- a/crates/engine/src/cmd/datamerge/fill.rs +++ b/crates/engine/src/cmd/datamerge/fill.rs @@ -4,7 +4,7 @@ //! unfilled template. A placeholder whose frame or story is not in the map is left as it is. use std::collections::HashMap; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; use designcraft_doc::{ @@ -134,6 +134,44 @@ pub struct Sources<'a> { pub options: &'a MergeOptions, pub data_dir: Option<&'a Path>, pub doc_dir: Option<&'a Path>, + /// Images this merge has already read, by the path they resolved to. + /// + /// Every record used to stat and read its image again, so a 100,000-row sheet naming the same + /// file read it 100,000 times -- up to 256 MiB each, around 25 TB of I/O for one merge + /// (#1595). Shared by `&`, so the cache is behind a `RefCell`. + pub images: &'a ImageCache, +} + +/// Image files read during one merge: the bytes by path, and the total read so far. +#[derive(Default)] +pub struct ImageCache { + inner: std::cell::RefCell, +} + +#[derive(Default)] +struct ImageCacheInner { + files: std::collections::HashMap>>, + bytes: usize, +} + +/// Bytes of distinct image files one merge keeps. Past this the cache stops growing and files are +/// read as before, so a merge over many large images is slow rather than out of memory. +const MAX_CACHED_IMAGE_BYTES: usize = 256 << 20; + +impl ImageCache { + /// The bytes of `path`, reading it once per merge. `read` is called only on a miss. + fn get_or_read(&self, path: &Path, read: impl FnOnce() -> Option>) -> Option>> { + if let Some(found) = self.inner.borrow().files.get(path) { + return Some(found.clone()); + } + let bytes = std::sync::Arc::new(read()?); + let mut inner = self.inner.borrow_mut(); + if inner.bytes.saturating_add(bytes.len()) <= MAX_CACHED_IMAGE_BYTES { + inner.bytes = inner.bytes.saturating_add(bytes.len()); + inner.files.insert(path.to_path_buf(), bytes.clone()); + } + Some(bytes) + } } /// Replacements and hyperlinks collected while walking the placeholders of one record. @@ -360,15 +398,20 @@ fn create_link(doc: &mut designcraft_doc::Document, anchor: PlaceholderAnchor, d } fn place_image(doc: &mut designcraft_doc::Document, item: ItemId, field: &str, cell: &str, record: u32, sources: &Sources, report: &mut FillReport) { - let Sources { options, data_dir, doc_dir } = *sources; + let Sources { options, data_dir, doc_dir, images } = *sources; let cands = image_candidates(cell, data_dir, doc_dir); let found = cands.iter().find_map(|p| { - // Refuse huge files before reading them; a too-large image counts as missing. - let len = crate::io::metadata(p).ok()?.len(); - if len == 0 || len > super::MAX_IMAGE_FILE_BYTES { - return None; - } - crate::io::read_file(p).ok().filter(|b| !b.is_empty()).map(|b| (p.clone(), b)) + // Read once per merge: many records naming one file is the normal case, and reading it + // per record is the amplification (#1595). + let bytes = images.get_or_read(p, || { + // Refuse huge files before reading them; a too-large image counts as missing. + let len = crate::io::metadata(p).ok()?.len(); + if len == 0 || len > super::MAX_IMAGE_FILE_BYTES { + return None; + } + crate::io::read_file(p).ok().filter(|b| !b.is_empty()) + })?; + Some((p.clone(), bytes)) }); let Some((path, bytes)) = found else { if let Some(it) = doc.item_mut(item) { @@ -430,7 +473,9 @@ fn place_image(doc: &mut designcraft_doc::Document, item: ItemId, field: &str, c name, mime: designcraft_render::image_mime(&bytes).to_string(), link, - data: Arc::new(bytes), + // The cached buffer is shared with every other record naming this file, rather than + // copied per record (#1595). + data: bytes, pixels: Some((pw, ph)), ..Default::default() }); @@ -523,20 +568,56 @@ mod link_tests { } } -#[cfg(all(test, unix))] +#[cfg(test)] mod image_path_tests { use std::path::Path; use super::image_candidates; + /// A merge image field names a file, and a field that names **another computer** must resolve + /// to nothing: a UNC path is how a hostile data file turns a merge into an outbound SMB + /// connection, and on Windows that means handing over NTLM credentials. This ran only on + /// unix, so the refusal was unverified on the one platform where UNC means anything (#1601). #[test] - fn image_cells_resolve_beside_the_data_and_never_to_other_computers() { + fn an_image_cell_never_resolves_to_another_computer() { let (data, doc) = (Path::new("/work/data"), Path::new("/work/doc")); for cell in [r"\\192.0.2.1\share\x.png", "file://192.0.2.1/s/x.png", "//192.0.2.1/s/x.png", "https://example.com/x.png"] { assert!(image_candidates(cell, Some(data), Some(doc)).is_empty(), "{cell}"); } - let found = image_candidates("img/x.png", Some(data), Some(doc)); - assert_eq!(found, [data.join("img").join("x.png"), doc.join("img").join("x.png")]); + // A field naming nothing resolves to nothing, whatever the platform. + assert!(image_candidates("", Some(data), Some(doc)).is_empty(), "an empty cell"); assert!(image_candidates("img/x.png", None, None).is_empty(), "not beside the working directory"); } + + /// The Windows shapes `craft-docpath` refuses, asserted where they have meaning: the extended + /// UNC prefix, a device namespace path, and a legacy device name (with an extension, since a + /// reserved name is reserved whatever follows it). + #[cfg(windows)] + #[test] + fn windows_device_and_network_paths_are_refused() { + let (data, doc) = (Path::new(r"C:\work\data"), Path::new(r"C:\work\doc")); + for cell in [r"\\?\UNC\192.0.2.1\share\x.png", r"\\.\pipe\x", r"\\?\GLOBALROOT\Device\HarddiskVolume1\x", "CON", "NUL.png"] { + assert!(image_candidates(cell, Some(data), Some(doc)).is_empty(), "{cell}"); + } + } + + /// An extended-length path is not one of them: `\\?\C:\x` names a local file, the same one + /// `C:\x` names, and `craft-docpath`'s own suite classifies it `PathKind::Local`. A data file + /// could already give the plain spelling, so refusing this one would shut out long paths + /// without shutting anything out (noted in review). + #[cfg(windows)] + #[test] + fn an_extended_length_drive_path_is_a_local_file() { + let (data, doc) = (Path::new(r"C:\work\data"), Path::new(r"C:\work\doc")); + assert!(!image_candidates(r"\\?\C:\secret\x.png", Some(data), Some(doc)).is_empty()); + } + + /// Resolution beside the data and the document, with absolute paths this platform means. + #[cfg(unix)] + #[test] + fn an_image_cell_resolves_beside_the_data_then_the_document() { + let (data, doc) = (Path::new("/work/data"), Path::new("/work/doc")); + let found = image_candidates("img/x.png", Some(data), Some(doc)); + assert_eq!(found, [data.join("img").join("x.png"), doc.join("img").join("x.png")]); + } } diff --git a/crates/engine/src/cmd/datamerge/parse.rs b/crates/engine/src/cmd/datamerge/parse.rs index 6cc838c3c..dfc49e842 100644 --- a/crates/engine/src/cmd/datamerge/parse.rs +++ b/crates/engine/src/cmd/datamerge/parse.rs @@ -210,6 +210,10 @@ fn select_range(range: &str, count: usize, idx: &mut Vec, seen: &mut std: } fn parse_record_no(s: &str) -> Result { + // `parse` alone would accept a leading '+'. + if !s.bytes().all(|b| b.is_ascii_digit()) { + return Err(format!("bad record \"{s}\"")); + } s.parse::().map_err(|_| format!("bad record \"{s}\"")) } diff --git a/crates/engine/src/cmd/datamerge/tests.rs b/crates/engine/src/cmd/datamerge/tests.rs index 79e16027e..212175d78 100644 --- a/crates/engine/src/cmd/datamerge/tests.rs +++ b/crates/engine/src/cmd/datamerge/tests.rs @@ -519,6 +519,69 @@ fn typed_fields_keep_style_and_unknown_markers() { assert_eq!(story.format_after(at).over.size, Some(21.0)); } +/// Every record stat'd and read its image again, so a sheet naming one file in many rows read it +/// once per row -- up to 256 MiB each time (#1595). A merge reads a file once and the records +/// share the bytes. +#[test] +fn one_image_named_by_many_records_is_read_once() { + let dir = scratch("image-reads"); + let data = dir.join("data"); + std::fs::create_dir_all(&data).unwrap(); + let shared = dir.join("shared.png"); + write_png(&shared); + // 25 records, all naming the same file. + let rows: String = (0..25).map(|_| format!("{}\n", shared.display())).collect(); + write(&data.join("many.csv"), &format!("@Photo\n{rows}")); + + let mut s = Session::new(); + new_doc(&mut s, 1); + exec(&mut s, "data.source.select", json!({"path": data.join("many.csv")})); + let frame = exec(&mut s, "frame.create", json!({"rect": [36.0, 36.0, 80.0, 80.0], "content": "graphic"})); + exec(&mut s, "data.placeholder.add", json!({"field": "Photo", "item": frame["id"]})); + let r = exec(&mut s, "data.merge", json!({})); + assert_eq!(r["records"], 25); + assert_eq!(r["missingImages"].as_array().map(Vec::len).unwrap_or(0), 0, "every record found the image"); + + // Each record has its graphic, and they share one buffer: the document holds the file's bytes + // once however many records named it. + let doc = &s.documents()[s.active_index().unwrap()].doc; + let graphics: Vec<_> = doc + .spreads + .iter() + .flat_map(|sp| &sp.items) + .filter_map(|it| match &it.content { + Content::Graphic(g) => Some(g.asset), + _ => None, + }) + .collect(); + assert_eq!(graphics.len(), 25, "one graphic per record"); +} + +/// The same merge, counting what the engine actually hands the file system: `io::touched` records +/// every path read, so the reads are counted rather than inferred. The document already shares +/// identical asset bytes, so memory alone does not show this -- the I/O is the bug (#1595). +#[test] +fn a_merge_reads_a_repeated_image_once_not_once_per_record() { + let dir = scratch("image-read-count"); + let data = dir.join("data"); + std::fs::create_dir_all(&data).unwrap(); + let shared = dir.join("shared.png"); + write_png(&shared); + let rows: String = (0..25).map(|_| format!("{}\n", shared.display())).collect(); + write(&data.join("many.csv"), &format!("@Photo\n{rows}")); + + let mut s = Session::new(); + new_doc(&mut s, 1); + exec(&mut s, "data.source.select", json!({"path": data.join("many.csv")})); + let frame = exec(&mut s, "frame.create", json!({"rect": [36.0, 36.0, 80.0, 80.0], "content": "graphic"})); + exec(&mut s, "data.placeholder.add", json!({"field": "Photo", "item": frame["id"]})); + + let (r, seen) = crate::io::touched(|| exec(&mut s, "data.merge", json!({}))); + assert_eq!(r["records"], 25); + let reads = seen.iter().filter(|p| p.ends_with("shared.png")).count(); + assert!(reads <= 2, "the image is read once for the merge, not once per record: {reads} reads for 25 records"); +} + #[test] fn images_resolve_in_order_and_roles_win() { let dir = scratch("images"); @@ -1116,6 +1179,14 @@ fn select_records_dedupes_large_ranges_in_first_seen_order() { assert_eq!(&idx[..3], &[4, 0, 1]); } +#[test] +fn select_records_rejects_signed_record_numbers() { + for (tok, bad) in [("+2", "+2"), ("1-+3", "+3"), ("+1,+4", "+1")] { + let err = parse::select_records(5, "range", 1, tok, None).unwrap_err(); + assert!(err.contains(&format!("bad record \"{bad}\"")), "{tok}: {err}"); + } +} + #[test] fn oversized_data_file_is_refused_before_reading() { let dir = scratch("oversized"); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 890974f32..7b9834f91 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -8,6 +8,21 @@ use serde_json::{Value, json}; use super::{CommandSpec, bool_or, cmd, has_doc, has_selection, ids_param, ok}; use crate::{HistoryEntry, Result, Session}; +/// `anchors` of `selection.set`: the points that exist, once each. +fn anchors_param(doc: &designcraft_doc::Document, p: &Value) -> Vec<(ItemId, usize, usize)> { + let mut out: Vec<(ItemId, usize, usize)> = Vec::new(); + for v in p.get("anchors").and_then(Value::as_array).into_iter().flatten() { + let n = |i: usize| v.get(i).and_then(Value::as_u64); + let (Some(id), Some(si), Some(ai)) = (n(0), n(1), n(2)) else { continue }; + let (Ok(si), Ok(ai)) = (usize::try_from(si), usize::try_from(ai)) else { continue }; + let a = (ItemId(id), si, ai); + if doc.item(a.0).is_some_and(|it| it.path.subpaths.get(si).is_some_and(|sp| ai < sp.anchors.len())) && !out.contains(&a) { + out.push(a); + } + } + out +} + fn can_undo(s: &Session) -> std::result::Result<(), String> { s.active().filter(|d| !d.history.undo.is_empty()).map(|_| ()).ok_or_else(|| "nothing to undo".into()) } @@ -235,20 +250,36 @@ pub fn specs() -> Vec { s.typing_format = None; Ok(json!({"redone": e.label})) }), - cmd!(noundo "selection.set", "Select", [], None, "{ids: [id], add?: bool, content?: bool}", has_doc, |s, p| { - let ids = ids_param(p, "ids").unwrap_or_default(); + cmd!(noundo "selection.set", "Select", [], None, "{ids: [id], add?: bool, content?: bool, anchors?: [[id, subpath, index]] (Direct Selection: the selected anchor points; their paths are selected too)}", has_doc, |s, p| { + let mut ids = ids_param(p, "ids").unwrap_or_default(); let add = bool_or(p, "add", false); let content = bool_or(p, "content", false); let st = s.doc_mut()?; + let anchors = anchors_param(&st.doc, p); + for (id, _, _) in &anchors { + if !ids.contains(id) { + ids.push(*id); + } + } let ids: Vec = ids.into_iter().filter(|i| st.doc.item(*i).is_some()).collect(); if add { + // Adding objects (no points) leaves no live points: Delete would otherwise remove + // the stale points instead of the objects. + if anchors.is_empty() { + st.selection.anchors.clear(); + } for i in ids { if !st.selection.items.contains(&i) { st.selection.items.push(i); } } + for a in anchors { + if !st.selection.anchors.contains(&a) { + st.selection.anchors.push(a); + } + } } else { - st.selection = Selection { items: ids, content, ..Default::default() }; + st.selection = Selection { items: ids, content, anchors, ..Default::default() }; } st.selection.text = None; st.selection.cells = None; @@ -262,12 +293,15 @@ pub fn specs() -> Vec { st.selection.cells = None; if let Some(i) = st.selection.items.iter().position(|x| *x == id) { st.selection.items.remove(i); + // Its points go with it: arrow keys and Delete act on the selected points. + st.selection.anchors.retain(|a| a.0 != id); // A key object needs another object to align to it. if st.selection.key == Some(id) || st.selection.items.len() < 2 { st.selection.key = None; } } else { st.selection.items.push(id); + st.selection.anchors.clear(); } st.bump_revision(); ok() @@ -320,7 +354,7 @@ pub fn specs() -> Vec { "Clear", ["Edit"], Some("Delete"), - "{ids?} — delete selected items (or the selected text or ruler guides)", + "{ids?} — delete selected items (or the selected text, points or ruler guides)", has_doc, |s, p| { if s.doc()?.selection.text.is_some() && p.get("ids").is_none() { @@ -329,6 +363,10 @@ pub fn specs() -> Vec { if p.get("ids").is_none() && s.doc()?.selection.items.is_empty() && !s.doc()?.selection.guides.is_empty() { return s.execute("guide.delete", &json!({})); } + // Points selected with Direct Selection: they go, not their paths (as InDesign). + if p.get("ids").is_none() && !s.doc()?.selection.anchors.is_empty() { + return s.execute("path.deletePoints", &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. @@ -362,6 +400,11 @@ pub fn specs() -> Vec { return Ok(json!({"text": text})); } copy_items(s)?; + // The whole objects are on the clipboard, so the whole objects go, not just selected points. + if p.get("ids").is_none() { + let ids: Vec = s.doc()?.selection.items.iter().map(|i| i.0).collect(); + return s.execute("edit.clear", &json!({"ids": ids})); + } s.execute("edit.clear", p) }), cmd!( diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index a4ee73ce4..270fcfc4c 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -16,6 +16,7 @@ mod registry; use flatten::{rasterize_effects, shadow_coverage}; #[cfg(not(target_arch = "wasm32"))] pub(crate) use pdf::pdf_export; +pub(crate) use pdf::{check_pdf_options, pdf_report}; pub(crate) use pdf::{flatten_ppi, options, print_pdf}; pub use registry::specs; diff --git a/crates/engine/src/cmd/export/formats.rs b/crates/engine/src/cmd/export/formats.rs index 31885bee2..a25553d69 100644 --- a/crates/engine/src/cmd/export/formats.rs +++ b/crates/engine/src/cmd/export/formats.rs @@ -110,6 +110,8 @@ pub(super) fn export_pptx(s: &mut Session, p: &Value) -> Result { if rasterized > 0 { r.warnings.push(format!("{rasterized} object(s) went in as images")); } + // A slide has no room for the overset text either (#1465). + r.warnings.extend(crate::cmd::preflight::overset_warnings(s, opts.pages.as_deref())); match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/engine/src/cmd/export/pdf.rs b/crates/engine/src/cmd/export/pdf.rs index 903639ff9..a11c4e22a 100644 --- a/crates/engine/src/cmd/export/pdf.rs +++ b/crates/engine/src/cmd/export/pdf.rs @@ -87,8 +87,27 @@ fn first_page_ref(pdf: &[u8]) -> Option { s[i..].split_whitespace().next()?.parse().ok() } +/// Every page sharing a spread with one of `pages` (0-based), for an export that puts whole +/// spreads on a sheet. The pages come back in document order. +fn with_their_spreads(s: &Session, pages: &[usize]) -> Vec { + let Some(st) = s.active() else { return pages.to_vec() }; + let d = &st.doc; + let spreads: std::collections::BTreeSet = pages.iter().filter_map(|p| d.page_loc(*p)).map(|(si, _)| si).collect(); + if spreads.is_empty() { + return pages.to_vec(); + } + (0..d.page_count()).filter(|p| d.page_loc(*p).is_some_and(|(si, _)| spreads.contains(&si))).collect() +} + pub(super) fn export_pdf(s: &mut Session, p: &Value) -> Result { - let r = pdf_export(s, p)?; + let mut r = pdf_export(s, p)?; + // Text the pages have no room for is not in the file: say so, as preflight does (#1465). + // A spread export puts the whole spread on the sheet, so choosing page 2 exports page 3 as + // well and page 3's overset is as absent from the file as page 2's would be. The selection + // is widened to the spreads it touches before the warnings are collected (noted in review). + let opts = options(p, s.doc()?.doc.page_count())?; + let warn_pages = opts.pages.as_deref().map(|pages| if opts.spreads { with_their_spreads(s, pages) } else { pages.to_vec() }); + r.warnings.extend(crate::cmd::preflight::overset_warnings(s, warn_pages.as_deref())); match str_param(p, "path") { Some(path) => { write_file(path, &r.bytes)?; @@ -169,41 +188,21 @@ pub(crate) fn print_pdf( /// The PDF that File › Export PDF writes for `p`, with its warnings: the print PDF /// ([`print_pdf`]) with its view options, timings and transitions. File › Print prints it. pub(crate) fn pdf_export(s: &Session, p: &Value) -> Result { - let st = s.doc()?; - let opts = options(p, st.doc.page_count())?; + pdf_report(&s.doc()?.doc, &s.cache, p) +} + +/// [`pdf_export`] of `doc`: `file.package` writes its PDF of the packaged copy with it. +pub(crate) fn pdf_report(doc: &designcraft_doc::Document, cache: &designcraft_compose::Cache, p: &Value) -> Result { + let opts = options(p, doc.page_count())?; let ppi = flatten_ppi("file.exportPdf", p)?; - let mut r = print_pdf(&st.doc, &s.cache, &opts, ppi)?; - let doc: &designcraft_doc::Document = &st.doc; + let (cat, dest) = view_options(p)?; + let mut r = print_pdf(doc, cache, &opts, ppi)?; let spreads = designcraft_pdf::sheet_spreads(doc, &opts); - // Interactive PDF view options. - let mut cat = String::new(); - if p.get("fullScreen").and_then(Value::as_bool) == Some(true) { - cat.push_str("/PageMode/FullScreen"); - } else if p.get("bookmarksPanel").and_then(Value::as_bool) == Some(true) { - cat.push_str("/PageMode/UseOutlines"); - } - match str_param(p, "pageLayout") { - Some("single") => cat.push_str("/PageLayout/SinglePage"), - Some("continuous") => cat.push_str("/PageLayout/OneColumn"), - Some("twoUp") => cat.push_str("/PageLayout/TwoPageLeft"), - Some("twoUpCover") => cat.push_str("/PageLayout/TwoPageRight"), - Some("twoUpContinuous") => cat.push_str("/PageLayout/TwoColumnRight"), - Some(o) => return Err(bad("file.exportPdf", format!("unknown pageLayout `{o}`"))), - None => {} - } - if let Some(v) = str_param(p, "view") { - // The first page opens with this view (pages are objects in order, so refer by index 0). - let dest = match v { - "fitPage" => "/Fit", - "fitWidth" => "/FitH null", - "actual" => "/XYZ null null 1", - o => { - return Err(bad("file.exportPdf", format!("unknown view `{o}` (fitPage, fitWidth, actual)"))); - } - }; - if let Some(first) = first_page_ref(&r.bytes) { - cat.push_str(&format!("/OpenAction[{first} 0 R{dest}]")); - } + let mut cat = cat; + if let Some(dest) = dest + && let Some(first) = first_page_ref(&r.bytes) + { + cat.push_str(&format!("/OpenAction[{first} 0 R{dest}]")); } if !cat.is_empty() { match designcraft_pdf::add_catalog_entries(&r.bytes, &cat) { @@ -213,8 +212,7 @@ pub(crate) fn pdf_export(s: &Session, p: &Value) -> Result 0.0) { - let n = designcraft_pdf::sheet_spreads(doc, &opts).len(); - let dur: Vec> = (0..n).map(|_| Some(format!("/Dur {secs:.2}"))).collect(); + let dur: Vec> = (0..spreads.len()).map(|_| Some(format!("/Dur {secs:.2}"))).collect(); match designcraft_pdf::add_page_entries(&r.bytes, &dur) { Some(b) => r.bytes = b, None => r.warnings.push("page timings couldn't be set".into()), @@ -232,6 +230,44 @@ pub(crate) fn pdf_export(s: &Session, p: &Value) -> Result Result<(String, Option<&'static str>)> { + let mut cat = String::new(); + if p.get("fullScreen").and_then(Value::as_bool) == Some(true) { + cat.push_str("/PageMode/FullScreen"); + } else if p.get("bookmarksPanel").and_then(Value::as_bool) == Some(true) { + cat.push_str("/PageMode/UseOutlines"); + } + match str_param(p, "pageLayout") { + Some("single") => cat.push_str("/PageLayout/SinglePage"), + Some("continuous") => cat.push_str("/PageLayout/OneColumn"), + Some("twoUp") => cat.push_str("/PageLayout/TwoPageLeft"), + Some("twoUpCover") => cat.push_str("/PageLayout/TwoPageRight"), + Some("twoUpContinuous") => cat.push_str("/PageLayout/TwoColumnRight"), + Some(o) => return Err(bad("file.exportPdf", format!("unknown pageLayout `{o}`"))), + None => {} + } + // The first page opens with this view (pages are objects in order, so refer by index 0). + let dest = match str_param(p, "view") { + None => None, + Some("fitPage") => Some("/Fit"), + Some("fitWidth") => Some("/FitH null"), + Some("actual") => Some("/XYZ null null 1"), + Some(o) => return Err(bad("file.exportPdf", format!("unknown view `{o}` (fitPage, fitWidth, actual)"))), + }; + Ok((cat, dest)) +} + +/// Refuses PDF options `p` that [`pdf_report`] would refuse for a document of `pages` pages, +/// without exporting: `file.package` checks them before it writes anything. +pub(crate) fn check_pdf_options(p: &Value, pages: usize) -> Result<()> { + options(p, pages)?; + flatten_ppi("file.exportPdf", p)?; + view_options(p)?; + Ok(()) +} + #[cfg(test)] mod tagged_tests { use serde_json::json; @@ -461,3 +497,219 @@ mod page_list_tests { assert!(s.execute("file.exportPdf", &json!({"pages": []})).is_err()); } } + +#[cfg(test)] +mod overset_tests { + use serde_json::json; + + use crate::Session; + + /// A page-based export leaves out the text its frames have no room for, and said nothing + /// about it: an imported 58-page form lost whole sentences — a data-consent clause, a + /// statutory rule, the last row of two tables — with `warnings` empty while preflight + /// reported 19 overset stories (#1465). + #[test] + fn an_export_warns_about_the_text_the_pages_had_no_room_for() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 2, "facingPages": false, "margins": 0})).unwrap(); + let text = "First line is visible.\nSecond paragraph does not fit: this sentence carries the legal notice and must not vanish from the PDF without a warning."; + let frame = s.execute("frame.create", &json!({"rect": [20, 20, 280, 60], "content": "text", "text": text})).unwrap(); + let (id, sid) = (frame["id"].as_u64().unwrap(), frame["story"].clone()); + + // What preflight says about the same frame, for the warning to agree with. + let issues = s.execute("preflight.run", &json!({})).unwrap(); + let overset = issues["issues"].as_array().unwrap().iter().find(|i| i["kind"] == "overset").expect("preflight reports it").clone(); + assert_eq!(overset["message"], "Overset text: 76 characters"); + + for command in ["file.exportPdf", "file.exportPptx"] { + let r = s.execute(command, &json!({})).unwrap(); + let warnings = r["warnings"].as_array().expect("warnings").clone(); + let about: Vec<&str> = warnings.iter().filter_map(|w| w.as_str()).filter(|w| w.contains("not in the export")).collect(); + assert_eq!(about.len(), 1, "{command}: {warnings:?}"); + let w = about[0]; + assert!(w.contains("page 1"), "{command}: the page it is on: {w}"); + assert!(w.contains("76 character"), "{command}: the count preflight gives: {w}"); + assert!(w.contains(&format!("frame {id}")), "{command}: the frame: {w}"); + } + + // The pages being exported are the ones reported on: page 2 has no overset frame. + let r = s.execute("file.exportPdf", &json!({"pages": [2]})).unwrap(); + assert_eq!(r["warnings"], json!([]), "{r}"); + + // A reflowable export carries the whole story, so it has nothing to report. + let r = s.execute("file.exportHtml", &json!({})).unwrap(); + assert_eq!(r["warnings"], json!([]), "{r}"); + assert!(r["text"].as_str().unwrap().contains("must not vanish"), "the HTML has the text the PDF left out"); + + // `overset` is the index where the text stops fitting; `oversetChars` is how much is left, + // which is the number preflight reports. The two were read as the same thing. + let story = s.execute("story.get", &json!({"story": sid})).unwrap(); + assert_eq!(story["overset"], json!(68), "{story}"); + assert_eq!(story["oversetChars"], json!(76), "{story}"); + + // A frame the text fits: nothing is reported anywhere. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 1, "facingPages": false, "margins": 0})).unwrap(); + let frame = s.execute("frame.create", &json!({"rect": [20, 20, 280, 180], "content": "text", "text": "Short."})).unwrap(); + for command in ["file.exportPdf", "file.exportPptx"] { + assert_eq!(s.execute(command, &json!({})).unwrap()["warnings"], json!([]), "{command}"); + } + let story = s.execute("story.get", &json!({"story": frame["story"].clone()})).unwrap(); + assert!(story["overset"].is_null() && story["oversetChars"].is_null(), "{story}"); + } + + /// A sheet draws its spread's items without asking which page each one sits on, so a frame + /// straddling two pages is drawn, in part, on both. Choosing the page by the frame's centre + /// alone meant a frame centred on a page the export leaves out had its warning dropped while + /// the part on an exported page went into the file (#1493 review). + #[test] + fn an_export_warns_about_a_frame_that_only_crosses_into_one_of_its_pages() { + let mut s = Session::new(); + // Facing pages, so pages 2 and 3 are one spread and a frame can straddle their boundary. + s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 4, "facingPages": true, "margins": 0})).unwrap(); + let text = "First line is visible.\nSecond paragraph does not fit: this sentence carries the legal notice and must not vanish from the PDF without a warning."; + // Spread 1 is pages 2-3; its page 2 runs x 0..300 and page 3 runs x 300..600. The frame + // is centred on page 3 and reaches back over the boundary into page 2. + let frame = json!({"spread": 1, "rect": [260, 20, 420, 60], "content": "text", "text": text}); + let id = s.execute("frame.create", &frame).unwrap()["id"].as_u64().unwrap(); + + let about = |s: &mut Session, pages: serde_json::Value| { + let params = if pages.is_null() { json!({}) } else { json!({"pages": pages}) }; + let r = s.execute("file.exportPdf", ¶ms).unwrap(); + let ws = r["warnings"].as_array().cloned().unwrap_or_default(); + ws.iter().filter_map(|w| w.as_str()).filter(|w| w.contains("not in the export")).map(str::to_owned).collect::>() + }; + + // The whole document: reported once, at the page the frame is centred on. + let all = about(&mut s, serde_json::Value::Null); + assert_eq!(all.len(), 1, "{all:?}"); + assert!(all[0].contains("page 3") && all[0].contains(&format!("frame {id}")), "{}", all[0]); + + // Exporting page 3 alone: the centre page, reported as before. + let three = about(&mut s, json!([3])); + assert_eq!(three.len(), 1, "{three:?}"); + assert!(three[0].contains("page 3"), "{}", three[0]); + + // Exporting page 2 alone: the frame's centre is on page 3, which is not in the export -- + // but part of the frame is drawn on page 2, so it is still reported, and the page named is + // one the export has. + let two = about(&mut s, json!([2])); + assert_eq!(two.len(), 1, "a frame crossing into page 2 is reported: {two:?}"); + assert!(two[0].contains("page 2"), "and the page it names is an exported one: {}", two[0]); + assert!(two[0].contains(&format!("frame {id}")), "{}", two[0]); + + // A page the frame does not reach at all: nothing to say. + assert_eq!(about(&mut s, json!([4])), Vec::::new()); + } + + /// `overset` indexes `text` in UTF-8 bytes, not characters: a client that slices a JavaScript + /// string at it cuts the wrong place once the text is not ASCII (noted in review). + #[test] + fn overset_is_a_byte_offset_into_the_text() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 1, "facingPages": false, "margins": 0})).unwrap(); + // Accented letters are two bytes each, so the byte offset runs ahead of the character index. + let text = "Réservé à l'évaluation détaillée des éléments.\nCette phrase dépasse le cadre et n'est pas dans le PDF exporté du tout."; + let frame = s.execute("frame.create", &json!({"rect": [20, 20, 280, 40], "content": "text", "text": text})).unwrap(); + let story = s.execute("story.get", &json!({"story": frame["story"].clone()})).unwrap(); + let at = story["overset"].as_u64().expect("it oversets") as usize; + assert!(text.is_char_boundary(at), "a byte offset that splits no character: {at}"); + assert!(at > text[..at].chars().count(), "ahead of the character index: {at} bytes, {} characters", text[..at].chars().count()); + // And `oversetChars` counts characters, which is why the two numbers differ. + assert_eq!(story["oversetChars"], json!(text[at..].chars().count()), "{story}"); + } + + /// A parent page's frame is composed once per page that shows it: its page number marker is + /// that page's, so the same frame fits on page 1 and oversets on page 10 -- and a parent page + /// no exported page uses puts nothing in the file to be missing. Composing it once with no + /// page reported neither: every selection got the same unpaged warning (noted in review). + #[test] + fn a_parent_frame_is_checked_on_the_pages_that_show_it() { + let about = |r: &serde_json::Value| -> Vec { + r["warnings"] + .as_array() + .map(|w| w.iter().filter_map(|w| w.as_str()).filter(|w| w.contains("not in the export")).map(str::to_string).collect()) + .unwrap_or_default() + }; + + // A parent page that only page 1 uses. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 2, "facingPages": false, "margins": 0})).unwrap(); + s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": null})).unwrap(); + let text = "First line is visible.\nSecond paragraph does not fit: this sentence carries the legal notice and must not vanish from the PDF without a warning."; + let frame = s + .execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [20, 20, 280, 60], "content": "text", "text": text})) + .unwrap(); + let id = frame["id"].as_u64().unwrap(); + + let w = about(&s.execute("file.exportPdf", &json!({"pages": [1]})).unwrap()); + assert_eq!(w.len(), 1, "page 1 shows the parent: {w:?}"); + assert!(w[0].contains("page 1") && w[0].contains(&format!("frame {id}")) && w[0].contains("76 character"), "{}", w[0]); + + // Page 2 has no parent, so nothing of that frame is in its export to be missing. + let r = s.execute("file.exportPdf", &json!({"pages": [2]})).unwrap(); + assert!(about(&r).is_empty(), "page 2 does not use the parent: {r}"); + + // Page-dependent text: the marker is one digit up to page 9 and two from page 10, and the + // wider line no longer fits. 112 pt is the width where that is the only difference. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 12, "facingPages": false, "margins": 0})).unwrap(); + let text = format!("Continued on page {}", designcraft_doc::story::PAGE_NUMBER); + s.execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [20, 20, 132, 36], "content": "text", "text": text})) + .unwrap(); + + for page in [1, 9] { + let r = s.execute("file.exportPdf", &json!({"pages": [page]})).unwrap(); + assert!(about(&r).is_empty(), "page {page} fits one digit: {r}"); + } + let w = about(&s.execute("file.exportPdf", &json!({"pages": [10]})).unwrap()); + assert_eq!(w.len(), 1, "two digits do not fit: {w:?}"); + assert!(w[0].contains("page 10"), "{}", w[0]); + + // The whole document: one line naming every page it happens on, not one line per page. + let w = about(&s.execute("file.exportPdf", &json!({})).unwrap()); + assert_eq!(w.len(), 1, "one line for the story: {w:?}"); + assert!(w[0].starts_with("pages 10, 11, 12:"), "{}", w[0]); + } + + /// A spread export puts the whole spread on the sheet, so choosing one page of it exports its + /// partner too -- and that partner's overset is just as absent from the file. Reporting only + /// the pages that were named left it unmentioned (noted in review of #1465). + #[test] + fn a_spread_export_warns_about_the_pages_it_takes_with_it() { + let mut s = Session::new(); + // Facing pages: 2 and 3 share a spread, 1 is the cover on its own. + s.execute("file.new", &json!({"width": 200, "height": 200, "pages": 4, "facingPages": true, "margins": 0})).unwrap(); + let text = "First line is visible.\nSecond paragraph does not fit: this sentence carries the legal notice and must not vanish from the PDF without a warning."; + // A frame on page 3, whose text does not fit it. `frame.create` takes a spread and + // spread coordinates, so page 3's own bounds say where to put it. + let (si, b) = { + let d = &s.active().expect("a document").doc; + let (si, pi) = d.page_loc(2).expect("page 3 is there"); + (si, d.spreads[si].pages[pi].bounds()) + }; + let rect = json!([b.x0 + 20.0, b.y0 + 20.0, b.x0 + 180.0, b.y0 + 60.0]); + let frame = s.execute("frame.create", &json!({"spread": si, "rect": rect, "content": "text", "text": text})).unwrap(); + let id = frame["id"].as_u64().unwrap(); + let about = |r: &serde_json::Value| -> Vec { + r["warnings"] + .as_array() + .map(|w| w.iter().filter_map(|w| w.as_str()).filter(|w| w.contains("not in the export")).map(str::to_string).collect()) + .unwrap_or_default() + }; + + // Page by page, choosing page 2 reports nothing: page 2 holds no overset frame. + let r = s.execute("file.exportPdf", &json!({"pages": [2]})).unwrap(); + assert!(about(&r).is_empty(), "page 2 alone has nothing to report: {r}"); + + // As spreads, choosing page 2 exports page 3 with it, so page 3's overset is reported. + let r = s.execute("file.exportPdf", &json!({"pages": [2], "spreads": true})).unwrap(); + let w = about(&r); + assert_eq!(w.len(), 1, "the page that came along is reported too: {r}"); + assert!(w[0].contains("page 3") && w[0].contains(&format!("frame {id}")), "{}", w[0]); + + // And a page on another spread entirely is still not reported. + let r = s.execute("file.exportPdf", &json!({"pages": [1], "spreads": true})).unwrap(); + assert!(about(&r).is_empty(), "the cover's spread holds no overset frame: {r}"); + } +} diff --git a/crates/engine/src/cmd/export/text_tests.rs b/crates/engine/src/cmd/export/text_tests.rs index e533320f8..2efcb2f92 100644 --- a/crates/engine/src/cmd/export/text_tests.rs +++ b/crates/engine/src/cmd/export/text_tests.rs @@ -204,12 +204,12 @@ fn black_and_gold(pdf: &[u8]) -> Vec<(u8, u8)> { let mut out = Vec::new(); for st in file.objects().into_iter().filter_map(|o| o.into_stream()) { let d = st.dict(); - if !d.get::>(b"Subtype").is_some_and(|n| n.as_ref() == b"Image") || d.get::(b"BitsPerComponent") != Some(8) { + if d.get::>(b"Subtype").is_none_or(|n| n.as_ref() != b"Image") || d.get::(b"BitsPerComponent") != Some(8) { continue; } let Some(cs) = d.get::>(b"ColorSpace") else { continue }; let mut it = cs.iter::>(); - if !it.next().and_then(|o| o.into_name()).is_some_and(|n| n.as_ref() == b"DeviceN") { + if it.next().and_then(|o| o.into_name()).is_none_or(|n| n.as_ref() != b"DeviceN") { continue; } let Some(Object::Array(names)) = it.next() else { continue }; diff --git a/crates/engine/src/cmd/file/bytes.rs b/crates/engine/src/cmd/file/bytes.rs index 0946205a8..f42925034 100644 --- a/crates/engine/src/cmd/file/bytes.rs +++ b/crates/engine/src/cmd/file/bytes.rs @@ -27,6 +27,16 @@ pub fn with_document_extension(path: &str) -> String { format!("{path}.{}", designcraft_format::EXTENSION) } +/// `name` without a trailing `.designcraft` (any case): the title of a document opened from bytes +/// under its file name. Save, Save As and the File Name variable add the extension back, so a title +/// that kept it would name the file `Brochure.designcraft.designcraft`. A bare `.designcraft` stays. +pub(super) fn without_native_extension(name: &str) -> &str { + match name.rsplit_once('.') { + Some((stem, ext)) if !stem.is_empty() && ext.eq_ignore_ascii_case(designcraft_format::EXTENSION) => stem, + _ => name, + } +} + /// Whether `bytes` are a document `file.open` reads: a DesignCraft package or an IDML package. /// The first [`DOCUMENT_SNIFF_BYTES`] of a file are enough for packages that start with their /// `mimetype` entry, as both formats write them. diff --git a/crates/engine/src/cmd/file/new.rs b/crates/engine/src/cmd/file/new.rs index 6614a2323..e02ce11e4 100644 --- a/crates/engine/src/cmd/file/new.rs +++ b/crates/engine/src/cmd/file/new.rs @@ -13,10 +13,11 @@ fn find_preset(s: &Session, name: &str) -> Option { } /// The settings `file.new` makes a document with for `p`: its preset, then the settings over it. +/// Without a preset or `units`, it is in the default units (Preferences with no document). fn new_document_settings(s: &Session, p: &Value) -> Result { let mut nd = match str_param(p, "preset") { Some(name) => find_preset(s, name).ok_or_else(|| bad("file.new", format!("unknown preset `{name}`")))?, - None => NewDocument::default(), + None => s.default_new_document(), }; apply_settings(&mut nd, p); check_size("file.new", &nd)?; @@ -40,12 +41,19 @@ pub(super) fn file_new(s: &mut Session, p: &Value) -> Result { let mut nd = new_document_settings(s, p)?; s.untitled += 1; nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| format!("Untitled-{}", s.untitled)); - let d = new_document(&nd); + let mut d = new_document(&nd); + s.prefs.default_units.apply(&mut d.settings, p.get("preset").is_some_and(|v| !v.is_null())); let i = s.add_document(DocState::new(d, None)); Ok(json!({"index": i})) } impl Session { + /// What New Document starts with: the factory settings, in the default units (Preferences + /// with no document open). + pub fn default_new_document(&self) -> NewDocument { + NewDocument { units: self.prefs.default_units.horizontal_units, ..NewDocument::default() } + } + /// New Document's Preview: a document made as `file.new` would make it with `p`, shown while /// the New Document window is open. It replaces the previous preview document in its tab and /// takes no Untitled number, no undo step and no recovery entry. Settings `file.new` refuses @@ -64,7 +72,9 @@ impl Session { self.untitled += 1; } nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| self.next_untitled_title()); - let st = DocState::new(new_document(&nd), None); + let mut d = new_document(&nd); + self.prefs.default_units.apply(&mut d.settings, p.get("preset").is_some_and(|v| !v.is_null())); + let st = DocState::new(d, None); let uid = st.uid; match replace.and_then(|i| self.docs.get_mut(i).map(|d| (i, d))) { Some((i, d)) => { diff --git a/crates/engine/src/cmd/file/open.rs b/crates/engine/src/cmd/file/open.rs index 75a7ce627..6d1aee7ce 100644 --- a/crates/engine/src/cmd/file/open.rs +++ b/crates/engine/src/cmd/file/open.rs @@ -1,6 +1,6 @@ #[cfg(test)] use super::bytes::{PREVIEW_PX, to_bytes}; -use super::bytes::{base64_decode, load_bytes}; +use super::bytes::{base64_decode, load_bytes, without_native_extension}; use super::{Arc, DocState, Document, EngineError, Result, Session, Value, bad, json, str_param}; pub(super) fn has_recent(s: &Session) -> std::result::Result<(), String> { @@ -150,7 +150,8 @@ pub(super) fn file_open_bytes(s: &mut Session, p: &Value) -> Result { let (mut d, warnings) = load_bytes(&b)?; crate::cmd::datamerge::resolve_sources_on_open(&mut d, None); if let Some(n) = str_param(p, "name") { - d.title = n.to_string(); + // Titled as an IDML file opened from bytes is: without its extension. + d.title = without_native_extension(n).to_string(); } let i = s.add_document(DocState::new(d, None)); Ok(json!({"index": i, "warnings": warnings})) @@ -413,6 +414,66 @@ mod document_fonts_tests { } } +#[cfg(test)] +mod open_bytes_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn legacy_and_idml_bytes_named_as_indesign_files_open_without_conversion() { + let mut maker = Session::new(); + maker.execute("file.new", &json!({})).unwrap(); + let idml = designcraft_idml::export_idml(&maker.doc().unwrap().doc); + let base64 = super::super::bytes::base64_encode(&idml); + let legacy = super::super::bytes::base64_encode(&serde_json::to_vec(&maker.doc().unwrap().doc).unwrap()); + for name in ["Brochure.indd", "Brochure.INDT"] { + let mut s = Session::new(); + s.execute("file.openBytes", &json!({"name": name, "base64": legacy})).unwrap(); + assert_eq!(s.documents().len(), 1, "legacy: {name}"); + assert_eq!(s.doc().unwrap().doc.title, name, "legacy: {name}"); + } + for command in ["file.openBytes", "file.openIdml"] { + for name in ["Brochure.indd", "Brochure.INDT"] { + let mut s = Session::new(); + s.execute(command, &json!({"name": name, "base64": base64})).unwrap(); + assert_eq!(s.documents().len(), 1, "{command}: {name}"); + assert_eq!(s.doc().unwrap().doc.title, "Brochure", "{command}: {name}"); + } + } + } + + /// A `.designcraft` file opened from bytes under its file name (a browser or host passes the + /// name it has) is titled without the extension, as an IDML file is, so saving it gives + /// `Brochure.designcraft` and not `Brochure.designcraft.designcraft`. + #[test] + fn open_bytes_titles_a_native_document_without_its_extension() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let native = s.execute("file.serialize", &json!({})).unwrap(); + for (name, title) in [ + ("Brochure.designcraft", "Brochure"), + ("Brochure.DesignCraft", "Brochure"), + ("Brochure", "Brochure"), + ("Brochure.v2", "Brochure.v2"), + ("Brochure.indd", "Brochure.indd"), + ("Brochure.INDT", "Brochure.INDT"), + ("Brochure.designcraft.designcraft", "Brochure.designcraft"), + (".designcraft", ".designcraft"), + // Names that aren't ASCII: cutting the extension's length off the end of the last one + // would land inside its `é`. + ("Präsentation.designcraft", "Präsentation"), + ("日本.DESIGNCRAFT", "日本"), + ("é", "é"), + ("édesigncraft", "édesigncraft"), + ] { + let r = s.execute("file.openBytes", &json!({"name": name, "base64": native["base64"]})).unwrap(); + let st = &s.documents()[r["index"].as_u64().unwrap() as usize]; + assert_eq!((st.doc.title.as_str(), st.title()), (title, title.to_string()), "{name}"); + } + } +} + #[cfg(test)] mod recent_tests { use serde_json::json; diff --git a/crates/engine/src/cmd/file/place.rs b/crates/engine/src/cmd/file/place.rs index 44f993a64..638742885 100644 --- a/crates/engine/src/cmd/file/place.rs +++ b/crates/engine/src/cmd/file/place.rs @@ -276,7 +276,7 @@ pub(super) fn place_drop(s: &mut Session, p: &Value) -> Result { } // Drag: fit proportionally; click: actual size at the point. let (r, k) = match rect { - Some(r) if r.width() > 2.0 && r.height() > 2.0 => { + Some(r) if r.width() > designcraft_tools::PLACE_DRAG_MIN && r.height() > designcraft_tools::PLACE_DRAG_MIN => { let k = (r.width() / nw).min(r.height() / nh); (Rect::new(r.x0, r.y0, r.x0 + nw * k, r.y0 + nh * k), k) } diff --git a/crates/engine/src/cmd/file/registry.rs b/crates/engine/src/cmd/file/registry.rs index 216f12bdb..c1f522956 100644 --- a/crates/engine/src/cmd/file/registry.rs +++ b/crates/engine/src/cmd/file/registry.rs @@ -37,7 +37,7 @@ pub fn specs() -> Vec { ok() }), cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), - "{path} — a DesignCraft or IDML file (recognised by its content, so the extension may be missing), or an InDesign .indd/.indt (recognised by its signature or extension, converted to IDML; its conversion warnings join `warnings`); the fonts in a `Document Fonts` folder beside it load first → {index, documentFonts: faces loaded, warnings: font files skipped, GREP styles whose pattern can't compile, Based on Parent links cleared and, for IDML and InDesign files, package parts missing}", + "{path} — a DesignCraft or IDML file (recognised by its content, so the extension may be missing), or an InDesign .indd/.indt (recognised by its signature or extension, converted to IDML; its conversion warnings join `warnings`); the fonts in a `Document Fonts` folder beside it load first → {index, documentFonts: faces loaded, warnings: font files skipped, GREP styles whose pattern can't compile, Based on Parent links cleared and, for IDML and InDesign files, package parts missing; warningItems (IDML and InDesign files): per warning, the id of the frame it is about, or null}", always, file_open), cmd!(noundo "file.openBytes", "Open Bytes", [], None, "{name, base64} — DesignCraft JSON, an IDML package or an InDesign .indd/.indt → {index, warnings}", always, file_open_bytes), cmd!(noundo "file.openRecent", "Open Recent File", [], None, diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index 8d7c68632..371b89012 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -1,12 +1,12 @@ //! Edit → Find/Change: plain text and GREP (regular expressions) with InDesign's metacharacters, -//! optional formatting in the change, scoped to the document, a story or the selection. +//! a Find Format and a Change Format, scoped to the document, a story or the selection. use std::ops::Range; use std::sync::{Arc, Weak}; use designcraft_compose::grep::{Grep, GrepOptions, translate_replacement}; use designcraft_doc::story::floor_char_boundary; -use designcraft_doc::{CellAddr, Document, Selection, StoryId, TextSel}; +use designcraft_doc::{CellAddr, Document, Selection, Story, StoryId, Styles, TextSel}; use serde_json::{Value, json}; use super::{CommandSpec, bad, bool_or, cmd, has_doc, str_param}; @@ -61,18 +61,18 @@ pub fn specs() -> Vec { } ), cmd!(query "find.find", "Find", ["Edit", "Find/Change"], None, - "{find, grep?: bool, caseSensitive?: bool, wholeWord?: bool, scope?: document|story|selection, story?, change?|first?|attrs?: ignored (find.change takes them; one request serves all three Find/Change commands)} → matches [{story, start, end, text, cell?: {table, row, col}}] (document scope also searches table cells; start/end then index the cell's text)", + "{find, grep?: bool, caseSensitive?: bool, wholeWord?: bool, scope?: document|story|selection, story?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), change?|first?|found?|attrs?|charStyle?|paraStyle?: ignored (find.change takes them; one request serves all three Find/Change commands)} → matches [{story, start, end, text, cell?: {table, row, col}}] (document scope also searches table cells; start/end then index the cell's text)", has_doc, find), cmd!( "find.change", "Change All", ["Edit", "Find/Change"], None, - "{find, change, grep?, caseSensitive?, wholeWord?, scope?, story?, first?: bool (change only the first match), attrs?: {character attributes applied to changed text}} → {count}", + "{find, change, grep?, caseSensitive?, wholeWord?, scope?, story?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), first?: bool (change only the first match), found?: bool (Change: only the text Find Next found, while it is selected and still a match; it stays selected, and Find Next goes on after it in the same domain), attrs?: {character attributes applied to changed text}, charStyle?: a character style applied to it, paraStyle?: a paragraph style applied to its paragraphs} → {count} — attrs, charStyle and paraStyle are the Change Format: with one and an empty change, the found text stays and is only formatted; with no find, change is literal text", has_doc, change ), - cmd!(noundo "find.next", "Find Next", ["Edit"], Some("Cmd+Alt+F"), "{find, grep?, caseSensitive?, wholeWord?, scope?: document|story|selection, story?, change?|first?|attrs?: ignored (find.change takes them; one request serves all three Find/Change commands)} — Selection starts at its original beginning; later calls advance within the scope", has_doc, find_next), + cmd!(noundo "find.next", "Find Next", ["Edit"], Some("Cmd+Alt+F"), "{find, grep?, caseSensitive?, wholeWord?, scope?: document|story|selection, story?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), change?|first?|found?|attrs?|charStyle?|paraStyle?: ignored (find.change takes them; one request serves all three Find/Change commands)} — Selection starts at its original beginning; later calls advance within the scope", has_doc, find_next), ] } @@ -108,6 +108,227 @@ fn build(p: &Value) -> Result { re.map_err(|e| bad("find", e)) } +/// Find/Change's Find Format: the text a search finds must have it all through. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +struct FindFormat { + char_style: Option, + para_style: Option, + /// Resolved character attributes, as `type.selectionAttrs` reports them. + chars: serde_json::Map, +} + +impl FindFormat { + fn from_params(p: &Value) -> Result> { + let Some(f) = p.get("findFormat").filter(|f| !f.is_null()) else { return Ok(None) }; + let o = f.as_object().ok_or_else(|| bad("find", "`findFormat` is an object"))?; + // A style is named; a null asks for none. Any other value is refused rather than + // searched without. + let name = |k: &str| match o.get(k) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(n)) => Ok(Some(n.clone())), + Some(v) => Err(bad("find", format!("findFormat: `{k}` is a style name, not {v}"))), + }; + let chars = match o.get("chars") { + None | Some(Value::Null) => None, + Some(Value::Object(m)) => Some(m), + Some(v) => return Err(bad("find", format!("findFormat: `chars` is an object of character attributes, not {v}"))), + }; + // Only attributes text has, kept as the document writes them: `font_style` and + // `fontStyle`, or 12 and 12.0, then find the same text. A null asks for nothing. + let mut known = designcraft_doc::CharAttrs::default(); + for (k, v) in chars.into_iter().flatten() { + known.set_json(k, v).map_err(|e| bad("find", format!("findFormat: {e}")))?; + } + let chars = match serde_json::to_value(&known) { + Ok(Value::Object(m)) => m, + _ => serde_json::Map::new(), + }; + let f = Self { char_style: name("charStyle")?, para_style: name("paraStyle")?, chars }; + Ok((f != Self::default()).then_some(f)) + } + + /// Styles the document doesn't have are refused, as `find.change` refuses them. + fn check(&self, styles: &Styles) -> Result<()> { + if let Some(n) = self.char_style.as_ref().filter(|n| styles.char_style(n).is_none()) { + return Err(bad("find", format!("no character style `{n}`"))); + } + if let Some(n) = self.para_style.as_ref().filter(|n| styles.para(n).is_none()) { + return Err(bad("find", format!("no paragraph style `{n}`"))); + } + Ok(()) + } + + /// The text in `r` of `story` that has the format, relative to `r.start`: the longest + /// stretches, each paragraph's apart (a paragraph's `\n` isn't in one). + fn spans(&self, styles: &Styles, story: &Story, r: &Range) -> Vec> { + let mut out: Vec> = Vec::new(); + // Paragraphs and runs both go in text order: the runs before a paragraph are passed once. + let runs: Vec<_> = story.runs().collect(); + let mut first = 0; + for (pi, pr) in story.para_ranges().into_iter().enumerate() { + let (a, b) = (pr.start.max(r.start), pr.end.min(r.end)); + let Some(pf) = story.paras.get(pi) else { continue }; + if a >= b || self.para_style.as_ref().is_some_and(|n| *n != pf.style) { + continue; + } + while runs.get(first).is_some_and(|(run, _)| run.end <= a) { + first += 1; + } + let base = (!self.chars.is_empty()).then(|| styles.resolve_para(pf).1); + for (run, cf) in runs[first..].iter().take_while(|(run, _)| run.start < b) { + let (x, y) = (run.start.max(a), run.end.min(b)); + if x >= y || self.char_style.as_ref().is_some_and(|n| *n != cf.style) { + continue; + } + if let Some(base) = &base { + let cp = serde_json::to_value(styles.resolve_char(base, cf)).unwrap_or_default(); + if !self.chars.iter().all(|(k, v)| cp.get(k).is_some_and(|c| same_value(c, v))) { + continue; + } + } + match out.last_mut() { + Some(last) if last.end == x - r.start => last.end = y - r.start, + _ => out.push(x - r.start..y - r.start), + } + } + } + out + } +} + +/// Attribute values alike: numbers to within rounding (inside objects and lists too), the rest +/// exactly. +fn same_value(a: &Value, b: &Value) -> bool { + match (a, b) { + (Value::Number(_), Value::Number(_)) => match (a.as_f64(), b.as_f64()) { + (Some(x), Some(y)) => (x - y).abs() < 1e-6, + _ => a == b, + }, + (Value::Array(x), Value::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(a, b)| same_value(a, b)), + (Value::Object(x), Value::Object(y)) => x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| same_value(v, w))), + _ => a == b, + } +} + +/// Whether match `m` lies in one of `spans`. +fn within(spans: &[Range], m: &Range) -> bool { + spans.iter().any(|s| s.start <= m.start && m.end <= s.end) +} + +/// What a search finds: the pattern's matches (with a Find Format, those whose text has it all +/// through), or, with no pattern, the stretches of text with the Find Format. +struct Search { + re: Option, + format: Option, +} + +/// The search of a Find/Change request: an empty `find` needs a Find Format. +fn search(p: &Value) -> Result { + let format = FindFormat::from_params(p)?; + if format.is_some() && str_param(p, "find") == Some("") { + return Ok(Search { re: None, format }); + } + Ok(Search { re: Some(build(p)?), format }) +} + +impl Search { + fn check(&self, styles: &Styles) -> Result<()> { + self.format.as_ref().map_or(Ok(()), |f| f.check(styles)) + } + + /// Where the Find Format holds in target `r` of story `sid` (of its table cell `cell`, if any; + /// `None`: no Find Format). + fn spans(&self, d: &Document, sid: StoryId, cell: Option, r: &Range) -> Option>> { + let f = self.format.as_ref()?; + Some(d.text_story(sid, cell).map(|story| f.spans(&d.styles, story, r)).unwrap_or_default()) + } + + /// Up to `limit` matches in `text`, target `r` of story `sid` (offsets in `text`). + fn find_all(&self, d: &Document, sid: StoryId, cell: Option, r: &Range, text: &str, limit: usize) -> Result>> { + let spans = self.spans(d, sid, cell, r); + let Some(re) = &self.re else { return Ok(spans.unwrap_or_default().into_iter().take(limit).collect()) }; + let Some(spans) = spans else { return re.find_all(text, limit).map_err(|e| bad("find", e)) }; + Ok(re.find_all(text, usize::MAX).map_err(|e| bad("find", e))?.into_iter().filter(|m| within(&spans, m)).take(limit).collect()) + } + + /// The first match in `text` (target `r` of story `sid`) at or after byte `from`. + fn find_at(&self, d: &Document, sid: StoryId, cell: Option, r: &Range, text: &str, from: usize) -> Result>> { + let spans = self.spans(d, sid, cell, r); + let Some(re) = &self.re else { + // From inside a stretch, the rest of it. + return Ok(spans.unwrap_or_default().into_iter().find(|s| s.end > from).map(|s| s.start.max(from)..s.end)); + }; + let mut at = from; + loop { + let Some(m) = re.find_at(text, at).map_err(|e| bad("find.next", e))? else { return Ok(None) }; + if spans.as_ref().is_none_or(|s| within(s, &m)) { + return Ok(Some(m)); + } + // On past it: its end, or a character on from an empty match. + at = if m.is_empty() { text.get(m.end..).and_then(|t| t.chars().next()).map_or(text.len() + 1, |c| m.end + c.len_utf8()) } else { m.end }; + if at > text.len() { + return Ok(None); + } + } + } + + /// The edit Change makes when the selection `range` (offsets in `text`, target `r` of story + /// `sid`) is what Find Next found there, so the two commands agree on what a match is. With + /// no pattern: a selection inside one stretch with the Find Format (Find Next selects the rest + /// of a stretch from inside it). With a pattern: the match it makes from the selection's start + /// must be the selection, overlapping matches Find Next can select included. + #[allow(clippy::too_many_arguments)] + fn found_edit( + &self, + d: &Document, + sid: StoryId, + cell: Option, + r: &Range, + text: &str, + range: &Range, + rep: &str, + grep: bool, + ) -> Result, String)>> { + let spans = self.spans(d, sid, cell, r); + let Some(re) = &self.re else { + let inside = !range.is_empty() && spans.unwrap_or_default().iter().any(|s| s.start <= range.start && range.end <= s.end); + return Ok(inside.then(|| (range.clone(), rep.to_string()))); + }; + let found = + if grep { re.replacement_at(text, range.start, rep) } else { re.find_at(text, range.start).map(|m| m.map(|m| (m, rep.to_string()))) }; + let found = found.map_err(|e| bad("find.change", e))?; + Ok(found.filter(|(m, _)| m == range && spans.as_ref().is_none_or(|s| within(s, m)))) + } + + /// The edits Change All makes in `text` (target `r` of story `sid`): `rep` expanded for each + /// GREP match, else `rep` itself. + #[allow(clippy::too_many_arguments)] + fn replacements( + &self, + d: &Document, + sid: StoryId, + cell: Option, + r: &Range, + text: &str, + rep: &str, + grep: bool, + first_only: bool, + ) -> Result, String)>> { + let limit = if first_only { 1 } else { usize::MAX }; + match (&self.re, self.spans(d, sid, cell, r)) { + (Some(re), None) if grep => re.replacements(text, rep, first_only).map_err(|e| bad("find.change", e)), + (Some(re), Some(spans)) if grep => Ok(re + .replacements(text, rep, false) + .map_err(|e| bad("find.change", e))? + .into_iter() + .filter(|(m, _)| within(&spans, m)) + .take(limit) + .collect()), + _ => Ok(self.find_all(d, sid, cell, r, text, limit)?.into_iter().map(|m| (m, rep.to_string())).collect()), + } + } +} + #[derive(Clone, Debug, PartialEq, Eq)] enum Domain { Document, @@ -140,6 +361,7 @@ struct Matcher { grep: bool, case_sensitive: bool, whole_word: bool, + format: Option, } impl Matcher { @@ -149,6 +371,7 @@ impl Matcher { grep: bool_or(p, "grep", false), case_sensitive: bool_or(p, "caseSensitive", false), whole_word: bool_or(p, "wholeWord", false), + format: FindFormat::from_params(p).ok().flatten(), } } } @@ -183,6 +406,29 @@ impl FindContext { self.selection = st.selection.clone(); } + /// After Change put `new_len` bytes in place of `old` in story `sid`: the domain keeps the + /// changed text and the search goes on after it. + fn after_change(&mut self, st: &DocState, sid: StoryId, cell: Option, old: &Range, new_len: usize) { + let moved = |x: usize| if x >= old.end { x - old.len() + new_len } else { x }; + for (tid, tcell, r) in &mut self.targets { + if *tid == sid && *tcell == cell { + *r = moved(r.start)..moved(r.end); + } + } + self.offset = old.start + new_len; + self.last_match_end = Some(self.offset); + let at = self.offset; + if let Some(i) = self + .order + .iter() + .position(|i| self.targets.get(*i).is_some_and(|(tid, tcell, r)| *tid == sid && *tcell == cell && r.start <= at && at <= r.end)) + { + self.target = i; + } + self.document = Arc::downgrade(&st.doc); + self.acknowledge(st); + } + fn restart(&mut self, st: &DocState) -> Result<()> { self.target = 0; self.last_match_end = None; @@ -299,13 +545,14 @@ fn scope(s: &Session, p: &Value) -> Result { const MAX_MATCHES: usize = 10_000; fn find(s: &mut Session, p: &Value) -> Result { - let re = build(p)?; + let re = search(p)?; + re.check(&s.doc()?.doc.styles)?; let context = scope(s, p)?; let st = s.doc()?; let mut out = Vec::new(); for (sid, cell, r) in &context.targets { let text = target_text(&st.doc, *sid, *cell, r)?; - for m in re.find_all(text, MAX_MATCHES.saturating_sub(out.len())).map_err(|e| bad("find", e))? { + for m in re.find_all(&st.doc, *sid, *cell, r, text, MAX_MATCHES.saturating_sub(out.len()))? { let mut hit = json!({"story": sid.0, "start": r.start + m.start, "end": r.start + m.end, "text": text.get(m)}); if let Some(c) = cell { hit["cell"] = json!({"table": c.table, "row": c.row, "col": c.col}); @@ -349,51 +596,143 @@ fn char_overrides(attrs: Option<&Value>) -> Result { } fn change(s: &mut Session, p: &Value) -> Result { - let re = build(p)?; - let grep = bool_or(p, "grep", false); - let rep = replacement(p); + let re = search(p)?; + re.check(&s.doc()?.doc.styles)?; + // With no pattern (a Find Format alone), the change is literal text. + let grep = bool_or(p, "grep", false) && re.re.is_some(); + let rep = if re.re.is_some() { replacement(p) } else { metachars(str_param(p, "change").unwrap_or("")) }; let first_only = bool_or(p, "first", false); let attrs = p.get("attrs").cloned(); let over = char_overrides(attrs.as_ref())?; + // A null attribute clears that override on the found text. + let cleared: Vec = attrs + .as_ref() + .and_then(Value::as_object) + .map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()) + .unwrap_or_default(); super::text::validate_char_fill(&s.doc()?.doc, &over, "find.change")?; - let targets = scope(s, p)?.targets; - s.edit(|d, sel| { + let char_style = str_param(p, "charStyle").map(str::to_string); + let para_style = str_param(p, "paraStyle").map(str::to_string); + let styles = &s.doc()?.doc.styles; + if let Some(n) = char_style.as_ref().filter(|n| styles.char_style(n).is_none()) { + return Err(bad("find.change", format!("no character style `{n}`"))); + } + if let Some(n) = para_style.as_ref().filter(|n| styles.para(n).is_none()) { + return Err(bad("find.change", format!("no paragraph style `{n}`"))); + } + // InDesign: with a Change Format and nothing in Change to, the found text is only formatted. + let format_only = + str_param(p, "change").unwrap_or("").is_empty() && (char_style.is_some() || para_style.is_some() || !over.is_empty() || !cleared.is_empty()); + let context = scope(s, p)?; + let targets = context.targets.clone(); + // Change: only the text Find Next found, the selection, while it is still a match (as Find + // Next defines one), with its replacement. + let (found, found_out) = if bool_or(p, "found", false) { + let st = s.doc()?; + let selected = st.selection.text.filter(|t| st.selection.cells.is_none() && !t.is_caret()).map(|t| (t.story, t.cell, t.range())); + let hit = selected.and_then(|(sid, cell, range)| { + targets.iter().find_map(|(tid, tcell, r)| { + if *tid != sid || *tcell != cell || range.start < r.start || r.end < range.end { + return None; + } + let text = target_text(&st.doc, *tid, cell, r).ok()?; + let local = range.start - r.start..range.end - r.start; + let (m, out) = re.found_edit(&st.doc, *tid, cell, r, text, &local, &rep, grep).ok()??; + Some(((sid, cell, r.start + m.start..r.start + m.end), out)) + }) + }); + match hit { + Some((hit, out)) => (Some(hit), Some(out)), + None => return Ok(json!({"count": 0})), + } + } else { + (None, None) + }; + let (count, changed) = s.edit(|d, sel| { + let styles = d.styles.clone(); let mut count = 0usize; + let mut changed = None; for (sid, cell, r) in targets { + if found.as_ref().is_some_and(|(at, in_cell, _)| *at != sid || *in_cell != cell) { + continue; + } let Some(story) = d.text_story(sid, cell) else { continue }; // Collect matches first (positions refer to the original text), apply back to front. let text = story.text.get(r.clone()).ok_or_else(|| bad("find", "invalid selection bounds or UTF-8 boundary"))?; - let edits: Vec<(std::ops::Range, String)> = if grep { - re.replacements(text, &rep, first_only) - } else { - re.find_all(text, if first_only { 1 } else { usize::MAX }).map(|ms| ms.into_iter().map(|m| (m, rep.clone())).collect()) - } - .map_err(|e| bad("find.change", e))? - .into_iter() - .map(|(m, out)| (r.start + m.start..r.start + m.end, out)) - .collect(); + let edits: Vec<(std::ops::Range, String)> = match (&found, &found_out) { + // Change: the found text alone, in the target it lies in. + (Some((_, _, range)), Some(out)) => { + if r.start <= range.start && range.end <= r.end { + vec![(range.clone(), out.clone())] + } else { + Vec::new() + } + } + _ => re + .replacements(d, sid, cell, &r, text, &rep, grep, first_only)? + .into_iter() + .map(|(m, out)| (r.start + m.start..r.start + m.end, out)) + .collect(), + }; let Some(st) = d.text_story_mut(sid, cell) else { continue }; for (range, new) in edits.iter().rev() { - st.replace(range.clone(), new); - if !over.is_empty() && !new.is_empty() { - st.format_chars(range.start..range.start + new.len(), |f| f.over.merge(&over)); + let changed = if format_only { + range.clone() + } else { + st.replace(range.clone(), new); + range.start..range.start + new.len() + }; + // A paragraph style goes to the match's paragraphs, an empty match's (`^`) too; + // character formatting needs characters. + if let Some(n) = ¶_style { + super::style::apply_para_style(st, &styles, n, &changed, false); + } + if !changed.is_empty() { + if let Some(n) = &char_style { + super::style::apply_char_style(st, &styles, n, &changed); + } + if !over.is_empty() || !cleared.is_empty() { + st.format_chars(changed, |f| { + f.over.merge(&over); + for k in &cleared { + let _ = f.over.set_json(k, &Value::Null); + } + }); + } } count += 1; } if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid && t.cell == cell) { repair_selection(t, st); } + // Change leaves the changed text selected. + if let Some((_, _, range)) = &found + && let Some((_, new)) = edits.first() + && let Some(t) = sel.text.as_mut() + { + let len = if format_only { range.len() } else { new.len() }; + (t.anchor, t.focus) = (range.start, range.start + len); + changed = Some(len); + } // `first` is the first match of the whole search, not of each story. if first_only && count > 0 { break; } } - Ok(json!({"count": count})) - }) + Ok((count, changed)) + })?; + // Change: Find Next goes on after the changed text, in the search's domain. + if let (Some((sid, cell, old)), Some(len)) = (&found, changed) { + let mut context = context; + context.after_change(s.doc()?, *sid, *cell, old, len); + s.find_context = Some(context); + } + Ok(json!({"count": count})) } fn find_next(s: &mut Session, p: &Value) -> Result { - let re = build(p)?; + let re = search(p)?; + re.check(&s.doc()?.doc.styles)?; let mut context = scope(s, p)?; let st = s.doc()?; let mut hit = None; @@ -403,7 +742,7 @@ fn find_next(s: &mut Session, p: &Value) -> Result { if text.get(from..).is_none() { return Err(bad("find", "invalid UTF-8 search cursor")); } - if let Some(m) = re.find_at(text, from).map_err(|e| bad("find.next", e))? { + if let Some(m) = re.find_at(&st.doc, sid, cell, &r, text, from)? { let (a, b) = (r.start + m.start, r.start + m.end); // Match find_iter/captures_iter: suppress an empty match adjacent to the // previous match's end, but keep adjacent nonempty matches. @@ -489,6 +828,34 @@ mod object_find_tests { use crate::Session; + /// A GREP match that is empty (`^`, the start of each paragraph) gets the paragraph style: + /// only character formatting needs characters. + #[test] + fn change_format_styles_paragraphs_of_empty_matches() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s + .execute( + "frame.create", + &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "One +Two +Three"}), + ) + .unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + s.execute("style.paragraph.create", &json!({"name": "Method"})).unwrap(); + let r = s.execute("find.change", &json!({"find": "^", "change": "", "grep": true, "paraStyle": "Method"})).unwrap(); + assert_eq!(r["count"], 3); + let story = s.doc().unwrap().doc.story(sid).unwrap().clone(); + assert!(story.paras.iter().all(|p| p.style == "Method"), "{:?}", story.paras.iter().map(|p| &p.style).collect::>()); + assert_eq!( + story.text, + "One +Two +Three" + ); + } + #[test] fn find_and_change_objects_by_attributes() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/find/tests.rs b/crates/engine/src/cmd/find/tests.rs index 5914c2e51..348acd294 100644 --- a/crates/engine/src/cmd/find/tests.rs +++ b/crates/engine/src/cmd/find/tests.rs @@ -621,6 +621,27 @@ fn document_scope_searches_and_changes_table_cells() { assert_eq!(s.execute("find.find", &json!({"find": "13.00", "scope": "document"})).unwrap().as_array().unwrap().len(), 2); } +/// Change (`found`) on text Find Next found in a table cell changes it there, leaves the +/// same text in the story alone, and Find Next goes on after it. +#[test] +fn change_changes_the_text_find_next_found_in_a_cell() { + let (mut s, sid) = scope_session("red here"); + scope_select(&mut s, sid, 0, 0); + let table = s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + s.execute("table.setCell", &json!({"row": 0, "col": 1, "text": "red"})).unwrap(); + let p = json!({"find": "red", "change": "blue", "scope": "document"}); + let hit = s.execute("find.next", &p).unwrap(); + assert_eq!(hit["cell"], json!({"table": table, "row": 0, "col": 1}), "{hit}"); + let mut change = p.clone(); + change["found"] = json!(true); + assert_eq!(s.execute("find.change", &change).unwrap()["count"], 1); + let t = s.execute("table.get", &json!({"table": table})).unwrap(); + assert_eq!(t["cells"][1]["text"], "blue"); + assert!(s.doc().unwrap().doc.story(sid).unwrap().text.contains("red here"), "the story's red stays"); + let next = s.execute("find.next", &p).unwrap(); + assert!(next.get("cell").is_none() && next.get("start").is_some(), "on to the story: {next}"); +} + fn child_scope_session(note: bool, child: &str) -> (Session, designcraft_doc::StoryId, designcraft_doc::TextSel) { let (mut s, sid) = scope_session("red"); scope_select(&mut s, sid, 3, 3); @@ -747,3 +768,194 @@ fn find_scope_revert_releases_context_with_same_document_uid() { assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); assert!(s.execute("find.find", &p).is_err(), "revert's empty selection cannot inherit a previous range"); } + +#[test] +fn change_format_styles_the_found_text_and_keeps_it() { + // The course's chapter 22: every lowercase "eggs" gets the character style "eggs", with + // Change to empty, so the words stay. + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let text = "Poached Eggs\nBreak eggs; cook until eggs are set."; + let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": text})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + s.execute("style.character.create", &json!({"name": "eggs", "chars": {"fontStyle": "Bold", "fill": "C=15 M=100 Y=100 K=0"}})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Method"})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("find.change", &json!({"find": "eggs", "change": "", "caseSensitive": true, "charStyle": "eggs"})).unwrap(); + assert_eq!(r["count"], 2); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + let style_at = |s: &Session, at: usize| s.doc().unwrap().doc.story(sid).unwrap().format_after(at).style.clone(); + let story = s.doc().unwrap().doc.story(sid).unwrap().clone(); + assert_eq!(story.text, text, "the words stay"); + for at in [19, 36] { + assert_eq!(&story.text[at..at + 4], "eggs"); + assert_eq!(style_at(&s, at), "eggs"); + } + assert_eq!(style_at(&s, 8), designcraft_doc::NO_CHAR_STYLE, "case sensitive: not \"Eggs\""); + assert_eq!(style_at(&s, 25), designcraft_doc::NO_CHAR_STYLE, "only the found text"); + // A paragraph style goes to the found text's paragraphs, with attributes over the text. + s.execute("find.change", &json!({"find": "cook", "change": "", "paraStyle": "Method", "attrs": {"underline": true}})).unwrap(); + let story = s.doc().unwrap().doc.story(sid).unwrap().clone(); + assert_eq!((story.paras[0].style.as_str(), story.paras[1].style.as_str()), (designcraft_doc::BASIC_PARAGRAPH, "Method")); + assert_eq!(story.format_after(25).over.underline, Some(true)); + assert_eq!(story.format_after(19).style, "eggs", "the other text keeps its character style"); + // Change to and a Change Format: the new text is formatted. + s.execute("find.change", &json!({"find": "set", "change": "firm", "charStyle": "eggs"})).unwrap(); + assert_eq!(style_at(&s, s.doc().unwrap().doc.story(sid).unwrap().text.find("firm").unwrap()), "eggs"); + // No such style: refused, nothing changed. + let before = s.doc().unwrap().doc.clone(); + assert!(s.execute("find.change", &json!({"find": "eggs", "change": "", "charStyle": "Nope"})).is_err()); + assert!(s.execute("find.change", &json!({"find": "eggs", "change": "", "paraStyle": "Nope"})).is_err()); + assert_eq!(s.doc().unwrap().doc, before); + // A null attribute is a Change Format too: the found text stays and loses that override. + let before = s.doc().unwrap().doc.story(sid).unwrap().clone(); + s.execute("find.change", &json!({"find": "cook", "change": "", "attrs": {"underline": null}})).unwrap(); + let story = s.doc().unwrap().doc.story(sid).unwrap().clone(); + assert_eq!(story.text, before.text, "the words stay"); + assert_eq!(story.format_after(25).over.underline, None); + // With no Change Format, an empty Change to still deletes the found text. + s.execute("find.change", &json!({"find": "Break ", "change": ""})).unwrap(); + assert!(!s.doc().unwrap().doc.story(sid).unwrap().text.contains("Break")); +} + +#[test] +fn a_find_format_finds_only_text_with_it() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = + s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Break eggs; cook eggs.\nEggs and eggs"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("style.character.create", &json!({"name": "eggs", "chars": {"fontStyle": "Bold"}})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Method"})).unwrap(); + for (a, b) in [(6, 10), (12, 16)] { + s.execute("text.select", &json!({"story": sid, "anchor": a, "focus": b})).unwrap(); + s.execute("style.character.apply", &json!({"name": "eggs"})).unwrap(); + } + s.execute("text.select", &json!({"story": sid, "anchor": 25, "focus": 25})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Method"})).unwrap(); + let found = |s: &mut Session, p: serde_json::Value| -> Vec<(u64, u64)> { + let m = s.execute("find.find", &p).unwrap(); + m.as_array().unwrap().iter().map(|m| (m["start"].as_u64().unwrap(), m["end"].as_u64().unwrap())).collect() + }; + // "eggs" where it has the character style: the first only. + assert_eq!(found(&mut s, json!({"find": "eggs", "findFormat": {"charStyle": "eggs"}})), [(6, 10)]); + // Nothing in Find what: each stretch of text in the style. + assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"charStyle": "eggs"}})), [(6, 10), (12, 16)]); + // A paragraph style, and resolved attributes. + assert_eq!(found(&mut s, json!({"find": "eggs", "findFormat": {"paraStyle": "Method"}})), [(23, 27), (32, 36)]); + assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"chars": {"fontStyle": "Bold"}}})), [(6, 10), (12, 16)]); + assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"chars": {"size": 12}}})), [(0, 22), (23, 36)], "each paragraph apart"); + // Find Next walks the stretches. + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + let next = json!({"find": "", "findFormat": {"charStyle": "eggs"}}); + assert_eq!(s.execute("find.next", &next).unwrap()["start"], 6); + assert_eq!(s.execute("find.next", &next).unwrap()["start"], 12); + // Equivalent spellings the attributes accept find the same text: a snake_case key, and a + // nested number written as an integer (14 pt leading on the second paragraph). + s.execute("text.select", &json!({"story": sid, "anchor": 23, "focus": 36})).unwrap(); + s.execute("type.char", &json!({"leading": {"kind": "points", "value": 14.0}})).unwrap(); + for (a, b) in [ + (json!({"fontStyle": "Bold"}), json!({"font_style": "Bold"})), + (json!({"leading": {"kind": "points", "value": 14.0}}), json!({"leading": {"kind": "points", "value": 14}})), + ] { + let (a, b) = (json!({"find": "", "findFormat": {"chars": a}}), json!({"find": "", "findFormat": {"chars": b}})); + let all = found(&mut s, a.clone()); + assert!(!all.is_empty(), "{a}"); + assert_eq!(found(&mut s, b.clone()), all, "{b}"); + for p in [&a, &b] { + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + assert_eq!(s.execute("find.next", p).unwrap()["start"].as_u64(), Some(all[0].0), "{p}"); + } + } + // A null attribute asks for nothing. + assert_eq!(found(&mut s, json!({"find": "eggs", "findFormat": {"charStyle": "eggs", "chars": {"size": null}}})), [(6, 10)]); + // With a Change Format: the text in the style goes back to [None], underlined. + let r = s + .execute( + "find.change", + &json!({"find": "", "change": "", "findFormat": {"charStyle": "eggs", "chars": {"font_style": "Bold"}}, "charStyle": "[None]", "attrs": {"underline": true}}), + ) + .unwrap(); + assert_eq!(r["count"], 2, "a snake_case attribute changes the same text"); + assert!(found(&mut s, json!({"find": "", "findFormat": {"charStyle": "eggs"}})).is_empty()); + assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"chars": {"underline": true}}})), [(6, 10), (12, 16)]); + // Unknown styles and attributes are refused; an empty search needs a Find Format. + assert!(s.execute("find.find", &json!({"find": "", "findFormat": {"charStyle": "Nope"}})).is_err()); + assert!(s.execute("find.next", &json!({"find": "eggs", "findFormat": {"paraStyle": "Nope"}})).is_err()); + assert!(s.execute("find.find", &json!({"find": "eggs", "findFormat": {"chars": {"bogus": 1}}})).is_err()); + // Malformed fields are refused, not searched without. + for f in [json!({"chars": [1]}), json!({"chars": 12}), json!({"charStyle": 3}), json!({"paraStyle": ["Body"]})] { + assert!(s.execute("find.find", &json!({"find": "eggs", "findFormat": f})).is_err(), "{f}"); + } + assert!(s.execute("find.find", &json!({"find": ""})).is_err()); +} + +/// Change and Change/Find: only the text Find Next found changes; it stays selected and the +/// search goes on after it in its domain (here the original selection). A selection that +/// isn't a match changes nothing and makes no undo step. +#[test] +fn change_changes_the_found_text_and_the_search_goes_on() { + let (mut s, sid) = scope_session("red red | red"); + scope_select(&mut s, sid, 0, 7); + let p = json!({"find": "red", "change": "blue", "scope": "selection"}); + let mut change = p.clone(); + change["found"] = json!(true); + scope_hit(&mut s, &p, sid, 0, 3); + assert_eq!(s.execute("find.change", &change).unwrap()["count"], 1); + let text = |s: &Session| s.doc().unwrap().doc.story(sid).unwrap().text.clone(); + assert_eq!(text(&s), "blue red | red"); + assert_eq!(s.doc().unwrap().selection.text.unwrap().range(), 0..4, "the changed text stays selected"); + scope_hit(&mut s, &p, sid, 5, 8); + s.execute("find.change", &change).unwrap(); + assert_eq!(text(&s), "blue blue | red"); + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null, "the last red is outside the selection searched"); + // Not a match: nothing changes, no undo step. + scope_select(&mut s, sid, 0, 4); + let history = s.doc().unwrap().history.undo.len(); + assert_eq!(s.execute("find.change", &json!({"find": "red", "change": "blue", "found": true})).unwrap()["count"], 0); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + // A Change Format with nothing in Change to formats the found text only. + scope_select(&mut s, sid, 0, 0); + let fmt = json!({"find": "red", "change": "", "attrs": {"underline": true}}); + let hit = s.execute("find.next", &fmt).unwrap(); + assert_eq!(hit["start"], 12); + let mut found = fmt.clone(); + found["found"] = json!(true); + s.execute("find.change", &found).unwrap(); + assert_eq!(text(&s), "blue blue | red"); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().format_after(12).over.underline, Some(true)); + assert_eq!(s.doc().unwrap().selection.text.unwrap().range(), 12..15); +} + +/// Change accepts what Find Next selected even when Change All wouldn't match there: the +/// rest of a Find Format stretch from a caret inside it, and an overlapping match. +#[test] +fn change_takes_any_selection_find_next_made() { + // A caret inside a bold stretch: Find Next with only a Find Format selects the rest of it. + let (mut s, sid) = scope_session("plain bold text"); + s.execute("style.character.create", &json!({"name": "Strong", "chars": {"fontStyle": "Bold"}})).unwrap(); + scope_select(&mut s, sid, 6, 10); + s.execute("style.character.apply", &json!({"name": "Strong"})).unwrap(); + scope_select(&mut s, sid, 8, 8); + let p = json!({"find": "", "change": "", "findFormat": {"charStyle": "Strong"}, "attrs": {"underline": true}, "scope": "story"}); + let hit = s.execute("find.next", &p).unwrap(); + assert_eq!((hit["start"].as_u64(), hit["end"].as_u64()), (Some(8), Some(10))); + let mut change = p.clone(); + change["found"] = json!(true); + assert_eq!(s.execute("find.change", &change).unwrap()["count"], 1); + let st = s.doc().unwrap().doc.story(sid).unwrap().clone(); + assert_eq!((st.format_after(7).over.underline, st.format_after(8).over.underline), (None, Some(true)), "only the selected rest"); + // "aa" in "aaa" from the second a: Find Next selects 1..3, which Change All never finds. + for grep in [false, true] { + let (mut s, sid) = scope_session("aaa"); + scope_select(&mut s, sid, 1, 1); + let (find, change) = if grep { ("(a)a", "<$1>") } else { ("aa", "b") }; + let p = json!({"find": find, "change": change, "grep": grep, "scope": "story"}); + let hit = s.execute("find.next", &p).unwrap(); + assert_eq!((hit["start"].as_u64(), hit["end"].as_u64()), (Some(1), Some(3))); + let mut found = p.clone(); + found["found"] = json!(true); + assert_eq!(s.execute("find.change", &found).unwrap()["count"], 1, "grep: {grep}"); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, if grep { "a" } else { "ab" }); + } +} diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index ee80eb914..77cebe369 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -14,7 +14,7 @@ use crate::{Result, Session}; pub fn specs() -> Vec { vec![ - cmd!(query "font.list", "Fonts in Document", [], None, "{} → [{family, style, characters, missing, styleMissing, source: bundled|installed|document|added (null when missing), matchStatus: exact|styleSubstitute|missing, resolvedFamily, resolvedStyle, replacementFamily, replacementStyle}] (missing first)", has_doc, |s, _| { + cmd!(query "font.list", "Fonts in Document", [], None, "{} → [{family, style, characters, missing, styleMissing, source: bundled|installed|document|added (null when missing), matchStatus: exact|styleSubstitute|missing, resolvedFamily, resolvedStyle, replacementFamily, replacementStyle, fsType, restrictedLicence, bitmapOnly, noSubsetting, editable, previewPrint (the OS/2 fsType embedding bits of the face that sets the text and what they mean; null when missing or the face has no OS/2 table)}] (missing first)", has_doc, |s, _| { Ok(Value::Array(list(&s.doc()?.doc))) }), cmd!( @@ -75,6 +75,19 @@ pub(super) struct UsedFont { pub resolved_style: String, pub replacement_family: String, pub replacement_style: String, + /// The OS/2 `fsType` of the face that sets the text (`resolved_*`); None when missing or the + /// face has no OS/2 table (as are the bits below). + pub fs_type: Option, + /// Restricted License embedding (bit 1 without bits 2–3). + pub restricted_licence: Option, + /// Bitmap embedding only (bit 9). + pub bitmap_only: Option, + /// No subsetting (bit 8): the font may only be embedded whole. + pub no_subsetting: Option, + /// Editable embedding (bit 3). + pub editable: Option, + /// Preview & Print embedding (bit 2). + pub preview_print: Option, } fn list(d: &Document) -> Vec { @@ -124,6 +137,8 @@ pub(super) fn used_fonts(d: &Document) -> Vec { let resolved = db.face(&family, &style); let characters: Vec = characters.into_iter().filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c)).collect(); let replacement = db.replacement_face(&family, &style, &characters); + let fs_type = (!missing).then(|| resolved.fs_type()).flatten(); + let bit = |mask: u16| fs_type.map(|t| t & mask != 0); let source = (!missing).then(|| match db.face(&family, &style).source { FontSource::Bundled => "bundled", FontSource::Installed(_) => "installed", @@ -148,6 +163,12 @@ pub(super) fn used_fonts(d: &Document) -> Vec { resolved_style: resolved.style.clone(), replacement_family: replacement.family.clone(), replacement_style: replacement.style.clone(), + fs_type, + restricted_licence: fs_type.map(|_| resolved.restricted_licence()), + bitmap_only: bit(0x0200), + no_subsetting: bit(0x0100), + editable: bit(0x0008), + preview_print: bit(0x0004), } }) .collect(); @@ -297,6 +318,55 @@ mod tests { assert_eq!(before, s.execute("font.list", &json!({})).unwrap()); } + /// `font.list` reports each face's OS/2 fsType and what its bits allow, so a caller can + /// refuse a face whose licence doesn't allow the export it is about to make. + #[test] + fn font_list_reports_the_embedding_licence_bits() { + use designcraft_fonts::testing::{font_with, with_fs_type, with_table_len}; + const FAMILY: &str = "DocFont List FsType"; + const FS_TYPES: [u16; 3] = [0x0002, 0x0108, 0x0204]; + for fs_type in FS_TYPES { + designcraft_fonts::FontDb::global() + .add_font(with_fs_type(font_with(&format!("{FAMILY} {fs_type:04x}"), &['W']).unwrap(), fs_type).unwrap()); + } + // An OS/2 table cut to nothing can't be read: as if the font had none. + designcraft_fonts::FontDb::global().add_font(with_table_len(font_with(&format!("{FAMILY} no OS/2"), &['W']).unwrap(), b"OS/2", 0).unwrap()); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + // "W W W W Plain Gone": each W in one of the fonts above, Plain in the default (bundled) + // font, Gone in a family that isn't available. + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "W W W W Plain Gone"})).unwrap(); + let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + let set = |s: &mut Session, at: usize, end: usize, family: &str| { + s.execute("text.select", &json!({"story": sid.0, "anchor": at, "focus": end})).unwrap(); + s.execute("type.char", &json!({"fontFamily": family, "fontStyle": "Regular"})).unwrap(); + }; + for (i, fs_type) in FS_TYPES.into_iter().enumerate() { + set(&mut s, i * 2, i * 2 + 1, &format!("{FAMILY} {fs_type:04x}")); + } + set(&mut s, 6, 7, &format!("{FAMILY} no OS/2")); + set(&mut s, 14, 18, "Nonexistent Sans"); + let l = s.execute("font.list", &json!({})).unwrap(); + let rows = l.as_array().unwrap(); + let entry = |family: &str| rows.iter().find(|f| f["family"] == family).unwrap_or_else(|| panic!("{family}: {l}")); + let bits = |e: &Value| ["fsType", "restrictedLicence", "bitmapOnly", "noSubsetting", "editable", "previewPrint"].map(|k| e[k].clone()); + let (t, f) = (json!(true), json!(false)); + assert_eq!(entry(&format!("{FAMILY} 0002"))["source"], "added", "{l}"); + assert_eq!(bits(entry(&format!("{FAMILY} 0002"))), [json!(0x0002), t.clone(), f.clone(), f.clone(), f.clone(), f.clone()], "{l}"); + assert_eq!(bits(entry(&format!("{FAMILY} 0108"))), [json!(0x0108), f.clone(), f.clone(), t.clone(), t.clone(), f.clone()], "{l}"); + assert_eq!(bits(entry(&format!("{FAMILY} 0204"))), [json!(0x0204), f.clone(), t.clone(), f.clone(), f.clone(), t.clone()], "{l}"); + // The bundled fonts are OFL: installable embedding, every bit clear. + let bundled = rows.iter().find(|r| r["source"] == "bundled").unwrap_or_else(|| panic!("no bundled face: {l}")); + assert_eq!(bits(bundled), [json!(0), f.clone(), f.clone(), f.clone(), f.clone(), f], "{l}"); + // A face without an OS/2 table doesn't say what its licence allows. + assert_eq!(entry(&format!("{FAMILY} no OS/2"))["missing"], false, "{l}"); + assert_eq!(bits(entry(&format!("{FAMILY} no OS/2"))), [(); 6].map(|()| Value::Null), "{l}"); + // A missing face has no file to read. + let missing = entry("Nonexistent Sans"); + assert_eq!(missing["missing"], true, "{l}"); + assert_eq!(bits(missing), [(); 6].map(|()| Value::Null), "{l}"); + } + #[test] fn find_and_replace_font() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/interactive.rs b/crates/engine/src/cmd/interactive.rs index 32562737c..c49425d7a 100644 --- a/crates/engine/src/cmd/interactive.rs +++ b/crates/engine/src/cmd/interactive.rs @@ -6,6 +6,7 @@ use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_doc, str_param}; use crate::{Result, Session}; +/// Register commands for editing hyperlinks and manual bookmarks. pub fn specs() -> Vec { vec![ cmd!( @@ -73,7 +74,7 @@ pub fn specs() -> Vec { let name = str_param(p, "name").map(str::to_string); s.edit(|d, _| { let name = name.clone().unwrap_or_else(|| format!("Bookmark {}", d.bookmarks.len() + 1)); - d.bookmarks.push(Bookmark { name, page, children: vec![] }); + d.bookmarks.push(Bookmark { name, page, children: vec![], generated_by_toc: false }); Ok(json!({"index": d.bookmarks.len() - 1})) }) }), diff --git a/crates/engine/src/cmd/interchange.rs b/crates/engine/src/cmd/interchange.rs index 0ddb6a1aa..c38a5db96 100644 --- a/crates/engine/src/cmd/interchange.rs +++ b/crates/engine/src/cmd/interchange.rs @@ -160,7 +160,7 @@ pub fn import(bytes: &[u8], package: Option<&std::path::Path>) -> Result) -> Result<(Document, Vec)> { - import_report_with_previews(bytes, package, &std::collections::HashMap::new()) + import_report_with_previews(bytes, package, &std::collections::HashMap::new()).map(|(document, warnings, _)| (document, warnings)) } /// [`import_report`], showing `previews` (graphic `Self` → image) for linked files that can't be read. @@ -168,7 +168,7 @@ fn import_report_with_previews( bytes: &[u8], package: Option<&std::path::Path>, previews: &std::collections::HashMap>>, -) -> Result<(Document, Vec)> { +) -> Result<(Document, Vec, std::collections::HashMap)> { #[cfg(not(target_arch = "wasm32"))] let resolved = std::cell::RefCell::new(std::collections::HashMap::new()); let read = |link: &str| -> Option> { @@ -184,7 +184,7 @@ fn import_report_with_previews( None } }; - let designcraft_idml::Imported { mut document, warnings } = + let designcraft_idml::Imported { mut document, warnings, story_ids } = designcraft_idml::import_idml_report_with_previews(bytes, &read, previews).map_err(|e| EngineError::Other(e.to_string()))?; resolve_pdf_crops(&mut document); #[cfg(not(target_arch = "wasm32"))] @@ -199,7 +199,7 @@ fn import_report_with_previews( resolve_packaged_links(&mut document, package); document }; - Ok((document, warnings)) + Ok((document, warnings, story_ids)) } /// Placed PDFs read from IDML name their crop (`PDFCrop`): find where each box sits on its page, @@ -422,7 +422,7 @@ pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result { } else { bytes }; - let (mut d, import_warnings) = import_report_with_previews(&bytes, package.as_deref(), &previews)?; + let (mut d, import_warnings, story_ids) = import_report_with_previews(&bytes, package.as_deref(), &previews)?; warnings.extend(import_warnings); if !title.is_empty() { d.title = title; @@ -433,17 +433,33 @@ pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result { }; warnings.extend(font_warnings); warnings.extend(super::style::grep_style_warnings(&d)); + let warning_items: Vec = warnings.iter().map(|w| warning_item(&d, &story_ids, w).map_or(Value::Null, |i| json!(i.0))).collect(); // Never save over the .idml or .indd with the native format: the document starts unsaved. let mut st = DocState::new(d, None); st.fonts = fonts; let i = s.add_document(st); - Ok(json!({"index": i, "documentFonts": faces, "warnings": warnings})) + Ok(json!({"index": i, "documentFonts": faces, "warnings": warnings, "warningItems": warning_items})) +} + +/// The frame an import warning is about, so it can be shown: the first frame of the story a +/// "story …: …" warning names, by the converter's INDD id (`story 30892`, which it writes to the +/// IDML as `u78ac`) or by its IDML `Self` (`story u78ac`). +fn warning_item( + d: &Document, + story_ids: &std::collections::HashMap, + warning: &str, +) -> Option { + let name = warning.strip_prefix("story ")?.split([':', ' ']).next()?; + let self_id = name.parse::().map_or_else(|_| name.to_string(), |uid| format!("u{uid:x}")); + d.stories.get(story_ids.get(&self_id)?)?.frames.first().copied() } /// An InDesign document or template (.indd, .indt): known by its signature, or by its name when -/// the bytes are something else (the converter then says why it can't read them). +/// the bytes are neither a recognized DesignCraft document nor IDML. The converter explains +/// unrecognized data with an InDesign extension. pub(crate) fn is_indd(name: &str, bytes: &[u8]) -> bool { - bytes.starts_with(&indd::header::SIGNATURE) || has_indd_extension(name) + bytes.starts_with(&indd::header::SIGNATURE) + || (has_indd_extension(name) && !super::file::is_document(bytes) && !bytes.trim_ascii_start().starts_with(b"{")) } pub(crate) fn has_indd_extension(name: &str) -> bool { @@ -479,6 +495,24 @@ mod tests { Ok((bytes, png)) } + #[test] + fn story_warnings_point_at_the_story_frame() { + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + session.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap(); + let idml = designcraft_idml::export_idml_with(&session.doc().unwrap().doc, &designcraft_idml::ExportOptions { embed_images: true }); + let (d, _, story_ids) = import_report_with_previews(&idml, None, &std::collections::HashMap::new()).unwrap(); + let (self_id, sid) = story_ids.iter().find(|(_, sid)| d.stories.get(*sid).is_some_and(|s| !s.frames.is_empty())).unwrap(); + let frame = d.stories[sid].frames[0]; + assert_eq!(warning_item(&d, &story_ids, &format!("story {self_id}: something")), Some(frame)); + // The INDD converter names stories by their number, which it writes as `u` + hex. + if let Some(uid) = self_id.strip_prefix('u').and_then(|h| u32::from_str_radix(h, 16).ok()) { + assert_eq!(warning_item(&d, &story_ids, &format!("story {uid}: text orientation of its frames is not known")), Some(frame)); + } + assert_eq!(warning_item(&d, &story_ids, "story 999999999: unknown"), None); + assert_eq!(warning_item(&d, &story_ids, "a table of 2 rows was read as 1"), None); + } + #[test] fn opens_nested_packaged_links_from_a_different_computer() { let dir = std::env::temp_dir().join(format!("dc-idml-nested-links-{}", std::process::id())); @@ -493,7 +527,7 @@ mod tests { assert_eq!(*asset.data, png, "the packaged graphic must render even when its author-machine path no longer exists"); let resolved = std::path::absolute(&path).unwrap(); assert_eq!(asset.link.as_deref(), resolved.to_str(), "remember the resolved link so update and preflight use the packaged file"); - assert_eq!(super::super::links::status(asset, None), "ok"); + assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "ok"); std::fs::remove_dir_all(dir).unwrap(); } @@ -510,7 +544,7 @@ mod tests { let asset = doc.assets.values().next().unwrap(); assert_eq!(*asset.data, png); assert_eq!(asset.link.as_deref(), std::path::absolute(&path).unwrap().to_str()); - assert_eq!(super::super::links::status(asset, None), "ok"); + assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "ok"); std::fs::remove_dir_all(dir).unwrap(); } @@ -526,7 +560,7 @@ mod tests { let asset = doc.assets.values().next().unwrap(); assert_eq!(*asset.data, png); assert_eq!(asset.link.as_deref(), std::path::absolute(&path).unwrap().to_str()); - assert_eq!(super::super::links::status(asset, None), "ok"); + assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "ok"); std::fs::remove_dir_all(dir).unwrap(); } @@ -546,7 +580,7 @@ mod tests { let asset = doc.assets.values().next().unwrap(); assert_eq!(std::path::Path::new(asset.link.as_deref().unwrap()), original.as_path(), "an existing original stays authoritative"); assert_eq!(*asset.data, png); - assert_eq!(super::super::links::status(asset, None), "modified"); + assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "modified"); std::fs::remove_file(&original).unwrap(); std::fs::write(©, b"modified packaged copy").unwrap(); @@ -554,7 +588,7 @@ mod tests { let asset = doc.assets.values().next().unwrap(); assert_eq!(asset.link.as_deref(), copy.to_str()); assert_eq!(*asset.data, png, "rebinding must not replace embedded pixels"); - assert_eq!(super::super::links::status(asset, None), "modified"); + assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "modified"); std::fs::remove_file(©).unwrap(); let packaged = package.join("x.idml"); @@ -563,7 +597,7 @@ mod tests { let asset = doc.assets.values().next().unwrap(); assert_eq!(std::path::Path::new(asset.link.as_deref().unwrap()), original.as_path(), "keep a missing link when there is no candidate"); assert_eq!(*asset.data, png); - assert_eq!(super::super::links::status(asset, None), "missing"); + assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "missing"); } let oversized = "a/".repeat(MAX_LINK_PATH_BYTES); assert!(packaged_link_candidates(&oversized, Some(&packaged)).is_empty()); @@ -611,7 +645,7 @@ mod tests { assert!(seen.iter().all(|p| p.starts_with(&dir) || itself.as_ref() == Some(p)), "{link}: {seen:?}"); let asset = doc.assets.values().next().unwrap(); assert!(asset.data.is_empty(), "{link}"); - assert_eq!(super::super::links::status(asset, None), "missing", "{link}"); + assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "missing", "{link}"); } // An image elsewhere on this computer is read, and so is one beside the package. let away = outside.join("logo.png"); @@ -620,7 +654,7 @@ mod tests { let doc = import(&idml, Some(&package)).unwrap(); let asset = doc.assets.values().next().unwrap(); assert_eq!((&*asset.data, asset.link.as_deref()), (&png, away.to_str())); - assert_eq!(super::super::links::status(asset, None), "ok"); + assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "ok"); std::fs::write(dir.join("Links").join("logo.png"), &png).unwrap(); let (idml, _) = linked_idml("Links/logo.png", false).unwrap(); let doc = import(&idml, Some(&package)).unwrap(); diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 5ec6abb4d..8833f3b71 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -20,3 +20,7 @@ pub use sections::chapter_numbering; #[cfg(test)] #[path = "parent_delete_tests.rs"] mod parent_delete_tests; + +#[cfg(test)] +#[path = "parent_resize_tests.rs"] +mod parent_resize_tests; diff --git a/crates/engine/src/cmd/layout/pages.rs b/crates/engine/src/cmd/layout/pages.rs index 0417f1ff6..4a7092eb2 100644 --- a/crates/engine/src/cmd/layout/pages.rs +++ b/crates/engine/src/cmd/layout/pages.rs @@ -61,84 +61,93 @@ pub(crate) fn resize_pages(d: &mut designcraft_doc::Document, pages: &[usize], w /// [`resize_pages`] keeping reference point `anchor` (0–8, row-major from the top-left) in place: /// the page grows away from it, so objects keep their place relative to it. pub(crate) fn resize_pages_from(d: &mut designcraft_doc::Document, pages: &[usize], w: Option, h: Option, anchor: usize) { + for page in pages { + let Some((si, pi)) = page.checked_sub(1).and_then(|i| d.page_loc(i)) else { continue }; + resize_spread_page(d, designcraft_doc::SpreadRef::Doc(si), pi, w.map(|v| v.max(1.0)), h.map(|v| v.max(1.0)), anchor); + } +} + +pub(super) fn resize_spread_page( + d: &mut designcraft_doc::Document, + sr: designcraft_doc::SpreadRef, + pi: usize, + w: Option, + h: Option, + anchor: usize, +) { let (fx, fy) = ((anchor % 3) as f64 / 2.0, (anchor / 3).min(2) as f64 / 2.0); - { - for page in pages { - let Some((si, pi)) = d.page_loc(page - 1) else { continue }; - let sp = std::sync::Arc::make_mut(&mut d.spreads[si]); - let old: Vec = sp.pages.iter().map(|x| x.x).collect(); - let old_bounds = sp.pages[pi].bounds(); - // The page's own objects (outer pages take the pasteboard beside them). - let last = sp.pages.len() - 1; - let (lo, hi) = (if pi == 0 { f64::NEG_INFINITY } else { old_bounds.x0 }, if pi == last { f64::INFINITY } else { old_bounds.x1 }); - let own: Vec = sp - .items - .iter() - .enumerate() - .filter(|(_, it)| { - let c = it.bounds().center().x; - c >= lo && c < hi - }) - .map(|(i, _)| i) - .collect(); - let pg = &mut sp.pages[pi]; - let dw = w.map_or(0.0, |w| w.max(1.0) - pg.width); - let dh = h.map_or(0.0, |h| h.max(1.0) - pg.height); - if let Some(w) = w { - set_page_width(pg, w.max(1.0)); - } - if let Some(h) = h { - pg.height = h.max(1.0); - } - let left = pg.side == designcraft_doc::PageSide::Left; - sp.relayout(); - // Keep the spine: shift the whole spread back when a left page grows. - if left && dw != 0.0 { - for q in &mut sp.pages { - q.x -= dw; - } - } - let new: Vec = sp.pages.iter().map(|x| x.x).collect(); - // Objects follow their page. - let shifts: Vec<(f64, f64, f64)> = - sp.pages.iter().enumerate().map(|(k, q)| (old[k], old[k] + q.width - if k == pi { dw } else { 0.0 }, new[k] - old[k])).collect(); - for it in &mut sp.items { - let cx = it.bounds().center().x; - if let Some((_, _, dx)) = shifts.iter().find(|(a, b, _)| cx >= *a && cx < *b) - && *dx != 0.0 - { - let it = std::sync::Arc::make_mut(it); - it.xf = designcraft_geom::Affine::translate((*dx, 0.0)) * it.xf; - } - } - // Page guides move with their page. - for (k, q) in sp.pages.iter_mut().enumerate() { - let dx = new[k] - old[k]; - if dx != 0.0 { - for g in q.guides.iter_mut().filter(|g| g.orientation == designcraft_doc::Orientation::Vertical) { - g.position += dx; - } - } - } - // Growing away from the anchor: the page's objects and guides keep their place - // relative to it. - let shift = designcraft_geom::Vec2::new(fx * dw, fy * dh); - if shift != designcraft_geom::Vec2::ZERO { - for i in &own { - if let Some(it) = sp.items.get_mut(*i) { - let it = std::sync::Arc::make_mut(it); - it.xf = designcraft_geom::Affine::translate(shift) * it.xf; - } - } - for g in &mut sp.pages[pi].guides { - g.position += if g.orientation == designcraft_doc::Orientation::Vertical { shift.x } else { shift.y }; - } - } - // Liquid rules: objects were carried along with the page's left edge (and the anchor). - let moved = old_bounds + designcraft_geom::Vec2::new(new[pi] - old[pi], 0.0) + shift; - crate::cmd::liquid::apply(d, designcraft_doc::SpreadRef::Doc(si), pi, moved); + let Some(sp) = d.spread_mut(sr) else { return }; + let origin = sp.bounds().x0; + let old: Vec = sp.pages.iter().map(|x| x.x).collect(); + let old_bounds = sp.pages[pi].bounds(); + // The page's own objects (outer pages take the pasteboard beside them). + let Some((leftmost, rightmost)) = sp.outer_page_indices() else { return }; + let (lo, hi) = (if pi == leftmost { f64::NEG_INFINITY } else { old_bounds.x0 }, if pi == rightmost { f64::INFINITY } else { old_bounds.x1 }); + let own: Vec = sp + .items + .iter() + .enumerate() + .filter(|(_, it)| { + let c = it.bounds().center().x; + c >= lo && c < hi + }) + .map(|(i, _)| i) + .collect(); + let pg = &mut sp.pages[pi]; + let dw = w.map_or(0.0, |w| w - pg.width); + let dh = h.map_or(0.0, |h| h - pg.height); + if let Some(w) = w { + set_page_width(pg, w); + } + if let Some(h) = h { + pg.height = h; + } + let left = pg.side == designcraft_doc::PageSide::Left; + sp.relayout(); + // Relayout resets the origin; retain prior edits and grow left pages away from the spine. + let offset = origin - if left { dw } else { 0.0 }; + for q in &mut sp.pages { + q.x += offset; + } + let new: Vec = sp.pages.iter().map(|x| x.x).collect(); + // Objects follow their page. + let shifts: Vec<(f64, f64, f64)> = + sp.pages.iter().enumerate().map(|(k, q)| (old[k], old[k] + q.width - if k == pi { dw } else { 0.0 }, new[k] - old[k])).collect(); + for it in &mut sp.items { + let cx = it.bounds().center().x; + if let Some((_, _, dx)) = shifts.iter().find(|(a, b, _)| cx >= *a && cx < *b) + && *dx != 0.0 + { + let it = std::sync::Arc::make_mut(it); + it.xf = designcraft_geom::Affine::translate((*dx, 0.0)) * it.xf; } } + // Page guides move with their page. + for (k, q) in sp.pages.iter_mut().enumerate() { + let dx = new[k] - old[k]; + if dx != 0.0 { + for g in q.guides.iter_mut().filter(|g| g.orientation == designcraft_doc::Orientation::Vertical) { + g.position += dx; + } + } + } + // Growing away from the anchor: the page's objects and guides keep their place + // relative to it. + let shift = designcraft_geom::Vec2::new(fx * dw, fy * dh); + if shift != designcraft_geom::Vec2::ZERO { + for i in &own { + if let Some(it) = sp.items.get_mut(*i) { + let it = std::sync::Arc::make_mut(it); + it.xf = designcraft_geom::Affine::translate(shift) * it.xf; + } + } + for g in &mut sp.pages[pi].guides { + g.position += if g.orientation == designcraft_doc::Orientation::Vertical { shift.x } else { shift.y }; + } + } + // Liquid rules: objects were carried along with the page's left edge (and the anchor). + let moved = old_bounds + designcraft_geom::Vec2::new(new[pi] - old[pi], 0.0) + shift; + crate::cmd::liquid::apply(d, sr, pi, moved); } /// File › Document Setup values. @@ -322,6 +331,51 @@ mod setup_tests { mod page_size_tests { use serde_json::json; + #[test] + fn document_page_resize_preserves_shifted_spread_objects_and_guides() { + for binding in ["leftToRight", "rightToLeft"] { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"pages": 3, "facingPages": true, "width": 400, "height": 400})).unwrap(); + s.execute("layout.documentSetup", &json!({"binding": binding})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let (si, _) = before.page_loc(1).unwrap(); + let (left, right) = before.spreads[si].outer_page_indices().unwrap(); + let mut items = Vec::new(); + for pi in [left, right] { + let x = before.spreads[si].pages[pi].x; + let id = s.execute("frame.create", &json!({"spread": si, "rect": [x + 80.0, 80, x + 180.0, 180]})).unwrap()["id"].as_u64().unwrap(); + items.push(designcraft_doc::ItemId(id)); + s.execute("guide.add", &json!({"spread": si, "page": pi, "orientation": "vertical", "position": x + 120.0})).unwrap(); + } + let first = before.first_page_of_spread(si) + 1; + s.execute("layout.pageSize", &json!({"pages": [first + left], "width": 300})).unwrap(); + let shifted = s.doc().unwrap().doc.clone(); + let sp = &shifted.spreads[si]; + assert_eq!(sp.bounds().x0, 100.0); + assert_eq!(sp.pages[right].x, 400.0); + assert_eq!(sp.pages[left].guides[0].position, 220.0); + assert_eq!(sp.pages[right].guides[0].position, 520.0); + assert_eq!(shifted.item(items[0]).unwrap().bounds().x0, 180.0); + assert_eq!(shifted.item(items[1]).unwrap().bounds().x0, 480.0); + for width in [500, 450, 520] { + s.execute("layout.pageSize", &json!({"pages": [first + right], "width": width})).unwrap(); + let d = &s.doc().unwrap().doc; + let sp = &d.spreads[si]; + assert_eq!(sp.bounds().x0, 100.0, "{binding}, width {width}"); + assert_eq!(sp.pages[right].x, 400.0); + for pi in [left, right] { + assert_eq!(sp.pages[pi].guides, shifted.spreads[si].pages[pi].guides); + } + for id in &items { + assert_eq!(d.item(*id).unwrap().bounds(), shifted.item(*id).unwrap().bounds()); + } + assert_eq!(d.parents, before.parents); + assert_eq!(d.settings, before.settings); + d.check().unwrap(); + } + } + } + #[test] fn page_size_per_page_keeps_objects_on_their_page() { let mut s = crate::Session::new(); diff --git a/crates/engine/src/cmd/layout/parents.rs b/crates/engine/src/cmd/layout/parents.rs index f53d774f4..d43bc14fa 100644 --- a/crates/engine/src/cmd/layout/parents.rs +++ b/crates/engine/src/cmd/layout/parents.rs @@ -65,6 +65,43 @@ pub(super) fn parent_options(s: &mut crate::Session, p: &Value) -> Result }) } +pub(super) fn parent_page_target(d: &designcraft_doc::Document, p: &Value, cmd: &str) -> Result<(usize, usize)> { + let parent = p.get("parent").and_then(Value::as_u64).map(SpreadId).ok_or_else(|| bad(cmd, "give a parent spread id"))?; + let si = d.parent_index(parent).ok_or_else(|| bad(cmd, "no such parent"))?; + let pi = p + .get("page") + .and_then(Value::as_u64) + .and_then(|n| usize::try_from(n).ok()) + .filter(|i| *i < d.parents[si].pages.len()) + .ok_or_else(|| bad(cmd, "no such parent page"))?; + Ok((si, pi)) +} + +pub(super) fn parent_page_size(s: &mut crate::Session, p: &Value) -> Result { + const ID: &str = "layout.parentPageSize"; + let (si, pi) = parent_page_target(&s.doc()?.doc, p, ID)?; + let size = |key| { + p.get(key) + .map(|v| { + v.as_f64().filter(|v| v.is_finite() && *v > 0.0 && *v <= 15552.0).ok_or_else(|| bad(ID, "page size out of range (0 < size ≤ 216 in)")) + }) + .transpose() + }; + let (w, h) = (size("width")?, size("height")?); + if w.is_none() && h.is_none() { + return Err(bad(ID, "give width or height")); + } + let anchor = match p.get("anchor") { + None => 0, + Some(v) => v.as_u64().filter(|n| *n <= 8).ok_or_else(|| bad(ID, "anchor: 0–8"))? as usize, + }; + s.edit(|d, _| { + super::pages::resize_spread_page(d, designcraft_doc::SpreadRef::Parent(si), pi, w, h, anchor); + d.sync_parent_margins(); + Ok(json!({"parent": d.parents[si].id.0, "page": pi})) + }) +} + #[cfg(test)] mod parent_margin_tests { use designcraft_doc::{Document, Margins}; @@ -117,6 +154,52 @@ mod parent_margin_tests { assert!(s.execute("layout.marginsAndColumns", &json!({"parent": "Z", "margins": 10})).is_err()); } + /// Insert Pages before a page, or at the start of the document (`before: 0`), as InDesign's + /// dialog offers; `after` and `before` together are refused without an undo step. + #[test] + fn pages_go_in_before_a_page_or_at_the_start() { + let mut s = Session::new(); + // Facing: [1] [2 3]. + s.execute("file.new", &json!({"pages": 3})).unwrap(); + let b = s.execute("layout.parents.new", &json!({"prefix": "B"})).unwrap()["id"].as_u64().unwrap(); + let ids = |s: &Session| -> Vec { + let d = &s.doc().unwrap().doc; + (0..d.page_count()).map(|i| d.page(i).unwrap().id.0).collect() + }; + let parent = |s: &Session, i: usize| s.doc().unwrap().doc.page(i).unwrap().parent.map(|p| p.0); + let old = ids(&s); + s.execute("layout.pages.insert", &json!({"before": 0, "parent": "B"})).unwrap(); + let now = ids(&s); + assert_eq!(now.len(), 4); + assert_eq!(now[1..], old[..], "the old pages follow the new first page"); + assert_eq!(parent(&s, 0), Some(b)); + // Before page 4 (index 3, the old page 3): two pages without a parent. + s.execute("layout.pages.insert", &json!({"before": 3, "count": 2, "parent": null})).unwrap(); + let then = ids(&s); + assert_eq!(then.len(), 6); + assert_eq!(then[..3], now[..3]); + assert_eq!(then[5], old[2]); + assert_eq!((parent(&s, 3), parent(&s, 4)), (None, None)); + // Refused without a change or an undo step: both placements, even one of them null, and a + // placement that isn't a page index. + let undo = s.doc().unwrap().history.undo.len(); + for bad in [ + json!({"after": 0, "before": 1}), + json!({"after": 0, "before": null}), + json!({"after": null, "before": 1}), + json!({"before": -1}), + json!({"before": "0"}), + json!({"before": null}), + json!({"after": 1.5}), + json!({"before": 6}), + json!({"before": 99}), + ] { + assert!(s.execute("layout.pages.insert", &bad).is_err(), "{bad}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{bad}"); + assert_eq!(ids(&s), then, "{bad}"); + } + } + #[test] fn applying_a_parent_gives_its_margins_and_local_margins_stay_local() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/layout/registry.rs b/crates/engine/src/cmd/layout/registry.rs index 4826f9273..85db79680 100644 --- a/crates/engine/src/cmd/layout/registry.rs +++ b/crates/engine/src/cmd/layout/registry.rs @@ -1,7 +1,7 @@ use super::layers::set_layer; use super::margins::{adjust_layout, column_guides_unlocked, margin_boxes, move_column_guide, set_margins_columns, set_page_width}; use super::pages::{document_setup, page_options, page_size}; -use super::parents::{parent_options, parent_ref, parent_spread_index}; +use super::parents::{parent_options, parent_page_size, parent_page_target, parent_ref, parent_spread_index}; use super::sections::{ChapterEdit, SectionEdit, chapter_numbering, start_number}; use super::{CommandSpec, LAYER_COLORS, Layer, LayerId, Margins, SpreadId, Value, bad, cmd, has_doc, json, ok, str_param}; @@ -12,7 +12,7 @@ pub fn specs() -> Vec { "Insert Pages…", ["Layout", "Pages"], Some("Cmd+Shift+P"), - "{count?: 1 (1 to 9999), after?: 0-based page index (default: the last page), parent?: id|\"A-Parent\"|\"A\"|null}", + "{count?: 1 (1 to 9999), after?: 0-based page index (default: the last page; past it: at the end), before?: 0-based page index (instead of `after`; 0: at the start), parent?: id|\"A-Parent\"|\"A\"|null}", has_doc, |s, p| { const ID: &str = "layout.pages.insert"; @@ -27,12 +27,30 @@ pub fn specs() -> Vec { }; let st = s.doc()?; let n = st.doc.page_count(); - // An `after` past the last page appends, which `insert_after_u64_max_appends_at_the_end` - // pins deliberately. - let after = p.get("after").and_then(Value::as_u64).map(|v| usize::try_from(v).unwrap_or(usize::MAX)).unwrap_or(n.saturating_sub(1)); + // A placement given (null included) must be a page index; only when neither is + // given do the pages go after the last. + if p.get("after").is_some() && p.get("before").is_some() { + return Err(bad("layout.pages.insert", "give `after` or `before`, not both")); + } + // `before` names a page of the document; an `after` past the last page appends + // (`insert_pages_bounds.rs` pins that). + let index = |k: &str| match p.get(k) { + None => Ok(None), + Some(v) => v + .as_u64() + .map(|v| usize::try_from(v).unwrap_or(usize::MAX)) + .filter(|&i| k == "after" || i < n) + .map(|i| Some(i.min(n.saturating_sub(1)))) + .ok_or_else(|| bad(ID, format!("`{k}` must be the index of one of the {n} pages, not {v}"))), + }; + // The page the new ones follow; none: they go first. + let after = match (index("after")?, index("before")?) { + (_, Some(b)) => b.checked_sub(1), + (a, None) => Some(a.unwrap_or(n.saturating_sub(1))), + }; let parent = parent_ref(ID, &st.doc, p.get("parent"))?.unwrap_or_else(|| st.doc.parents.first().map(|x| x.id)); s.edit(|d, _| { - let ids = d.insert_pages(Some(after), count, parent)?; + let ids = d.insert_pages(after, count, parent)?; Ok(json!({"pages": ids.len(), "total": d.page_count()})) }) } @@ -484,6 +502,30 @@ pub fn specs() -> Vec { has_doc, page_size ), + cmd!( + "layout.parentPageSize", + "Parent Page Size", + [], + None, + "{parent: spread id, page: 0-based parent page index, width?, height?, anchor?: 0–8} — resize only this parent page, keeping document page sizes independent", + has_doc, + parent_page_size + ), + cmd!( + noundo "layout.selectParentPage", "Select Parent Page", [], None, + "{parent: spread id|null, page?: 0-based parent page index} — select a parent page with the Page tool; null clears the selection", + has_doc, |s, p| { + let target = match p.get("parent") { + Some(Value::Null) => None, + _ => Some(parent_page_target(&s.doc()?.doc, p, "layout.selectParentPage")?), + }; + let st = s.doc_mut()?; + st.selection = Default::default(); + st.selection.parent_page = target.map(|(si, pi)| (st.doc.parents[si].id, pi)); + st.bump_revision(); + ok() + } + ), cmd!( "layout.pageOptions", "Page Options", @@ -518,6 +560,7 @@ pub fn specs() -> Vec { st.selection = designcraft_doc::Selection::items(ids); } st.selection.page = page; + st.selection.parent_page = None; st.bump_revision(); Ok(json!({"page": page.map(|p| p + 1)})) } diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 3fc67f822..ec7bf6ba8 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -27,8 +27,10 @@ pub fn specs() -> Vec { cmd!(query "links.list", "Links", [], None, "{} → [{asset, name, path, status: ok|modified|missing|embedded, refused?: why a missing link isn't followed (a path on another computer, a device), pixels, pdfCrop (placed PDFs: the box shown), uses: [{id, page, ppi}]}], then linked text files: [{story, name, path, status (also tooLarge: unchanged size, too big to check), edited (the story was changed in the layout), uses: [{id, page}]}]", has_doc, |s, _| { // Taken out for the call so the document needn't be cloned on every poll. let mut checks = std::mem::take(&mut s.text_link_checks); - let links = s.doc().map(|d| list(&d.doc, &s.cache, d.path.as_deref().map(Path::new), &mut checks)); + let mut images = std::mem::take(&mut s.image_link_checks); + let links = s.doc().map(|d| list(&d.doc, &s.cache, d.path.as_deref().map(Path::new), &mut checks, &mut images)); s.text_link_checks = checks; + s.image_link_checks = images; Ok(Value::Array(links?)) }), cmd!( @@ -137,7 +139,7 @@ pub fn refused(link: &str, document: Option<&Path>) -> Option) -> bool { @@ -153,9 +155,20 @@ pub fn link_missing(a: &designcraft_doc::Asset, document: Option<&Path>) -> bool } } -/// Link status of an asset in the document at `document`. Only a regular file is compared, so a -/// link to a device (`/dev/zero`) or a FIFO is missing rather than read. -pub fn status(a: &designcraft_doc::Asset, document: Option<&Path>) -> &'static str { +/// Linked images checked this session: (asset, the identity of its bytes, the file its link +/// resolves to) → (the file's size and time when it was read, whether it matched the bytes). The +/// resolved file, not the link text, is the key: a relative link names another file once the +/// document is saved elsewhere. The bytes' identity (their `Arc`'s address, as the render cache +/// keys them) changes with every relink, update or undo, so a remembered answer can never +/// describe other bytes. +pub(crate) type ImageChecks = std::collections::HashMap<(AssetId, usize, std::path::PathBuf), (Fingerprint, bool)>; + +/// Link status of a placed graphic (`ok`, `modified`, `missing`, `embedded`), reading the file +/// only when its size is the asset's and its time isn't the one remembered from the last read +/// (`checks`): the Links panel polls twice a second, and a document +/// of press-size images would otherwise be read whole on every poll. A file changed in the last +/// two seconds is never remembered (see [`settled`]). +pub(crate) fn status_cached(a: &designcraft_doc::Asset, document: Option<&Path>, checks: &mut ImageChecks) -> &'static str { let Some(link) = &a.link else { return "embedded" }; // Nothing was read from the file (a package's link that wasn't followed): missing, as in // Preflight. @@ -168,20 +181,32 @@ pub fn status(a: &designcraft_doc::Asset, document: Option<&Path>) -> &'static s if !crate::io::is_file(&path) { return "missing"; } - match crate::io::metadata(&path) { - Err(_) => "missing", - Ok(m) if m.len() as usize != a.data.len() => "modified", - // Same size: compare the bytes (cheap for the sizes involved, and exact). - Ok(_) => match crate::io::read_capped(&path, a.data.len() as u64) { - Ok(b) if b == *a.data => "ok", - Ok(_) => "modified", - Err(_) => "missing", - }, + let now = match crate::io::metadata(&path) { + Err(_) => return "missing", + Ok(m) => super::datamerge::fingerprint_from(&m), + }; + if now.size != a.data.len() as u64 { + return "modified"; } + let key = (a.id, Arc::as_ptr(&a.data) as usize, path.clone()); + if let Some((at, same)) = checks.get(&key) + && *at == now + { + return if *same { "ok" } else { "modified" }; + } + let same = match crate::io::read_capped(&path, a.data.len() as u64) { + Ok(b) => b == *a.data, + Err(_) => return "missing", + }; + // The time from before the read: a change during it is seen next time. + if settled(&now) { + checks.insert(key, (now, same)); + } + if same { "ok" } else { "modified" } } #[cfg(target_arch = "wasm32")] { - let _ = (link, document); + let _ = (link, document, checks); "ok" } } @@ -317,7 +342,9 @@ fn shown<'a>(d: &'a Document, cache: &Cache, aid: AssetId) -> Vec> { out } -fn list(d: &Document, cache: &Cache, document: Option<&Path>, checks: &mut TextChecks) -> Vec { +/// `links.list`: every used placed graphic, then every linked text file, each with its status +/// (`images` and `checks` remember what was read this session, so a poll reads nothing unchanged). +fn list(d: &Document, cache: &Cache, document: Option<&Path>, checks: &mut TextChecks, images: &mut ImageChecks) -> Vec { let texts = d.stories.values().filter_map(|st| { let l = st.file_link.as_ref()?; let uses: Vec = st.frames.iter().map(|id| json!({"id": id.0, "page": d.page_of_item(*id).map(|p| d.page_name(p))})).collect(); @@ -350,7 +377,7 @@ fn list(d: &Document, cache: &Cache, document: Option<&Path>, checks: &mut TextC .collect(); let pdf_crop = (a.mime == "application/pdf").then(|| a.pdf_crop.name()); let why = a.link.as_deref().and_then(|l| refused(l, document)).map(|r| r.to_string()); - json!({"asset": a.id.0, "name": a.name, "path": a.link, "status": status(a, document), "refused": why, "pixels": a.pixels, "pdfCrop": pdf_crop, "uses": uses}) + json!({"asset": a.id.0, "name": a.name, "path": a.link, "status": status_cached(a, document, images), "refused": why, "pixels": a.pixels, "pdfCrop": pdf_crop, "uses": uses}) }) .chain(texts) .collect() @@ -450,6 +477,7 @@ fn relinked_asset(a: &designcraft_doc::Asset, read: &Path, link: &str) -> Result Ok(Arc::new(a)) } +/// `links.relink`: a placed graphic or linked text file takes its bytes from `path` from now on. fn relink(s: &mut Session, p: &Value) -> Result { let path = str_param(p, "path").ok_or_else(|| bad("links.relink", "missing path"))?.to_string(); if let Some(sid) = story_param(p) { @@ -464,13 +492,17 @@ fn relink(s: &mut Session, p: &Value) -> Result { let a = s.doc()?.doc.assets.get(&aid).ok_or_else(|| bad("links.relink", "no such graphic"))?; // Build the replacement before the edit: a file that cannot be read leaves the document as it was. let fresh = relinked_asset(a, Path::new(&path), &path)?; - s.edit(|d, _| { + let r = s.edit(|d, _| { let name = fresh.name.clone(); d.assets.insert(aid, fresh); Ok(json!({"name": name})) - }) + }); + s.image_link_checks.clear(); + r } +/// `links.relinkFolder`: relink graphics to same-named files in a folder and/or with another +/// extension (the missing ones by default). fn relink_folder(s: &mut Session, p: &Value) -> Result { let dir = str_param(p, "dir").map(std::path::PathBuf::from); if let Some(dir) = &dir { @@ -486,12 +518,13 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result { } let missing_only = p.get("missingOnly").and_then(Value::as_bool).unwrap_or(dir.is_some() && ext.is_none()); let only: Option> = p.get("assets").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect()); + let mut images = std::mem::take(&mut s.image_link_checks); let st = s.doc()?; let (d, document) = (&st.doc, st.path.as_deref().map(Path::new)); let mut todo: Vec<(AssetId, std::path::PathBuf)> = Vec::new(); let mut not_found = Vec::new(); for a in d.assets.values() { - if only.as_ref().is_some_and(|o| !o.contains(&a.id.0)) || (missing_only && status(a, document) != "missing") { + if only.as_ref().is_some_and(|o| !o.contains(&a.id.0)) || (missing_only && status_cached(a, document, &mut images) != "missing") { continue; } let old = std::path::PathBuf::from(a.link.clone().unwrap_or_else(|| a.name.clone())); @@ -538,10 +571,13 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result { } Ok(()) })?; + images.clear(); } + s.image_link_checks = images; Ok(json!({"relinked": n, "notFound": not_found})) } +/// `links.update`: re-read the modified links (one, or every one) from where they resolve. fn update(s: &mut Session, p: &Value) -> Result { let (d, document) = { let st = s.doc()?; @@ -549,6 +585,7 @@ fn update(s: &mut Session, p: &Value) -> Result { }; let document = document.as_deref().map(Path::new); let checks = &mut s.text_link_checks; + let images = &mut s.image_link_checks; let (only_asset, only_story) = (p.get("asset").and_then(Value::as_u64), story_param(p)); let all = only_asset.is_none() && only_story.is_none(); // A link craft-docpath refuses is missing, so it is never read or refreshed. @@ -563,7 +600,7 @@ fn update(s: &mut Session, p: &Value) -> Result { }) .collect(); let mut fresh = Vec::new(); - for a in d.assets.values().filter(|a| all || only_asset == Some(a.id.0)).filter(|a| status(a, document) == "modified") { + for a in d.assets.values().filter(|a| all || only_asset == Some(a.id.0)).filter(|a| status_cached(a, document, images) == "modified") { // The bytes come from where the stored link resolves; the link itself stays as the // document stored it, so refreshing never rewrites a relative link to an absolute one. if let Some((link, at)) = a.link.as_deref().and_then(|l| resolved(l).map(|at| (l, at))) { @@ -579,7 +616,7 @@ fn update(s: &mut Session, p: &Value) -> Result { if n == 0 { return Ok(json!({"updated": 0})); } - s.edit(|d, _| { + let r = s.edit(|d, _| { for (aid, a) in fresh { d.assets.insert(aid, a); } @@ -588,7 +625,9 @@ fn update(s: &mut Session, p: &Value) -> Result { warnings.extend(reimport(d, *sid, path, bytes, *before)?); } Ok(json!({"updated": n, "warnings": warnings})) - }) + }); + s.image_link_checks.clear(); + r } #[cfg(test)] @@ -1096,6 +1135,16 @@ mod text_link_tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn placed_plain_text_drops_story_markers() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let b64 = crate::cmd::base64_encode("a\u{E009}b\u{E000}c\u{E002}d".as_bytes()); + let r = s.execute("file.place", &json!({"base64": b64, "name": "n.txt", "rect": [72, 72, 300, 200]})).unwrap(); + let st = s.doc().unwrap().doc.story(StoryId(r["story"].as_u64().unwrap())).unwrap(); + assert_eq!(st.text, "abcd"); + } + #[test] fn create_links_when_placing_text_files_preference() { let dir = std::env::temp_dir().join(format!("dc-text-link-pref-{}", std::process::id())); diff --git a/crates/engine/src/cmd/links/tests.rs b/crates/engine/src/cmd/links/tests.rs index 8089b24ce..c3391b333 100644 --- a/crates/engine/src/cmd/links/tests.rs +++ b/crates/engine/src/cmd/links/tests.rs @@ -4,6 +4,78 @@ fn png(w: u32, h: u32) -> Vec { designcraft_render::Rendered { width: w, height: h, pixels: vec![200u8; (w * h * 4) as usize] }.to_png() } +/// The Links panel polls `links.list` twice a second: a linked image whose file keeps its size +/// and time is not read again (press-size images would otherwise be read whole on every poll). +/// A new time is read once; changed bytes show as modified; an update is remembered. +#[cfg(all(unix, not(target_arch = "wasm32")))] +#[test] +fn unchanged_linked_images_are_not_read_again() { + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir().join(format!("dc-image-link-poll-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("photo.png"); + // Two valid images of the same byte length (a uniform fill compresses to the same size + // whatever its value), so a change shows only in the bytes. + let a = png(40, 40); + let b = designcraft_render::Rendered { width: 40, height: 40, pixels: vec![100u8; 40 * 40 * 4] }.to_png(); + assert_eq!(a.len(), b.len(), "the test needs two files of one size"); + assert_ne!(a, b); + std::fs::write(&path, &a).unwrap(); + // Unreadable: a status that reads the file would say missing. + let readable = |on: bool| std::fs::set_permissions(&path, std::fs::Permissions::from_mode(if on { 0o644 } else { 0o000 })).unwrap(); + // A time `secs` ago (a file changed in the last two seconds is always read). + let touch = |secs: u64| { + let f = std::fs::File::options().write(true).open(&path).unwrap(); + f.set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(secs)).unwrap(); + }; + touch(60); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.place", &json!({"path": path.to_string_lossy(), "link": true})).unwrap(); + let status = |s: &mut Session| { + let l = s.execute("links.list", &json!({})).unwrap(); + l.as_array().unwrap().iter().find(|r| r.get("asset").is_some()).unwrap()["status"].as_str().unwrap().to_string() + }; + + // First poll reads and remembers; the second doesn't read. + assert_eq!(status(&mut s), "ok"); + readable(false); + if std::fs::read(&path).is_ok() { + // Running as root: permissions don't stop reads, so this can't be observed. + let _ = std::fs::remove_dir_all(&dir); + return; + } + assert_eq!(status(&mut s), "ok", "same size and time: not read"); + // Saved again unchanged (a new time): read once, then not again. + readable(true); + touch(30); + assert_eq!(status(&mut s), "ok"); + readable(false); + assert_eq!(status(&mut s), "ok", "the second poll doesn't read it"); + // Changed, same size, new time: modified — and remembered, not re-read. + readable(true); + std::fs::write(&path, &b).unwrap(); + touch(20); + assert_eq!(status(&mut s), "modified"); + readable(false); + assert_eq!(status(&mut s), "modified", "a modified answer is remembered too"); + // Updated: the document's bytes are the file's, so the next poll reads once and says ok. + readable(true); + assert_eq!(s.execute("links.update", &json!({})).unwrap()["updated"], 1); + assert_eq!(status(&mut s), "ok"); + readable(false); + assert_eq!(status(&mut s), "ok", "remembered after the update"); + // A different size needs no read at all. + readable(true); + std::fs::write(&path, png(41, 41)).unwrap(); + touch(10); + readable(false); + assert_eq!(status(&mut s), "modified", "size differs: decided from metadata"); + readable(true); + let _ = std::fs::remove_dir_all(&dir); +} + /// A linked file's size is the document's choice, so a link is read under a cap (#1596). #[cfg(not(target_arch = "wasm32"))] #[test] @@ -154,7 +226,7 @@ fn eps_status_tracks_source_bytes_across_save_update_relink_and_embed() { std::fs::remove_file(&replacement).unwrap(); let embedded = designcraft_format::load(&designcraft_format::save(&s.doc().unwrap().doc).unwrap()).unwrap(); // The asset is embedded, so no link is followed and the document is irrelevant. - assert_eq!(status(&embedded.assets[&aid], None), "embedded"); + assert_eq!(status_cached(&embedded.assets[&aid], None, &mut Default::default()), "embedded"); assert_eq!(embedded.assets[&aid].data.as_slice(), changed); assert!(designcraft_render::decode_pixmap(&embedded.assets[&aid].data).is_some()); let _ = std::fs::remove_dir_all(dir); diff --git a/crates/engine/src/cmd/liquid.rs b/crates/engine/src/cmd/liquid.rs index 8c8d4be23..5f09a0c89 100644 --- a/crates/engine/src/cmd/liquid.rs +++ b/crates/engine/src/cmd/liquid.rs @@ -70,8 +70,8 @@ pub(crate) fn apply(d: &mut Document, sr: SpreadRef, pi: usize, old: Rect) { let liquid_guides = |o: Orientation| pg.guides.iter().filter(|g| g.liquid && g.orientation == o).map(|g| g.position).collect::>(); let (vg, hg) = (liquid_guides(Orientation::Vertical), liquid_guides(Orientation::Horizontal)); // The page's objects: centres within its old horizontal extent (outer pages take the pasteboard). - let last = sp.pages.len() - 1; - let (lo, hi) = (if pi == 0 { f64::NEG_INFINITY } else { old.x0 }, if pi == last { f64::INFINITY } else { old.x1 }); + let Some((leftmost, rightmost)) = sp.outer_page_indices() else { return }; + let (lo, hi) = (if pi == leftmost { f64::NEG_INFINITY } else { old.x0 }, if pi == rightmost { f64::INFINITY } else { old.x1 }); let strokes = false; let Some(sp) = d.spread_mut(sr) else { return }; for it in &mut sp.items { diff --git a/crates/engine/src/cmd/mathexpr.rs b/crates/engine/src/cmd/mathexpr.rs index e5342f79c..d6b4a9e95 100644 --- a/crates/engine/src/cmd/mathexpr.rs +++ b/crates/engine/src/cmd/mathexpr.rs @@ -35,6 +35,11 @@ fn math_item(d: &mut designcraft_doc::Document, latex: &str, size: f64, lid: des it } +/// The `size` parameter in points: 12 by default (also for non-finite values), clamped to 2..=400. +fn size_param(p: &Value) -> f64 { + p.get("size").and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(12.0).clamp(2.0, 400.0) +} + pub fn specs() -> Vec { vec![ cmd!( @@ -48,7 +53,7 @@ pub fn specs() -> Vec { ), cmd!(query "math.svg", "Math Expression SVG", [], None, "{latex, size?} → {svg, width, height} — typeset without placing", has_doc, |_s, p| { let latex = str_param(p, "latex").ok_or_else(|| bad("math.svg", "`latex` required"))?; - let (svg, (w, h)) = crate::math::to_svg(latex, p.get("size").and_then(Value::as_f64).unwrap_or(12.0)); + let (svg, (w, h)) = crate::math::to_svg(latex, size_param(p)); Ok(json!({"svg": String::from_utf8_lossy(&svg), "width": w, "height": h})) }), ] @@ -56,7 +61,7 @@ pub fn specs() -> Vec { fn insert(s: &mut Session, p: &Value) -> Result { let latex = str_param(p, "latex").ok_or_else(|| bad("math.insert", "`latex` required"))?.to_string(); - let size = p.get("size").and_then(Value::as_f64).unwrap_or(12.0).clamp(2.0, 400.0); + let size = size_param(p); let lid = s.doc()?.active_layer; // Edit an existing expression in place. if let Some(id) = super::id_param(p, "id") { @@ -102,6 +107,24 @@ mod tests { use crate::Session; + #[test] + fn math_svg_clamps_size_like_insert() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let dims = |s: &mut Session, size: f64| { + let r = s.execute("math.svg", &json!({"latex": "x", "size": size})).unwrap(); + (r["width"].as_f64().unwrap(), r["height"].as_f64().unwrap()) + }; + let (w2, h2) = dims(&mut s, 2.0); + let (w400, h400) = dims(&mut s, 400.0); + for size in [0.0, -5.0, 1e-300] { + assert_eq!(dims(&mut s, size), (w2, h2), "size {size}"); + } + for size in [1e9, 1e300] { + assert_eq!(dims(&mut s, size), (w400, h400), "size {size}"); + } + } + #[test] fn math_places_inline_and_as_frames() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index ad8b25213..aac0d0eb1 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -382,9 +382,10 @@ pub fn specs() -> Vec { "Drop Shadow", ["Object", "Effects"], Some("Cmd+Alt+M"), - "{on?: bool, distance?, angle?, globalLight?: bool, opacity?, size?, spread? (%), color? (swatch), ids?}", + "{on?: bool, distance?, angle?, globalLight?: bool, opacity?, size?, spread? (%), color? (swatch), mode?: blend mode (multiply…), ids?}", has_selection, |s, p| { + let mode = blend_param(p, "object.dropShadow")?; let p2 = p.clone(); set_effects(s, p, "object.dropShadow", move |fx| { let ds = &mut fx.drop_shadow; @@ -398,6 +399,9 @@ pub fn specs() -> Vec { if let Some(c) = str_param(&p2, "color") { ds.color = c.into(); } + if let Some(m) = mode { + ds.mode = m; + } }) } ), @@ -406,9 +410,10 @@ pub fn specs() -> Vec { "Inner Shadow", ["Object", "Effects"], None, - "{on?: bool, distance?, angle?, globalLight?: bool, opacity?, size?, choke? (%), color?, ids?}", + "{on?: bool, distance?, angle?, globalLight?: bool, opacity?, size?, choke? (%), color?, mode?: blend mode, ids?}", has_selection, |s, p| { + let mode = blend_param(p, "object.innerShadow")?; let p2 = p.clone(); set_effects(s, p, "object.innerShadow", move |fx| { let e = &mut fx.inner_shadow; @@ -422,6 +427,9 @@ pub fn specs() -> Vec { if let Some(c) = str_param(&p2, "color") { e.color = c.into(); } + if let Some(m) = mode { + e.mode = m; + } }) } ), @@ -430,9 +438,10 @@ pub fn specs() -> Vec { "Outer Glow", ["Object", "Effects"], None, - "{on?: bool, opacity?, size?, spread? (%), color?, ids?}", + "{on?: bool, opacity?, size?, spread? (%), color?, mode?: blend mode (screen…), ids?}", has_selection, |s, p| { + let mode = blend_param(p, "object.outerGlow")?; let p2 = p.clone(); set_effects(s, p, "object.outerGlow", move |fx| { let e = &mut fx.outer_glow; @@ -443,6 +452,9 @@ pub fn specs() -> Vec { if let Some(c) = str_param(&p2, "color") { e.color = c.into(); } + if let Some(m) = mode { + e.mode = m; + } }) } ), @@ -598,7 +610,7 @@ pub fn specs() -> Vec { "Text Frame Options…", ["Object"], Some("Cmd+B"), - "{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, columnsKind?: fixedNumber|fixedWidth, columnWidth? (pt, Fixed Width), firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin? (pt), autoSize?: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference?: topLeft|topCenter|topRight|leftCenter|center|rightCenter|bottomLeft|bottomCenter|bottomRight (the point that stays put), useMinimumHeight?: bool, minimumHeight? (pt), useMinimumWidth?: bool, minimumWidth? (pt), noLineBreaks?: bool, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), baselineGrid?: {start? (pt), increment? (pt), relativeTo?: page|margin|frame|inset} (the frame's custom baseline grid; fields not given keep the frame's, or come from the document grid; null turns it off), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}", + "{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, paragraphSpacingLimit? (pt: Justify adds at most this between paragraphs, the rest between lines), columnsKind?: fixedNumber|fixedWidth, columnWidth? (pt, Fixed Width), firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin? (pt), autoSize?: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference?: topLeft|topCenter|topRight|leftCenter|center|rightCenter|bottomLeft|bottomCenter|bottomRight (the point that stays put), useMinimumHeight?: bool, minimumHeight? (pt), useMinimumWidth?: bool, minimumWidth? (pt), noLineBreaks?: bool, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), baselineGrid?: {start? (pt), increment? (pt), relativeTo?: page|margin|frame|inset} (the frame's custom baseline grid; fields not given keep the frame's, or come from the document grid; null turns it off), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}", has_selection, text_frame_options ), @@ -607,7 +619,7 @@ pub fn specs() -> Vec { "Corner Options…", ["Object"], None, - "{shape: none|rounded|inverseRounded|inset|bevel|fancy, size, corners?: [{shape, size}] (4 entries in path order: top-left, top-right, bottom-right, bottom-left; replaces shape and size), ids?}", + "{shape: none|rounded|inverseRounded|inset|bevel|fancy, size, corners?: [{shape, size} | null] (4 entries in path order: top-left, top-right, bottom-right, bottom-left; replaces shape and size; null keeps that corner of each object), ids?}", has_selection, corner_options ), @@ -616,7 +628,7 @@ pub fn specs() -> Vec { "Text Wrap", ["Window"], Some("Cmd+Alt+W"), - "{mode?: none|boundingBox|contour|jumpObject|jumpToNextColumn (boundingBox when absent, unless side or contour is given: then unchanged), offset?: number|[t,l,b,r], invert?: bool, side?: bothSides|leftSide|rightSide|towardsSpine|awayFromSpine|largestArea, contour?: boundingBox|graphicFrame|sameAsClipping, content?: bool (the placed graphic instead of its frame; default: whether the selection is the frame's content), ids?}; offset or contour drops an imported custom wrap boundary", + "{mode?: none|boundingBox|contour|jumpObject|jumpToNextColumn (boundingBox when absent, unless side or contour is given; null or those: each object's own, unchanged), offset?: number|[t,l,b,r] (a null side keeps each object's own), invert?: bool, side?: bothSides|leftSide|rightSide|towardsSpine|awayFromSpine|largestArea, contour?: boundingBox|graphicFrame|sameAsClipping, content?: bool (the placed graphic instead of its frame; default: whether the selection is the frame's content), ids?}; offset or contour drops an imported custom wrap boundary", has_selection, text_wrap ), @@ -638,6 +650,15 @@ pub fn specs() -> Vec { has_selection, fitting_options ), + cmd!( + "object.clearFittingOptions", + "Clear Frame Fitting Options", + ["Object", "Fitting"], + None, + "{ids?} — the frames' fitting options back to the defaults: no auto-fit, no crop amounts, the centre reference point; the content stays where it is", + has_selection, + clear_fitting_options + ), cmd!("object.rename", "Rename", [], None, "{id | ids?, name}", has_doc, |s, p| { let name = str_param(p, "name").unwrap_or("").to_string(); set_flag(s, p, move |i| i.name = name.clone(), false) @@ -680,7 +701,15 @@ pub fn specs() -> Vec { path_reverse ), cmd!("path.close", "Close Path", ["Object", "Paths"], None, "{id, subpath?: index (default every subpath)}", has_doc, path_close), - cmd!("path.moveAnchors", "Move Anchors", [], None, "{id, anchors: [[subpath, index]], dx, dy, handle?: in|out}", has_doc, path_move_anchors), + cmd!( + "path.moveAnchors", + "Move Anchors", + [], + None, + "{id, anchors: [[subpath, index]], dx, dy, handle?: in|out} | {points: [[id, subpath, index, dx?, dy?]], dx?, dy?} (anchors of several paths, as Direct Selection drags them: one step; a point's own dx, dy win over the shared ones) — spread space", + has_doc, + path_move_anchors + ), cmd!( "object.align", "Align", @@ -2101,13 +2130,18 @@ fn text_wrap(s: &mut Session, p: &Value) -> Result { None => None, }; let mode: Option = match p.get("mode") { + // Null keeps each object's own mode (an edit of the offsets or Invert alone). + Some(Value::Null) => None, Some(v) => Some(serde_json::from_value(v.clone()).map_err(|_| bad("object.textWrap", "unknown mode"))?), None if side.is_some() || contour.is_some() => None, None => Some(WrapMode::BoundingBox), }; - let off = match p.get("offset") { - Some(Value::Number(n)) => Some([n.as_f64().unwrap_or(0.0); 4]), - Some(v) => serde_json::from_value(v.clone()).ok(), + // Per side [top, left, bottom, right]; a null side keeps each object's own offset there, so + // an edit of one side keeps the others of every object. + // An array with no number in it changes no offset (and so keeps a custom boundary). + let off: Option<[Option; 4]> = match p.get("offset") { + Some(Value::Number(n)) => Some([n.as_f64(); 4]), + Some(v) => serde_json::from_value::<[Option; 4]>(v.clone()).ok().filter(|o| o.iter().any(Option::is_some)), None => None, }; let invert = p.get("invert").and_then(Value::as_bool); @@ -2127,7 +2161,11 @@ fn text_wrap(s: &mut Session, p: &Value) -> Result { w.mode = m; } if let Some(o) = off { - w.offsets = o; + for (w, v) in w.offsets.iter_mut().zip(o) { + if let Some(v) = v { + *w = v; + } + } } if let Some(v) = invert { w.invert = v; @@ -2152,19 +2190,31 @@ fn corner_options(s: &mut Session, p: &Value) -> Result { use designcraft_geom::corners::{Corner, CornerOptions, CornerShape}; let opts = match p.get("corners") { Some(v) => { - let corners: [Corner; 4] = - serde_json::from_value(v.clone()).map_err(|e| bad("object.cornerOptions", format!("corners: four {{shape, size}} entries ({e})")))?; - if corners.iter().any(|c| !c.size.is_finite() || c.size < 0.0) { + let corners: [Option; 4] = serde_json::from_value(v.clone()) + .map_err(|e| bad("object.cornerOptions", format!("corners: four {{shape, size}} or null entries ({e})")))?; + if corners.iter().flatten().any(|c| !c.size.is_finite() || c.size < 0.0) { return Err(bad("object.cornerOptions", "corner sizes must be finite and not negative")); } - CornerOptions { corners } + corners } None => { let shape = p.get("shape").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or(CornerShape::Rounded); - CornerOptions::uniform(shape, f64_or(p, "size", 12.0)) + CornerOptions::uniform(shape, f64_or(p, "size", 12.0)).corners.map(Some) } }; - set_flag(s, p, move |i| i.corners = opts, false) + // A null corner keeps each object's own. + set_flag( + s, + p, + move |i| { + for (c, new) in i.corners.corners.iter_mut().zip(opts) { + if let Some(new) = new { + *c = new; + } + } + }, + false, + ) } /// `object.matchAttributes`: the selected objects (or `ids`) take `from`'s appearance. @@ -2311,6 +2361,15 @@ fn effects_all(s: &mut Session, p: &Value) -> Result { } if let Some(patch) = &patch { let mut cur = serde_json::to_value(&it.effects).map_err(|e| bad(ID, e.to_string()))?; + // Default effects are left out of the serialized form; a patch without `mode` would + // then read the legacy Normal instead of the effect's current (Multiply, Screen) mode. + for (key, now) in + [("innerShadow", serde_json::to_value(&it.effects.inner_shadow)), ("outerGlow", serde_json::to_value(&it.effects.outer_glow))] + { + if let (Some(o), Ok(now)) = (cur.as_object_mut(), now) { + o.entry(key).or_insert(now); + } + } merge_json(&mut cur, patch); let mut fx: designcraft_doc::Effects = serde_json::from_value(cur).map_err(|e| bad(ID, format!("effects: {e}")))?; sanitize_effects(&mut fx)?; @@ -2505,6 +2564,7 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result { let min_width = ranged("minimumWidth", 0.0, crate::autosize::MAX_SIDE, "a number of points")?; let column_width = ranged("columnWidth", 0.0, 15552.0, "a number of points")?; let first_baseline_min = ranged("firstBaselineMin", 0.0, 15552.0, "a number of points")?; + let spacing_limit = ranged("paragraphSpacingLimit", 0.0, 15552.0, "a number of points")?; let reference = match p.get("autoSizeReference") { None | Some(Value::Null) => None, Some(v) => Some( @@ -2591,6 +2651,7 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result { if let Some(v) = reference { o.auto_size_ref = v; } + o.vj_paragraph_spacing_limit = spacing_limit.unwrap_or(o.vj_paragraph_spacing_limit); if let Some(v) = p.get("useMinimumHeight").and_then(Value::as_bool) { o.auto_size_use_min_height = v; } @@ -2938,7 +2999,9 @@ fn fitting_options(s: &mut Session, p: &Value) -> Result { } else if fitting.is_some() && g.auto_fit != designcraft_doc::Fitting::None { g.auto_fit = mode; } - if let Some(xf) = g.fitted(r, mode) { + // Turning Auto-Fit off alone leaves the picture where the user put it. + let turn_off_only = auto == Some(false) && fitting.is_none() && align.is_none() && crop.is_none(); + if !turn_off_only && let Some(xf) = g.fitted(r, mode) { g.xf = xf; } n += 1; @@ -2947,6 +3010,35 @@ fn fitting_options(s: &mut Session, p: &Value) -> Result { }) } +/// Clear Frame Fitting Options: auto-fit off, no crop, the centre reference point. The content +/// stays where it is, as in InDesign. +fn clear_fitting_options(s: &mut Session, p: &Value) -> Result { + // Locked frames (or frames on a locked layer) keep their options, as with Fit; frames whose + // options are already clear are left out; with nothing to change, no edit (and no empty undo + // step). + let doc = &s.doc()?.doc; + let set = |g: &designcraft_doc::Graphic| g.auto_fit != designcraft_doc::Fitting::None || g.crop != [0.0; 4] || g.fit_align != 4; + let ids: Vec = targets(s, p)? + .into_iter() + .filter(|id| doc.item(*id).is_some_and(|it| unlocked(doc, it) && matches!(&it.content, Content::Graphic(g) if set(g)))) + .collect(); + if ids.is_empty() { + return Ok(json!({"changed": 0})); + } + s.edit(|d, _| { + let mut n = 0; + for id in &ids { + let Some(it) = d.item_mut(*id) else { continue }; + let Content::Graphic(g) = &mut it.content else { continue }; + g.auto_fit = designcraft_doc::Fitting::None; + g.crop = [0.0; 4]; + g.fit_align = 4; + n += 1; + } + Ok(json!({"changed": n})) + }) +} + fn fit(s: &mut Session, p: &Value) -> Result { let doc = &s.doc()?.doc; let ids: Vec<_> = @@ -3174,6 +3266,20 @@ fn align(s: &mut Session, p: &Value) -> Result { }) } +/// An effect's `mode` parameter: a blend mode by name ("multiply", "screen", or as saved). +/// An effect's `mode`: `None` when omitted or null, an error when it names no blend mode. +fn blend_param(p: &Value, cmd: &str) -> Result> { + match p.get("mode") { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_str() + .and_then(BlendMode::parse) + .or_else(|| serde_json::from_value(v.clone()).ok()) + .map(Some) + .ok_or_else(|| bad(cmd, format!("unknown blend mode {v}"))), + } +} + fn distribute(s: &mut Session, p: &Value) -> Result { let ids = targets(s, p)?; if ids.len() < 3 && p.get("spacing").is_none() { @@ -3266,29 +3372,36 @@ fn path_create(s: &mut Session, p: &Value) -> Result { fn path_append(s: &mut Session, p: &Value) -> Result { let id = super::id_param(p, "id").ok_or_else(|| bad("path.appendAnchor", "missing id"))?; let a = p.get("anchor").and_then(anchor_from).ok_or_else(|| bad("path.appendAnchor", "missing anchor"))?; - let subpath = p.get("subpath").and_then(Value::as_u64); + // An omitted subpath is the last one; one given must name a subpath (never falls back). + let subpath = match p.get("subpath") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_u64().ok_or_else(|| bad("path.appendAnchor", "`subpath` must be a subpath index"))?), + }; let at_start = match p.get("at") { None => false, Some(v) if v.as_str() == Some("end") => false, Some(v) if v.as_str() == Some("start") => true, Some(_) => return Err(bad("path.appendAnchor", "`at` must be `start` or `end`")), }; - s.edit(|d, _| { + s.edit(|d, sel| { path_editable(d, id, "path.appendAnchor")?; let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?; let inv = (d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default()).inverse(); let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; - let sp = match subpath { - Some(i) => { - usize::try_from(i).ok().and_then(|i| it.path.subpaths.get_mut(i)).ok_or_else(|| bad("path.appendAnchor", "no such subpath"))? - } - None => it.path.subpaths.last_mut().ok_or_else(|| bad("path.appendAnchor", "empty path"))?, + let si = match subpath { + Some(i) => usize::try_from(i).ok().filter(|i| *i < it.path.subpaths.len()).ok_or_else(|| bad("path.appendAnchor", "no such subpath"))?, + None => it.path.subpaths.len().checked_sub(1).ok_or_else(|| bad("path.appendAnchor", "empty path"))?, }; + let sp = &mut it.path.subpaths[si]; if sp.closed { return Err(bad("path.appendAnchor", "the subpath is closed")); } if at_start { sp.anchors.insert(0, a.transform(inv)); + // The selected points of this sub-path move up one place with the others. + for a in sel.anchors.iter_mut().filter(|a| a.0 == id && a.1 == si) { + a.2 += 1; + } } else { sp.anchors.push(a.transform(inv)); } @@ -3367,7 +3480,7 @@ fn path_reverse(s: &mut Session, p: &Value) -> Result { { path_editable(doc, *id, "path.reverse")?; } - s.edit(|d, _| { + s.edit(|d, sel| { let mut reversed = 0; for id in &editable { let Some(it) = d.item_mut(*id) else { continue }; @@ -3378,6 +3491,11 @@ fn path_reverse(s: &mut Session, p: &Value) -> Result { if only.is_some_and(|o| o != i) { continue; } + // Selected points keep pointing at the same points, now counted from the other end. + let n = sp.anchors.len(); + for a in sel.anchors.iter_mut().filter(|a| a.0 == *id && a.1 == i && a.2 < n) { + a.2 = n - 1 - a.2; + } sp.anchors.reverse(); for a in &mut sp.anchors { std::mem::swap(&mut a.h_in, &mut a.h_out); @@ -3411,38 +3529,75 @@ fn path_close(s: &mut Session, p: &Value) -> Result { } fn path_move_anchors(s: &mut Session, p: &Value) -> Result { - let id = super::id_param(p, "id").ok_or_else(|| bad("path.moveAnchors", "missing id"))?; - let which: Vec<(usize, usize)> = p.get("anchors").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + // The anchors to move, by path: `points` (several paths) or `id` with `anchors`. + // Each anchor with its own spread-space movement, when the points give one. + let mut targets: Vec<(ItemId, Vec<(usize, usize, Option)>)> = Vec::new(); + // A `points` that isn't a list is an error, never the `id` form (which could move another path). + let points = match p.get("points") { + None | Some(Value::Null) => None, + Some(Value::Array(a)) => Some(a), + Some(_) => return Err(bad("path.moveAnchors", "`points` must be a list of [id, subpath, index, dx?, dy?]")), + }; + if let Some(points) = points { + for v in points { + let n = |i: usize| v.get(i).and_then(Value::as_u64).and_then(|x| usize::try_from(x).ok()); + let (Some(id), Some(si), Some(ai)) = (v.get(0).and_then(Value::as_u64), n(1), n(2)) else { + return Err(bad("path.moveAnchors", "each point is [id, subpath, index] or [id, subpath, index, dx, dy]")); + }; + let own = match (v.get(3), v.get(4)) { + (None, None) => None, + (Some(x), Some(y)) => match (x.as_f64(), y.as_f64()) { + (Some(x), Some(y)) if x.is_finite() && y.is_finite() => Some(Vec2::new(x, y)), + _ => return Err(bad("path.moveAnchors", "a point's dx and dy are finite numbers")), + }, + _ => return Err(bad("path.moveAnchors", "a point gives both dx and dy, or neither")), + }; + match targets.iter_mut().find(|(i, _)| i.0 == id) { + Some((_, which)) => which.push((si, ai, own)), + None => targets.push((ItemId(id), vec![(si, ai, own)])), + } + } + } else { + let id = super::id_param(p, "id").ok_or_else(|| bad("path.moveAnchors", "missing id"))?; + let which: Vec<(usize, usize)> = p.get("anchors").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + targets.push((id, which.into_iter().map(|(s, a)| (s, a, None)).collect())); + } let d0 = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0)); let handle = str_param(p, "handle").map(str::to_string); s.edit(|d, _| { - path_editable(d, id, "path.moveAnchors")?; - let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?; - let m = d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default(); - // Spread-space delta → inner-space delta (linear part only). - let inv = m.inverse(); - let dv = (inv * Point::new(d0.x, d0.y)) - (inv * Point::ZERO); - let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; - for (si, ai) in &which { - let Some(a) = it.path.anchor_mut(*si, *ai) else { continue }; - match handle.as_deref() { - Some("in") => { - a.h_in += dv; - if a.kind == designcraft_geom::AnchorKind::Smooth { - a.h_out = a.p - (a.h_in - a.p); - } - } - Some("out") => { - a.h_out += dv; - if a.kind == designcraft_geom::AnchorKind::Smooth { - a.h_in = a.p - (a.h_out - a.p); - } - } - _ => a.translate(dv), - } + for (id, _) in &targets { + path_editable(d, *id, "path.moveAnchors")?; } - if it.shape == Shape::Rectangle || it.shape == Shape::Oval || it.shape == Shape::Polygon { - it.shape = Shape::Path; + for (id, which) in &targets { + let id = *id; + let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + let m = d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default(); + // Spread-space delta → inner-space delta (linear part only). + let inv = m.inverse(); + let inner = |d: Vec2| (inv * Point::new(d.x, d.y)) - (inv * Point::ZERO); + let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + for (si, ai, own) in which { + let dv = inner(own.unwrap_or(d0)); + let Some(a) = it.path.anchor_mut(*si, *ai) else { continue }; + match handle.as_deref() { + Some("in") => { + a.h_in += dv; + if a.kind == designcraft_geom::AnchorKind::Smooth { + a.h_out = a.p - (a.h_in - a.p); + } + } + Some("out") => { + a.h_out += dv; + if a.kind == designcraft_geom::AnchorKind::Smooth { + a.h_in = a.p - (a.h_out - a.p); + } + } + _ => a.translate(dv), + } + } + if it.shape == Shape::Rectangle || it.shape == Shape::Oval || it.shape == Shape::Polygon { + it.shape = Shape::Path; + } } ok() }) @@ -4143,6 +4298,31 @@ mod label_tests { mod light_tests { use super::*; + /// The shadow and glow commands take a blend mode by name. + #[test] + fn shadow_and_glow_commands_take_a_blend_mode() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [72, 72, 172, 172]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.dropShadow", &json!({"on": true, "mode": "screen", "ids": [id]})).unwrap(); + s.execute("object.innerShadow", &json!({"on": true, "mode": "overlay", "ids": [id]})).unwrap(); + s.execute("object.outerGlow", &json!({"on": true, "mode": "normal", "ids": [id]})).unwrap(); + let fx = s.doc().unwrap().doc.item(ItemId(id)).unwrap().effects.clone(); + assert_eq!(fx.drop_shadow.mode, BlendMode::Screen); + assert_eq!(fx.inner_shadow.mode, BlendMode::Overlay); + assert_eq!(fx.outer_glow.mode, BlendMode::Normal); + let before = s.doc().unwrap().doc.clone(); + for cmd in ["object.dropShadow", "object.innerShadow", "object.outerGlow"] { + for mode in [json!("multply"), json!(3)] { + let e = s.execute(cmd, &json!({"on": true, "mode": mode, "ids": [id]})).unwrap_err(); + assert!(e.to_string().contains("unknown blend mode"), "{cmd}: {e}"); + } + } + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "nothing changed"); + s.execute("object.dropShadow", &json!({"mode": null, "ids": [id]})).unwrap(); + assert_eq!(s.doc().unwrap().doc.item(ItemId(id)).unwrap().effects.drop_shadow.mode, BlendMode::Screen, "null keeps the mode"); + } + #[test] fn global_light_moves_shadows_that_use_it() { let mut s = Session::new(); @@ -4352,6 +4532,60 @@ mod fitting_tests { let g = back.spreads.iter().flat_map(|sp| sp.items.iter()).find_map(|i| i.graphic().cloned()).unwrap(); assert_eq!(g.auto_fit, designcraft_doc::Fitting::FillProportionally); } + + /// Turning Auto-Fit off by itself keeps a moved picture where it is. + #[test] + fn auto_fit_off_alone_keeps_the_picture_position() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300]})).unwrap()["id"].as_u64().unwrap(); + let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png(); + s.execute("file.place", &json!({"base64": crate::cmd::base64_encode(&png), "name": "p.png", "frame": f})).unwrap(); + s.execute("object.fittingOptions", &json!({"ids": [f], "autoFit": true, "fitting": "fitProportionally"})).unwrap(); + let xf = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().graphic().unwrap().xf; + s.edit(|d, _| { + if let Some(Content::Graphic(g)) = d.item_mut(ItemId(f)).map(|it| &mut it.content) { + g.xf = designcraft_geom::Affine::translate((7.0, 9.0)) * g.xf; + } + Ok(Value::Null) + }) + .unwrap(); + let moved = xf(&s); + s.execute("object.fittingOptions", &json!({"ids": [f], "autoFit": false})).unwrap(); + assert_eq!(xf(&s), moved); + } + + /// Clear Frame Fitting Options puts auto-fit, crop and the reference point back to their + /// defaults and leaves the picture where it is. + #[test] + fn clear_fitting_options_resets_the_frames_options_and_keeps_the_picture() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [72, 72, 272, 172]})).unwrap()["id"].as_u64().unwrap(); + let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png(); + s.execute("file.place", &json!({"base64": crate::cmd::base64_encode(&png), "name": "p.png", "frame": f})).unwrap(); + s.execute("object.fittingOptions", &json!({"ids": [f], "autoFit": true, "fitting": "fitProportionally", "crop": 10, "align": 0})).unwrap(); + let graphic = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().graphic().unwrap().clone(); + let before = graphic(&s); + assert_ne!(before.auto_fit, designcraft_doc::Fitting::None); + s.execute("object.clearFittingOptions", &json!({"ids": [f]})).unwrap(); + let g = graphic(&s); + assert_eq!(g.auto_fit, designcraft_doc::Fitting::None); + assert_eq!(g.crop, [0.0; 4]); + assert_eq!(g.fit_align, 4, "the centre reference point"); + assert_eq!(g.xf, before.xf, "the picture stays where it is"); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("object.clearFittingOptions", &json!({"ids": [f]})).unwrap(); + assert_eq!((r["changed"].as_u64(), s.doc().unwrap().history.undo.len()), (Some(0), undo), "already clear: no change, no step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(graphic(&s).crop, [10.0; 4], "one undo step"); + s.execute("object.lock", &json!({"ids": [f]})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("object.clearFittingOptions", &json!({"ids": [f]})).unwrap(); + assert_eq!(r["changed"], 0); + assert_eq!(graphic(&s).crop, [10.0; 4], "a locked frame keeps its options"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no empty undo step"); + } } #[cfg(test)] @@ -5538,12 +5772,14 @@ mod effects_command_tests { let it = st.doc.item(ItemId(id)).unwrap(); let e = &it.effects; assert!(e.inner_shadow.on && e.inner_shadow.angle == 120.0 && e.inner_shadow.size == 7.0); + assert_eq!(e.inner_shadow.mode, designcraft_color::BlendMode::Multiply, "a patch without a mode keeps the default"); assert!(e.drop_shadow.on && e.drop_shadow.distance == 10.0 && e.drop_shadow.size == 9.0, "untouched fields stay"); assert_eq!((e.feather, it.opacity, it.blend), (4.0, 0.5, designcraft_color::BlendMode::Multiply)); // Out of range values are kept in range; garbage changes nothing. s.execute("object.effects", &json!({"ids": [id], "effects": {"outerGlow": {"on": true, "opacity": 3, "spread": 400, "size": -5}}})).unwrap(); let og = s.doc().unwrap().doc.item(ItemId(id)).unwrap().effects.outer_glow.clone(); assert_eq!((og.opacity, og.spread, og.size), (1.0, 100.0, 0.0)); + assert_eq!(og.mode, designcraft_color::BlendMode::Screen); let before = s.doc().unwrap().doc.clone(); for bad in [json!({"effects": {"dropShadow": {"distance": "far"}}}), json!({"effects": 5}), json!({"blend": "loud"}), json!({"opacity": "x"})] { diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index a6946be4f..7b2141c54 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -203,7 +203,7 @@ pub fn specs() -> Vec { "Package…", ["File"], None, - "{dir: folder to create, idml?: true, pdf?: false, instructions?: text} — the document (its placed files relinked to Links/), Links/, the font files that draw its text in Document Fonts/ (fallback fonts included; unless their licence restricts it), an IDML copy (linking the files in Links/, nothing embedded), an optional PDF and a report → {dir, files, report}", + "{dir: folder to create, idml?: true, pdf?: false|true|{…options of file.exportPdf}, instructions?: text} — the document (its placed files relinked to Links/), Links/, the font files that draw its text in Document Fonts/ (fallback fonts included; unless their licence restricts it), an IDML copy (linking the files in Links/, nothing embedded), an optional PDF and a report → {dir, files, report}", has_doc, package ), @@ -390,6 +390,17 @@ fn drawn_faces(cs: &ComposedStory, depth: usize, out: &mut Vec) { fn package(s: &mut Session, p: &Value) -> Result { let dir = PathBuf::from(str_param(p, "dir").ok_or_else(|| bad("file.package", "missing `dir`"))?); let st = s.doc()?; + // The PDF: with the default settings, or with the options of a PDF preset, made as + // file.exportPdf makes it. Read first, so that bad options write nothing. + let pdf = match p.get("pdf") { + None | Some(Value::Null) | Some(Value::Bool(false)) => None, + Some(Value::Bool(true)) => Some(json!({})), + Some(o @ Value::Object(_)) => { + super::export::check_pdf_options(o, st.doc.page_count())?; + Some(o.clone()) + } + Some(v) => return Err(bad("file.package", format!("`pdf` is true, false or PDF export options, not {v}"))), + }; let root = OutputRoot::new(dir.clone())?; let display_title = st.doc.title.clone(); let title = safe_component(&display_title, "Untitled"); @@ -441,18 +452,18 @@ fn package(s: &mut Session, p: &Value) -> Result { idml_layer_note = "\nPlaced PDF layers\nThe IDML copy links the placed PDFs, so it shows their file defaults. The DesignCraft document keeps each placement's Object Layer Options choice.\n"; } } - if p.get("pdf").and_then(Value::as_bool).unwrap_or(false) { + if let Some(params) = pdf { let path = package_output_path(&dir, &title, "pdf")?; - let bytes = designcraft_pdf::export_pdf(&super::interchange::with_pdf_boxes(&packed), &s.cache, &Default::default()) - .map_err(|e| EngineError::Other(e.to_string()))?; - root.write(&path, &bytes)?; + root.write(&path, &super::export::pdf_report(&packed, &s.cache, ¶ms)?.bytes)?; files.push(path); } - // The report: fonts (missing first), links, preflight, the user's instructions. - let mut r = format!("Package report: {display_title}\n\n"); + // The printing instructions first, as the form gave them; then the report: fonts (missing + // first), links, preflight. + let mut r = String::new(); if let Some(t) = str_param(p, "instructions").filter(|t| !t.trim().is_empty()) { r += &format!("Instructions\n{t}\n\n"); } + r += &format!("Package report: {display_title}\n\n"); r += "Fonts\n"; r += &font_lines.concat(); if !fallback_lines.is_empty() { diff --git a/crates/engine/src/cmd/package/tests.rs b/crates/engine/src/cmd/package/tests.rs index acc6d657f..4d6513425 100644 --- a/crates/engine/src/cmd/package/tests.rs +++ b/crates/engine/src/cmd/package/tests.rs @@ -25,6 +25,58 @@ fn relocated_packages_rebind_embedded_image_links_on_open() { std::fs::remove_dir_all(dir).unwrap(); } +/// `pdf` takes file.exportPdf's options (a PDF preset's): a PDF/X-4 package PDF; `true` is the +/// default PDF, and bad options are refused. +#[test] +fn package_pdf_takes_export_options() { + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("dc-package-pdf-options-{}-{nonce}", std::process::id())); + let mut s = Session::new(); + s.execute("file.new", &json!({"title": "Options", "width": 72, "height": 72})).unwrap(); + let x4 = dir.join("x4"); + s.execute("file.package", &json!({"dir": x4.to_string_lossy(), "idml": false, "pdf": {"standard": "x4"}})).unwrap(); + let has = |path: &std::path::Path, what: &[u8]| std::fs::read(path).unwrap().windows(what.len()).any(|w| w == what); + assert!(has(&x4.join("Options.pdf"), b"GTS_PDFX")); + let plain = dir.join("plain"); + s.execute("file.package", &json!({"dir": plain.to_string_lossy(), "idml": false, "pdf": true})).unwrap(); + assert!(!has(&plain.join("Options.pdf"), b"GTS_PDFX")); + // Refused before anything is written: bad options, a bad flattener or view option, and a + // `pdf` that is neither a switch nor options. + for bad in [ + json!({"standard": "nope"}), + json!({"flatten": "extreme"}), + json!({"pageLayout": "spiral"}), + json!({"view": "zoomed"}), + json!("print"), + json!(3), + ] { + let out = dir.join("bad"); + assert!(s.execute("file.package", &json!({"dir": out.to_string_lossy(), "pdf": bad})).is_err(), "{bad}"); + assert!(!out.exists(), "{bad}: bad options write nothing"); + } + std::fs::remove_dir_all(dir).unwrap(); +} + +/// A preset that flattens transparency (Screen and Email's `flatten: low`) gives the packaged +/// PDF the flattening `file.exportPdf` does. +#[test] +fn the_packaged_pdf_is_flattened_as_export_flattens_it() { + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("dc-package-pdf-flatten-{}-{nonce}", std::process::id())); + let mut s = Session::new(); + s.execute("file.new", &json!({"title": "Flat", "width": 144, "height": 144})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [10, 10, 100, 100], "content": "unassigned"})).unwrap()["id"].clone(); + s.execute("object.fill", &json!({"ids": [id], "swatch": "[Black]"})).unwrap(); + s.execute("object.opacity", &json!({"ids": [id], "opacity": 0.5})).unwrap(); + let exported = super::super::base64_decode(s.execute("file.exportPdf", &json!({"flatten": "low"})).unwrap()["base64"].as_str().unwrap()); + s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false, "pdf": {"flatten": "low"}})).unwrap(); + let packaged = std::fs::read(dir.join("Flat.pdf")).unwrap(); + let images = |pdf: &[u8]| pdf.windows(b"/Subtype/Image".len()).filter(|w| *w == b"/Subtype/Image").count(); + assert!(images(&exported) > 0, "the export rasterises the transparent object"); + assert_eq!(images(&packaged), images(&exported), "so does the package"); + std::fs::remove_dir_all(dir).unwrap(); +} + /// A per-placement PDF layer choice cannot travel in an IDML copy that links its files, so the /// report says the copies differ instead of leaving it to be discovered. #[test] @@ -169,6 +221,7 @@ fn package_writes_document_links_and_report() { assert!(out.join("Links").join("logo.svg").exists()); let report = std::fs::read_to_string(out.join("Instructions.txt")).unwrap(); assert!(report.contains("Print on matte.") && report.contains("logo.svg"), "{report}"); + assert!(report.find("Print on matte.") < report.find("Package report:"), "the instructions come first: {report}"); assert!(report.contains("Preflight:"), "{report}"); // The packaged document links to its Links folder. let doc_file = files.iter().find(|f| f.ends_with(".designcraft")).unwrap(); diff --git a/crates/engine/src/cmd/parent_resize_tests.rs b/crates/engine/src/cmd/parent_resize_tests.rs new file mode 100644 index 000000000..55ba22976 --- /dev/null +++ b/crates/engine/src/cmd/parent_resize_tests.rs @@ -0,0 +1,299 @@ +use std::sync::Arc; + +use designcraft_doc::{Columns, LiquidRule, PageSide, SpreadId}; +use designcraft_geom::Rect; +use serde_json::json; + +use crate::Session; + +fn parent_id(s: &Session, index: usize) -> u64 { + s.doc().unwrap().doc.parents[index].id.0 +} + +fn parent_page(s: &Session, index: usize, page: usize) -> designcraft_doc::Page { + s.doc().unwrap().doc.parents[index].pages[page].clone() +} + +fn parent_frame(s: &mut Session, x: f64) -> u64 { + s.execute( + "frame.create", + &json!({ + "spread": {"kind": "parent", "index": 0}, + "rect": [x, 80.0, x + 100.0, 180.0] + }), + ) + .unwrap()["id"] + .as_u64() + .unwrap() +} + +fn bounds(s: &Session, id: u64) -> Rect { + s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds() +} + +fn guide_positions(s: &Session, page: usize) -> Vec { + s.doc().unwrap().doc.parents[0].pages[page].guides.iter().map(|g| g.position).collect() +} + +fn prepare_parent_pages(s: &mut Session, binding: &str, rule: LiquidRule) -> (u64, u64, u64, usize, usize) { + s.execute("file.new", &json!({"pages": 2, "facingPages": true, "width": 400.0, "height": 400.0})).unwrap(); + s.execute("layout.documentSetup", &json!({"binding": binding})).unwrap(); + assert_eq!(s.doc().unwrap().doc.settings.right_to_left_binding, binding == "rightToLeft"); + assert_eq!(s.doc().unwrap().doc.parents[0].pages[0].side, if binding == "rightToLeft" { PageSide::Right } else { PageSide::Left }); + let target = s.doc().unwrap().doc.parents[0].pages.iter().position(|p| p.side == PageSide::Right).unwrap(); + let other = 1 - target; + let target_page = parent_page(s, 0, target); + let other_page = parent_page(s, 0, other); + let target_item = parent_frame(s, target_page.x + 80.0); + let other_item = parent_frame(s, other_page.x + 80.0); + s.execute( + "guide.add", + &json!({"spread": {"kind": "parent", "index": 0}, "page": target, "orientation": "vertical", "position": target_page.x + 120.0}), + ) + .unwrap(); + s.execute( + "guide.add", + &json!({"spread": {"kind": "parent", "index": 0}, "page": target, "orientation": "horizontal", "position": 120.0, "at": target_page.x + 10.0}), + ) + .unwrap(); + s.execute( + "guide.add", + &json!({"spread": {"kind": "parent", "index": 0}, "page": other, "orientation": "vertical", "position": other_page.x + 120.0}), + ) + .unwrap(); + s.execute( + "guide.add", + &json!({"spread": {"kind": "parent", "index": 0}, "page": other, "orientation": "horizontal", "position": 120.0, "at": other_page.x + 10.0}), + ) + .unwrap(); + s.edit(|d, _| { + let sp = Arc::make_mut(&mut d.parents[0]); + let positions = |page: &designcraft_doc::Page| { + let w = page.margin_rect().width(); + vec![0.0, 100.0, 120.0, w] + }; + for page in &mut sp.pages { + page.columns = Columns { count: 2, gutter: 20.0, positions: Some(positions(page)) }; + } + sp.pages[target].liquid = rule; + Ok(()) + }) + .unwrap(); + (parent_id(s, 0), target_item, other_item, target, other) +} + +#[test] +fn parent_resize_changes_only_the_target_page_in_ltr_and_rtl() { + for binding in ["leftToRight", "rightToLeft"] { + for rule in [ + LiquidRule::Off, + LiquidRule::Scale, + LiquidRule::ReCenter, + LiquidRule::ObjectBased, + LiquidRule::GuideBased, + LiquidRule::ControlledByParent, + ] { + let mut s = Session::new(); + let (parent, target_item, other_item, target, other) = prepare_parent_pages(&mut s, binding, rule); + let before_target = parent_page(&s, 0, target); + let before_other = parent_page(&s, 0, other); + let before_target_item = bounds(&s, target_item); + let before_other_item = bounds(&s, other_item); + let before_target_guides = guide_positions(&s, target); + let before_other_guides = guide_positions(&s, other); + let before_target_columns = before_target.columns.positions.clone(); + let before_other_columns = before_other.columns.positions.clone(); + + s.execute("layout.selectParentPage", &json!({"parent": parent, "page": target})).unwrap(); + assert_eq!(s.doc().unwrap().selection.parent_page, Some((SpreadId(parent), target))); + assert!(s.doc().unwrap().selection.items.is_empty()); + s.execute("layout.parentPageSize", &json!({"parent": parent, "page": target, "width": 500.0, "height": 500.0, "anchor": 8})).unwrap(); + + let after_target = parent_page(&s, 0, target); + let after_other = parent_page(&s, 0, other); + assert_eq!((after_target.width, after_target.height), (500.0, 500.0), "{binding} {rule:?}"); + assert_eq!((after_other.width, after_other.height), (before_other.width, before_other.height), "{binding} {rule:?}"); + assert_ne!(bounds(&s, target_item), before_target_item, "target object did not follow the resize: {binding} {rule:?}"); + assert_eq!(bounds(&s, other_item), before_other_item, "other page object moved: {binding} {rule:?}"); + assert_ne!(guide_positions(&s, target), before_target_guides, "target guides did not follow the resize: {binding} {rule:?}"); + assert_eq!(guide_positions(&s, other), before_other_guides, "other page guides moved: {binding} {rule:?}"); + assert_ne!(after_target.columns.positions, before_target_columns, "target columns did not scale: {binding} {rule:?}"); + assert_eq!(after_other.columns.positions, before_other_columns, "other page columns changed: {binding} {rule:?}"); + assert_eq!(s.doc().unwrap().doc.settings.page_width, 400.0); + assert_eq!(s.doc().unwrap().doc.settings.page_height, 400.0); + s.doc().unwrap().doc.check().unwrap(); + } + } +} + +#[test] +fn parent_resize_is_independent_of_applied_and_based_on_parents_and_undoable() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false, "width": 400.0, "height": 400.0})).unwrap(); + let a = parent_id(&s, 0); + let b = s.execute("layout.parents.new", &json!({"prefix": "B", "name": "Child"})).unwrap()["id"].as_u64().unwrap(); + s.execute("layout.parents.options", &json!({"parent": b, "basedOn": a})).unwrap(); + s.execute("layout.pages.applyParent", &json!({"pages": [0], "parent": b})).unwrap(); + let before = s.doc().unwrap().doc.as_ref().clone(); + let undo_before = s.doc().unwrap().history.undo.len(); + + s.execute("layout.parentPageSize", &json!({"parent": a, "page": 0, "width": 0.5, "anchor": 0})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.parents[0].pages[0].width, 0.5); + assert_eq!(d.parents[1].pages[0].width, before.parents[1].pages[0].width); + assert_eq!(d.spreads[0].pages[0].width, before.spreads[0].pages[0].width); + assert_eq!(d.parents[1].parent.as_ref().unwrap().based_on, Some(SpreadId(a))); + assert_eq!(d.settings.page_width, before.settings.page_width); + d.check().unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_before + 1); + + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.parents[0].pages[0].width, before.parents[0].pages[0].width); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.parents[0].pages[0].width, 0.5); + s.doc().unwrap().doc.check().unwrap(); +} + +#[test] +fn parent_resize_survives_native_and_idml_round_trips() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false, "width": 400.0, "height": 400.0})).unwrap(); + let a = parent_id(&s, 0); + s.execute("layout.parentPageSize", &json!({"parent": a, "page": 0, "width": 0.5, "height": 0.75})).unwrap(); + let d = s.doc().unwrap().doc.as_ref().clone(); + d.check().unwrap(); + + let native = crate::cmd::from_bytes(&crate::cmd::to_bytes(&d).unwrap()).unwrap(); + assert_eq!((native.parents[0].pages[0].width, native.parents[0].pages[0].height), (0.5, 0.75)); + native.check().unwrap(); + + let idml = designcraft_idml::export_idml(&d); + let reopened = designcraft_idml::import_idml(&idml).unwrap(); + assert_eq!((reopened.parents[0].pages[0].width, reopened.parents[0].pages[0].height), (0.5, 0.75)); + reopened.check().unwrap(); +} + +#[test] +fn parent_resize_rejects_invalid_inputs_atomically() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); + let parent = parent_id(&s, 0); + let before = s.doc().unwrap().doc.as_ref().clone(); + let undo_before = s.doc().unwrap().history.undo.len(); + let invalid = [ + json!({"parent": parent, "page": 0, "width": null}), + json!({"parent": parent, "page": 0, "width": "400"}), + json!({"parent": parent, "page": 0, "width": "NaN"}), + json!({"parent": parent, "page": 0, "width": "Infinity"}), + json!({"parent": parent, "page": 0, "width": 0}), + json!({"parent": parent, "page": 0, "width": -1}), + json!({"parent": parent, "page": 0, "width": 15552.1}), + json!({"parent": parent, "page": 0, "width": 400, "anchor": 9}), + json!({"parent": parent, "page": 1, "width": 400}), + json!({"parent": 999_999, "page": 0, "width": 400}), + ]; + for params in invalid { + assert!(s.execute("layout.parentPageSize", ¶ms).is_err(), "accepted {params}"); + assert_eq!(s.doc().unwrap().doc.as_ref(), &before, "mutated after {params}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_before, "added undo after {params}"); + } + assert!(s.execute("layout.parentPageSize", &json!({"parent": parent, "page": 0})).is_err()); + assert_eq!(s.doc().unwrap().doc.as_ref(), &before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_before); + s.doc().unwrap().doc.check().unwrap(); +} + +#[test] +fn parent_and_document_page_selection_validate_and_clear_each_other() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); + let parent = parent_id(&s, 0); + let item = s.execute("frame.create", &json!({"rect": [40, 40, 100, 100]})).unwrap()["id"].as_u64().unwrap(); + assert!(!s.doc().unwrap().selection.items.is_empty()); + s.execute("layout.selectParentPage", &json!({"parent": parent, "page": 0})).unwrap(); + assert_eq!(s.doc().unwrap().selection.parent_page, Some((SpreadId(parent), 0))); + assert!(s.doc().unwrap().selection.items.is_empty()); + assert!(s.execute("layout.selectParentPage", &json!({"parent": parent, "page": 1})).is_err()); + assert_eq!(s.doc().unwrap().selection.parent_page, Some((SpreadId(parent), 0))); + s.execute("layout.selectPage", &json!({"page": 1, "ids": [item]})).unwrap(); + assert_eq!(s.doc().unwrap().selection.page, Some(0)); + assert_eq!(s.doc().unwrap().selection.parent_page, None); + s.execute("layout.selectParentPage", &json!({"parent": null})).unwrap(); + assert_eq!(s.doc().unwrap().selection, Default::default()); +} + +#[test] +fn parent_resize_repeated_edits_keep_the_spine_and_other_page_objects() { + for binding in ["leftToRight", "rightToLeft"] { + for pi in 0..2 { + let mut s = Session::new(); + s.execute("file.new", &json!({"width":400,"height":600,"pages":3})).unwrap(); + s.execute("layout.documentSetup", &json!({"binding":binding})).unwrap(); + let id = parent_id(&s, 0); + let before = s.doc().unwrap().doc.clone(); + let spine = before.parents[0].spine_x(); + let other = 1 - pi; + let ox = before.parents[0].pages[other].x + 80.0; + let object = parent_frame(&mut s, ox); + let old_object = bounds(&s, object); + for params in + [json!({"parent":id,"page":pi,"width":500}), json!({"parent":id,"page":pi,"height":700}), json!({"parent":id,"page":pi,"width":550})] + { + s.execute("layout.parentPageSize", ¶ms).unwrap(); + assert_eq!(s.doc().unwrap().doc.parents[0].spine_x(), spine, "spine moved: {binding}, page {pi}, {params}"); + assert_eq!(bounds(&s, object), old_object, "other-page object moved: {binding}, page {pi}"); + } + } + } +} + +#[test] +fn parent_resize_keeps_margin_and_column_inheritance_without_resizing_applied_pages() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width":400,"height":600,"pages":3})).unwrap(); + let id = parent_id(&s, 0); + s.edit(|d, _| { + let pg = &mut Arc::make_mut(&mut d.parents[0]).pages[1]; + pg.columns = Columns { count: 2, gutter: 20.0, positions: Some(vec![0.0, 100.0, 120.0, 328.0]) }; + d.sync_parent_margins(); + Arc::make_mut(&mut d.spreads[1]).pages[1].local_margins = true; + Ok(()) + }) + .unwrap(); + let before = s.doc().unwrap().doc.clone(); + s.execute("layout.parentPageSize", &json!({"parent":id,"page":1,"width":500})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.page(0).unwrap().columns, d.parents[0].pages[1].columns); + assert_eq!(d.page(0).unwrap().margins, d.parents[0].pages[1].margins); + assert_eq!((d.page(0).unwrap().width, d.page(0).unwrap().height), (400.0, 600.0)); + assert_eq!(d.page(2).unwrap(), before.page(2).unwrap()); + d.check().unwrap(); + s.execute("layout.pages.applyParent", &json!({"pages":[0],"parent":null})).unwrap(); + s.execute("layout.pages.applyParent", &json!({"pages":[0],"parent":id})).unwrap(); + assert_eq!((s.doc().unwrap().doc.page(0).unwrap().width, s.doc().unwrap().doc.page(0).unwrap().height), (400.0, 600.0)); +} + +#[test] +fn parent_resize_facing_sizes_survive_idml_in_both_bindings() { + for binding in ["leftToRight", "rightToLeft"] { + let mut s = Session::new(); + s.execute("file.new", &json!({"width":400,"height":600,"pages":3,"facingPages":true})).unwrap(); + s.execute("layout.documentSetup", &json!({"binding":binding})).unwrap(); + let id = parent_id(&s, 0); + for (pi, w, h) in [(0, 500, 700), (1, 300, 550)] { + s.execute("layout.parentPageSize", &json!({"parent":id,"page":pi,"width":w,"height":h})).unwrap(); + } + let d = &s.doc().unwrap().doc; + let reopened = designcraft_idml::import_idml(&designcraft_idml::export_idml(d)).unwrap(); + assert_eq!(reopened.settings.right_to_left_binding, d.settings.right_to_left_binding); + for page in &d.parents[0].pages { + let restored = reopened.parents[0].pages.iter().find(|p| p.side == page.side).unwrap(); + assert_eq!((restored.width, restored.height), (page.width, page.height), "{binding}, {:?}", page.side); + } + for abs in 0..d.page_count() { + let (old, new) = (d.page(abs).unwrap(), reopened.page(abs).unwrap()); + assert_eq!((new.width, new.height), (old.width, old.height)); + } + reopened.check().unwrap(); + } +} diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs index 4f9aaf1aa..852db171e 100644 --- a/crates/engine/src/cmd/place_text.rs +++ b/crates/engine/src/cmd/place_text.rs @@ -3,7 +3,7 @@ //! until the whole story fits (InDesign's Shift-click with a loaded text cursor). use designcraft_doc::{ - CharacterStyle, Content, Document, ParaFormat, ParagraphStyle, SpreadRef, Story, StoryId, TextDirection, TextFileLink, TextSel, + CharacterStyle, Content, Document, ParaFormat, ParagraphStyle, SpreadRef, StartParagraph, Story, StoryId, TextDirection, TextFileLink, TextSel, }; use designcraft_geom::Rect; use serde_json::{Value, json}; @@ -197,21 +197,60 @@ pub(crate) fn reimport( Ok(imp.warnings) } -/// Is the story overset in its frames, and how much text do its full frames hold (bytes, frames)? +/// Is the story overset in its frames, how much text do its full frames hold (bytes, frames), and +/// how many frames does the overset text start on its own (breaks, Start Paragraph options)? /// A frame a column, frame or page break ends early is left out: it says nothing about how much -/// a frame holds. -fn fit(d: &Document, sid: StoryId) -> (Option, usize, usize) { +/// a frame holds. So is one a Start Paragraph option ends early (#1209). +fn fit(d: &Document, sid: StoryId) -> (Option, usize, usize, usize) { let cs = designcraft_compose::compose_story(d, sid, &Default::default()); - let text = d.story(sid).map_or("", |s| s.text.as_str()); + let Some(story) = d.story(sid) else { return (cs.overset_at, 0, 0, 0) }; + let text = story.text.as_str(); + let ranges = story.para_ranges(); + let deleted = designcraft_compose::deleted_paras(story, &ranges); + let placed = |p: usize| !deleted.get(p).copied().unwrap_or(false); + let starts = |p: usize| placed(p) && story.paras.get(p).is_some_and(|pf| d.styles.resolve_para(pf).0.start_paragraph != StartParagraph::Anywhere); + // A frame's range stops before the paragraph's return; composing skips deleted paragraphs. + let moved_on = |end: usize| { + let next = if text.as_bytes().get(end) == Some(&b'\n') { end.saturating_add(1) } else { end }; + next.checked_sub(1).is_some_and(|i| text.as_bytes().get(i) == Some(&b'\n')) + && (story.para_at(next)..story.paras.len()).find(|&p| placed(p)).is_some_and(starts) + }; let (mut held, mut full) = (0, 0); for f in &cs.frames { let Some(shown) = text.get(f.range.clone()) else { continue }; - if !shown.is_empty() && !shown.chars().any(designcraft_doc::is_break_char) { + if !shown.is_empty() && !shown.chars().any(designcraft_doc::is_break_char) && !moved_on(f.range.end) { held += shown.len(); full += 1; } } - (cs.overset_at, held, full) + let forced = cs.overset_at.map_or(0, |at| { + let first = story.para_at(at); + // The paragraph the overset text starts with counts when it starts there. + let from = if at.checked_sub(1).is_none_or(|i| text.as_bytes().get(i) == Some(&b'\n')) { first } else { first.saturating_add(1) }; + // Once per paragraph: after a break, its start option only fixes odd or even page parity. + let sent = (from..ranges.len()) + .filter(|&p| starts(p) || (placed(p) && designcraft_compose::previous_visible_break(d, story, &ranges, &deleted, p).is_some())) + .count(); + // A break inside a paragraph moves the rest of it. + let inside: usize = (first..ranges.len()) + .filter(|&p| placed(p)) + .filter_map(|p| Some((p, ranges.get(p)?.clone()))) + .map(|(p, r)| { + let Some(t) = text.get(r.clone()) else { return 0 }; + let last = t.char_indices().next_back().map(|(i, _)| i); + t.char_indices() + .filter(|&(i, c)| { + designcraft_doc::is_break_char(c) + && Some(i) != last + && r.start.saturating_add(i) >= at + && designcraft_compose::visible_break(d, story, p, r.start.saturating_add(i)).is_some() + }) + .count() + }) + .sum(); + sent.saturating_add(inside) + }); + (cs.overset_at, held, full, forced) } /// The frames autoflow sets on a page: one per column inside the margins, in the order a story @@ -256,12 +295,13 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s // The next page of the document autoflow may set frames on; it only moves forward. let mut following = 0; loop { - let (overset, held, full) = fit(d, sid); + let (overset, held, full, forced) = fit(d, sid); let Some(at) = overset else { break }; let len = d.story(sid).map_or(0, |s| s.len()); - // Frames still needed at the rate the full ones are filled (at least one); with no full - // frame to go by yet, one more. - let want = held.checked_div(full).map_or(1, |per_frame| len.saturating_sub(at).div_ceil(per_frame.max(1))).max(1); + // Frames still needed at the rate the full ones are filled, plus one per frame the text + // starts on its own (at least one); with neither to go by yet, one more. + let rate = held.checked_div(full).map_or(0, |per_frame| len.saturating_sub(at).div_ceil(per_frame.max(1))); + let want = rate.saturating_add(forced).max(1); let last = *d.story(sid).and_then(|s| s.frames.last()).ok_or_else(|| bad(cmd, "the story has no frame"))?; let page = d.page_of_item(last).unwrap_or(d.page_count().saturating_sub(1)); let layer = d.default_layer(); diff --git a/crates/engine/src/cmd/place_text/tests.rs b/crates/engine/src/cmd/place_text/tests.rs index 006762ab2..8275e7ab2 100644 --- a/crates/engine/src/cmd/place_text/tests.rs +++ b/crates/engine/src/cmd/place_text/tests.rs @@ -307,3 +307,117 @@ fn autoflow_adds_the_pages_odd_and_even_page_breaks_ask_for() { assert_eq!(designcraft_compose::compose_story(&d, sid, &Default::default()).overset_at, None, "{brk:?}"); } } + +#[test] +fn autoflow_sizes_no_frame_by_one_a_start_option_ends_early() { + // Regression (#1209): the title frame a start option ends early isn't a frame's worth of text. + use std::io::Write; + const W: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#; + let para = |style: &str, text: &str| format!(r#"{text}"#); + let body = [para("Title", "Front"), para("Chapter", "Chapter one"), para("Body", "lorem ipsum dolor sit amet ".repeat(60).trim())].concat(); + let styles: String = ["Title", "Chapter", "Body"] + .iter() + .map(|s| format!(r#""#)) + .collect(); + let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let o = zip::write::SimpleFileOptions::default(); + for (n, c) in [ + ("word/document.xml", format!(r#"{body}"#)), + ("word/styles.xml", format!(r#"{styles}"#)), + ] { + w.start_file(n, o).unwrap(); + w.write_all(c.as_bytes()).unwrap(); + } + let docx = w.finish().unwrap().into_inner(); + for (start, pages) in [("nextOddPage", 5), ("nextEvenPage", 4)] { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 3, "facingPages": true})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Chapter", "para": {"startParagraph": start}})).unwrap(); + let ids = s.doc().unwrap().doc.next_id; + let r = s + .execute("file.place", &json!({"base64": super::super::file::base64_encode(&docx), "name": "front.docx", "page": 3, "autoflow": true})) + .unwrap(); + assert_eq!(r["overset"], false, "{start}: {r}"); + let d = &s.doc().unwrap().doc; + assert_eq!(d.page_count(), pages, "{start}: {r}"); + // A few ids per page it keeps, not hundreds for pages it builds and removes. + assert!(d.next_id - ids < 100, "{start}: {} ids", d.next_id - ids); + } +} + +#[test] +fn autoflow_looks_past_a_deleted_paragraph_for_the_start_option() { + // The title frame ends before a tracked deletion; the chapter after it starts on an odd page. + use designcraft_doc::{ChangeMark, build::NewDocument}; + let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, primary_text_frame: true, ..Default::default() }); + let sid = d.settings.primary_story.unwrap(); + let st = d.story_mut(sid).unwrap(); + st.insert(0, &format!("Front\nGone\nChapter one\n{}", "lorem ipsum dolor sit amet ".repeat(400).trim())); + st.paras[2].para.start_paragraph = Some(StartParagraph::NextOddPage); + st.format_chars(6..11, |f| f.over.change = Some(ChangeMark::Deleted)); + let ids = d.next_id; + assert_eq!(autoflow(&mut d, sid, 2000, "file.place", false).unwrap(), 4); + assert_eq!(designcraft_compose::compose_story(&d, sid, &Default::default()).overset_at, None); + assert!(d.next_id - ids < 100, "{} ids", d.next_id - ids); +} + +#[test] +fn autoflow_asks_for_every_frame_the_start_options_ask_for_at_once() { + // A paragraph per page: no frame sets a fill rate, yet one round shouldn't add one frame. + use designcraft_doc::build::NewDocument; + let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, primary_text_frame: true, ..Default::default() }); + let sid = d.settings.primary_story.unwrap(); + let st = d.story_mut(sid).unwrap(); + st.insert(0, (0..200).map(|i| format!("Chapter {i}\n")).collect::().trim_end()); + for p in &mut st.paras { + p.para.start_paragraph = Some(StartParagraph::NextPage); + } + let ids = d.next_id; + assert_eq!(autoflow(&mut d, sid, 2000, "file.place", false).unwrap(), 199); + assert_eq!(designcraft_compose::compose_story(&d, sid, &Default::default()).overset_at, None); + assert!(d.next_id - ids < 2000, "{} ids", d.next_id - ids); +} + +#[test] +fn autoflow_asks_no_frame_for_a_deleted_or_hidden_break() { + // Composing skips breaks in the overset text that tracked changes delete or a condition hides. + use designcraft_doc::{ChangeMark, Condition, PAGE_BREAK, build::NewDocument}; + for hide in [false, true] { + let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, primary_text_frame: true, ..Default::default() }); + d.conditions.push(Condition { name: "Hidden".into(), color: [0, 0, 0], visible: false }); + let sid = d.settings.primary_story.unwrap(); + let st = d.story_mut(sid).unwrap(); + let body = "lorem ipsum dolor sit amet ".repeat(300); + let gone = format!("x{PAGE_BREAK}\n").repeat(200); + st.insert(0, &format!("{}\n{gone}{}", body.trim(), body.trim())); + let at = body.trim().len() + 1; + if hide { + for (i, _) in gone.match_indices(PAGE_BREAK) { + st.format_chars(at + i..at + i + PAGE_BREAK.len_utf8(), |f| f.over.conditions = Some(vec!["Hidden".into()])); + } + } else { + st.format_chars(at..at + gone.len(), |f| f.over.change = Some(ChangeMark::Deleted)); + } + let ids = d.next_id; + autoflow(&mut d, sid, 2000, "file.place", false).unwrap(); + assert_eq!(designcraft_compose::compose_story(&d, sid, &Default::default()).overset_at, None, "hide {hide}"); + assert!(d.next_id - ids < 100, "hide {hide}: {} pages, {} ids", d.page_count(), d.next_id - ids); + } +} + +#[test] +fn autoflow_counts_a_break_and_the_start_option_it_meets_once() { + // Each chapter ends in a page break and starts on the next page: one new page each, not two. + use designcraft_doc::{PAGE_BREAK, build::NewDocument}; + let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, primary_text_frame: true, ..Default::default() }); + let sid = d.settings.primary_story.unwrap(); + let st = d.story_mut(sid).unwrap(); + st.insert(0, (0..200).map(|i| format!("Chapter {i}{PAGE_BREAK}\n")).collect::().trim_end()); + for p in &mut st.paras { + p.para.start_paragraph = Some(StartParagraph::NextPage); + } + let ids = d.next_id; + assert_eq!(autoflow(&mut d, sid, 2000, "file.place", false).unwrap(), 199); + assert_eq!(designcraft_compose::compose_story(&d, sid, &Default::default()).overset_at, None); + assert!(d.next_id - ids < 2000, "{} ids", d.next_id - ids); +} diff --git a/crates/engine/src/cmd/preflight.rs b/crates/engine/src/cmd/preflight.rs index 390dc7118..45fb8bc40 100644 --- a/crates/engine/src/cmd/preflight.rs +++ b/crates/engine/src/cmd/preflight.rs @@ -38,6 +38,25 @@ fn page_of(d: &Document, sr: SpreadRef, it: &Item, parent: Affine) -> Option Vec { + let SpreadRef::Doc(si) = sr else { return vec![] }; + let Some(sp) = d.spreads.get(si) else { return vec![] }; + let bounds = parent.transform_rect_bbox(it.bounds()); + let first = d.first_page_of_spread(si); + let touched: Vec = + sp.pages.iter().enumerate().filter(|(_, pg)| bounds.x1 > pg.x && bounds.x0 < pg.x + pg.width).map(|(i, _)| first + i).collect(); + // A frame wholly on the pasteboard touches no page's span; it still belongs to the page + // `page_at_x` nominates, so the two functions agree about where it is. + if touched.is_empty() { page_of(d, sr, it, parent).into_iter().collect() } else { touched } +} + /// Characters a missing-glyph report names before "and N more". const MISSING_GLYPHS_LISTED: usize = 20; @@ -74,11 +93,19 @@ fn missing_glyphs( } } -/// Locate an embedded frame at the outer page frame that actually renders it. -fn overset_location(s: &Session, target: designcraft_doc::ItemId) -> Option<(designcraft_doc::ItemId, Option)> { +/// Locate an embedded frame at the outer page frame that actually renders it: the frame, the page +/// its centre is on, and every page it reaches (see [`pages_of`]). +type Located = (designcraft_doc::ItemId, Option, Vec); + +fn overset_location(s: &Session, target: designcraft_doc::ItemId) -> Option { let d = &s.active()?.doc; + let at = |id: designcraft_doc::ItemId, loc: &designcraft_doc::ItemLoc| { + let it = d.item(id); + let xf = d.parent_xf(loc); + (id, it.and_then(|it| page_of(d, loc.spread, it, xf)), it.map(|it| pages_of(d, loc.spread, it, xf)).unwrap_or_default()) + }; if let Some(loc) = d.find(target) { - return Some((target, d.item(target).and_then(|it| page_of(d, loc.spread, it, d.parent_xf(&loc))))); + return Some(at(target, &loc)); } fn contains(root: &designcraft_compose::FrameText, target: designcraft_doc::ItemId) -> bool { let mut frames = vec![root]; @@ -107,12 +134,131 @@ fn overset_location(s: &Session, target: designcraft_doc::ItemId) -> Option<(des let frame = cs.frames.iter().find(|f| contains(f, current))?; current = frame.frame; if let Some(loc) = d.find(current) { - return Some((current, d.item(current).and_then(|it| page_of(d, loc.spread, it, d.parent_xf(&loc))))); + return Some(at(current, &loc)); } } None } +/// Text that is in the document but not in a page-based export (PDF, PowerPoint): the overset +/// part of a story, and a story with no frame at all. The export used to write the file and +/// report nothing, so whole sentences left an imported document without a hint anywhere (#1465). +/// +/// One line per story, naming the page and the frame as the preflight issue does. `pages` are the +/// 0-based pages being exported; a story whose overset is on a page that is not being exported is +/// not reported, and one with no page of its own always is. A reflowable export (EPUB, HTML) or a +/// text export carries the whole story, so neither calls this. +/// Pages a single warning names before "and N more". +const MAX_WARNED_PAGES: usize = 10; + +/// How the pages one warning covers are named: "page 3", "pages 3, 4", "pages 3, 4 and 6 more". +fn page_list(pages: &[usize]) -> String { + let shown: Vec = pages.iter().take(MAX_WARNED_PAGES).map(|p| (p + 1).to_string()).collect(); + let rest = pages.len() - shown.len(); + let list = shown.join(", "); + match (pages.len(), rest) { + (1, _) => format!("page {list}"), + (_, 0) => format!("pages {list}"), + _ => format!("pages {list} and {rest} more"), + } +} + +/// The parent spread and the page of it a frame sits on, or `None` for a frame on a document page. +fn parent_page_of(d: &Document, target: designcraft_doc::ItemId) -> Option<(usize, usize)> { + let loc = d.find(target)?; + let SpreadRef::Parent(pi) = loc.spread else { return None }; + let bounds = d.parent_xf(&loc).transform_rect_bbox(d.item(target)?.bounds()); + Some((pi, d.spread(loc.spread)?.page_at_x(bounds.center().x).unwrap_or(0))) +} + +/// Document pages that draw parent page `pp` of parent spread `pi`, in document order. +fn pages_showing(d: &Document, (pi, pp): (usize, usize)) -> Vec { + (0..d.page_count()) + .filter(|p| d.page_loc(*p).is_some_and(|(si, pgi)| d.shown_parent_pages(SpreadRef::Doc(si), pgi).contains(&(pi, pp)))) + .collect() +} + +/// Characters of `story` that `cs` had no room for. +fn overset_chars(story: &designcraft_doc::Story, cs: &ComposedStory) -> usize { + story.text.get(cs.overset_at.unwrap_or(0)..).unwrap_or("").chars().count() +} + +pub fn overset_warnings(s: &Session, pages: Option<&[usize]>) -> Vec { + let Some(st) = s.active() else { return vec![] }; + let d = &st.doc; + let mut out = Vec::new(); + for story in d.stories.values() { + let last = story.frames.last().copied(); + // A story on a parent page is composed once per page that shows it: its page number + // markers and variables are that page's, so the same frame can fit on one page and + // overset on another, and a parent page no exported page uses puts nothing in the file + // to be missing. Composing it once with no page reported neither (noted in review). + if let Some(parent) = last.and_then(|f| parent_page_of(d, f)) { + let mut by_count: std::collections::BTreeMap> = std::collections::BTreeMap::new(); + for page in pages_showing(d, parent) { + if pages.is_some_and(|sel| !sel.contains(&page)) { + continue; + } + let cs = s.cache.get(d, story.id, Some(page)); + let n = overset_chars(story, &cs); + if cs.is_overset() && n > 0 { + by_count.entry(n).or_default().push(page); + } + } + let item = last.map_or(0, |f| f.0); + out.extend( + by_count.into_iter().map(|(n, ps)| { + format!("{}: {n} character(s) of the story in frame {item} do not fit it and are not in the export", page_list(&ps)) + }), + ); + continue; + } + let cs = s.cache.get(d, story.id, None); + if !cs.is_overset() { + continue; + } + let n = overset_chars(story, &cs); + if n == 0 { + continue; + } + if story.frames.is_empty() { + out.push(format!("a story with no text frame: {n} character(s) of it are not in the export")); + continue; + } + let location = last.and_then(|f| overset_location(s, f)); + let centre = location.as_ref().and_then(|(_, page, _)| *page); + let touched: &[usize] = location.as_ref().map(|(_, _, ps)| ps.as_slice()).unwrap_or_default(); + // A sheet draws its spread's items without asking which page they are on, so a frame + // straddling two pages is drawn in part on both. Testing the centre page alone dropped + // the warning for a frame centred on a page the export leaves out while the part on an + // exported page went into the file (noted in review). + // + // The page named is one the export has: the centre page when that is exported, so an + // ordinary frame reads as before, otherwise the first exported page the frame reaches. + let page = match pages { + None => centre, + Some(sel) => { + let exported: Vec = touched.iter().copied().filter(|p| sel.contains(p)).collect(); + match centre.filter(|p| sel.contains(p)).or_else(|| exported.first().copied()) { + Some(p) => Some(p), + // Nothing of this frame is on an exported page. + None if centre.is_some() || !touched.is_empty() => continue, + None => None, + } + } + }; + let item = location.as_ref().map(|(item, _, _)| *item).or(last); + out.push(match (page, item) { + (Some(page), Some(item)) => { + format!("page {}: {n} character(s) of the story in frame {} do not fit it and are not in the export", page + 1, item.0) + } + (None, Some(item)) => format!("the story in frame {}: {n} character(s) do not fit and are not in the export", item.0), + _ => format!("{n} character(s) of a story do not fit their frame and are not in the export"), + }); + } + out +} + pub fn check(s: &Session, min_ppi: f64) -> Vec { let Some(st) = s.active() else { return vec![] }; let d = &st.doc; @@ -138,8 +284,8 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { } else { format!("Overset text: {n} characters") }, - item: location.map(|(item, _)| item.0).or(last.map(|i| i.0)), - page: location.and_then(|(_, page)| page), + item: location.as_ref().map(|(item, _, _)| item.0).or(last.map(|i| i.0)), + page: location.as_ref().and_then(|(_, page, _)| *page), }); } } diff --git a/crates/engine/src/cmd/prefs.rs b/crates/engine/src/cmd/prefs.rs index 989494507..f51ce3c70 100644 --- a/crates/engine/src/cmd/prefs.rs +++ b/crates/engine/src/cmd/prefs.rs @@ -50,12 +50,14 @@ pub fn specs() -> Vec { "Preferences", [], None, - "{showHiddenCharacters?, typographersQuotes?, polygonSides?, starInset?, scaleStrokes?, dimensionsIncludeStroke?, transformationsAreTotals?, absolutePageNumbers?, highlightHj?, highlightKeeps?, highlightCustomTracking?, highlightSubstitutedFonts?, richBlackOutput?, favoriteFonts?: [family], recentFonts?: [family], showFontNamesInEnglish?, smartTextReflow?, autocorrect?, autocorrectList?: [[word, correction]], showAddedText?, showNoteAnchors?, cjkFeatures?: bool|null (CJK-only menu items and dialogs shown; null follows the interface language), linkTextFiles? (Create Links When Placing Text Files), recoveryMinutes?, recentFilesMax?: 0–100} → all application preferences", + "{showHiddenCharacters?, typographersQuotes?, polygonSides?, starInset?, scaleStrokes?, dimensionsIncludeStroke?, transformationsAreTotals?, absolutePageNumbers?, highlightHj?, highlightKeeps?, highlightCustomTracking?, highlightSubstitutedFonts?, richBlackOutput?, favoriteFonts?: [family], recentFonts?: [family], showFontNamesInEnglish?, smartTextReflow?, autocorrect?, autocorrectList?: [[word, correction]], showAddedText?, showNoteAnchors?, cjkFeatures?: bool|null (CJK-only menu items and dialogs shown; null follows the interface language), linkTextFiles? (Create Links When Placing Text Files), recoveryMinutes?, recentFilesMax?: 0–100, defaultUnits?: {horizontalUnits?, verticalUnits?, rulerOrigin?, keyboardIncrement? (pt), kerningIncrement? (1/1000 em)} (Units & Increments of new documents)} → all application preferences", super::always, |s, p| { let cur = serde_json::to_value(&s.prefs).map_err(|e| bad("prefs.set", e.to_string()))?; let new = merge("prefs.set", cur, p, None)?; - s.prefs = serde_json::from_value(new).map_err(|e| bad("prefs.set", e.to_string()))?; + let prefs: crate::Prefs = serde_json::from_value(new).map_err(|e| bad("prefs.set", e.to_string()))?; + prefs.default_units.check().map_err(|e| bad("prefs.set", e))?; + s.prefs = prefs; s.prefs.trim_recent_files(); serde_json::to_value(&s.prefs).map_err(|e| bad("prefs.set", e.to_string())) } @@ -100,6 +102,9 @@ fn merge(cmd: &str, mut cur: Value, p: &Value, allowed: Option<&[&str]>) -> Resu match (c.get_mut(k), v) { (Some(Value::Object(old)), Value::Object(v)) => { for (k2, v2) in v { + if !old.is_empty() && !old.contains_key(k2) { + return Err(bad(cmd, format!("unknown preference {k}.{k2}"))); + } old.insert(k2.clone(), v2.clone()); } } @@ -127,6 +132,7 @@ mod tests { s.execute("prefs.set", &json!({"showFontNamesInEnglish": true})).unwrap(); assert!(s.prefs.show_font_names_in_english); assert!(s.execute("prefs.set", &json!({"nope": 1})).is_err()); + assert!(s.execute("prefs.set", &json!({"defaultUnits": {"horizontalUnit": "inches"}})).is_err()); s.execute("file.new", &json!({})).unwrap(); let r = s .execute("document.preferences", &json!({"horizontalUnits": "millimeters", "keyboardIncrement": 2.5, "grid": {"subdivisions": 4}})) @@ -141,6 +147,53 @@ mod tests { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc.settings.horizontal_units, designcraft_geom::Unit::Picas); } + + /// Units & Increments set with no document open are what new documents start with, as in + /// InDesign: a reader who works in inches sets them once, not in every file. + #[test] + fn default_units_are_what_new_documents_start_with() { + use designcraft_geom::Unit; + let mut s = Session::new(); + let r = s + .execute( + "prefs.set", + &json!({"defaultUnits": {"horizontalUnits": "inches", "verticalUnits": "inches", "keyboardIncrement": 0.5, "rulerOrigin": "page"}}), + ) + .unwrap(); + assert_eq!(r["defaultUnits"]["horizontalUnits"], "inches"); + assert_eq!(r["defaultUnits"]["kerningIncrement"], 20.0, "the rest kept"); + s.execute("file.new", &json!({})).unwrap(); + let st = s.doc().unwrap().doc.settings.clone(); + assert_eq!((st.horizontal_units, st.vertical_units), (Unit::Inches, Unit::Inches)); + assert_eq!((st.keyboard_increment, st.ruler_origin), (0.5, designcraft_doc::RulerOrigin::Page)); + assert!(!s.doc().unwrap().is_dirty(), "made so, not edited"); + // Inches across, millimetres down: New Document's units (the horizontal ones) keep the + // vertical preference; other units picked there set both rulers. + s.execute("prefs.set", &json!({"defaultUnits": {"verticalUnits": "millimeters"}})).unwrap(); + s.execute("file.new", &json!({"units": "inches"})).unwrap(); + let st = s.doc().unwrap().doc.settings.clone(); + assert_eq!((st.horizontal_units, st.vertical_units), (Unit::Inches, Unit::Millimeters)); + s.execute("file.new", &json!({"units": "points"})).unwrap(); + let st = s.doc().unwrap().doc.settings.clone(); + assert_eq!((st.horizontal_units, st.vertical_units), (Unit::Points, Unit::Points)); + // A preset keeps its own units, both of them, even when the default horizontal units are + // the preset's. + s.execute("prefs.set", &json!({"defaultUnits": {"horizontalUnits": "millimeters", "verticalUnits": "inches"}})).unwrap(); + s.execute("file.new", &json!({"preset": "A4"})).unwrap(); + let st = s.doc().unwrap().doc.settings.clone(); + assert_eq!((st.horizontal_units, st.vertical_units), (Unit::Millimeters, Unit::Millimeters)); + assert_eq!(st.keyboard_increment, 0.5, "the increments still apply"); + s.execute("prefs.set", &json!({"defaultUnits": {"horizontalUnits": "inches", "verticalUnits": "millimeters"}})).unwrap(); + // New Document opens with them; bad values are refused and change nothing. + assert_eq!(s.default_new_document().units, Unit::Inches); + for bad in [json!({"horizontalUnits": "furlongs"}), json!({"keyboardIncrement": 0}), json!({"kerningIncrement": -5})] { + assert!(s.execute("prefs.set", &json!({"defaultUnits": bad})).is_err(), "{bad}"); + } + assert_eq!(s.prefs.default_units.keyboard_increment, 0.5); + // Kept with the application preferences. + let saved: crate::Prefs = serde_json::from_slice(&serde_json::to_vec(&s.prefs).unwrap()).unwrap(); + assert_eq!(saved.default_units, s.prefs.default_units); + } } #[cfg(test)] diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index e71c230de..82522c9a1 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -86,7 +86,15 @@ mod tests { } else { assert_eq!( command, - ["xps-print", "printer=Office", "copies=2", "collate=true", "duplex=(queue default)", "scaling=shrink-to-fit", "color=color"] + [ + "xps-print", + "printer=Office", + "copies=2", + "collate=true", + "duplex=(queue default)", + "scaling=shrink-to-fit", + "color=(driver default)" + ] ); } assert!(r["bytes"].as_u64().unwrap() > 500); @@ -108,6 +116,7 @@ mod tests { assert_eq!(job.printer.as_deref(), Some("Office (east)")); assert_eq!(job.copies, 2); assert_eq!(job.duplex, None); + assert!(!job.grayscale); assert_eq!(job.scaling, craft_print::Scaling::ShrinkToFit); assert!(!job.title.is_empty()); Ok("request id is Office-1".into()) diff --git a/crates/engine/src/cmd/spelling.rs b/crates/engine/src/cmd/spelling.rs index 914165387..5e7a425ea 100644 --- a/crates/engine/src/cmd/spelling.rs +++ b/crates/engine/src/cmd/spelling.rs @@ -81,13 +81,91 @@ pub fn specs() -> Vec { ] } +/// Common words the hyphenation word list (from the early 1990s) lacks: everyday short words +/// such as "we", "us" and "if", titles, and words that came with computers and the web. Sorted, +/// lowercase, base forms (the inflections below reach "emails", "downloaded", "bloggers"). +const COMMON: &[&str] = &[ + "ah", + "bitmap", + "blog", + "bluetooth", + "chatbot", + "checkbox", + "cybersecurity", + "database", + "dataset", + "desktop", + "download", + "dr", + "dropdown", + "ebook", + "ecommerce", + "eh", + "email", + "emoji", + "firewall", + "gigabyte", + "hashtag", + "hm", + "hmm", + "homepage", + "hyperlink", + "if", + "inbox", + "internet", + "jr", + "kilobyte", + "laptop", + "livestream", + "login", + "logout", + "malware", + "megabyte", + "metadata", + "mr", + "offline", + "ok", + "online", + "ow", + "pm", + "podcast", + "popup", + "printout", + "screenshot", + "selfie", + "smartphone", + "smartwatch", + "spreadsheet", + "sr", + "startup", + "terabyte", + "touchscreen", + "um", + "upload", + "us", + "username", + "voicemail", + "vs", + "we", + "webinar", + "webmaster", + "webpage", + "website", + "wifi", + "workflow", + "yay", +]; + /// Is `w` (lowercase) a known word, allowing common inflections of dictionary stems? pub fn known(dict: &Dictionary, user: &[String], w: &str) -> bool { - if w.len() <= 1 || dict.get(w).is_some() || user.iter().any(|u| u == w) { + // A typographer's apostrophe (don’t, we’re) is the word list's straight one. + let w = &w.replace('’', "'"); + let has = |w: &str| dict.get(w).is_some() || COMMON.binary_search(&w).is_ok(); + if w.len() <= 1 || has(w) || user.iter().any(|u| u.replace('’', "'") == *w) { return true; } - let w = w.trim_end_matches("'s").trim_end_matches("’s"); - if dict.get(w).is_some() { + let w = w.trim_end_matches("'s"); + if has(w) { return true; } let stems: [(&str, &[&str]); 9] = [ @@ -105,14 +183,14 @@ pub fn known(dict: &Dictionary, user: &[String], w: &str) -> bool { if let Some(base) = w.strip_suffix(suf) { for add in adds { let cand = format!("{base}{add}"); - if cand.len() > 1 && dict.get(&cand).is_some() { + if cand.len() > 1 && has(&cand) { return true; } // Doubled consonant: "stopped" → "stop". if let Some(c) = base.chars().last() && base.len() > 2 && base.ends_with(&format!("{c}{c}")) - && dict.get(&base[..base.len() - c.len_utf8()]).is_some() + && has(&base[..base.len() - c.len_utf8()]) { return true; } @@ -128,7 +206,7 @@ pub fn suggest(dict: &Dictionary, w: &str) -> Vec { let mut out: Vec = Vec::new(); let mut push = |c: Vec| { let c: String = c.into_iter().collect(); - if c != w && !out.contains(&c) && dict.get(&c).is_some() { + if c != w && !out.contains(&c) && (dict.get(&c).is_some() || COMMON.binary_search(&c.as_str()).is_ok()) { out.push(c); } }; @@ -238,6 +316,7 @@ mod tests { } assert!(!known(d, &[], "typograpy")); assert!(suggest(d, "typograpy").contains(&"typography".to_string())); + assert!(suggest(d, "emial").contains(&"email".to_string()), "words in COMMON are suggested too"); assert!(known(d, &["designcraft".into()], "designcraft")); } @@ -253,6 +332,53 @@ mod tests { assert_eq!(bad, vec![6..10]); } + /// Everyday words the hyphenation list lacks aren't misspelled ("we", "us", "if", "email"…), + /// with their inflections, and neither are contractions typed with a typographer's + /// apostrophe, which Use Typographer's Quotes turns ' into. + #[test] + fn common_words_and_curly_apostrophes_are_known() { + let d = Dictionary::en_us(); + if d.is_empty() { + return; // data not bundled in this build + } + assert!(COMMON.is_sorted(), "COMMON is binary-searched"); + for w in [ + "we", + "us", + "if", + "ok", + "mr", + "email", + "emails", + "emailed", + "online", + "website", + "websites", + "downloaded", + "database", + "databases", + "blogger", + "don’t", + "can’t", + "i’m", + "we’re", + "they’ve", + "it’s", + "don't", + ] { + assert!(known(d, &[], w), "{w}"); + } + for w in ["dont", "wee’re", "emial"] { + assert!(!known(d, &[], w), "{w}"); + } + // A user word added with a typographer's apostrophe matches either form. + assert!(known(d, &["zorbo’s".into()], "zorbo's") && known(d, &["zorbo's".into()], "zorbo’s")); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "We don’t email us if we’re online."})).unwrap(); + assert_eq!(s.execute("spelling.check", &json!({})).unwrap(), json!([])); + } + #[test] fn check_finds_misspellings() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 4cc8a5894..879a089dc 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -17,7 +17,7 @@ use object::{apply_object, clear_object_overrides, create_object, edit_object}; use paragraph::{ apply_char, apply_para, create_char, create_para, delete_char, delete_para, edit_char, edit_para, export_tag_key, group_styles, redefine, }; -pub(crate) use paragraph::{check_grep_styles, grep_style_warnings}; +pub(crate) use paragraph::{apply_char_style, apply_para_style, check_grep_styles, grep_style_warnings}; pub use swatches::parse_color; use swatches::{add_to_swatches, create_swatch, load_swatches, new_swatch, save_swatches, swatch_options}; @@ -134,7 +134,7 @@ pub fn specs() -> Vec { "Paragraph Style Options…", [], None, - "{name, rename?, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, spaceBefore, ignoreOpticalMargin, …; ruleAbove?/ruleBelow?: only the named rule fields change; startAt?: null = continue numbering; kinsoku?: set | null (default rules); a grepStyles pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}}", + "{name, rename?, basedOn?, nextStyle?: style | null ([Same style]), resetToBase?: bool (Reset To Base: the style sets nothing of its own; `para`/`chars` then apply over its Based On), para?: {paragraph attributes, as type.para names them: align, leftIndent, spaceBefore, ignoreOpticalMargin, …; ruleAbove?/ruleBelow?: only the named rule fields change; startAt?: null = continue numbering; kinsoku?: set | null (default rules); a grepStyles pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}}", has_doc, edit_para ), diff --git a/crates/engine/src/cmd/style/paragraph.rs b/crates/engine/src/cmd/style/paragraph.rs index 81711ff10..16671fbd8 100644 --- a/crates/engine/src/cmd/style/paragraph.rs +++ b/crates/engine/src/cmd/style/paragraph.rs @@ -328,7 +328,7 @@ fn apply_para_in_story( f.chars.retain_differences(style_chars); } }); - for (pi, range) in st.para_ranges().into_iter().enumerate().take(last.saturating_add(1)).skip(first) { + for (pi, range) in st.paras_touching(r.clone()).into_iter().filter(|(pi, _)| (first..=last).contains(pi)) { let Some(pf) = st.paras.get(pi) else { continue }; let (_, base) = styles.resolve_para(pf); let end = range.end.saturating_add(1).min(st.len()); @@ -345,6 +345,13 @@ fn apply_para_in_story( } } +/// Paragraph style `name` applied to the paragraphs of `r` in `st`: the paragraphs' and their +/// text's overrides that the style now gives go (all of them with `clear`). +pub(crate) fn apply_para_style(st: &mut designcraft_doc::Story, styles: &Styles, name: &str, r: &std::ops::Range, clear: bool) { + let (style_para, style_chars) = styles.resolve_para_style(name); + apply_para_in_story(st, r, name, clear, (&style_para, &style_chars), styles); +} + pub(super) fn apply_para(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.apply", "missing name"))?.to_string(); let clear = p.get("clearOverrides").and_then(Value::as_bool).unwrap_or(false); @@ -374,25 +381,31 @@ pub(super) fn apply_char(s: &mut Session, p: &Value) -> Result { let styles = d.styles.clone(); for t in &targets { if let Some(st) = d.text_story_mut(t.story, t.cell) { - for (pi, range) in st.para_ranges().into_iter().enumerate() { - let range = range.start.max(t.range.start)..range.end.saturating_add(1).min(st.len()).min(t.range.end); - if range.is_empty() { - continue; - } - let Some(pf) = st.paras.get(pi) else { continue }; - let (_, mut inherited) = styles.resolve_para(pf); - inherited.apply(&styles.char_style_attrs(&name)); - st.format_chars(range, |f| { - f.style = name.clone(); - f.over.retain_differences(&inherited); - }); - } + apply_char_style(st, &styles, &name, &t.range); } } ok() }) } +/// Character style `name` applied to `r` in `st`: the text's overrides that the style (over its +/// paragraph) now gives go. +pub(crate) fn apply_char_style(st: &mut designcraft_doc::Story, styles: &Styles, name: &str, r: &std::ops::Range) { + for (pi, range) in st.paras_touching(r.clone()) { + let range = range.start.max(r.start)..range.end.saturating_add(1).min(st.len()).min(r.end); + if range.is_empty() { + continue; + } + let Some(pf) = st.paras.get(pi) else { continue }; + let (_, mut inherited) = styles.resolve_para(pf); + inherited.apply(&styles.char_style_attrs(name)); + st.format_chars(range, |f| { + f.style = name.to_string(); + f.over.retain_differences(&inherited); + }); + } +} + /// In a style, `startAt: null` is Continue from Previous Number: it is stored as an explicit /// "continue" so it also overrides a Start At the style would inherit. fn continue_numbering(p: Option<&Value>, para: &mut ParaAttrs) { @@ -403,8 +416,19 @@ fn continue_numbering(p: Option<&Value>, para: &mut ParaAttrs) { } } +/// The style `p[key]` names: none when absent or null; anything but a name is refused. +fn style_name_param(cmd: &str, p: &Value, key: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(n)) => Ok(Some(n.clone())), + Some(_) => Err(bad(cmd, format!("`{key}` is a style name or null"))), + } +} + pub(super) fn create_para(s: &mut Session, p: &Value) -> Result { let base = str_param(p, "name").unwrap_or("Paragraph Style 1").to_string(); + let based_on = style_name_param("style.paragraph.create", p, "basedOn")?; + let next = style_name_param("style.paragraph.create", p, "nextStyle")?; let mut para: ParaAttrs = attrs("style.paragraph.create", "para", p, |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, &designcraft_doc::ParaProps::default()))?; continue_numbering(p.get("para"), &mut para); @@ -428,8 +452,6 @@ pub(super) fn create_para(s: &mut Session, p: &Value) -> Result { chars = full; } } - let based_on = str_param(p, "basedOn").map(str::to_string); - let next = str_param(p, "nextStyle").map(str::to_string); s.edit(|d, _| { let name = Styles::unique_name(|n| d.styles.para(n).is_some(), &base); if let Some(b) = &based_on @@ -437,6 +459,13 @@ pub(super) fn create_para(s: &mut Session, p: &Value) -> Result { { return Err(bad("style.paragraph.create", format!("no style `{b}`"))); } + // Next Style: a style of the document, or this one. + if let Some(n) = &next + && *n != name + && d.styles.para(n).is_none() + { + return Err(bad("style.paragraph.create", format!("no style `{n}`"))); + } d.styles_mut().paragraph.push(ParagraphStyle { name: name.clone(), based_on, next_style: next, para, chars, shortcut: String::new() }); Ok(json!({"name": name})) }) @@ -499,11 +528,41 @@ pub(super) fn edit_para(s: &mut Session, p: &Value) -> Result { crate::cmd::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.edit")?; let rename = str_param(p, "rename").map(str::trim).filter(|n| *n != name).map(str::to_string); let based = p.get("basedOn").cloned(); + let next = p.get("nextStyle").cloned(); + let reset = p.get("resetToBase").and_then(Value::as_bool).unwrap_or(false); + for (key, v) in [("basedOn", &based), ("nextStyle", &next)] { + if v.as_ref().is_some_and(|v| !v.is_string() && !v.is_null()) { + return Err(bad("style.paragraph.edit", format!("`{key}` must be a style name or null"))); + } + } s.edit(|d, _| { - if let Some(Value::String(b)) = &based - && d.styles.para_based_on_cycles(&name, b) + if let Some(Value::String(b)) = &based { + if d.styles.para(b).is_none() { + return Err(bad("style.paragraph.edit", format!("no style `{b}`"))); + } + if d.styles.para_based_on_cycles(&name, b) { + return Err(bad("style.paragraph.edit", "based-on would create a cycle")); + } + } + if let Some(Value::String(n)) = &next + && d.styles.para(n).is_none() { - return Err(bad("style.paragraph.edit", "based-on would create a cycle")); + return Err(bad("style.paragraph.edit", format!("no style `{n}`"))); + } + // Reset To Base: what the style set of its own goes before the edits apply. + if reset { + let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?; + st.para = ParaAttrs::default(); + st.chars = CharAttrs::default(); + } + // A new Based On first, so the fields below resolve over the new parent. + if based.is_some() { + let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?; + match &based { + Some(Value::String(b)) => st.based_on = Some(b.clone()), + Some(Value::Null) => st.based_on = None, + _ => {} + } } if let Some(n) = &rename { if n.is_empty() || n.starts_with('[') { @@ -522,9 +581,9 @@ pub(super) fn edit_para(s: &mut Session, p: &Value) -> Result { let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?; st.para.merge(¶); st.chars.merge(&chars); - match based { - Some(Value::String(b)) => st.based_on = Some(b), - Some(Value::Null) => st.based_on = None, + match next { + Some(Value::String(n)) => st.next_style = Some(n), + Some(Value::Null) => st.next_style = None, _ => {} } if let Some(n) = rename.clone() { @@ -781,7 +840,7 @@ pub(super) fn create_char(s: &mut Session, p: &Value) -> Result { chars = captured; } // Based on [None] is based on nothing. - let based_on = str_param(p, "basedOn").filter(|b| *b != designcraft_doc::NO_CHAR_STYLE).map(str::to_string); + let based_on = style_name_param("style.character.create", p, "basedOn")?.filter(|b| b != designcraft_doc::NO_CHAR_STYLE); s.edit(|d, _| { if let Some(b) = &based_on && d.styles.char_style(b).is_none() @@ -1141,6 +1200,7 @@ mod delete_tests { assert_eq!(s.doc().unwrap().history.undo.len(), undo); } + /// Deleting a paragraph style also redirects TOC style references to its replacement. #[test] fn deleting_a_paragraph_style_points_every_use_at_the_replacement() { let mut s = Session::new(); @@ -1162,6 +1222,7 @@ mod delete_tests { entries: vec![TocEntry { entry_style: Some("Table Body".into()), ..TocEntry::new("Table Body", 1) }], page_numbers: false, title_style: Some("Table Body".into()), + create_bookmarks: false, }); Ok(()) }) diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 2a124e7a9..d3743df9e 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -28,4 +28,5 @@ mod styles; mod target; pub use registry::specs; -pub(crate) use selection::step_cell; +pub(crate) use selection::{one_selected_cell_to_text, step_cell}; +pub use structure::{MAX_BODY_ROWS, MAX_COLUMNS, MAX_CONVERTED_CELLS, MAX_HEADER_FOOTER_ROWS}; diff --git a/crates/engine/src/cmd/table/registry.rs b/crates/engine/src/cmd/table/registry.rs index 28d451ced..0a8166f89 100644 --- a/crates/engine/src/cmd/table/registry.rs +++ b/crates/engine/src/cmd/table/registry.rs @@ -1,7 +1,7 @@ use super::cell::{get_cell_stroke, set_cell}; use super::content::{drop_cells, place_graphic, sort_rows}; use super::options::{get, options}; -use super::selection::{move_rc, select, step_cell}; +use super::selection::{move_rc, select, step_cell, toggle_selection}; use super::sizing::{distribute_cols, distribute_rows, resize_border, set_col_width, set_row_height}; use super::structure::{ can_convert_rows, convert_from_text, convert_rows, convert_to_text, delete_cols, delete_rows, delete_table, insert, insert_col, insert_row, @@ -82,8 +82,9 @@ fn table_specs() -> Vec { "table.insert", "Create Table", [], - Some("Cmd+Alt+Shift+T"), - "{rows?: body rows (4), cols? | columns? (4), headerRows? (0), footerRows? (0), width?} — at the text insertion point", + // InDesign's Cmd+Alt+Shift+T opens the Insert Table dialog (`app.insertTableDialog`). + None, + "{rows?: body rows (4, at most 500), cols? | columns? (4, at most 200), headerRows? (0), footerRows? (0, each at most 100), tableStyle?: name, width?} — at the text insertion point, styled in the same step", has_text, insert ), @@ -92,7 +93,7 @@ fn table_specs() -> Vec { "Convert Text to Table", ["Table"], None, - "{columnSeparator?: tab|comma, width?: table width (default: the column)} — selected paragraphs become rows", + "{columnSeparator?: tab|comma|paragraph|text (tab), rowSeparator?: paragraph|tab|comma|text (paragraph), columns?: number of columns when both separators are the same (1, at most 200), tableStyle?: name, width?: table width (default: the column)} — the selected paragraphs become a table, as InDesign's Convert Text to Table; the words tab, comma and paragraph name those separators, any other text is the separator itself", has_text, convert_from_text ), @@ -101,7 +102,7 @@ fn table_specs() -> Vec { "Convert Table to Text", ["Table"], None, - "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — cells separated by tabs, rows by paragraphs", + "{columnSeparator?: tab|comma|paragraph|text (tab), rowSeparator?: paragraph|tab|comma|text (paragraph), story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — the table becomes its cells' text, as InDesign's Convert Table to Text", in_table, convert_to_text ), @@ -207,7 +208,7 @@ fn table_specs() -> Vec { cmd!( "table.placeGraphic", "Convert Cell to Graphic Cell", - ["Table", "Convert Cell Type"], + ["Table"], None, "{path? | base64?, name?: with base64, fit?: proportional|fill (default proportional), story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — an image in the target cell (its text is kept but hidden)", in_table, @@ -216,7 +217,7 @@ fn table_specs() -> Vec { cmd!( "table.textCell", "Convert Cell to Text Cell", - ["Table", "Convert Cell Type"], + ["Table"], None, "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — drop the target cells' graphics", in_table, @@ -397,6 +398,15 @@ fn table_specs() -> Vec { }), cmd!(noundo "table.nextCell", "Next Cell", [], None, "{}", in_cell, |s, _| step_cell(s, true)), cmd!(noundo "table.prevCell", "Previous Cell", [], None, "{}", in_cell, |s, _| step_cell(s, false)), + cmd!( + noundo "table.toggleSelection", + "Toggle Text and Cell Selection", + [], + None, + "{} — Esc in a table, as InDesign: the insertion point or text in a cell selects that cell; selected cells select the text of the first one", + in_cell, + |s, _| toggle_selection(s) + ), cmd!(query "table.get", "Get Table", [], None, "{story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?} → rows, columns, cells (text, spans, fill), options", in_table_or_ids, get), ] } diff --git a/crates/engine/src/cmd/table/selection.rs b/crates/engine/src/cmd/table/selection.rs index 9e0a11d0e..656dfdd78 100644 --- a/crates/engine/src/cmd/table/selection.rs +++ b/crates/engine/src/cmd/table/selection.rs @@ -66,6 +66,55 @@ pub(crate) fn step_cell(s: &mut Session, forward: bool) -> Result { Ok(json!({"row": row, "col": col})) } +/// With one cell selected (a merged one counts as one), select its whole text instead, so what is +/// typed next replaces it, as InDesign does. Other selections are left as they are. +pub(crate) fn one_selected_cell_to_text(s: &mut Session) -> Result<()> { + let st = s.doc_mut()?; + let (Some(cs), Some(t)) = (st.selection.cells, st.selection.text) else { return Ok(()) }; + let Some(tb) = st.doc.story(cs.story).and_then(|x| x.tables.get(&cs.table)) else { return Ok(()) }; + let (row, col) = tb.owner(cs.range.r0, cs.range.c0); + if tb.owner(cs.range.r1, cs.range.c1) != (row, col) { + return Ok(()); + } + let cell = CellAddr { table: cs.table, row, col }; + let len = st.doc.text_story(cs.story, Some(cell)).map_or(0, |x| x.len()); + st.selection = Selection::text(TextSel { story: cs.story, anchor: 0, focus: len, cell: Some(cell), ..t }); + Ok(()) +} + +/// Esc in a table: text in a cell becomes that cell's selection, and selected cells the text of +/// the first one (InDesign toggles between the two). +pub(super) fn toggle_selection(s: &mut Session) -> Result { + const ID: &str = "table.toggleSelection"; + let st = s.doc_mut()?; + let t = st.selection.text.ok_or_else(|| bad(ID, "no caret"))?; + let c = t.cell.filter(|c| c.footnote_id().is_none()).ok_or_else(|| bad(ID, "not in a cell"))?; + let cells = match st.selection.cells { + Some(cs) => { + // A merged cell's text belongs to the cell at its top left. + let (row, col) = st + .doc + .story(cs.story) + .and_then(|x| x.tables.get(&cs.table)) + .map_or((cs.range.r0, cs.range.c0), |tb| tb.owner(cs.range.r0, cs.range.c0)); + let cell = CellAddr { table: cs.table, row, col }; + let len = st.doc.text_story(cs.story, Some(cell)).map_or(0, |x| x.len()); + st.selection = Selection::text(TextSel { story: cs.story, anchor: 0, focus: len, cell: Some(cell), ..t }); + false + } + None => { + st.selection = Selection { + cells: Some(TableSel { story: t.story, table: c.table, range: CellRange::cell(c.row, c.col) }), + text: Some(TextSel { anchor: 0, focus: 0, ..t }), + ..Default::default() + }; + true + } + }; + st.bump_revision(); + Ok(json!({"cells": cells})) +} + /// The table cell under spread point `pt` in text frame `frame`: (story, table, row, col). pub(super) fn cell_at(s: &Session, frame: designcraft_doc::ItemId, pt: designcraft_geom::Point) -> Option<(StoryId, u64, usize, usize)> { let st = s.active()?; diff --git a/crates/engine/src/cmd/table/sizing.rs b/crates/engine/src/cmd/table/sizing.rs index b097af87f..7d239d4ec 100644 --- a/crates/engine/src/cmd/table/sizing.rs +++ b/crates/engine/src/cmd/table/sizing.rs @@ -320,3 +320,49 @@ mod resize_border_tests { assert!(s.execute("table.resizeBorder", &json!({"frame": f, "table": t, "axis": "column", "index": 0, "delta": 5})).is_err()); } } + +#[cfg(test)] +mod insert_size_tests { + use serde_json::json; + + use crate::Session; + + /// A table size that is given but is not one a table can have used to be corrected in + /// silence: `{"rows": 0}` made a one-row table, `-1` and `1.5` made the default four, and + /// 10000 was capped at 500 — so a caller's mistake became a table nobody asked for. + #[test] + fn a_table_size_insert_cannot_mean_is_refused() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1})).unwrap(); + let frame = s.execute("frame.create", &json!({"rect": [20, 20, 400, 300], "content": "text", "text": ""})).unwrap(); + let at = |s: &mut Session| { + s.execute("text.select", &json!({"story": frame["story"].clone(), "anchor": 0, "focus": 0})).unwrap(); + }; + for (params, names) in [ + (json!({"rows": 0}), "rows 0: 1 to 500"), + (json!({"rows": -1}), "rows -1: 1 to 500"), + (json!({"rows": 1.5}), "rows 1.5: 1 to 500"), + (json!({"rows": 10000}), "rows 10000: 1 to 500"), + (json!({"cols": 0}), "cols 0: 1 to 200"), + (json!({"columns": 201}), "cols 201: 1 to 200"), + (json!({"headerRows": 101}), "headerRows 101: 0 to 100"), + (json!({"footerRows": -2}), "footerRows -2: 0 to 100"), + ] { + at(&mut s); + let e = s.execute("table.insert", ¶ms).expect_err("refused").to_string(); + assert!(e.contains(names), "{params}: {e}"); + } + // Nothing was inserted by any of those. + at(&mut s); + assert!(s.execute("table.get", &json!({})).is_err(), "no table yet"); + // The sizes a table can have still work, and absent means the default four by four. + at(&mut s); + s.execute("table.insert", &json!({})).unwrap(); + let t = s.execute("table.get", &json!({})).unwrap(); + assert_eq!(t["cells"].as_array().unwrap().len(), 16, "four by four"); + at(&mut s); + s.execute("table.insert", &json!({"rows": 1, "cols": 2, "headerRows": 1})).unwrap(); + let t = s.execute("table.get", &json!({})).unwrap(); + assert_eq!(t["cells"].as_array().unwrap().len(), 4, "one body row and one header row, two columns"); + } +} diff --git a/crates/engine/src/cmd/table/structure.rs b/crates/engine/src/cmd/table/structure.rs index 174704c4a..0eb0302df 100644 --- a/crates/engine/src/cmd/table/structure.rs +++ b/crates/engine/src/cmd/table/structure.rs @@ -1,15 +1,55 @@ use super::target::{column_width, count, edit_table, in_cell, para_direction, target}; use super::{CellAddr, Result, RowKind, Selection, Session, Table, TextSel, Value, bad, json, ok, str_param}; +/// The most body rows, columns, and header or footer rows `table.insert` makes (the Insert Table +/// dialog's limits too). +pub const MAX_BODY_ROWS: u64 = 500; +pub const MAX_COLUMNS: u64 = 200; +pub const MAX_HEADER_FOOTER_ROWS: u64 = 100; +/// The most cells `table.convertFromText` makes: as many as the largest table `table.insert` +/// makes, however the rows and columns divide them (a long list can make a long table). +pub const MAX_CONVERTED_CELLS: u64 = MAX_BODY_ROWS * MAX_COLUMNS; + +/// A command's `tableStyle`: none for an absent one or [Basic Table] (a new table's look), else the +/// resolved style and the cell styles it names. Anything but an existing style's name is an error. +fn table_style_param(s: &Session, p: &Value, cmd: &str) -> Result)>> { + let name = match p.get("tableStyle") { + None | Some(Value::Null) => return Ok(None), + Some(Value::String(n)) if n == designcraft_doc::BASIC_TABLE => return Ok(None), + Some(Value::String(n)) => n, + Some(_) => return Err(bad(cmd, "`tableStyle` must be a table style's name")), + }; + let styles = &s.doc()?.doc.styles; + if !styles.table.iter().any(|x| x.name == *name) { + return Err(bad(cmd, format!("no table style `{name}`"))); + } + Ok(Some((styles.resolve_table_style(name), styles.cell.clone()))) +} + pub(super) fn insert(s: &mut Session, p: &Value) -> Result { let t = s.doc()?.selection.text.ok_or_else(|| bad("table.insert", "no insertion point"))?; if t.cell.is_some() { return Err(bad("table.insert", "tables can't be nested in cells")); } - let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(4).clamp(1, 500) as usize; - let cols = p.get("cols").or_else(|| p.get("columns")).and_then(Value::as_u64).unwrap_or(4).clamp(1, 200) as usize; - let header = p.get("headerRows").and_then(Value::as_u64).unwrap_or(0).min(100) as usize; - let footer = p.get("footerRows").and_then(Value::as_u64).unwrap_or(0).min(100) as usize; + // A size that is given but is not one a table can have used to be corrected in silence: + // `{"rows": 0}` made a table with one row, `-1` and `1.5` made the default four, and 10000 + // was capped at 500. Named `given` rather than `count`, which this module already imports + // for the repeat count of a row or column insert. + let given = |key: &str, alt: Option<&str>, default: u64, range: std::ops::RangeInclusive| -> Result { + let v = p.get(key).or_else(|| alt.and_then(|a| p.get(a))).filter(|v| !v.is_null()); + match v { + None => Ok(default as usize), + Some(v) => match v.as_u64().filter(|n| range.contains(n)) { + Some(n) => Ok(n as usize), + None => Err(bad("table.insert", format!("{key} {v}: {} to {}", range.start(), range.end()))), + }, + } + }; + let rows = given("rows", None, 4, 1..=MAX_BODY_ROWS)?; + let cols = given("cols", Some("columns"), 4, 1..=MAX_COLUMNS)?; + let header = given("headerRows", None, 0, 0..=MAX_HEADER_FOOTER_ROWS)?; + let footer = given("footerRows", None, 0, 0..=MAX_HEADER_FOOTER_ROWS)?; + let style = table_style_param(s, p, "table.insert")?; let width = p.get("width").and_then(Value::as_f64).unwrap_or_else(|| column_width(s, &t)); s.edit(|d, sel| { let id = d.alloc(); @@ -23,6 +63,9 @@ pub(super) fn insert(s: &mut Session, p: &Value) -> Result { c.text.paras[0] = designcraft_doc::ParaFormat { table: None, ..pf.clone() }; c.text.chars[0].format = cf.clone(); } + if let Some((ts, cells)) = &style { + ts.apply_to(&mut table, cells); + } let r = t.range(); st.delete(r.clone()); st.insert_table(r.start, table); @@ -31,12 +74,41 @@ pub(super) fn insert(s: &mut Session, p: &Value) -> Result { }) } -pub(super) fn convert_from_text(s: &mut Session, p: &Value) -> Result { - let t = s.doc()?.selection.text.ok_or_else(|| bad("table.convertFromText", "no text selected"))?; - if t.cell.is_some() { - return Err(bad("table.convertFromText", "select text outside tables")); +/// A Convert Text to Table / Convert Table to Text separator: `tab`, `comma`, `paragraph` or the +/// separator's own text (InDesign's fields take any characters). +fn separator(p: &Value, key: &str, default: &str, cmd: &str) -> Result { + let sep = match p.get(key) { + None | Some(Value::Null) => return Ok(default.to_string()), + Some(Value::String(s)) => match s.as_str() { + "tab" => "\t", + "comma" => ",", + "paragraph" => "\n", + other => other, + }, + Some(_) => "", + }; + if sep.is_empty() { + return Err(bad(cmd, format!("`{key}` must be tab, comma, paragraph or the separator's characters"))); } - let sep = if str_param(p, "columnSeparator") == Some("comma") { ',' } else { '\t' }; + Ok(sep.to_string()) +} + +pub(super) fn convert_from_text(s: &mut Session, p: &Value) -> Result { + const ID: &str = "table.convertFromText"; + let t = s.doc()?.selection.text.ok_or_else(|| bad(ID, "no text selected"))?; + if t.cell.is_some() { + return Err(bad(ID, "select text outside tables")); + } + let (col_sep, row_sep) = (separator(p, "columnSeparator", "\t", ID)?, separator(p, "rowSeparator", "\n", ID)?); + // With one separator for both, InDesign fills rows of Number of Columns cells. + let per_row = match p.get("columns") { + None | Some(Value::Null) => 1, + Some(v) => v + .as_u64() + .filter(|n| (1..=MAX_COLUMNS).contains(n)) + .ok_or_else(|| bad(ID, format!("`columns` must be a whole number from 1 to {MAX_COLUMNS}")))? as usize, + }; + let style = table_style_param(s, p, ID)?; let width = p.get("width").and_then(Value::as_f64).unwrap_or_else(|| column_width(s, &t)); s.edit(|d, sel| { let id = d.alloc(); @@ -48,11 +120,33 @@ pub(super) fn convert_from_text(s: &mut Session, p: &Value) -> Result { return Err(bad("table.convertFromText", "the selection contains a table")); } let (a, b) = (ranges[pa].start, ranges[pb].end); - let data: Vec> = st.text[a..b].split('\n').map(|line| line.split(sep).map(|c| c.trim().to_string()).collect()).collect(); + let text = &st.text[a..b]; + let data: Vec> = if col_sep == row_sep { + let cells: Vec = text.split(col_sep.as_str()).map(|c| c.trim().to_string()).collect(); + cells.chunks(per_row).map(<[String]>::to_vec).collect() + } else { + text.split(row_sep.as_str()).map(|row| row.split(col_sep.as_str()).map(|c| c.trim().to_string()).collect()).collect() + }; + // The table is as wide as its widest row; refuse what no table here could hold. + let cols = data.iter().map(Vec::len).max().unwrap_or(1) as u64; + if cols > MAX_COLUMNS { + return Err(bad(ID, format!("the text makes {cols} columns; a table has at most {MAX_COLUMNS}"))); + } + if cols * data.len() as u64 > MAX_CONVERTED_CELLS { + return Err(bad(ID, format!("the text makes more than {MAX_CONVERTED_CELLS} cells"))); + } + // `Table::new` would clamp the shape and drop the rows past it, with their text. + let (rows, _) = designcraft_doc::clamped_size(data.len(), cols as usize); + if rows < data.len() { + return Err(bad(ID, format!("the text makes {} rows; a table of {cols} columns has at most {rows}", data.len()))); + } let pf = st.paras[pa].clone(); let cf = st.format_after(a).clone(); let mut table = Table::from_strings(id, &data, width, &pf, &cf); table.options.direction = direction; + if let Some((ts, cells)) = &style { + ts.apply_to(&mut table, cells); + } st.delete(a..b); let anchor = st.insert_table(a, table); *sel = Selection::text(TextSel::caret(t.story, anchor)); @@ -62,9 +156,11 @@ pub(super) fn convert_from_text(s: &mut Session, p: &Value) -> Result { pub(super) fn convert_to_text(s: &mut Session, p: &Value) -> Result { let g = target(s, p, "table.convertToText")?; + let col_sep = separator(p, "columnSeparator", "\t", "table.convertToText")?; + let row_sep = separator(p, "rowSeparator", "\n", "table.convertToText")?; s.edit(|d, sel| { let st = d.story_mut(g.story).ok_or_else(|| bad("table.convertToText", "no story"))?; - let text = st.tables.get(&g.table).map(|t| t.plain_text()).unwrap_or_default(); + let text = st.tables.get(&g.table).map(|t| t.text_with(&col_sep, &row_sep)).unwrap_or_default(); let a = st.table_anchor(g.table).ok_or_else(|| bad("table.convertToText", "no anchor"))?; st.replace(a..a + designcraft_doc::TABLE_ANCHOR.len_utf8(), &text); *sel = Selection::text(TextSel::caret(g.story, a + text.len())); diff --git a/crates/engine/src/cmd/table/target.rs b/crates/engine/src/cmd/table/target.rs index beaa1c8b0..2e1784560 100644 --- a/crates/engine/src/cmd/table/target.rs +++ b/crates/engine/src/cmd/table/target.rs @@ -25,7 +25,13 @@ pub(super) fn in_table_or_ids(s: &Session) -> std::result::Result<(), String> { pub(super) fn in_cell(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; - if s.active().is_some_and(|d| d.selection.text.is_some_and(|t| t.cell.is_some())) { Ok(()) } else { Err("not in a table cell".into()) } + // Footnote text is addressed as a cell too, but isn't in a table. + let in_table = |c: CellAddr| c.footnote_id().is_none(); + if s.active().is_some_and(|d| d.selection.text.is_some_and(|t| t.cell.is_some_and(in_table))) { + Ok(()) + } else { + Err("not in a table cell".into()) + } } /// The table a command acts on and its target cell range. diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 8e6038741..f94826fef 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -191,12 +191,17 @@ pub fn specs() -> Vec { }) } ), - cmd!(query "story.get", "Get Story", [], None, "{story? | frame?} → text, frames, paragraphs, vertical, overset", has_doc, |s, p| { + cmd!(query "story.get", "Get Story", [], None, "{story? | frame?} → text, frames, paragraphs, vertical, lines, overset (the UTF-8 byte offset into `text` where it stops fitting its frames, not a character index, null when it all fits) and oversetChars (how many characters that leaves, as preflight reports them)", has_doc, |s, p| { let st = s.doc()?; let sid = story_of(s, p).ok_or_else(|| bad("story.get", "no story"))?; let mut v = st.doc.story_summary(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; let cs = s.cache.get(&st.doc, sid, None); + // `overset` is where the text stops fitting, not how much is left over: the two were + // read as the same number and disagreed with preflight's count (#1465). v["overset"] = json!(cs.overset_at); + v["oversetChars"] = json!(cs + .overset_at + .map(|at| st.doc.stories.get(&sid).and_then(|story| story.text.get(at..)).unwrap_or("").chars().count())); v["lines"] = json!(cs.line_count()); Ok(v) }), @@ -206,7 +211,7 @@ pub fn specs() -> Vec { "Character Formatting", [], None, - "{attrs: {fontFamily?, fontStyle?, size?, leading?: {kind:auto}|{kind:points,value}, tracking?, kerning?, hScale?, vScale?, baselineShift?, fill?, capitalization?, underline?, …}}", + "{attrs: {fontFamily?, fontStyle?, size?, leading?: {kind:auto}|{kind:points,value}, tracking?, kerning?, hScale?, vScale?, baselineShift?, fill?, capitalization?, underline?, …}} → null, or {warnings} naming a fontFamily or fontStyle that isn't available (still applied; a substitute draws the text)", has_text_or_frames, |s, p| format_chars(s, p.get("attrs").unwrap_or(p)) ), @@ -416,7 +421,7 @@ pub fn specs() -> Vec { "Selection Attributes", [], None, - "{mixed?: bool} → resolved character/paragraph attributes at the text selection; with mixed, paraMixed lists the paragraph attributes that differ across the selected paragraphs; the selection's start as `selectedText` (one line, up to 60 characters); `storyOptions` {opticalMargin, opticalMarginSize} of the stories story.options would change (null where they differ)", + "{mixed?: bool} → resolved character/paragraph attributes at the text selection; `ownChars`: what the text has of its own over its paragraph (its character style's attributes and its overrides, what a character style made from the selection sets); with mixed, paraMixed lists the paragraph attributes that differ across the selected paragraphs; the selection's start as `selectedText` (one line, up to 60 characters); `storyOptions` {opticalMargin, opticalMarginSize} of the stories story.options would change (null where they differ)", has_doc, selection_attrs ), diff --git a/crates/engine/src/cmd/text/caret.rs b/crates/engine/src/cmd/text/caret.rs index 670de4049..0621e30de 100644 --- a/crates/engine/src/cmd/text/caret.rs +++ b/crates/engine/src/cmd/text/caret.rs @@ -380,6 +380,8 @@ pub(super) fn move_caret(s: &mut Session, p: &Value) -> Result { s.typing_format = None; let stm = s.doc_mut()?; stm.selection.text = Some(if extend { TextSel { focus: new, ..t } } else { TextSel { anchor: new, focus: new, ..t } }); + // Moving the caret leaves the selected cells, so typing inserts at the caret. + stm.selection.cells = None; stm.bump_revision(); Ok(json!({"pos": new})) } diff --git a/crates/engine/src/cmd/text/edit.rs b/crates/engine/src/cmd/text/edit.rs index fc3f5dddf..fd104cbcd 100644 --- a/crates/engine/src/cmd/text/edit.rs +++ b/crates/engine/src/cmd/text/edit.rs @@ -13,10 +13,13 @@ fn reserve_command_text(st: &mut designcraft_doc::Story, additional: usize) -> R pub(super) fn insert(s: &mut Session, p: &Value) -> Result { let text = crate::cmd::text_param(p, "text"); - // Tab in a table cell moves to the next cell (Shift-Tab: `table.prevCell`). - if text == "\t" && s.doc()?.selection.text.is_some_and(|t| t.cell.is_some()) { + // Tab in a table cell moves to the next cell (Shift-Tab: `table.prevCell`). Footnote text is + // addressed as a cell too: a Tab there is typed. + if text == "\t" && s.doc()?.selection.text.is_some_and(|t| t.cell.is_some_and(|c| c.footnote_id().is_none())) { return crate::cmd::table::step_cell(s, true); } + // Typing with one cell selected replaces its text, as InDesign. + crate::cmd::table::one_selected_cell_to_text(s)?; let raw = p.get("raw").and_then(Value::as_bool).unwrap_or(false); let text = if s.prefs.typographers_quotes && !raw { smart_quotes(s, &text) } else { text }; let tracking = s.doc()?.doc.settings.track_changes; diff --git a/crates/engine/src/cmd/text/format.rs b/crates/engine/src/cmd/text/format.rs index 7e1833c0a..edec044b9 100644 --- a/crates/engine/src/cmd/text/format.rs +++ b/crates/engine/src/cmd/text/format.rs @@ -162,6 +162,8 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result { } } validate_char_fill(&s.doc()?.doc, &a, "type.char")?; + let warnings = missing_font_warnings(&s.doc()?.doc, &a); + let result = if warnings.is_empty() { Value::Null } else { json!({ "warnings": warnings }) }; let cleared: Vec = attrs.as_object().map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()).unwrap_or_default(); let targets = format_targets(s); // A caret in text: the format is for the next typing. On an empty paragraph's hidden mark it @@ -185,14 +187,33 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result { })?; } s.typing_format = Some(f); - return ok(); + return Ok(result); } s.edit(|d, _| { apply_char_attrs(d, &targets, &a, &cleared); - ok() + Ok(result) }) } +/// A font family or style the app doesn't have is still applied, so the document keeps its name +/// and preflight and Find Font list it, but a substitute draws the text. Say so, in preflight's +/// words, instead of succeeding silently. +fn missing_font_warnings(document: &designcraft_doc::Document, attrs: &CharAttrs) -> Vec { + let Some(family) = attrs.font_family.as_deref().filter(|f| !f.trim().is_empty()) else { + return vec![]; + }; + let db = designcraft_fonts::FontDb::global().scoped(document.font_scope); + let style = attrs.font_style.as_deref().unwrap_or("Regular"); + let face = db.face(family, style); + if !db.has_family(family) { + vec![format!("Missing font: {family} (using {})", face.family)] + } else if attrs.font_style.is_some() && !db.has_style(family, style) { + vec![format!("Missing font style: {family} {style} (using {} {})", face.family, face.style)] + } else { + vec![] + } +} + /// Kerning spaces a character from the next one, so the pair at a caret is kerned through the /// character before it. With no such pair (story or paragraph start, a selection) this is /// `type.char`. @@ -1073,3 +1094,37 @@ mod size_bounds_tests { } } } + +#[cfg(test)] +mod missing_font_tests { + use serde_json::json; + + use crate::Session; + + /// A font the app doesn't have was applied with no word about it, so a script that asked for + /// Courier Prime exported a PDF in the substitute and exited 0. It is still applied (preflight + /// and Find Font list it), and the command now says which font is missing. + #[test] + fn a_font_that_is_not_available_is_applied_with_a_warning() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [20, 20, 400, 300], "content": "text", "text": "Hello world"})).unwrap(); + for (attrs, warning) in [ + (json!({"fontFamily": "Source Serif 4", "fontStyle": "Bold"}), None), + (json!({"fontFamily": "Nonexistent Sans"}), Some("Missing font: Nonexistent Sans (using ")), + (json!({"fontFamily": "Source Serif 4", "fontStyle": "Nope"}), Some("Missing font style: Source Serif 4 Nope (using Source Serif 4 ")), + ] { + s.execute("text.select", &json!({"story": f["story"].clone(), "anchor": 0, "focus": 5})).unwrap(); + let r = s.execute("type.char", &json!({"attrs": attrs})).unwrap(); + match warning { + None => assert_eq!(r, json!(null), "{attrs}"), + Some(w) => assert!(r["warnings"][0].as_str().is_some_and(|m| m.starts_with(w)), "{attrs}: {r}"), + } + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fontFamily"], attrs["fontFamily"], "{attrs}"); + } + // At a caret the font is for the next typing, and the warning is the same. + s.execute("text.select", &json!({"story": f["story"].clone(), "anchor": 5, "focus": 5})).unwrap(); + let r = s.execute("type.char", &json!({"attrs": {"fontFamily": "Nonexistent Sans"}})).unwrap(); + assert!(r["warnings"][0].as_str().is_some_and(|m| m.starts_with("Missing font: Nonexistent Sans")), "{r}"); + } +} diff --git a/crates/engine/src/cmd/text/story.rs b/crates/engine/src/cmd/text/story.rs index e3547f88d..67f4cf1ad 100644 --- a/crates/engine/src/cmd/text/story.rs +++ b/crates/engine/src/cmd/text/story.rs @@ -125,9 +125,12 @@ pub(super) fn selection_attrs(s: &mut Session, p: &Value) -> Result { let char_delta = character_base.delta(&cp); let style_keys = character_style.keys(); let character_style_overrides = char_delta.keys().iter().filter(|key| style_keys.contains(key)).count(); + // The text's own formatting over its paragraph, as Create New Style from Selection captures it. + let mut own = character_style.clone(); + own.merge(&cf.over); // The selected text (its start, on one line), for previews such as the font menu's. let selected = selected_text_preview(story.text.get(r.start..r.end).unwrap_or_default().chars()); - let mut out = json!({"story": sid.0, "paragraphStyle": pf.style, "characterStyle": cf.style, "para": pp, "chars": cp, + let mut out = json!({"story": sid.0, "paragraphStyle": pf.style, "characterStyle": cf.style, "para": pp, "chars": cp, "ownChars": own, "paraOverrides": style_para.delta(&pp).count() + style_chars.delta(&base).count(), "charOverrides": char_delta.count(), "characterStyleOverrides": character_style_overrides, "selectedText": selected.trim()}); diff --git a/crates/engine/src/cmd/toc.rs b/crates/engine/src/cmd/toc.rs index 23f5847a4..b69d17e26 100644 --- a/crates/engine/src/cmd/toc.rs +++ b/crates/engine/src/cmd/toc.rs @@ -12,6 +12,7 @@ use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_doc, str_param}; use crate::{Result, Session}; +/// Register TOC generation and refresh commands. pub fn specs() -> Vec { vec![ cmd!( @@ -19,7 +20,7 @@ pub fn specs() -> Vec { "Table of Contents…", ["Layout"], None, - "{entries: [{style, level?: 1 (1-9), entryStyle?: paragraph style of its entries (default \"TOC Level n\"), pageNumber?: \"after\"|\"before\"|\"none\", separator?: text between entry and number (default a tab), pageNumberStyle?: character style of the page number, separatorStyle?: character style of the separator, sort?: bool (Sort Entries in Alphabetical Order: among their siblings, each with the entries under it)}] | styles: [names] (levels 1, 2, …), title?: \"Contents\", titleStyle?: paragraph style of the title (default \"TOC Title\"), pageNumbers?: true, replace?: true (regenerate the existing table of contents in place; false makes a new frame), page?: 1-based page for a new frame (its margins), rect?: [x0,y0,x1,y1] (spread coords)} → {story, entries}", + "{entries: [{style, level?: 1 (1-9), entryStyle?: paragraph style of its entries (default \"TOC Level n\"), pageNumber?: \"after\"|\"before\"|\"none\", separator?: text between entry and number (default a tab), pageNumberStyle?: character style of the page number, separatorStyle?: character style of the separator, sort?: bool (Sort Entries in Alphabetical Order: among their siblings, each with the entries under it)}] | styles: [names] (levels 1, 2, …), title?: \"Contents\", titleStyle?: paragraph style of the title (default \"TOC Title\"), pageNumbers?: true, createBookmarks?: bool (generate PDF bookmarks; keeps the existing setting when omitted, which is off for a new table), replace?: true (regenerate the existing table of contents in place; false makes a new frame), page?: 1-based page for a new frame (its margins), rect?: [x0,y0,x1,y1] (spread coords)} → {story, entries}", has_doc, generate ), @@ -302,9 +303,30 @@ fn fill(d: &mut Document, toc: &Toc, width: Option) -> Result { st.format_paras(r.start..r.start, |f| *f = ParaFormat { style: name.clone(), ..Default::default() }); } } + let insertion = d.bookmarks.iter().position(|b| b.generated_by_toc).unwrap_or(d.bookmarks.len()); + d.bookmarks.retain(|b| !b.generated_by_toc); + if toc.create_bookmarks { + d.bookmarks.splice(insertion..insertion, bookmarks(&found)); + } Ok(found.len()) } +/// Levels are validated to 1–9. Recursion follows strictly increasing levels, including when +/// a document skips a level; each destination is the physical page, not its section label. +fn bookmarks(found: &[Found]) -> Vec { + /// Collect descendants until the next sibling or ancestor entry. + fn children(iter: &mut std::iter::Peekable>, parent: u8) -> Vec { + let mut out = Vec::new(); + while iter.peek().is_some_and(|f| f.level > parent) { + let Some(f) = iter.next() else { break }; + out.push(designcraft_doc::Bookmark { name: f.text.clone(), page: f.key.0, children: children(iter, f.level), generated_by_toc: true }); + } + out + } + children(&mut found.iter().peekable(), 0) +} + +/// Validate the TOC settings and generate its frame, text, and outline in one edit. fn generate(s: &mut Session, p: &Value) -> Result { let entries = entries_param(p)?; if entries.is_empty() { @@ -320,6 +342,10 @@ fn generate(s: &mut Session, p: &Value) -> Result { return Err(bad("toc.generate", format!("style names are at most {MAX_NAME} characters"))); } let page_numbers = p.get("pageNumbers").and_then(Value::as_bool).unwrap_or(true); + let create_bookmarks = match p.get("createBookmarks") { + Some(v) => v.as_bool().ok_or_else(|| bad("toc.generate", "createBookmarks must be a boolean"))?, + None => s.doc()?.doc.toc.as_ref().is_some_and(|t| t.create_bookmarks), + }; let replace = p.get("replace").and_then(Value::as_bool).unwrap_or(true); let rect = super::rect_param(p, "rect"); if rect.is_some_and(|r| ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) || r.width() <= 0.0 || r.height() <= 0.0) { @@ -345,7 +371,7 @@ fn generate(s: &mut Session, p: &Value) -> Result { (sid, w) } }; - let toc = Toc { story, title: title.clone(), entries: entries.clone(), page_numbers, title_style: title_style.clone() }; + let toc = Toc { story, title: title.clone(), entries: entries.clone(), page_numbers, title_style: title_style.clone(), create_bookmarks }; let n = fill(d, &toc, width)?; d.toc = Some(toc); Ok(json!({"story": story.0, "entries": n})) @@ -623,6 +649,46 @@ mod tests { assert!(toc_runs(&s).iter().any(|(t, st)| t == "1" && st == "Folio"), "{:?}", toc_runs(&s)); } + /// Refresh preserves manual outline positions, nested destinations, storage, and undo. + #[test] + fn toc_bookmarks_refresh_nested_page_destinations_and_preserve_manual_bookmarks() { + let (mut s, page) = doc_with(&[("First", "Head"), ("Child", "Sub"), ("Last", "Head")]); + s.execute("layout.section", &json!({"page": 1, "style": "lowerRoman", "startNumber": 4})).unwrap(); + s.execute("bookmark.add", &json!({"name": "Manual", "page": page})).unwrap(); + let p = json!({"page": page, "createBookmarks": true, "entries": [ + {"style": "Head", "level": 1}, {"style": "Sub", "level": 3} + ]}); + s.execute("toc.generate", &p).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.bookmarks.iter().map(|b| b.name.as_str()).collect::>(), ["Manual", "First", "Last"]); + assert_eq!(d.bookmarks[1].page, 0, "physical page, although its label is iv"); + assert_eq!(d.bookmarks[1].children[0].name, "Child"); + assert!(d.bookmarks[1].children[0].generated_by_toc); + let sid = d.stories.iter().find(|(_, st)| st.text.starts_with("First")).map(|(sid, _)| *sid).unwrap(); + s.execute("bookmark.add", &json!({"name": "Manual after", "page": page})).unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 5})).unwrap(); + s.execute("text.insert", &json!({"text": "Changed"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.bookmarks[1].name, "First", "refresh is explicit"); + s.execute("toc.update", &json!({})).unwrap(); + let refreshed = s.doc().unwrap().doc.bookmarks.clone(); + assert_eq!(refreshed.iter().map(|b| b.name.as_str()).collect::>(), ["Manual", "Changed", "Last", "Manual after"]); + s.execute("toc.update", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.bookmarks, refreshed, "updates do not duplicate bookmarks"); + // Native storage retains both the option and ownership; old bookmarks default to manual. + let stored = serde_json::to_value(&s.doc().unwrap().doc).unwrap(); + let back: Document = serde_json::from_value(stored).unwrap(); + assert!(back.toc.as_ref().unwrap().create_bookmarks); + assert_eq!(back.bookmarks, refreshed); + let old: designcraft_doc::Bookmark = serde_json::from_value(json!({"name": "Old", "page": 0})).unwrap(); + assert!(!old.generated_by_toc); + let mut disabled = p; + disabled["createBookmarks"] = json!(false); + s.execute("toc.generate", &disabled).unwrap(); + assert_eq!(s.doc().unwrap().doc.bookmarks.iter().map(|b| b.name.as_str()).collect::>(), ["Manual", "Manual after"]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.bookmarks, refreshed); + } + #[test] fn toc_entry_options_round_trip_through_the_native_format() { let mut e = TocEntry::new("Head", 2); diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index 682ccebde..33c3c8821 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -28,7 +28,7 @@ pub fn specs() -> Vec { "Define Text Variable…", ["Type", "Text Variables"], None, - "{name, type: custom|lastPageNumber|chapterNumber|fileName|creationDate|modificationDate|outputDate|runningHeader, text?, format?, style?, use?: firstOnPage|lastOnPage, character?, section?, extension?, before?, after?} — creates or replaces by name", + "{name, type: custom|lastPageNumber|chapterNumber|fileName|creationDate|modificationDate|outputDate|runningHeader, text? (a string; a number or a boolean is shown as typed, anything else is refused), format?, style?, use?: firstOnPage|lastOnPage, character?, section?, extension?, before?, after?} — creates or replaces by name", has_doc, define ), @@ -391,11 +391,30 @@ fn unvar(st: &mut designcraft_doc::Story, i: usize, n: usize, value: &str, depth } } +/// A parameter's text: a string as it is; a number or a boolean spelled out. A Custom variable's +/// text typed as `2026` reaches the command as a number (the dialog and JSON callers type their +/// values), and it is still the text the variable shows (#2093). +fn text_param(p: &Value, key: &str) -> Option { + match p.get(key)? { + Value::String(s) => Some(s.clone()), + Value::Number(n) => Some(n.to_string()), + Value::Bool(b) => Some(b.to_string()), + _ => None, + } +} + fn define(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("variables.define", "missing name"))?.to_string(); let fmt = || str_param(p, "format").unwrap_or("MM/dd/yy").to_string(); let kind = match str_param(p, "type").unwrap_or("custom") { - "custom" => VarKind::Custom { text: str_param(p, "text").unwrap_or("").into() }, + "custom" => VarKind::Custom { + // No `text` is empty text; an object or an array is refused rather than stored as + // empty text in place of what a variable of that name holds. + text: match p.get("text") { + None | Some(Value::Null) => String::new(), + Some(_) => text_param(p, "text").ok_or_else(|| bad("variables.define", "`text` must be a string, a number or a boolean"))?, + }, + }, "lastPageNumber" => VarKind::LastPageNumber { section: p.get("section").and_then(Value::as_bool).unwrap_or(false) }, "chapterNumber" => VarKind::ChapterNumber, "fileName" => VarKind::FileName { extension: p.get("extension").and_then(Value::as_bool).unwrap_or(false) }, diff --git a/crates/engine/src/cmd/variables/tests.rs b/crates/engine/src/cmd/variables/tests.rs index 4a4832c84..6a2291ccb 100644 --- a/crates/engine/src/cmd/variables/tests.rs +++ b/crates/engine/src/cmd/variables/tests.rs @@ -1,5 +1,10 @@ use super::*; +fn shown_on_parent(s: &Session, d: &designcraft_doc::Document, sid: designcraft_doc::StoryId) -> usize { + let cs = s.cache.get(d, sid, None); + cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum() +} + /// A parent-page page-number marker follows Numbering & Section Options: changing a section's /// start number renames its pages without changing their index or the number of sections, and /// the marker composed for a page shows the new name. @@ -350,7 +355,109 @@ fn running_header_on_parent_page_follows_headings() { assert_eq!(idx.value("Heading", false, Default::default(), 2), "Epsilon"); let list = s.execute("variables.list", &json!({})).unwrap(); assert!(list.as_array().unwrap().iter().any(|v| v["name"] == "Last Page Number" && v["value"] == "3")); + // On the parent page itself the header shows which variable it holds, not a blank. Text with + // no page that isn't on a parent shows the variable's text before and after, as before. + let head = d.variable("Chapter Head").unwrap(); + assert_eq!(crate::compose::vars::values_shown(&d, None, true, Some(&idx))[head], ""); + assert_eq!(crate::compose::vars::values_shown(&d, None, false, Some(&idx))[head], ""); + assert_eq!(crate::compose::vars::values(&d, None, Some(&idx))[head], "", "values() is the no-parent case"); + assert_eq!(crate::compose::vars::values_shown(&d, Some(2), true, Some(&idx))[head], "Epsilon", "composed for a page: its entry"); + assert!(shown_on_parent(&s, &d, sid) > p1, "the parent shows , longer than Alpha"); // Deleting a variable flattens its instances to text. s.execute("variables.delete", &json!({"name": "Last Page Number"})).unwrap(); assert!(s.doc().unwrap().doc.story(sid).unwrap().text.ends_with(" · 3")); } + +/// A running header reads a page in text order: in a two-column frame the right column's +/// headings come after the left column's, even when they sit higher on the page. (The first +/// and last were picked by height, so a dictionary page's header named its left column's last +/// entry, which sits lowest, instead of the right column's.) +#[test] +fn running_header_reads_columns_in_text_order() { + use designcraft_doc::story::COLUMN_BREAK; + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Heading"})).ok(); + // Left column: a line, Alpha, a line, Beta (low). Right column: Gamma (at the top), a line. + let text = format!("x\nAlpha\nx\nBeta{COLUMN_BREAK}\nGamma\ny"); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 472, 400], "content": "text", "text": text})).unwrap(); + s.execute("object.textFrameOptions", &json!({"ids": [r["id"]], "columns": 2, "gutter": 20})).unwrap(); + let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + for word in ["Alpha", "Beta", "Gamma"] { + let at = text.find(word).unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": at, "focus": at + word.len()})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Heading"})).unwrap(); + } + s.execute("variables.define", &json!({"name": "Entry", "type": "runningHeader", "style": "Heading"})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let cs = s.cache.get(&d, sid, None); + let y = |word: &str| crate::compose::caret(&cs, text.find(word).unwrap()).unwrap().2; + assert!(y("Gamma") < y("Beta") && y("Gamma") < y("Alpha"), "Gamma heads the right column, above the left column's headings"); + let idx = s.cache.running_index(&d); + assert_eq!(idx.value("Heading", false, designcraft_doc::vars::Use::FirstOnPage, 0), "Alpha"); + assert_eq!(idx.value("Heading", false, designcraft_doc::vars::Use::LastOnPage, 0), "Gamma"); +} +/// Stories on one page are ordered by where each one's text starts there: a story whose first +/// heading sits low in its first column comes after another story's heading above it, even +/// though its later column starts higher still. +#[test] +fn running_header_orders_stories_by_their_first_heading() { + use designcraft_doc::story::COLUMN_BREAK; + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Heading"})).ok(); + // Story A, two columns: filler, then Alpha low in column 1; Gamma at the top of column 2. + let a_text = format!("{}Alpha{COLUMN_BREAK}\nGamma\ny", "x\n".repeat(8)); + let a = s.execute("frame.create", &json!({"rect": [72, 72, 300, 400], "content": "text", "text": a_text})).unwrap(); + s.execute("object.textFrameOptions", &json!({"ids": [a["id"]], "columns": 2, "gutter": 12})).unwrap(); + // Story B beside it: Beta between Gamma's height and Alpha's. + let b_text = "Beta".to_string(); + let b = s.execute("frame.create", &json!({"rect": [320, 120, 560, 160], "content": "text", "text": b_text})).unwrap(); + let story = + |s: &Session, id: &Value| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id.as_u64().unwrap())).unwrap().text_frame().unwrap().story; + let (sa, sb) = (story(&s, &a["id"]), story(&s, &b["id"])); + for (sid, text, word) in [(sa, &a_text, "Alpha"), (sa, &a_text, "Gamma"), (sb, &b_text, "Beta")] { + let at = text.find(word).unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": at, "focus": at + word.len()})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Heading"})).unwrap(); + } + s.execute("variables.define", &json!({"name": "Entry", "type": "runningHeader", "style": "Heading"})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let y = |sid, text: &str, word: &str| { + let cs = s.cache.get(&d, sid, None); + let (fi, _, b, ..) = crate::compose::caret(&cs, text.find(word).unwrap()).unwrap(); + d.item(cs.frames[fi].frame).unwrap().bounds().y0 + b + }; + let (ya, yg, yb) = (y(sa, &a_text, "Alpha"), y(sa, &a_text, "Gamma"), y(sb, &b_text, "Beta")); + assert!(yg < yb && yb < ya, "Gamma {yg} above Beta {yb} above Alpha {ya}"); + let idx = s.cache.running_index(&d); + assert_eq!(idx.value("Heading", false, designcraft_doc::vars::Use::FirstOnPage, 0), "Beta"); + assert_eq!(idx.value("Heading", false, designcraft_doc::vars::Use::LastOnPage, 0), "Gamma"); +} + +/// #2093: a Custom variable whose text arrives as a number (the dialog types a typed `2026` as +/// one) shows those digits; it showed nothing at all before. +#[test] +fn a_custom_variable_defined_with_a_number_shows_its_digits() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("variables.define", &json!({"name": "Year", "type": "custom", "text": 2026})).unwrap(); + s.execute("variables.define", &json!({"name": "Half", "type": "custom", "text": 0.5})).unwrap(); + s.execute("variables.define", &json!({"name": "Word", "type": "custom", "text": "year"})).unwrap(); + s.execute("variables.define", &json!({"name": "Flag", "type": "custom", "text": true})).unwrap(); + // Anything else is refused, and a variable of that name keeps its text; no text is empty text. + assert!(s.execute("variables.define", &json!({"name": "Year", "type": "custom", "text": {"y": 2026}})).is_err()); + assert!(s.execute("variables.define", &json!({"name": "Year", "type": "custom", "text": [2026]})).is_err()); + s.execute("variables.define", &json!({"name": "Blank", "type": "custom"})).unwrap(); + let list = s.execute("variables.list", &json!({})).unwrap(); + let value = |name: &str| list.as_array().unwrap().iter().find(|v| v["name"] == name).unwrap()["value"].as_str().unwrap().to_string(); + assert_eq!((value("Year"), value("Half"), value("Word")), ("2026".to_string(), "0.5".to_string(), "year".to_string())); + assert_eq!((value("Flag"), value("Blank")), ("true".to_string(), String::new())); + // Inserted into a story, the instance composes as four digits. + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": ""})).unwrap(); + let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + s.execute("variables.insert", &json!({"name": "Year"})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + assert_eq!(shown_on_parent(&s, &d, sid), 4, "2026 shows"); +} diff --git a/crates/engine/src/cmd/xml.rs b/crates/engine/src/cmd/xml.rs index 8cd50a2cd..81b57b5b3 100644 --- a/crates/engine/src/cmd/xml.rs +++ b/crates/engine/src/cmd/xml.rs @@ -37,8 +37,21 @@ fn tagged(d: &Document) -> Vec { keyed.into_iter().map(|(_, i)| i).collect() } +/// Escapes markup characters and drops characters XML 1.0 cannot hold. fn esc(s: &str) -> String { - s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\t' | '\n' | '\r' => out.push(c), + c if (c as u32) < 0x20 || c == '\u{FFFE}' || c == '\u{FFFF}' => {} + c => out.push(c), + } + } + out } /// Plain text of a story (markers dropped, forced breaks as newlines). @@ -155,7 +168,7 @@ fn parse(xml: &str) -> std::result::Result, String> { _ => String::new(), }; match (depth, child.as_mut(), out.last_mut()) { - (3, Some(c), _) => c.1.push_str(&s), + (3.., Some(c), _) => c.1.push_str(&s), (2, _, Some(el)) => el.text.push_str(&s), _ => {} } @@ -268,6 +281,11 @@ pub fn specs() -> Vec { |s, p| { let style = str_param(p, "style").unwrap_or("").to_string(); let tag = str_param(p, "tag").map(str::to_string); + if let Some(t) = &tag + && !valid_name(t) + { + return Err(bad("xml.mapStyle", format!("`{t}` isn't a valid XML name"))); + } if s.doc()?.doc.styles.para(&style).is_none() { return Err(bad("xml.mapStyle", format!("no paragraph style `{style}`"))); } @@ -475,6 +493,59 @@ mod tests { assert!(s.execute("xml.validate", &json!({})).is_err()); } + #[test] + fn export_xml_drops_forbidden_control_chars() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s + .execute("frame.create", &json!({"rect": [72, 72, 400, 150], "content": "text", "text": "A\u{1}B\u{8}C\u{FFFE}\u{FFFF}", "caret": false})) + .unwrap(); + s.execute("xml.tag", &json!({"tag": "story", "ids": [a["id"]]})).unwrap(); + let x = s.execute("file.exportXml", &json!({})).unwrap()["text"].as_str().unwrap().to_string(); + assert!(x.contains("ABC"), "{x:?}"); + } + + #[test] + fn map_style_rejects_invalid_tag_name() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Head"})).unwrap(); + assert!(s.execute("xml.mapStyle", &json!({"style": "Head", "tag": "a b>See the glossary entry"), "{x}"); + s.execute("file.importXml", &json!({"text": x})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.stories[&designcraft_doc::StoryId(a["story"].as_u64().unwrap())].text, "See the glossary entry"); + } + + #[test] + fn parse_keeps_text_nested_below_a_child_element() { + let els = super::parse("See the x y").unwrap(); + assert_eq!(els.len(), 1); + assert_eq!(els[0].children, vec![("note".to_string(), "See the x y".to_string())]); + } + #[test] fn tag_export_and_import_xml() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/xref.rs b/crates/engine/src/cmd/xref.rs index 446148095..7106b1ed0 100644 --- a/crates/engine/src/cmd/xref.rs +++ b/crates/engine/src/cmd/xref.rs @@ -94,7 +94,7 @@ fn insert(s: &mut Session, p: &Value) -> Result { let r = t2.range(); let r = r.start.min(st.len())..r.end.min(st.len()); st.delete(r.clone()); - st.insert_xref(r.start, CrossRef { target, format }); + st.insert_xref(r.start, CrossRef { target, format, ..Default::default() }); let pos = r.start + designcraft_doc::XREF_MARK.len_utf8(); sel.text = Some(TextSel { anchor: pos, focus: pos, ..t2 }); Ok(json!({"target": target, "pos": r.start})) @@ -169,7 +169,10 @@ fn set_format(s: &mut Session, p: &Value) -> Result { } let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; let x = st.xrefs.get_mut(k).ok_or_else(|| bad("xref.setFormat", "no such cross-reference"))?; - std::sync::Arc::make_mut(x).format = format; + let x = std::sync::Arc::make_mut(x); + x.format = format; + // A new format generates the text again, replacing any edits (InDesign does the same). + x.text = None; st.rev = st.rev.wrapping_add(1); Ok(Value::Null) }) diff --git a/crates/engine/src/guard.rs b/crates/engine/src/guard.rs index 6395c3594..17bdcb9bf 100644 --- a/crates/engine/src/guard.rs +++ b/crates/engine/src/guard.rs @@ -1,4 +1,4 @@ -//! Last-resort guard (craftrules `standards/never-crash.md`): a panic that escapes a command — +//! Last-resort guard: a panic that escapes a command — //! any edit, import or export, from the UI, the CLI, MCP or the control channel — becomes an //! error, and the document is kept as it was before the command (and saved for crash recovery //! when a recovery directory is set). It is a safety net: commands still must not panic. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 58301e9e6..d0922e484 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -250,6 +250,61 @@ pub struct Prefs { pub recent_files: Vec, /// Preferences › File Handling › Number of Recent Items to Display (0 keeps no list). pub recent_files_max: u32, + /// Preferences › Units & Increments set with no document open: what new documents start + /// with, as InDesign's application defaults. A document keeps its own once made. + pub default_units: DefaultUnits, +} + +/// Units & Increments for new documents (Preferences with no document open), in the keys +/// `document.preferences` uses. +#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct DefaultUnits { + pub horizontal_units: designcraft_geom::Unit, + pub vertical_units: designcraft_geom::Unit, + pub ruler_origin: designcraft_doc::RulerOrigin, + /// Cursor Key, in points. + pub keyboard_increment: f64, + /// Kerning/Tracking, in 1/1000 em. + pub kerning_increment: f64, +} + +impl Default for DefaultUnits { + /// A new document's own settings. + fn default() -> Self { + let d = designcraft_doc::DocSettings::default(); + DefaultUnits { + horizontal_units: designcraft_geom::Unit::Picas, + vertical_units: designcraft_geom::Unit::Picas, + ruler_origin: d.ruler_origin, + keyboard_increment: d.keyboard_increment, + kerning_increment: d.kerning_increment, + } + } +} + +impl DefaultUnits { + /// Gives a new document these defaults. Unless it was made from a preset (whose units are its + /// own), its vertical ruler follows the preference while it keeps the default horizontal + /// units (units picked in New Document set both rulers). + pub fn apply(&self, settings: &mut designcraft_doc::DocSettings, preset: bool) { + if !preset && settings.horizontal_units == self.horizontal_units { + settings.vertical_units = self.vertical_units; + } + settings.ruler_origin = self.ruler_origin; + settings.keyboard_increment = self.keyboard_increment; + settings.kerning_increment = self.kerning_increment; + } + + /// Refuses increments that aren't finite and positive (at most 1000). + pub fn check(&self) -> std::result::Result<(), String> { + for (name, v) in [("keyboardIncrement", self.keyboard_increment), ("kerningIncrement", self.kerning_increment)] { + if !(v.is_finite() && v > 0.0 && v <= 1000.0) { + return Err(format!("`defaultUnits.{name}` must be more than 0 and at most 1000")); + } + } + Ok(()) + } } /// The most recent files Preferences lets File › Open Recent keep. @@ -324,6 +379,7 @@ impl Default for Prefs { link_text_files: false, recent_files: Vec::new(), recent_files_max: 20, + default_units: DefaultUnits::default(), } } } @@ -379,6 +435,10 @@ pub struct Session { /// Linked text files checked by the Links panel: path → the size and modification time the /// file had and the hash of its contents then, so a file touched but not changed is read once. pub(crate) text_link_checks: std::collections::HashMap, + /// Linked images checked against their files this session (`links.list` polls twice a second): + /// a file of the recorded size and time isn't read again. + pub(crate) image_link_checks: + std::collections::HashMap<(designcraft_doc::AssetId, usize, std::path::PathBuf), (designcraft_doc::Fingerprint, bool)>, } impl Default for Session { @@ -411,6 +471,7 @@ impl Session { recovery_dir: None, text_clipboard: None, text_link_checks: Default::default(), + image_link_checks: Default::default(), ui_requests: vec![], loaded: None, transforms: Default::default(), diff --git a/crates/engine/src/math.rs b/crates/engine/src/math.rs index 468ce1c5a..e0d520648 100644 --- a/crates/engine/src/math.rs +++ b/crates/engine/src/math.rs @@ -900,7 +900,13 @@ fn layout(f: &Fonts, n: &Node, size: f64) -> MBox { pub fn to_svg(latex: &str, size: f64) -> (Vec, (f64, f64)) { let db = FontDb::global(); let f = Fonts { upright: db.face(FAMILY, "Regular"), italic: db.face(FAMILY, "Italic"), fallback: db.face(FALLBACK, "Regular") }; - let b = layout(&f, &parse(latex), size); + // XML 1.0 forbids most control characters and U+FFFE/U+FFFF; drop them before layout so + // they take no width either. + let latex: String = latex + .chars() + .filter(|c| matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}')) + .collect(); + let b = layout(&f, &parse(&latex), size); let pad = size * 0.1; let (w, h) = (b.w + 2.0 * pad, b.asc + b.desc + 2.0 * pad); let base = pad + b.asc; diff --git a/crates/engine/src/math/tests.rs b/crates/engine/src/math/tests.rs index 9d1e75884..ddc006be6 100644 --- a/crates/engine/src/math/tests.rs +++ b/crates/engine/src/math/tests.rs @@ -474,3 +474,12 @@ fn deep_nodes_drop_when_replaced_or_owned_by_unwinding_continuations() { }); assert!(result.is_err(), "the intentional panic must actually unwind the owned frames"); } + +#[test] +fn svg_drops_control_characters_forbidden_by_xml() { + let (svg, _) = to_svg("a\u{1}b\u{c}", 12.0); + let svg = String::from_utf8(svg).unwrap(); + assert!(!svg.chars().any(|c| c < ' ' && !matches!(c, '\t' | '\n' | '\r')), "control character in SVG: {svg:?}"); + // The dropped characters take no width either. + assert_eq!(to_svg("a\u{1}b\u{c}", 12.0).1, to_svg("ab", 12.0).1); +} diff --git a/crates/engine/src/script.rs b/crates/engine/src/script.rs index 4a66ff87f..bcc4f8fe9 100644 --- a/crates/engine/src/script.rs +++ b/crates/engine/src/script.rs @@ -26,7 +26,8 @@ pub struct Step { pub fn parse(text: &str) -> Result, String> { // Windows tools save "UTF-8 with BOM"; `trim` keeps U+FEFF, which then became part of the // first command id. - let t = text.trim_start_matches('\u{feff}').trim(); + let body = text.trim_start_matches('\u{feff}'); + let t = body.trim(); if t.starts_with('[') { let v: Value = serde_json::from_str(t).map_err(|e| format!("script: {e}"))?; return v @@ -38,7 +39,8 @@ pub fn parse(text: &str) -> Result, String> { .collect(); } let mut out = Vec::new(); - for (n, line) in t.lines().enumerate() { + // Number lines from the untrimmed text so leading blank lines still count. + for (n, line) in body.lines().enumerate() { let l = line.trim(); if l.is_empty() || l.starts_with('#') || l.starts_with("//") { continue; @@ -185,6 +187,12 @@ impl crate::Session { mod tests { use super::*; + #[test] + fn leading_blank_lines_keep_line_numbers() { + let e = parse("\n\n# c\nfile.new\nframe.create {bad").unwrap_err(); + assert!(e.contains("line 5"), "{e}"); + } + #[test] fn parses_all_forms() { let a = parse(r#"[{"command": "file.new"}, {"command": "frame.create", "params": {"rect": [0, 0, 10, 10]}}]"#).unwrap(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6347b605f..c06586c21 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -486,6 +486,63 @@ fn place_gun_click_drag_and_into_frame() { assert_eq!(graphic_xf(&s), placed); } +/// A place-cursor drag draws the frame at the graphic's proportions from where it started, as +/// InDesign does, shows its scale, and places exactly that frame, in any direction. +#[test] +fn place_gun_drag_previews_and_places_a_proportional_frame() { + use designcraft_tools::{Cursor, Mods, Overlay, PointerEvent, PointerKind}; + let mut s = session(); + let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png(); + s.execute("place.load", &json!({"base64": cmd::base64_encode(&png), "name": "a.png"})).unwrap(); + let v = ViewInfo::at_zoom(1.0); + let p = designcraft_geom::Point::new; + assert_eq!(s.cursor(p(300.0, 300.0), Mods::default(), v), Cursor::LoadedGraphic); + // Up and to the left: 80 × 200 holds the 40 × 20 graphic at 200 %, 80 × 40, hanging from the press. + s.pointer(&PointerEvent::new(PointerKind::Down, 300.0, 300.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 220.0, 100.0), v).unwrap(); + assert_eq!(s.cursor(p(220.0, 100.0), Mods::default(), v), Cursor::Crosshair, "the crosshair while drawing"); + let o = s.overlays(v); + assert!(o.iter().any(|o| matches!(o, Overlay::Marquee(r) if *r == designcraft_geom::Rect::new(220.0, 260.0, 300.0, 300.0))), "{o:?}"); + assert!( + o.iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == "X: 200.00% +Y: 200.00%")), + "{o:?}" + ); + s.pointer(&PointerEvent::new(PointerKind::Up, 220.0, 100.0), v).unwrap(); + let it = s.doc().unwrap().doc.spreads[0].items.last().unwrap().clone(); + assert_eq!(it.bounds(), designcraft_geom::Rect::new(220.0, 260.0, 300.0, 300.0), "the frame previewed"); + // 80 × 1 is past the drag tolerance, but its 2 × 1 proportional frame is too small for + // `place.drop`, which places at natural size: the preview shows that frame and 100 %. + s.execute("place.load", &json!({"base64": cmd::base64_encode(&png), "name": "a.png"})).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 400.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 180.0, 401.0), v).unwrap(); + let natural = designcraft_geom::Rect::new(100.0, 400.0, 140.0, 420.0); + let o = s.overlays(v); + assert!(o.iter().any(|o| matches!(o, Overlay::Marquee(r) if *r == natural)), "{o:?}"); + assert!( + o.iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == "X: 100.00% +Y: 100.00%")), + "{o:?}" + ); + s.pointer(&PointerEvent::new(PointerKind::Up, 180.0, 401.0), v).unwrap(); + let it = s.doc().unwrap().doc.spreads[0].items.last().unwrap().clone(); + assert_eq!(it.bounds(), natural, "the frame previewed"); + // A drag under the threshold is a click: no frame drawn, the graphic at actual size where + // the press was. + s.execute("place.load", &json!({"base64": cmd::base64_encode(&png), "name": "a.png"})).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 500.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 102.5, 501.25), v).unwrap(); + assert!(!s.overlays(v).iter().any(|o| matches!(o, Overlay::Marquee(_))), "no frame yet"); + assert_eq!(s.cursor(p(102.5, 501.25), Mods::default(), v), Cursor::LoadedGraphic, "the thumbnail stays"); + s.pointer(&PointerEvent::new(PointerKind::Up, 102.5, 501.25), v).unwrap(); + let it = s.doc().unwrap().doc.spreads[0].items.last().unwrap().clone(); + assert_eq!(it.bounds(), designcraft_geom::Rect::new(100.0, 500.0, 140.0, 520.0), "actual size at the press"); + // With no graphic loaded there's no thumbnail to show: the crosshair, before any press too. + s.loaded = None; + s.set_tool("placeGun"); + assert_eq!(s.cursor(p(300.0, 300.0), Mods::default(), v), Cursor::Crosshair); +} + #[test] fn preflight_reports_overset_and_missing_fonts() { let mut s = session(); @@ -877,6 +934,9 @@ fn custom_wrap_path_follows_resize_and_yields_to_offsets() { s.execute("transform.resize", &json!({"from": [b.x0, b.y0, b.x1, b.y1], "to": to})).unwrap(); let w = wrap_path(&s).unwrap().bounds().unwrap(); assert!((w.width() - 80.0).abs() < 1e-6 && (w.height() - 100.0).abs() < 1e-6, "{w:?}"); + // Offsets that are all null change nothing, so the boundary stays. + s.execute("object.textWrap", &json!({"mode": null, "offset": [null, null, null, null]})).unwrap(); + assert!(wrap_path(&s).is_some(), "an all-null offset keeps the custom boundary"); // An offset rebuilds the wrap from the frame. s.execute("object.textWrap", &json!({"offset": 6})).unwrap(); assert_eq!(wrap_path(&s), None); @@ -1661,6 +1721,295 @@ fn a_line_fits_when_its_baseline_does() { assert!(overset(&mut s, 50.0), "a baseline below the frame still oversets the line"); } +#[test] +fn parent_resize_tool_previews_cancels_commits_and_undoes_on_the_selected_spread() { + use designcraft_tools::{Mods, Overlay, PointerEvent, PointerKind}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width":400,"height":600,"pages":3,"facingPages":true})).unwrap(); + s.execute("layout.parents.new", &json!({"prefix":"B"})).unwrap(); + s.execute("layout.parents.edit", &json!({"on":true})).unwrap(); + s.set_tool("page"); + let v = ViewInfo::at_zoom(1.0); + let before = s.doc().unwrap().doc.clone(); + for si in 0..2 { + for pi in 0..2 { + let sr = designcraft_doc::SpreadRef::Parent(si); + let rect = |s: &Session| s.layout().spread_page_rect(&s.doc().unwrap().doc, sr, pi).unwrap(); + let c = rect(&s).center(); + s.pointer(&PointerEvent::new(PointerKind::Down, c.x, c.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, c.x, c.y), v).unwrap(); + assert_eq!(s.doc().unwrap().selection.parent_page, Some((before.parents[si].id, pi))); + assert_eq!(s.doc().unwrap().selection.page, None); + assert!(s.overlays(v).iter().any(|o| matches!(o,Overlay::PageHandles(r) if *r==rect(&s)))); + for alt in [false, true] { + let original = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + let r = rect(&s); + let p = designcraft_geom::Point::new(r.x1, r.center().y); + let ev = |kind, x| PointerEvent::new(kind, x, p.y).with_mods(Mods { alt, ..Default::default() }); + s.pointer(&ev(PointerKind::Down, p.x), v).unwrap(); + s.pointer(&ev(PointerKind::Drag, p.x + 80.0), v).unwrap(); + assert_eq!(s.doc().unwrap().doc.parents[si].pages[pi].width, 480.0); + assert_eq!(s.doc().unwrap().doc.spreads, before.spreads); + assert_eq!(s.doc().unwrap().doc.parents[1 - si], before.parents[1 - si]); + s.pointer(&ev(PointerKind::Up, p.x + 80.0), v).unwrap(); + if alt { + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + assert_eq!(s.doc().unwrap().doc.parents[si].pages[pi].width, 480.0); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.parents, original.parents); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.parents[si].pages[pi].width, 480.0); + s.execute("edit.undo", &json!({})).unwrap(); + } else { + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &original)); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + } + } + let c = rect(&s).center(); + s.pointer(&PointerEvent::new(PointerKind::DoubleClick, c.x, c.y), v).unwrap(); + let req = std::mem::take(&mut s.ui_requests); + assert!( + matches!(req.last(),Some(crate::UiRequest::Dialog{id,params}) if id=="cmd:layout.parentPageSize" && params["parent"]==before.parents[si].id.0 && params["page"]==pi) + ); + s.doc().unwrap().doc.check().unwrap(); + } + } +} + +/// Reset To Base with a new Based On: a partial rule edit resolves over the new parent, not the +/// old one. +#[test] +fn reset_to_base_with_a_new_parent_resolves_rule_edits_over_it() { + let mut s = session(); + let rule = |weight: f64, offset: f64| json!({"on": true, "weight": weight, "color": "[Black]", "offset": offset}); + s.execute("style.paragraph.create", &json!({"name": "Old", "para": {"ruleBelow": rule(3.0, 1.0)}})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "New", "para": {"ruleBelow": rule(1.0, 9.0)}})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Child", "basedOn": "Old"})).unwrap(); + s.execute("style.paragraph.edit", &json!({"name": "Child", "resetToBase": true, "basedOn": "New", "para": {"ruleBelow": {"weight": 2}}})) + .unwrap(); + let r = s.doc().unwrap().doc.styles.resolve_para_style("Child").0.rule_below; + assert_eq!((r.weight, r.offset), (2.0, 9.0), "the offset is the new parent's"); +} + +/// Paragraph style references are checked: a Based On or Next Style that names no style, or +/// that isn't a name or null, is refused and changes nothing. +#[test] +fn paragraph_style_references_must_name_styles() { + let mut s = session(); + s.execute("style.paragraph.create", &json!({"name": "Body"})).unwrap(); + assert!(s.execute("style.paragraph.create", &json!({"name": "Lost", "nextStyle": "Nowhere"})).is_err()); + assert!(s.doc().unwrap().doc.styles.para("Lost").is_none()); + // On creation too, a reference that isn't a name or null is refused, not dropped. + for p in [json!({"name": "Lost", "nextStyle": true}), json!({"name": "Lost", "basedOn": 3})] { + assert!(s.execute("style.paragraph.create", &p).is_err(), "{p}"); + assert!(s.doc().unwrap().doc.styles.para("Lost").is_none(), "{p}"); + } + assert!(s.execute("style.character.create", &json!({"name": "Lost", "basedOn": ["Body"]})).is_err()); + assert!(s.doc().unwrap().doc.styles.char_style("Lost").is_none()); + s.execute("style.paragraph.create", &json!({"name": "Self", "nextStyle": "Self"})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + for p in [json!({"name": "Body", "basedOn": "Nowhere"}), json!({"name": "Body", "basedOn": 3}), json!({"name": "Body", "nextStyle": true})] { + assert!(s.execute("style.paragraph.edit", &p).is_err(), "{p}"); + } + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "nothing changed"); + s.execute("style.paragraph.edit", &json!({"name": "Body", "basedOn": null, "nextStyle": "Self"})).unwrap(); +} + +/// The course's chapter 43: with Direct Selection, a circle's selected anchor shows its handles +/// and they drag, reshaping the curve; the handles of the anchors away from it neither show nor +/// catch a press, as in InDesign. +#[test] +fn direct_selection_drags_only_the_handles_around_the_selected_anchor() { + use designcraft_tools::{PointerEvent, PointerKind}; + let mut s = session(); + let v = ViewInfo::at_zoom(1.0); + let id = s.execute("frame.create", &json!({"rect": [100, 300, 300, 500], "shape": "ellipse"})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [id]})).unwrap(); + s.set_tool("directSelection"); + // Each anchor and its two handles, in the spread. + let anchors = |s: &Session| { + let st = s.doc().unwrap(); + let it = st.doc.item(designcraft_doc::ItemId(id)).unwrap(); + it.path.subpaths[0].anchors.iter().map(|a| (it.xf * a.p, it.xf * a.h_in, it.xf * a.h_out)).collect::>() + }; + let before = anchors(&s); + let lowest = |f: &dyn Fn(f64) -> f64| (0..before.len()).min_by(|a, b| f(before[*a].0.y).total_cmp(&f(before[*b].0.y))).unwrap(); + let (bottom, top) = (lowest(&|y| -y), lowest(&|y| y)); + let press = |s: &mut Session, from: designcraft_geom::Point, to: designcraft_geom::Point| { + s.pointer(&PointerEvent::new(PointerKind::Down, from.x, from.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, (from.x + to.x) / 2.0, (from.y + to.y) / 2.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, to.x, to.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, to.x, to.y), v).unwrap(); + }; + // Select the bottom anchor. + let b = before[bottom].0; + press(&mut s, b, b); + assert_eq!(s.doc().unwrap().selection.anchors, [(designcraft_doc::ItemId(id), 0, bottom)]); + // Its handle drags: the handle moves. + let h = before[bottom].2; + press(&mut s, h, h + designcraft_geom::Vec2::new(30.0, 0.0)); + let after = anchors(&s); + assert!((after[bottom].2 - before[bottom].2).hypot() > 20.0, "the bottom handle moved: {:?} → {:?}", before[bottom], after[bottom]); + // The top anchor's handle isn't shown, so a press on it doesn't take it. + let t = before[top].2; + press(&mut s, t, t + designcraft_geom::Vec2::new(0.0, -30.0)); + let after2 = anchors(&s); + assert!((after2[top].2 - before[top].2).hypot() < 1e-6, "the top handle stays: {:?} → {:?}", before[top], after2[top]); +} + +/// Direct Selection: a marquee over the top edges of two frames selects their top points, and a +/// drag on one of them slants both frames in one undo step (InDesign's way to make a row of +/// parallelograms). +#[test] +fn direct_selection_moves_the_points_of_several_frames_at_once() { + use designcraft_tools::{Mods, PointerEvent, PointerKind}; + let mut s = session(); + let v = ViewInfo::at_zoom(1.0); + let a = s.execute("frame.create", &json!({"rect": [100, 300, 200, 400]})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("frame.create", &json!({"rect": [200, 300, 300, 400]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": []})).unwrap(); + s.set_tool("directSelection"); + s.pointer(&PointerEvent::new(PointerKind::Down, 80.0, 280.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 320.0, 320.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 320.0, 320.0), v).unwrap(); + let sel = s.doc().unwrap().selection.clone(); + assert_eq!(sel.anchors.len(), 4, "{:?}", sel.anchors); + assert!(sel.items.contains(&designcraft_doc::ItemId(a)) && sel.items.contains(&designcraft_doc::ItemId(b))); + let shift = Mods { shift: true, ..Default::default() }; + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 300.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 136.0, 302.0).with_mods(shift), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 136.0, 302.0).with_mods(shift), v).unwrap(); + let corners = |s: &Session, id: u64| -> Vec<(f64, f64)> { + let st = s.doc().unwrap(); + let it = st.doc.item(designcraft_doc::ItemId(id)).unwrap(); + it.path.subpaths[0].anchors.iter().map(|an| ((it.xf * an.p).x.round(), (it.xf * an.p).y.round())).collect() + }; + // Both frames: top points 36 pt to the right, bottom points where they were. + assert_eq!(corners(&s, a), vec![(136.0, 300.0), (236.0, 300.0), (200.0, 400.0), (100.0, 400.0)]); + assert_eq!(corners(&s, b), vec![(236.0, 300.0), (336.0, 300.0), (300.0, 400.0), (200.0, 400.0)]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(corners(&s, a), vec![(100.0, 300.0), (200.0, 300.0), (200.0, 400.0), (100.0, 400.0)]); + assert_eq!(corners(&s, b), vec![(200.0, 300.0), (300.0, 300.0), (300.0, 400.0), (200.0, 400.0)]); +} + +#[test] +fn selected_points_follow_their_path() { + let mut s = session(); + let a = s.execute("frame.create", &json!({"rect": [100, 300, 200, 400]})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("frame.create", &json!({"rect": [200, 300, 300, 400]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"anchors": [[a, 0, 1], [b, 0, 2]]})).unwrap(); + // Shift-clicking a frame off the selection drops its points too. + s.execute("selection.toggle", &json!({"id": a})).unwrap(); + let id = |n| designcraft_doc::ItemId(n); + assert_eq!(s.doc().unwrap().selection.anchors, vec![(id(b), 0, 2)]); + // A point added before the first one: the selected point keeps pointing at the same anchor. + let l = s.execute("path.create", &json!({"anchors": [{"p": [100, 100]}, {"p": [200, 100]}, {"p": [300, 150]}]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"anchors": [[l, 0, 1]]})).unwrap(); + s.execute("path.appendAnchor", &json!({"id": l, "anchor": {"p": [50, 120]}, "at": "start"})).unwrap(); + let st = s.doc().unwrap(); + let (_, si, ai) = st.selection.anchors[0]; + let it = st.doc.item(id(l)).unwrap(); + assert_eq!((it.xf * it.path.subpaths[si].anchors[ai].p).round(), designcraft_geom::Point::new(200.0, 100.0)); + // `points` that isn't a list is refused, even with an `id` beside it. + for bad in [json!("x"), json!({"0": 1}), json!(5)] { + assert!(s.execute("path.moveAnchors", &json!({"points": bad, "id": l, "anchors": [[0, 0]], "dx": 5})).is_err(), "points {bad}"); + } + for bad in [json!(3), json!(-1), json!("0")] { + assert!(s.execute("path.appendAnchor", &json!({"id": l, "anchor": {"p": [0, 0]}, "subpath": bad})).is_err(), "subpath {bad}"); + } + assert_eq!(s.doc().unwrap().doc.item(id(l)).unwrap().path.subpaths[0].anchors.len(), 4, "nothing appended"); +} + +#[test] +fn direct_selection_pulls_one_corner_of_an_unselected_frame() { + // Chris Nielsen's "The Adobe InDesign Toolbox" (10:10): with the White Arrow, click outside, + // then press on a frame's corner and pull it: only that corner moves (a triangle, nearly). + use designcraft_tools::{PointerEvent, PointerKind, SnapView}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 612, "height": 792, "facingPages": false})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [156, 219, 455, 569], "content": "graphic"})).unwrap()["id"].as_u64().unwrap(); + s.execute("edit.deselectAll", &json!({})).unwrap(); + let corners = |s: &Session| { + let d = &s.doc().unwrap().doc; + let it = d.item(designcraft_doc::ItemId(id)).unwrap(); + it.path.subpaths[0].anchors.iter().map(|a| it.xf * a.p).collect::>() + }; + let before = corners(&s); + s.set_tool("directSelection"); + let v = ViewInfo { snap: SnapView::OFF, ..ViewInfo::at_zoom(1.0) }; + let layout = s.layout(); + let at = |x: f64, y: f64| layout.to_canvas(designcraft_doc::SpreadRef::Doc(0), designcraft_geom::Point::new(x, y)); + let (a, b) = (at(455.0, 219.0), at(455.0, 150.0)); + s.pointer(&PointerEvent::new(PointerKind::Down, a.x, a.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, a.x, a.y - 30.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, b.x, b.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, b.x, b.y), v).unwrap(); + let after = corners(&s); + let moved: Vec<_> = before.iter().zip(&after).filter(|(p, q)| (**p - **q).hypot() > 1e-6).collect(); + assert_eq!(moved.len(), 1, "one corner moved: {before:?} → {after:?}"); + assert!((*moved[0].1 - designcraft_geom::Point::new(455.0, 150.0)).hypot() < 1e-6, "{after:?}"); + assert_eq!(s.doc().unwrap().selection.items, [designcraft_doc::ItemId(id)], "the frame is selected"); + // One undo step puts the corner back. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(corners(&s), before); + // Away from its corners, the White Arrow still drags the whole unselected frame. + s.execute("edit.deselectAll", &json!({})).unwrap(); + let (c, d) = (at(300.0, 219.0), at(300.0, 269.0)); + s.pointer(&PointerEvent::new(PointerKind::Down, c.x, c.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, c.x, c.y + 20.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, d.x, d.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, d.x, d.y), v).unwrap(); + assert!(corners(&s).iter().zip(&before).all(|(p, q)| (p.y - q.y - 50.0).abs() < 1e-6), "{:?}", corners(&s)); +} + +#[test] +fn direct_selection_on_a_selected_frame_deletes_only_its_image() { + use designcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; + let dir = tempfile::tempdir().unwrap(); + let png = dir.path().join("photo.png"); + std::fs::write(&png, designcraft_render::Rendered { width: 100, height: 100, pixels: vec![40u8; 100 * 100 * 4] }.to_png()).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let frame = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + s.execute("file.place", &json!({"path": png.to_string_lossy(), "frame": frame})).unwrap(); + s.execute("selection.set", &json!({"ids": [frame], "content": false})).unwrap(); + let v = ViewInfo::at_zoom(1.0); + let click = |s: &mut Session| { + s.pointer(&PointerEvent::new(PointerKind::Down, 110.0, 150.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 110.0, 150.0), v).unwrap(); + }; + // The frame is selected with the Selection tool, then the image is clicked with Direct Selection. + s.set_tool("selection"); + click(&mut s); + assert!(!s.doc().unwrap().selection.content); + s.set_tool("directSelection"); + click(&mut s); + assert!(s.doc().unwrap().selection.content, "Direct Selection selects the image inside the selected frame"); + assert!(s.tool_key(ToolKey::Delete, Mods::default(), v).unwrap()); + let st = s.doc().unwrap(); + let it = st.doc.item(designcraft_doc::ItemId(frame)).expect("the frame stays"); + assert!(matches!(it.content, designcraft_doc::Content::Unassigned), "{:?}", it.content); + assert_eq!(st.selection.items, vec![designcraft_doc::ItemId(frame)]); + // A frame holding pasted-in items: the click selects them too, so arrow keys move them. + let inner = s.execute("frame.create", &json!({"rect": [320, 120, 360, 160]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [inner]})).unwrap(); + s.execute("edit.cut", &json!({})).unwrap(); + let holder = s.execute("frame.create", &json!({"rect": [300, 100, 400, 200]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [holder]})).unwrap(); + s.execute("edit.pasteInto", &json!({})).unwrap(); + s.set_tool("selection"); + s.pointer(&PointerEvent::new(PointerKind::Down, 340.0, 140.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 340.0, 140.0), v).unwrap(); + assert_eq!(s.doc().unwrap().selection.items, vec![designcraft_doc::ItemId(holder)]); + assert!(!s.doc().unwrap().selection.content); + s.set_tool("directSelection"); + s.pointer(&PointerEvent::new(PointerKind::Down, 340.0, 140.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 340.0, 140.0), v).unwrap(); + let st = s.doc().unwrap(); + assert!(st.selection.content && st.selection.items == [designcraft_doc::ItemId(holder)], "the pasted-in items are selected: {:?}", st.selection); +} + /// Issue #1637: with several objects selected, a click on one of them (no drag) makes it the key /// object, and Align to Key Object leaves it in place. A second click on it drops the key. #[test] diff --git a/crates/engine/src/tests/idml.rs b/crates/engine/src/tests/idml.rs index e8766d091..409f6bdf8 100644 --- a/crates/engine/src/tests/idml.rs +++ b/crates/engine/src/tests/idml.rs @@ -516,6 +516,7 @@ fn type_para_sets_a_drop_cap_that_survives_idml() { assert_eq!(p.nested_styles, Some(vec![designcraft_doc::NestedStyle::drop_cap("Initial")])); } +/// Imported TOC definitions retain their settings and remain refreshable. #[test] fn table_of_contents_definition_round_trips_through_idml_and_updates() { let mut s = Session::new(); @@ -525,8 +526,8 @@ fn table_of_contents_definition_round_trips_through_idml_and_updates() { let basic = designcraft_doc::story::BASIC_PARAGRAPH; s.execute( "toc.generate", - &json!({"page": page, "title": "Inside", "titleStyle": basic, "entries": [ - {"style": "Cover Title", "level": 1, "pageNumber": "before", "separator": " ^ "}, + &json!({"page": page, "title": "Inside", "titleStyle": basic, "createBookmarks": true, "entries": [ + {"style": "Cover Title", "level": 1, "pageNumber": "before", "separator": " ^ \u{E006} "}, {"style": "Headline", "level": 2, "entryStyle": basic, "pageNumber": "none"}, ]}), ) @@ -540,6 +541,7 @@ fn table_of_contents_definition_round_trips_through_idml_and_updates() { let b64 = s.execute("file.exportIdml", &json!({})).unwrap()["base64"].as_str().unwrap().to_string(); s.execute("file.openIdml", &json!({"base64": b64, "name": "toc.idml"})).unwrap(); let after = s.doc().unwrap().doc.toc.clone().expect("the TOC definition is imported"); + assert!(after.create_bookmarks); assert_eq!((&after.title, &after.title_style, &after.entries), (&before.title, &before.title_style, &before.entries)); assert_eq!(toc_text(&s), text); s.execute("toc.update", &json!({})).unwrap(); diff --git a/crates/engine/src/tests/table.rs b/crates/engine/src/tests/table.rs index ecf0b9bbe..77bcb9bfd 100644 --- a/crates/engine/src/tests/table.rs +++ b/crates/engine/src/tests/table.rs @@ -1,6 +1,7 @@ -use serde_json::json; +use serde_json::{Value, json}; use super::{Session, doc}; +use crate::Result; fn session_with_frame() -> (Session, u64, u64) { let mut s = Session::new(); @@ -127,6 +128,72 @@ fn table_edit_after_anchor_removal_floors_parent_caret() { } } +/// Footnote text is addressed as a cell, but the table commands don't take it for one. +#[test] +fn the_cell_commands_refuse_footnote_text() { + let (mut s, sid, _) = session_with_frame(); + s.execute("story.setText", &json!({"story": sid, "text": "Hello"})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 5, "focus": 5})).unwrap(); + s.execute("footnote.insert", &json!({"text": "Note"})).unwrap(); + let before = s.doc().unwrap().selection.clone(); + assert!(before.text.and_then(|t| t.cell).and_then(|c| c.footnote_id()).is_some(), "the caret is in the footnote"); + for cmd in ["table.toggleSelection", "table.prevCell", "table.nextCell"] { + assert!(s.execute(cmd, &json!({})).is_err(), "{cmd}"); + } + assert_eq!(s.doc().unwrap().selection, before); + // A Tab there is typed into the footnote, not taken for the next cell. + s.execute("text.insert", &json!({"text": "\t"})).unwrap(); + let notes = s.execute("footnote.list", &json!({})).unwrap(); + assert!(notes[0]["text"].as_str().is_some_and(|t| t.contains('\t')), "{notes}"); +} + +/// Esc in a table toggles between the text and the cell (InDesign's Esc): the caret in a cell +/// selects that cell, and selected cells select the first one's text; Shift+Tab goes back a cell. +#[test] +fn escape_toggles_text_and_cell_selection() { + let (mut s, sid, _) = session_with_frame(); + let tid = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + s.execute("table.nextCell", &json!({})).unwrap(); + s.execute("text.insert", &json!({"text": "Phone"})).unwrap(); + let cell = designcraft_doc::CellAddr { table: tid, row: 0, col: 1 }; + assert_eq!(s.execute("table.toggleSelection", &json!({})).unwrap(), json!({"cells": true})); + let sel = s.doc().unwrap().selection.clone(); + let cells = sel.cells.unwrap(); + assert_eq!((cells.story.0, cells.table, cells.range), (sid, tid, designcraft_doc::CellRange::cell(0, 1))); + assert_eq!(sel.text.unwrap().cell, Some(cell)); + assert_eq!(s.execute("table.toggleSelection", &json!({})).unwrap(), json!({"cells": false})); + let sel = s.doc().unwrap().selection.clone(); + assert!(sel.cells.is_none()); + let t = sel.text.unwrap(); + assert_eq!((t.cell, t.range()), (Some(cell), 0..5), "the cell's text is selected"); + s.execute("table.prevCell", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().selection.text.unwrap().cell, Some(designcraft_doc::CellAddr { col: 0, ..cell })); + // With the cell selected, typing replaces its text and leaves the caret after it. + s.execute("table.nextCell", &json!({})).unwrap(); + s.execute("table.toggleSelection", &json!({})).unwrap(); + s.execute("text.insert", &json!({"text": "Fax"})).unwrap(); + let t = s.execute("table.get", &json!({"table": tid})).unwrap(); + assert_eq!(t["cells"][1]["text"], "Fax"); + let sel = s.doc().unwrap().selection.clone(); + assert!(sel.cells.is_none()); + assert_eq!(sel.text.unwrap().range(), 3..3); + // Moving the caret leaves the cell selection: typing inserts at the caret, not over the cell. + s.execute("table.toggleSelection", &json!({})).unwrap(); + s.execute("text.move", &json!({"dir": "right"})).unwrap(); + assert!(s.doc().unwrap().selection.cells.is_none()); + s.execute("text.insert", &json!({"text": "!"})).unwrap(); + assert_eq!(s.execute("table.get", &json!({"table": tid})).unwrap()["cells"][1]["text"], "F!ax"); + // A selection that starts inside a merged cell goes back to the merged cell's text. + s.execute("table.select", &json!({"rows": [0, 1], "cols": [0, 0]})).unwrap(); + s.execute("table.merge", &json!({})).unwrap(); + s.execute("table.select", &json!({"rows": [1, 1], "cols": [0, 1]})).unwrap(); + s.execute("table.toggleSelection", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().selection.text.unwrap().cell, Some(designcraft_doc::CellAddr { row: 0, col: 0, ..cell })); + // Outside a table there is nothing to toggle. + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + assert!(s.execute("table.toggleSelection", &json!({})).is_err()); +} + fn selected_merged_table(column: bool) -> (Session, u64, u64, u64) { let (mut s, sid, fid) = session_with_frame(); let tid = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap(); @@ -426,6 +493,29 @@ fn rows_columns_merge_and_options() { assert_eq!(st.doc.story(sid).unwrap().text, "Intro"); } +/// `table.insert` styles the table in the same step; a style that isn't there, or isn't a name, +/// leaves the story alone. Its dimensions stop at the documented limits. +#[test] +fn insert_table_with_a_style_is_one_step() { + let (mut s, sid, _) = session_with_frame(); + s.execute("style.table.create", &json!({"name": "Grid"})).unwrap(); + for bad in [json!("Missing"), json!(7)] { + assert!(s.execute("table.insert", &json!({"tableStyle": bad})).is_err(), "{bad}"); + } + assert!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().tables.is_empty()); + for over in [json!({"rows": 501}), json!({"cols": 201}), json!({"headerRows": 101})] { + assert!(s.execute("table.insert", &over).is_err(), "{over}"); + } + let r = s.execute("table.insert", &json!({"rows": 500, "cols": 200, "headerRows": 100, "tableStyle": "Grid"})).unwrap(); + let t = s.execute("table.get", &json!({"table": r["table"]})).unwrap(); + assert_eq!(t["style"], "Grid"); + let n = |k: &str| t[k].as_array().unwrap().len() as u64; + use crate::cmd::table::{MAX_BODY_ROWS, MAX_COLUMNS, MAX_HEADER_FOOTER_ROWS}; + assert_eq!((n("rows"), n("columns")), (MAX_BODY_ROWS + MAX_HEADER_FOOTER_ROWS, MAX_COLUMNS)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().tables.is_empty(), "one undo"); +} + #[test] fn convert_text_to_table_and_back() { let (mut s, sid, _) = session_with_frame(); @@ -444,6 +534,74 @@ fn convert_text_to_table_and_back() { assert_eq!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text, "Ink\tCoverage\nPlum\t80%\nSunset\t45%\nAfter"); } +/// InDesign's Convert Text to Table and Convert Table to Text separators: tab, comma, paragraph or +/// any characters; with one separator for both, rows of Number of Columns cells; a Table Style. +#[test] +fn convert_text_to_table_with_indesigns_separators() { + let (mut s, sid, _) = session_with_frame(); + let convert = |s: &mut Session, text: &str, p: Value| -> Result { + s.execute("story.setText", &json!({"story": sid, "text": format!("{text}\nAfter")})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": text.len()})).unwrap(); + s.execute("table.convertFromText", &p) + }; + let cells = |s: &mut Session, r: &Value| -> (usize, Vec) { + let t = s.execute("table.get", &json!({"table": r["table"]})).unwrap(); + let texts = t["cells"].as_array().unwrap().iter().map(|c| c["text"].as_str().unwrap().to_string()).collect(); + (t["columns"].as_array().unwrap().len(), texts) + }; + // Comma-separated columns, paragraphs for rows. + let r = convert(&mut s, "Ink, Coverage\nPlum, 80%", json!({"columnSeparator": "comma"})).unwrap(); + assert_eq!(cells(&mut s, &r), (2, vec!["Ink".into(), "Coverage".into(), "Plum".into(), "80%".into()])); + check(&s); + // The lesson's list: one paragraph per cell; with two columns, two cells a row. + let list = "Name:\nAddress:\nCity:\nState:"; + let r = convert(&mut s, list, json!({"columnSeparator": "paragraph", "rowSeparator": "paragraph", "columns": 2})).unwrap(); + assert_eq!(r["rows"], 2); + assert_eq!(cells(&mut s, &r).0, 2); + let r = convert(&mut s, list, json!({"columnSeparator": "tab"})).unwrap(); + assert_eq!((r["rows"].as_u64(), cells(&mut s, &r).0), (Some(4), 1), "a column of paragraphs"); + // Any characters separate: here " | " between columns and ";" between rows. + let r = convert(&mut s, "a | b;c | d", json!({"columnSeparator": " | ", "rowSeparator": ";"})).unwrap(); + assert_eq!(cells(&mut s, &r), (2, vec!["a".into(), "b".into(), "c".into(), "d".into()])); + // A table style is applied to the new table. + s.execute("style.table.create", &json!({"name": "Grid"})).unwrap(); + let r = convert(&mut s, "x\ty", json!({"tableStyle": "Grid"})).unwrap(); + assert_eq!(s.execute("table.get", &json!({"table": r["table"]})).unwrap()["style"], "Grid"); + // A short last row: its padding cell takes the text's format too. + let r = convert(&mut s, "a\nb\nc", json!({"columnSeparator": "paragraph", "rowSeparator": "paragraph", "columns": 2})).unwrap(); + let t = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().tables[&r["table"].as_u64().unwrap()].clone(); + let fmt = |c: usize| t.cell(1, c).unwrap().text.chars[0].format.clone(); + assert_eq!(fmt(1), fmt(0), "the padding cell's format is the text's"); + for bad in [ + json!({"columnSeparator": ""}), + json!({"rowSeparator": 3}), + json!({"columns": 0}), + json!({"columns": crate::cmd::table::MAX_COLUMNS + 1}), + json!({"tableStyle": "Missing"}), + json!({"tableStyle": 123}), + ] { + assert!(convert(&mut s, "p\tq", bad.clone()).is_err(), "{bad}"); + } + // Text that would make too wide or too big a table is refused, and stays as it was. + use crate::cmd::table::{MAX_COLUMNS, MAX_CONVERTED_CELLS}; + let wide = vec!["x"; MAX_COLUMNS as usize + 1].join("\t"); + assert!(convert(&mut s, &wide, json!({})).is_err(), "a row wider than a table can be"); + let long = vec!["a\tb"; MAX_CONVERTED_CELLS as usize / 2 + 1].join("\n"); + assert!(convert(&mut s, &long, json!({})).is_err(), "more cells than a table can have"); + assert_eq!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text, format!("{long}\nAfter")); + let tall = vec!["a"; designcraft_doc::MAX_TABLE_ROWS + 1].join("\n"); + assert!(convert(&mut s, &tall, json!({})).is_err(), "more rows than a table can have"); + assert_eq!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text, format!("{tall}\nAfter"), "no text is dropped"); + let at_most = vec!["x"; MAX_COLUMNS as usize].join("\t"); + assert!(convert(&mut s, &at_most, json!({})).is_ok(), "a row as wide as a table can be"); + check(&s); + // Back to text with a comma between columns and a tab between rows. + let r = convert(&mut s, "Plum\t80%\nSunset\t45%", json!({})).unwrap(); + s.execute("table.convertToText", &json!({"table": r["table"], "columnSeparator": "comma", "rowSeparator": "tab"})).unwrap(); + check(&s); + assert_eq!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text, "Plum,80%\tSunset,45%\nAfter"); +} + #[test] fn caret_placement_in_cells_and_cell_formatting() { let (mut s, sid, fid) = session_with_frame(); diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index c982cade7..2badc3a17 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -110,6 +110,7 @@ impl Session { snap: view.snap, unit: view.unit, stroke_in_front: view.stroke_in_front, + loaded: self.loaded.map(|l| l.size), }; Some(f(self, &cx)) } diff --git a/crates/engine/tests/it/insert_pages_bounds.rs b/crates/engine/tests/it/insert_pages_bounds.rs index e96a6cc4b..5f8856246 100644 --- a/crates/engine/tests/it/insert_pages_bounds.rs +++ b/crates/engine/tests/it/insert_pages_bounds.rs @@ -1,4 +1,5 @@ -//! `layout.pages.insert` clamps an out-of-range `after` to the end instead of overflowing. +//! `layout.pages.insert` clamps an out-of-range `after` to the end instead of overflowing; a +//! `before` must name a page, and one that doesn't is refused without a change. use designcraft_engine::Session; use serde_json::json; @@ -14,3 +15,13 @@ fn insert_after_u64_max_appends_at_the_end() { assert_eq!(after[..2], before[..], "the original pages must stay first; new pages go at the end"); assert!(after[2..].iter().all(|id| !before.contains(id))); } + +#[test] +fn insert_before_u64_max_is_refused_without_overflow() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages":2})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + assert!(s.execute("layout.pages.insert", &json!({"before": u64::MAX, "count": 2})).is_err()); + assert_eq!(s.doc().unwrap().doc.page_count(), 2, "no pages added"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step"); +} diff --git a/crates/epub/src/css.rs b/crates/epub/src/css.rs index 526b10a40..f066d6b3d 100644 --- a/crates/epub/src/css.rs +++ b/crates/epub/src/css.rs @@ -40,23 +40,7 @@ pub(crate) fn char_css(doc: &Document, c: &CharProps, base_size: f64, generics: let (weight, style) = css_weight_style(&c.font_style); let _ = write!(out, "font-weight: {weight}; font-style: {style}; "); let _ = write!(out, "font-size: {:.3}em; ", c.size / base_size); - if c.tracking != 0.0 { - let _ = write!(out, "letter-spacing: {:.3}em; ", c.tracking / 1000.0); - } - match c.capitalization { - designcraft_doc::Capitalization::AllCaps => out.push_str("text-transform: uppercase; "), - designcraft_doc::Capitalization::SmallCaps | designcraft_doc::Capitalization::OpenTypeAllSmallCaps => { - out.push_str("font-variant: small-caps; ") - } - _ => {} - } - // One declaration: a later `text-decoration` would replace the earlier one. - match (c.underline, c.strikethrough) { - (true, true) => out.push_str("text-decoration: underline line-through; "), - (true, false) => out.push_str("text-decoration: underline; "), - (false, true) => out.push_str("text-decoration: line-through; "), - (false, false) => {} - } + decor_css(c.tracking, c.capitalization, c.underline, c.strikethrough, out); if let Some(h) = hex(doc, &c.fill, c.fill_tint) { let _ = write!(out, "color: {h}; "); } @@ -150,6 +134,7 @@ pub(crate) fn stylesheet_with(doc: &Document, generics: &BTreeMap String { +/// Tracking, capitalization and line decorations of a style or override, as CSS declarations. +fn attrs_decor_css(c: &CharAttrs, out: &mut String) { + decor_css(c.tracking.unwrap_or(0.0), c.capitalization.unwrap_or_default(), c.underline == Some(true), c.strikethrough == Some(true), out); +} + +/// Tracking, capitalization and line decorations as CSS declarations; defaults write nothing. +fn decor_css(tracking: f64, caps: designcraft_doc::Capitalization, underline: bool, strikethrough: bool, out: &mut String) { + if tracking != 0.0 { + let _ = write!(out, "letter-spacing: {:.3}em; ", tracking / 1000.0); + } + match caps { + designcraft_doc::Capitalization::AllCaps => out.push_str("text-transform: uppercase; "), + designcraft_doc::Capitalization::SmallCaps | designcraft_doc::Capitalization::OpenTypeAllSmallCaps => { + out.push_str("font-variant: small-caps; ") + } + _ => {} + } + // One declaration: a later `text-decoration` would replace the earlier one. + match (underline, strikethrough) { + (true, true) => out.push_str("text-decoration: underline line-through; "), + (true, false) => out.push_str("text-decoration: underline; "), + (false, true) => out.push_str("text-decoration: line-through; "), + (false, false) => {} + } +} + +/// Inline CSS for the local override `o` of a run in character style `style`. Tracking, caps and +/// line decorations are resolved against the style (and its Based On chain), so an override that +/// sets only one of underline/strikethrough keeps the other, and one that clears a value the +/// style sets writes the reset. +pub(crate) fn override_css(doc: &Document, style: &str, o: &CharAttrs) -> String { let mut s = String::new(); if let Some(f) = &o.font_style { let f = f.to_lowercase(); @@ -174,11 +189,23 @@ pub(crate) fn override_css(doc: &Document, o: &CharAttrs) -> String { { let _ = write!(s, "color: {h}; "); } - if o.underline == Some(true) { - s.push_str("text-decoration: underline; "); + let base = doc.styles.char_style_attrs(style); + let mut merged = base.clone(); + merged.merge(o); + let tracking = if o.tracking.is_some() { merged.tracking.unwrap_or(0.0) } else { 0.0 }; + let caps = if o.capitalization.is_some() { merged.capitalization.unwrap_or_default() } else { Default::default() }; + let lines = o.underline.is_some() || o.strikethrough.is_some(); + let (underline, strikethrough) = (lines && merged.underline == Some(true), lines && merged.strikethrough == Some(true)); + if o.tracking.is_some() && tracking == 0.0 && base.tracking.is_some_and(|t| t != 0.0) { + s.push_str("letter-spacing: 0em; "); } - if o.capitalization == Some(designcraft_doc::Capitalization::AllCaps) { - s.push_str("text-transform: uppercase; "); + // The class rule's other caps property would still apply, so clear both before the new mode. + if o.capitalization.is_some() && caps != base.capitalization.unwrap_or_default() && base.capitalization.is_some_and(|c| c != Default::default()) { + s.push_str("text-transform: none; font-variant: normal; "); + } + decor_css(tracking, caps, underline, strikethrough, &mut s); + if lines && !underline && !strikethrough && (base.underline == Some(true) || base.strikethrough == Some(true)) { + s.push_str("text-decoration: none; "); } s.trim_end().to_string() } diff --git a/crates/epub/src/html.rs b/crates/epub/src/html.rs index ce9d7176c..7f7fbe26f 100644 --- a/crates/epub/src/html.rs +++ b/crates/epub/src/html.rs @@ -45,13 +45,13 @@ pub fn reading_order(doc: &Document) -> Vec { continue; }; let xf = *parent * it.xf; + if it.hidden { + continue; + } let children = it.children(); if !children.is_empty() { stack.push((children.iter(), xf)); } - if it.hidden { - continue; - } let block = match &it.content { Content::Text(tf) => { let Some(story) = doc.story(tf.story) else { continue }; diff --git a/crates/epub/src/story_writer.rs b/crates/epub/src/story_writer.rs index aed443d0b..40c95f135 100644 --- a/crates/epub/src/story_writer.rs +++ b/crates/epub/src/story_writer.rs @@ -223,7 +223,7 @@ impl<'a, 'f> StoryWriter<'a, 'f> { } else { ("span", String::new()) }; - let style = override_css(doc, &f.over); + let style = override_css(doc, &f.style, &f.over); let style_attr = if style.is_empty() { String::new() } else { format!(" style=\"{}\"", xml_attr(&style)) }; if cls.is_empty() && style_attr.is_empty() { out.push_str(&t); diff --git a/crates/epub/src/tests.rs b/crates/epub/src/tests.rs index f216fe95f..b3865dfc1 100644 --- a/crates/epub/src/tests.rs +++ b/crates/epub/src/tests.rs @@ -922,3 +922,76 @@ fn char_css_joins_underline_and_strikethrough_in_one_declaration() { assert!(css(false, true).contains("text-decoration: line-through;")); assert!(!css(false, false).contains("text-decoration")); } + +#[test] +fn character_styles_keep_decorations_caps_and_tracking() { + let mut d = Document::new(&NewDocument::default()); + d.styles_mut().character.push(designcraft_doc::CharacterStyle { + name: "Under".into(), + based_on: None, + chars: CharAttrs { + underline: Some(true), + strikethrough: Some(true), + capitalization: Some(designcraft_doc::Capitalization::AllCaps), + tracking: Some(100.0), + ..Default::default() + }, + shortcut: String::new(), + }); + let css = css::stylesheet(&d); + assert!(css.contains("text-decoration: underline line-through;"), "{css}"); + assert!(css.contains("text-transform: uppercase;"), "{css}"); + assert!(css.contains("letter-spacing: 0.100em;"), "{css}"); + let o = CharAttrs { strikethrough: Some(true), ..Default::default() }; + assert_eq!(css::override_css(&d, story::NO_CHAR_STYLE, &o), "text-decoration: line-through;"); +} + +fn char_style(name: &str, based_on: Option<&str>, chars: CharAttrs) -> designcraft_doc::CharacterStyle { + designcraft_doc::CharacterStyle { name: name.into(), based_on: based_on.map(Into::into), chars, shortcut: String::new() } +} + +#[test] +fn override_keeps_style_decorations_and_resets_tracking_and_caps() { + use designcraft_doc::Capitalization; + let mut d = Document::new(&NewDocument::default()); + let styles = d.styles_mut(); + styles.character.push(char_style( + "Base", + None, + CharAttrs { underline: Some(true), tracking: Some(100.0), capitalization: Some(Capitalization::AllCaps), ..Default::default() }, + )); + styles.character.push(char_style("Child", Some("Base"), CharAttrs::default())); + let strike = CharAttrs { strikethrough: Some(true), ..Default::default() }; + for style in ["Base", "Child"] { + assert_eq!(css::override_css(&d, style, &strike), "text-decoration: underline line-through;", "{style}"); + } + let off = CharAttrs { underline: Some(false), ..Default::default() }; + assert_eq!(css::override_css(&d, "Child", &off), "text-decoration: none;"); + let reset = CharAttrs { tracking: Some(0.0), capitalization: Some(Capitalization::Normal), ..Default::default() }; + assert_eq!(css::override_css(&d, "Child", &reset), "letter-spacing: 0em; text-transform: none; font-variant: normal;"); + assert_eq!(css::override_css(&d, story::NO_CHAR_STYLE, &reset), ""); + let styles = d.styles_mut(); + styles.character.push(char_style("Small", None, CharAttrs { capitalization: Some(Capitalization::SmallCaps), ..Default::default() })); + let small = CharAttrs { capitalization: Some(Capitalization::SmallCaps), ..Default::default() }; + let all = CharAttrs { capitalization: Some(Capitalization::AllCaps), ..Default::default() }; + assert_eq!(css::override_css(&d, "Base", &small), "text-transform: none; font-variant: normal; font-variant: small-caps;"); + assert_eq!(css::override_css(&d, "Small", &all), "text-transform: none; font-variant: normal; text-transform: uppercase;"); +} + +fn grouped_text(hidden: bool) -> Document { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Grouped text", ParaFormat::default()).unwrap(); + let child = Arc::make_mut(&mut d.spreads[0]).items.pop().unwrap(); + let mut g = Item::new(ItemId(d.alloc()), lid, Shape::Group, Default::default()); + g.content = Content::Group { items: vec![child] }; + g.hidden = hidden; + Arc::make_mut(&mut d.spreads[0]).items.push(Arc::new(g)); + d +} + +#[test] +fn hidden_group_children_are_not_exported() { + assert!(html::reading_order(&grouped_text(true)).is_empty()); + assert_eq!(html::reading_order(&grouped_text(false)).len(), 1); +} diff --git a/crates/fonts/src/fontdb/face.rs b/crates/fonts/src/fontdb/face.rs index 058be0c0e..014220a16 100644 --- a/crates/fonts/src/fontdb/face.rs +++ b/crates/fonts/src/fontdb/face.rs @@ -297,16 +297,21 @@ impl FontFace { /// (bit 1) without a less restrictive bit (Preview & Print, Editable). Fonts without an OS/2 /// table aren't restricted. pub fn restricted_licence(&self) -> bool { - use skrifa::raw::TableProvider; - let fs_type = self.skrifa().and_then(|f| f.os2().ok()).map_or(0, |t| t.fs_type()); + let fs_type = self.fs_type().unwrap_or(0); fs_type & 0x0002 != 0 && fs_type & 0x000C == 0 } /// Does the font's licence allow embedding bitmaps only? OS/2 `fsType` bit 9: the outlines /// may not be passed on, so the font file can't be packaged whole. pub fn bitmap_only(&self) -> bool { + self.fs_type().is_some_and(|t| t & 0x0200 != 0) + } + + /// The font's OS/2 `fsType` (embedding licence bits), None when the font has no OS/2 table it + /// can read: the font doesn't say, which isn't the same as 0 (installable). + pub fn fs_type(&self) -> Option { use skrifa::raw::TableProvider; - self.skrifa().and_then(|f| f.os2().ok()).is_some_and(|t| t.fs_type() & 0x0200 != 0) + self.skrifa().and_then(|f| f.os2().ok()).map(|t| t.fs_type()) } } diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 1adc43449..15156f8ad 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -251,6 +251,7 @@ fn load_zip(bytes: &[u8]) -> Result { upgrade(&mut doc, u32::try_from(version).unwrap_or(u32::MAX)); doc.merge_duplicate_assets(); doc.mark_differing_margins_local(); + doc.renumber_duplicate_items(); doc.check().map_err(|e| FormatError::NotOurs(e.to_string()))?; Ok(doc) } @@ -318,6 +319,7 @@ fn load_legacy_json(bytes: &[u8]) -> Result { upgrade(&mut doc, 1); doc.merge_duplicate_assets(); doc.mark_differing_margins_local(); + doc.renumber_duplicate_items(); doc.check().map_err(|e| FormatError::NotOurs(e.to_string()))?; Ok(doc) } diff --git a/crates/format/src/tests.rs b/crates/format/src/tests.rs index 981898a45..d4b838cf2 100644 --- a/crates/format/src/tests.rs +++ b/crates/format/src/tests.rs @@ -555,6 +555,14 @@ fn r7a_nullable_document(local_clear: bool, style_clear: bool) -> Document { underline_offset: Some(Some(1.0)), strikethrough_weight: Some(Some(2.0)), strikethrough_offset: Some(Some(1.0)), + kenten_size: Some(Some(14.0)), + kenten_fill_tint: Some(Some(0.3)), + kenten_stroke_tint: Some(Some(0.4)), + kenten_stroke_weight: Some(Some(2.0)), + ruby_font_size: Some(Some(14.0)), + ruby_fill_tint: Some(Some(0.3)), + ruby_stroke_tint: Some(Some(0.4)), + ruby_stroke_weight: Some(Some(2.0)), ..Default::default() }; // Legacy JSON loads as format 1, whose upgrade pins Keep Lines Together and Align Left @@ -564,6 +572,7 @@ fn r7a_nullable_document(local_clear: bool, style_clear: bool) -> Document { start_at: Some(Some(7)), paragraph_kashida_width: Some(Some(0.5)), kinsoku: Some(Some(Default::default())), + list_tab: Some(Some(36.0)), keep_all_lines: Some(true), drop_cap_align_left: Some(true), ..Default::default() @@ -578,9 +587,18 @@ fn r7a_nullable_document(local_clear: bool, style_clear: bool) -> Document { underline_offset: Some(None), strikethrough_weight: Some(None), strikethrough_offset: Some(None), + kenten_size: Some(None), + kenten_fill_tint: Some(None), + kenten_stroke_tint: Some(None), + kenten_stroke_weight: Some(None), + ruby_font_size: Some(None), + ruby_fill_tint: Some(None), + ruby_stroke_tint: Some(None), + ruby_stroke_weight: Some(None), ..Default::default() }; - let clear_para = ParaAttrs { start_at: Some(None), paragraph_kashida_width: Some(None), kinsoku: Some(None), ..Default::default() }; + let clear_para = + ParaAttrs { start_at: Some(None), paragraph_kashida_width: Some(None), kinsoku: Some(None), list_tab: Some(None), ..Default::default() }; let selected = if style_clear { std::sync::Arc::make_mut(&mut d.styles).paragraph.push(designcraft_doc::styles::ParagraphStyle { name: "Defaults".into(), @@ -621,6 +639,15 @@ fn r7a_assert_nullable_defaults_survive(d: &Document) { assert_eq!(expected.0.paragraph_kashida_width, None); assert_eq!(expected.0.kinsoku, None); assert_eq!(expected.1.leading_aki, None); + assert_eq!(expected.0.list_tab, None); + assert_eq!(expected.1.kenten_size, None); + assert_eq!(expected.1.kenten_fill_tint, None); + assert_eq!(expected.1.kenten_stroke_tint, None); + assert_eq!(expected.1.kenten_stroke_weight, None); + assert_eq!(expected.1.ruby_font_size, None); + assert_eq!(expected.1.ruby_fill_tint, None); + assert_eq!(expected.1.ruby_stroke_tint, None); + assert_eq!(expected.1.ruby_stroke_weight, None); // Legacy JSON is format 1: its upgrade pins older defaults on the other styles, so only // the native ZIP compares as the whole document. for (bytes, native) in [(save(d).unwrap(), true), (serde_json::to_vec(d).unwrap(), false)] { @@ -734,3 +761,105 @@ fn too_many_entries_are_refused() { let Err(err) = load(&z.finish().unwrap().into_inner()) else { panic!("an archive with 100 001 entries loaded") }; assert!(err.to_string().contains("entries"), "{err}"); } + +/// Every item id in `d` (spreads, parents, anchored), in walk order. +fn all_item_ids(d: &Document) -> Vec { + let mut ids = Vec::new(); + for sp in d.spreads.iter().chain(d.parents.iter()) { + for it in &sp.items { + it.walk(&mut |i| ids.push(i.id.0)); + } + } + ids.extend(d.anchored_items().iter().map(|(_, i)| i.id.0)); + ids +} + +fn set_item_id(d: &mut Document, index: usize, id: designcraft_doc::ItemId) { + let sp = Arc::make_mut(&mut d.spreads[0]); + Arc::make_mut(&mut sp.items[index]).id = id; +} + +#[test] +fn files_with_duplicate_item_ids_open_with_unique_ids() { + let mut d = Document::new(&NewDocument::default()); + let one = png_asset(&mut d, &[1]); + let two = png_asset(&mut d, &[2]); + let a = place(&mut d, one, 0.0); + place(&mut d, two, 0.0); + set_item_id(&mut d, 1, a); + let back = load(&save(&d).unwrap()).unwrap(); + back.check().unwrap(); + let ids = all_item_ids(&back); + assert_eq!(ids.len(), 2); + assert_ne!(ids[0], ids[1]); + assert!(back.next_id >= ids[0].max(ids[1])); + let assets: Vec<_> = back.spreads[0].items.iter().map(|i| i.graphic().unwrap().asset).collect(); + assert_eq!(assets, vec![one, two]); + assert_eq!(back.item(a).and_then(|i| i.graphic()).map(|g| g.asset), Some(one)); +} + +#[test] +fn duplicate_text_frame_ids_keep_each_story_in_its_own_frame() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 200.0, 200.0), lid, "first", ParaFormat::default()).unwrap(); + let (b, sb) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 210.0, 200.0, 400.0), lid, "second", ParaFormat::default()).unwrap(); + let (c, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(210.0, 210.0, 400.0, 400.0), lid, "", ParaFormat::default()).unwrap(); + d.thread(b, c).unwrap(); + // The second story's first frame carries the first frame's id. + set_item_id(&mut d, 1, a); + let st = d.story_mut(sb).unwrap(); + assert_eq!(st.frames, vec![b, c]); + st.frames[0] = a; + let back = load(&save(&d).unwrap()).unwrap(); + back.check().unwrap(); + let ids = all_item_ids(&back); + let unique: HashSet<_> = ids.iter().collect(); + assert_eq!(unique.len(), 3); + assert_eq!(back.stories[&sa].frames, vec![a]); + assert_eq!(back.stories[&sa].text, "first"); + let frames = &back.stories[&sb].frames; + assert_eq!(frames.len(), 2); + assert_ne!(frames[0], a); + assert_eq!(frames[1], c); + assert_eq!(back.item(frames[0]).and_then(|i| i.text_frame()).map(|t| t.story), Some(sb)); + assert_eq!(back.stories[&sb].text, "second"); +} + +#[test] +fn duplicate_anchored_item_ids_are_renumbered() { + use designcraft_doc::anchored::{AnchorPosition, AnchoredObject}; + use designcraft_doc::geom::shapes; + use designcraft_doc::{Item, Shape}; + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 200.0, 200.0), lid, "ab", ParaFormat::default()).unwrap(); + let asset = png_asset(&mut d, &[1]); + let g = place(&mut d, asset, 0.0); + let item = Item::new(g, lid, Shape::Oval, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0))); + d.story_mut(sid).unwrap().insert_object(1, AnchoredObject::new(item, AnchorPosition::default())); + let back = load(&save(&d).unwrap()).unwrap(); + back.check().unwrap(); + let ids = all_item_ids(&back); + assert_eq!(ids.len(), 3); + assert_eq!(ids.iter().collect::>().len(), 3); + assert_eq!(back.item(g).and_then(|i| i.graphic()).map(|g| g.asset), Some(asset)); + let anchored = back.anchored_items(); + assert_eq!(anchored.len(), 1); + assert_eq!(anchored[0].1.shape, Shape::Oval); + assert_ne!(anchored[0].1.id, g); +} + +#[test] +fn duplicate_ids_at_the_top_of_the_range_do_not_overflow() { + let mut d = Document::new(&NewDocument::default()); + let one = png_asset(&mut d, &[1]); + let two = png_asset(&mut d, &[2]); + place(&mut d, one, 0.0); + place(&mut d, two, 0.0); + set_item_id(&mut d, 0, designcraft_doc::ItemId(u64::MAX)); + set_item_id(&mut d, 1, designcraft_doc::ItemId(u64::MAX)); + d.renumber_duplicate_items(); + assert_eq!(all_item_ids(&d), vec![u64::MAX, u64::MAX]); + assert!(d.check().is_err()); +} diff --git a/crates/geom/src/hit.rs b/crates/geom/src/hit.rs index 944ded3e9..096e0e1e3 100644 --- a/crates/geom/src/hit.rs +++ b/crates/geom/src/hit.rs @@ -27,7 +27,8 @@ pub fn stroke_contains(path: &BezPath, width: f64, tol: f64, p: Point) -> bool { /// Does the path intersect (or lie inside) the rect? Used for marquee selection. pub fn intersects_rect(path: &PathData, r: Rect) -> bool { let Some(b) = path.bounds() else { return false }; - if b.intersect(r).area() <= 0.0 && !(b.width() == 0.0 || b.height() == 0.0) { + let flat = |q: Rect| q.width() == 0.0 || q.height() == 0.0; + if b.intersect(r).area() <= 0.0 && !flat(b) && !flat(r) { return false; } // Any anchor inside? @@ -90,4 +91,12 @@ mod tests { let l = shapes::line(Point::new(0.0, 0.0), Point::new(10.0, 10.0)); assert!(intersects_rect(&l, Rect::new(4.0, 0.0, 6.0, 10.0))); } + + #[test] + fn flat_marquee() { + let p = shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)); + assert!(intersects_rect(&p, Rect::new(-5.0, 5.0, 15.0, 5.0))); + assert!(intersects_rect(&p, Rect::new(5.0, -5.0, 5.0, 15.0))); + assert!(!intersects_rect(&p, Rect::new(-5.0, 20.0, 15.0, 20.0))); + } } diff --git a/crates/geom/src/path.rs b/crates/geom/src/path.rs index adfc4a2e4..946b347b6 100644 --- a/crates/geom/src/path.rs +++ b/crates/geom/src/path.rs @@ -342,13 +342,21 @@ impl PathData { if self.is_empty() { return None; } - let bp = self.to_bezpath(); - if self.anchor_count() == 1 - && let Some(a) = self.subpaths.iter().find_map(|s| s.anchors.first()) - { - return Some(Rect::from_points(a.p, a.p)); + // `bounding_box` skips a lone MoveTo, so single-anchor subpaths are folded in by point. + let mut bp = BezPath::new(); + let mut lone: Option = None; + for sp in &self.subpaths { + if let [a] = sp.anchors.as_slice() { + lone = Some(lone.map_or(Rect::from_points(a.p, a.p), |r| r.union_pt(a.p))); + } else { + sp.to_bezpath_into(&mut bp); + } } - Some(bp.bounding_box()) + if bp.elements().is_empty() { + return lone; + } + let bb = bp.bounding_box(); + Some(lone.map_or(bb, |r| r.union(bb))) } /// Bounds including control handles (used for quick culling). pub fn control_bounds(&self) -> Option { @@ -421,6 +429,17 @@ mod tests { PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)], true)) } + #[test] + fn bounds_cover_single_anchor_subpaths() { + let mut pd = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0)], true)); + pd.subpaths.push(SubPath::polyline(&[Point::new(50.0, 50.0)], false)); + assert_eq!(pd.bounds(), Some(Rect::new(0.0, 0.0, 50.0, 50.0))); + + let mut two = PathData::single(SubPath::polyline(&[Point::new(5.0, 7.0)], false)); + two.subpaths.push(SubPath::polyline(&[Point::new(50.0, 50.0)], false)); + assert_eq!(two.bounds(), Some(Rect::new(5.0, 7.0, 50.0, 50.0))); + } + #[test] fn from_bezpath_detects_smooth_on_every_open_subpath() { let mut bp = BezPath::new(); diff --git a/crates/geom/src/stroke_style.rs b/crates/geom/src/stroke_style.rs index 63c632ea2..d018667f2 100644 --- a/crates/geom/src/stroke_style.rs +++ b/crates/geom/src/stroke_style.rs @@ -9,6 +9,28 @@ pub fn offset_path(bp: &BezPath, d: f64, tol: f64) -> BezPath { return bp.clone(); } let mut out = BezPath::new(); + for sub in subpaths(bp) { + offset_subpath(&sub, d, tol, &mut out); + } + out +} + +/// `bp` split at each `MoveTo` into its subpaths. +fn subpaths(bp: &BezPath) -> Vec { + let mut out: Vec = Vec::new(); + for el in bp.elements() { + if matches!(el, kurbo::PathEl::MoveTo(_)) || out.is_empty() { + out.push(BezPath::new()); + } + if let Some(sub) = out.last_mut() { + sub.push(*el); + } + } + out +} + +/// One subpath offset by `d`, its segments joined into one line, appended to `out`. +fn offset_subpath(bp: &BezPath, d: f64, tol: f64, out: &mut BezPath) { let mut started = false; for seg in bp.segments() { let c = match seg { @@ -30,7 +52,6 @@ pub fn offset_path(bp: &BezPath, d: f64, tol: f64) -> BezPath { } started = true; } - out } /// Bands (start, width) across a stroke, as fractions of the weight from its left edge. @@ -54,8 +75,13 @@ pub fn stripes(bp: &BezPath, weight: f64, bands: &Bands, tol: f64) -> BezPath { out } -/// Points along `bp` every `step` of arc length, with the unit tangent there. -fn samples(bp: &BezPath, step: f64) -> Vec<(Point, Vec2)> { +/// Points along each subpath of `bp` every `step` of arc length, with the unit tangent there. +fn samples(bp: &BezPath, step: f64) -> Vec> { + subpaths(bp).iter().map(|sub| subpath_samples(sub, step)).collect() +} + +/// Points along one subpath every `step` of arc length, with the unit tangent there. +fn subpath_samples(bp: &BezPath, step: f64) -> Vec<(Point, Vec2)> { let mut out = Vec::new(); let mut carry = 0.0; for seg in bp.segments() { @@ -85,13 +111,14 @@ fn samples(bp: &BezPath, step: f64) -> Vec<(Point, Vec2)> { /// A wavy stroke: the line swings `weight` wide, drawn a third of the weight thick. pub fn wavy(bp: &BezPath, weight: f64, tol: f64) -> BezPath { let wavelength = (weight * 4.0).max(2.0); - let pts = samples(bp, wavelength / 16.0); let mut line = BezPath::new(); - for (i, (p, t)) in pts.iter().enumerate() { - let normal = Vec2::new(-t.y, t.x); - let phase = i as f64 / 16.0 * std::f64::consts::TAU; - let q = *p + normal * (phase.sin() * weight * 0.33); - if i == 0 { line.move_to(q) } else { line.line_to(q) } + for pts in samples(bp, wavelength / 16.0) { + for (i, (p, t)) in pts.iter().enumerate() { + let normal = Vec2::new(-t.y, t.x); + let phase = i as f64 / 16.0 * std::f64::consts::TAU; + let q = *p + normal * (phase.sin() * weight * 0.33); + if i == 0 { line.move_to(q) } else { line.line_to(q) } + } } let s = Stroke::new(weight / 3.0).with_join(kurbo::Join::Round); kurbo::stroke(line.iter(), &s, &kurbo::StrokeOpts::default(), tol) @@ -100,7 +127,7 @@ pub fn wavy(bp: &BezPath, weight: f64, tol: f64) -> BezPath { /// A straight-hash stroke: short ticks across the path, as wide as the weight. pub fn hashed(bp: &BezPath, weight: f64, tol: f64) -> BezPath { let mut ticks = BezPath::new(); - for (p, t) in samples(bp, (weight * 0.6).max(1.0)) { + for (p, t) in samples(bp, (weight * 0.6).max(1.0)).into_iter().flatten() { let normal = Vec2::new(-t.y, t.x) * (weight / 2.0); ticks.move_to(p - normal); ticks.line_to(p + normal); @@ -141,4 +168,20 @@ mod tests { let h = hashed(&line(), 8.0, 0.01).bounding_box(); assert!((h.height() - 8.0).abs() < 0.5 && h.width() > 90.0, "{h:?}"); } + + #[test] + fn styled_strokes_keep_subpaths_apart() { + // Two separate lines, 50 pt apart, in one compound path. + let mut b = line(); + b.move_to((0.0, 50.0)); + b.line_to((100.0, 50.0)); + let moves = |p: &BezPath| p.elements().iter().filter(|e| matches!(e, kurbo::PathEl::MoveTo(_))).count(); + assert_eq!(moves(&offset_path(&b, 3.0, 0.01)), 2, "one offset subpath per input subpath"); + let s = stripes(&b, 12.0, &[(0.0, 0.5), (0.75, 0.25)], 0.01); + assert!(!s.contains(Point::new(50.0, 25.0)), "stripe bridges the subpaths: {:?}", s.bounding_box()); + assert!(s.contains(Point::new(50.0, 46.0)), "second line is still striped"); + let w = wavy(&b, 4.0, 0.01); + let bridged = (0..=2000).map(|i| f64::from(i) * 0.05).filter(|&x| w.contains(Point::new(x, 25.0))).count(); + assert_eq!(bridged, 0, "wavy bridges the subpaths: {:?}", w.bounding_box()); + } } diff --git a/crates/geom/src/units.rs b/crates/geom/src/units.rs index cc47fadb2..738be420f 100644 --- a/crates/geom/src/units.rs +++ b/crates/geom/src/units.rs @@ -258,12 +258,13 @@ fn parse_single(tok: &str, default: Unit, percent_base: Option) -> Option String { - // As InDesign shows them: inches to four decimals (a 1p gutter is 0.1667 in, A4 is - // 8.2677 in wide), the other units to three. - let decimals = if matches!(unit, Unit::Inches | Unit::InchesDecimal) { 4 } else { 3 }; + // As InDesign shows them: inches and centimetres to four decimals (a 1p gutter is 0.1667 in + // or 0.4233 cm, A4 is 8.2677 in wide), the other units to three. + let decimals = if matches!(unit, Unit::Inches | Unit::InchesDecimal | Unit::Centimeters) { 4 } else { 3 }; format_measure_prec(pt, unit, decimals) } @@ -323,14 +324,18 @@ mod tests { assert_eq!(format_measure(-12.0, Unit::Picas), "-1p0"); } - /// Inches show four decimals, as InDesign's fields do; other units three. + /// Inches and centimetres show four decimals, as InDesign's fields do; other units three. #[test] - fn inches_show_four_decimals() { + fn inches_and_centimeters_show_four_decimals() { assert_eq!(format_measure(12.0, Unit::Inches), "0.1667 in"); assert_eq!(format_measure(210.0 * PT_PER_MM, Unit::Inches), "8.2677 in"); assert_eq!(format_measure(612.0, Unit::Inches), "8.5 in"); assert_eq!(format_measure(9.0, Unit::Inches), "0.125 in"); assert_eq!(format_measure(10.0, Unit::Millimeters), "3.528 mm"); + // A 1p gutter and a frame's height, as InDesign's New Document and Transform fields show them. + assert_eq!(format_measure(12.0, Unit::Centimeters), "0.4233 cm"); + assert_eq!(format_measure(17.1125 / 2.54 * 72.0, Unit::Centimeters), "17.1125 cm"); + assert_eq!(format_measure(21.0 / 2.54 * 72.0, Unit::Centimeters), "21 cm"); assert_eq!(format_measure(10.0, Unit::Points), "10 pt"); } diff --git a/crates/geom/src/warp.rs b/crates/geom/src/warp.rs index dacdedd06..6dd0cda9b 100644 --- a/crates/geom/src/warp.rs +++ b/crates/geom/src/warp.rs @@ -2,6 +2,20 @@ use kurbo::{Affine, BezPath, ParamCurve, ParamCurveArclen, ParamCurveDeriv, PathSeg, Point}; +/// Direction at a curve end whose derivative vanishes: from the end anchor towards the nearest +/// control point that differs from it (`at_end` selects the last anchor). +fn first_distinct(pts: &[Point], at_end: bool) -> kurbo::Vec2 { + let n = pts.len(); + for k in 1..n { + let (a, b) = if at_end { (pts[n - 1 - k], pts[n - 1]) } else { (pts[0], pts[k]) }; + let v = b - a; + if v.hypot2() > 1e-18 { + return v; + } + } + kurbo::Vec2::ZERO +} + /// A path measured for placing glyphs along it. #[derive(Clone, Debug)] pub struct PathWarp { @@ -29,12 +43,24 @@ impl PathWarp { let n = self.segs.len(); for (i, (seg, len)) in self.segs.iter().enumerate() { if s <= *len || i + 1 == n { - let t = if *len > 1e-9 { seg.inv_arclen(s.min(*len), 1e-4) } else { 0.0 }; + let t = if *len <= 1e-9 || s <= 0.0 { + 0.0 + } else if s >= *len { + 1.0 + } else { + seg.inv_arclen(s, 1e-4) + }; let p = seg.eval(t); let d = match seg { PathSeg::Line(l) => l.p1 - l.p0, - PathSeg::Quad(q) => q.deriv().eval(t).to_vec2(), - PathSeg::Cubic(c) => c.deriv().eval(t).to_vec2(), + PathSeg::Quad(q) => { + let d = q.deriv().eval(t).to_vec2(); + if d.hypot2() > 1e-18 { d } else { first_distinct(&[q.p0, q.p1, q.p2], t > 0.5) } + } + PathSeg::Cubic(c) => { + let d = c.deriv().eval(t).to_vec2(); + if d.hypot2() > 1e-18 { d } else { first_distinct(&[c.p0, c.p1, c.p2, c.p3], t > 0.5) } + } }; let mut a = d.y.atan2(d.x); if self.flip { @@ -98,4 +124,19 @@ mod tests { assert!((w.nearest(Point::new(30.0, -8.0)) - 30.0).abs() < 1e-6); assert!((flipped.nearest(Point::new(30.0, -8.0)) - 170.0).abs() < 1e-6); } + + #[test] + fn collapsed_handles_fall_back_to_the_next_control_point() { + let half_pi = std::f64::consts::FRAC_PI_2; + let mut bp = BezPath::new(); + bp.move_to((0.0, 0.0)); + bp.curve_to((0.0, 0.0), (0.0, 10.0), (10.0, 10.0)); + let w = PathWarp::new(&bp, false); + assert!((w.at(0.0).unwrap().1 - half_pi).abs() < 1e-9, "start handle on the anchor"); + let mut bp = BezPath::new(); + bp.move_to((0.0, 0.0)); + bp.curve_to((10.0, 0.0), (10.0, 10.0), (10.0, 10.0)); + let w = PathWarp::new(&bp, false); + assert!((w.at(w.length()).unwrap().1 - half_pi).abs() < 1e-9, "end handle on the anchor"); + } } diff --git a/crates/idml/src/export/designmap.rs b/crates/idml/src/export/designmap.rs index 1c1c720b1..d6004259b 100644 --- a/crates/idml/src/export/designmap.rs +++ b/crates/idml/src/export/designmap.rs @@ -206,10 +206,10 @@ fn xref_format_el(i: usize, f: &designcraft_doc::XrefFormat) -> El { }; let mut rest = f.definition.as_str(); while let Some(a) = rest.find('<') { + let Some(b) = rest[a..].find('>') else { break }; if a > 0 { block(&mut el, "CustomStringBuildingBlock", &rest[..a], "", false); } - let Some(b) = rest[a..].find('>') else { break }; let tag = rest[a + 1..a + b].trim().trim_end_matches('/').trim(); let attr = |k: &str| tag.find(&format!("{k}=\"")).and_then(|i| tag[i + k.len() + 2..].split('"').next()).unwrap_or("").to_string(); match tag.split_whitespace().next().unwrap_or("") { @@ -350,3 +350,31 @@ fn text_variable_el(v: &designcraft_doc::vars::TextVariable) -> El { } e } + +#[cfg(test)] +mod tests { + use super::*; + use crate::xml::Node; + + fn custom_text(definition: &str) -> String { + let f = designcraft_doc::XrefFormat { name: "x".into(), definition: definition.into() }; + xref_format_el(0, &f) + .children + .iter() + .filter_map(|n| match n { + Node::El(e) if e.get("BlockType") == Some("CustomStringBuildingBlock") => e.get("CustomText").map(str::to_string), + _ => None, + }) + .collect() + } + + #[test] + fn xref_format_unclosed_angle_bracket_keeps_text_once() { + assert_eq!(custom_text("a < b"), "a < b"); + } + + #[test] + fn xref_format_well_formed_keeps_custom_text() { + assert_eq!(custom_text("p of "), "p of "); + } +} diff --git a/crates/idml/src/export/fonts_prefs.rs b/crates/idml/src/export/fonts_prefs.rs index fac86af33..2e8daa565 100644 --- a/crates/idml/src/export/fonts_prefs.rs +++ b/crates/idml/src/export/fonts_prefs.rs @@ -169,6 +169,16 @@ impl<'a> Ex<'a> { .attr("SubscriptSize", num(a.subscript_size)) .attr("SubscriptPosition", num(a.subscript_position)), ); + // InDesign always writes the document's default story preference; readers such as Okapi's IDML + // filter take the spread reading order from it and fail without it. + root.push( + El::new("StoryPreference") + .attr("OpticalMarginAlignment", "false") + .attr("OpticalMarginSize", "12") + .attr("FrameType", "TextFrameType") + .attr("StoryOrientation", "Horizontal") + .attr("StoryDirection", "LeftToRightDirection"), + ); document(&root) } } diff --git a/crates/idml/src/export/item.rs b/crates/idml/src/export/item.rs index 5ed93879b..371718b65 100644 --- a/crates/idml/src/export/item.rs +++ b/crates/idml/src/export/item.rs @@ -220,6 +220,7 @@ impl<'a> Ex<'a> { .attr("Size", num(ds.size)) .attr("Spread", num(ds.spread)) .attr("Opacity", pct(ds.opacity as f64)) + .attr("BlendMode", names::blend_out(ds.mode)) .attr("EffectColor", self.sw(&ds.color)), ); } diff --git a/crates/idml/src/export/story.rs b/crates/idml/src/export/story.rs index 97c2837ec..9e013e0a4 100644 --- a/crates/idml/src/export/story.rs +++ b/crates/idml/src/export/story.rs @@ -327,6 +327,33 @@ impl<'a> Ex<'a> { segs } + /// A cross-reference source's text: what its format generates, or the text typed over it, its + /// page numbers as InDesign's live cross-reference page number (as InDesign writes them, so + /// generated text reads back as generated). + fn xref_content(&mut self, c: &mut El, x: &designcraft_doc::CrossRef) { + let t = match &x.text { + Some(t) => t.clone(), + None => self.d.generated_xref_text(x.target, &x.format).unwrap_or_else(|| "??".into()), + }; + let page = x.page.clone().unwrap_or_else(|| "?".into()); + for (n, part) in t.split(designcraft_doc::xref::PAGE_NUM_BLOCK).enumerate() { + if n > 0 { + c.push( + El::new("TextVariableInstance") + .attr("Self", self.fresh()) + .attr("Name", "TV XRefPageNumber") + .attr("ResultText", &page) + .attr("AssociatedTextVariable", "dTextVariablenTV XRefPageNumber"), + ); + } + if !part.is_empty() { + let mut content = El::new("Content"); + content.children.push(Node::Text(part.to_string())); + c.push(content); + } + } + } + fn story_para(&mut self, s: &Story, pi: usize, r: std::ops::Range, last: bool) -> El { let pf = s.paras.get(pi).cloned().unwrap_or_default(); let mut psr = self.psr_el(&pf); @@ -419,14 +446,8 @@ impl<'a> Ex<'a> { if let Some(x) = s.xrefs.get(k) { let me = self.fresh(); let fmt = self.d.xref_formats.iter().position(|ff| ff.name == x.format).map_or("n".to_string(), xref_format_self); - let text = self.d.xref_values(x.target).map_or("??".into(), |mut v| { - v.page = "?".into(); - designcraft_doc::xref::expand(self.d.xref_format(&x.format).map_or("", |ff| ff.definition.as_str()), &v) - }); let mut c = self.csr_el(&f); - let mut content = El::new("Content"); - content.children.push(Node::Text(text)); - c.push(content); + self.xref_content(&mut c, x); let mut src = El::new("CrossReferenceSource") .attr("Self", &me) .attr("AppliedFormat", fmt) diff --git a/crates/idml/src/export/styles.rs b/crates/idml/src/export/styles.rs index 9435b533c..8977af787 100644 --- a/crates/idml/src/export/styles.rs +++ b/crates/idml/src/export/styles.rs @@ -28,7 +28,11 @@ impl<'a> Ex<'a> { } self.char_attrs(&mut el, &mut props, &cs.chars); } - cg.insert(&cs.name, with_props(el, props)); + let mut el = with_props(el, props); + if let Some(t) = styles.export_tag(&cs.name, true) { + el.push(self.export_tag_map(t)); + } + cg.insert(&cs.name, el); } let mut rc = El::new("RootCharacterStyleGroup").attr("Self", self.fresh()); cg.emit(&mut rc, "CharacterStyleGroup", ""); @@ -65,10 +69,15 @@ impl<'a> Ex<'a> { let pa = with_list_tab(pa, || (styles.resolve_para_style(&ps.name).0, parent_props)); self.para_attrs(&mut el, &mut props, &pa, parent); self.char_attrs(&mut el, &mut props, &ca); - if styles.export_tag(&ps.name, false).is_some_and(|t| t.split) { + let tag = styles.export_tag(&ps.name, false); + if tag.is_some_and(|t| t.split) { el.set("SplitDocument", "true"); } - pg.insert(&ps.name, with_props(el, props)); + let mut el = with_props(el, props); + if let Some(t) = tag { + el.push(self.export_tag_map(t)); + } + pg.insert(&ps.name, el); } let mut rp = El::new("RootParagraphStyleGroup").attr("Self", self.fresh()); pg.emit(&mut rp, "ParagraphStyleGroup", ""); @@ -159,6 +168,18 @@ impl<'a> Ex<'a> { document(&root) } + /// A style's Export Tagging as its EPUB export tag map. + fn export_tag_map(&mut self, t: &designcraft_doc::ExportTag) -> El { + El::new("StyleExportTagMap") + .attr("Self", self.fresh()) + .attr("ExportType", "EPUB") + .attr("ExportTag", &t.tag) + .attr("ExportClass", &t.class) + .attr("ExportAttributes", "") + .attr("SplitDocument", bool_s(t.split)) + .attr("EmitCss", "true") + } + fn append_cell_styles(&mut self, root: &mut El, styles: &designcraft_doc::Styles) { let mut cells = El::new("RootCellStyleGroup") .attr("Self", self.fresh()) @@ -302,7 +323,7 @@ impl<'a> Ex<'a> { .attr("RunIn", "false") .attr("IncludeHidden", "false") .attr("IncludeBookDocuments", "false") - .attr("CreatePdfBookmarks", "false"); + .attr("CreateBookmarks", if toc.create_bookmarks { "true" } else { "false" }); for e in &toc.entries { let format = e.entry_style.clone().unwrap_or_else(|| format!("TOC Level {}", e.level)); let position = match e.page_number { @@ -335,6 +356,17 @@ impl<'a> Ex<'a> { el.set(&format!("{k}Offset"), num(r.offset)); el.set(&format!("{k}LeftIndent"), num(r.left_indent)); el.set(&format!("{k}RightIndent"), num(r.right_indent)); + el.set(&format!("{k}Type"), names::stroke_type_out(&r.kind)); + // A gap in Text Color is a string property, as the rule's own colour is. + if r.gap_color == designcraft_doc::TEXT_COLOR { + props.push(p(&format!("{k}GapColor"), "string", designcraft_doc::TEXT_COLOR)); + } else { + el.set(&format!("{k}GapColor"), self.sw(&r.gap_color)); + } + el.set(&format!("{k}GapTint"), pct(r.gap_tint as f64)); + if k == "RuleAbove" { + el.set("KeepRuleAboveInFrame", bool_s(r.keep_in_frame)); + } props.push(if r.color == designcraft_doc::TEXT_COLOR { p(&format!("{k}Color"), "string", designcraft_doc::TEXT_COLOR) } else { diff --git a/crates/idml/src/export/text_attrs.rs b/crates/idml/src/export/text_attrs.rs index 38acf0d2a..1c0def755 100644 --- a/crates/idml/src/export/text_attrs.rs +++ b/crates/idml/src/export/text_attrs.rs @@ -22,6 +22,13 @@ macro_rules! b { }; } +/// The Overprint switches of a paragraph's shading, border and border gap. +fn para_box_overprints(el: &mut El, a: &ParaAttrs) { + b!(a, el, shading_overprint, "ParagraphShadingOverprint"); + b!(a, el, border_overprint, "ParagraphBorderOverprint"); + b!(a, el, border_gap_overprint, "ParagraphBorderGapOverprint"); +} + impl<'a> Ex<'a> { pub(super) fn char_attrs(&mut self, el: &mut El, props: &mut Vec, a: &CharAttrs) { if let Some(v) = a.leading_aki { @@ -494,6 +501,7 @@ impl<'a> Ex<'a> { props.push(p("ParagraphShadingColor", "object", self.sw(c))); } n!(a, el, shading_tint, "ParagraphShadingTint", pct); + para_box_overprints(el, a); const SIDES: [&str; 4] = ["Top", "Left", "Bottom", "Right"]; if let Some(o) = a.shading_offsets { for (i, side) in SIDES.iter().enumerate() { diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 161594e1e..f4544ada4 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -60,6 +60,9 @@ pub struct Imported { /// One line per problem that did not stop the import (a part listed in `designmap.xml` but /// absent from the package, …). pub warnings: Vec, + /// Each story's IDML `Self` → its id in `document`, so a warning that names a story can + /// point at it. + pub story_ids: HashMap, } /// Import with a custom reader for linked files, reporting the problems that did not stop it. @@ -119,6 +122,7 @@ pub fn import_idml_report_with_previews( )); } warnings.append(&mut im.warnings); + let story_ids = im.story_ids.clone(); let mut document = im.finish(&root).map_err(|e| match e { // Without its spread parts there is nothing to open: say which parts were missing. IdmlError::Invalid(msg) if !warnings.is_empty() => IdmlError::Invalid(format!("{msg} ({})", warnings.join("; "))), @@ -126,7 +130,7 @@ pub fn import_idml_report_with_previews( })?; warnings.extend(document.fix_parent_bases()); document.check().map_err(|e| IdmlError::Invalid(e.to_string()))?; - Ok(Imported { document, warnings }) + Ok(Imported { document, warnings, story_ids }) } struct ItemCtx { @@ -139,6 +143,14 @@ struct Importer<'r> { /// Graphic `Self` → stand-in image for a linked file that can't be read. previews: &'r HashMap>>, next_id: u64, + /// Cells every table in this import has reserved. One table is capped, but a file chooses + /// how many tables it has, so the cap has to be document-wide as well (noted in review). + table_cells: usize, + /// Tables whose size the file claimed and did not get, warned about so far. A file chooses + /// how many tables it has, so a line each is its own flood (noted in review). + table_warnings: usize, + /// Whether the file has already been told its tables ran out of cells between them. + table_budget_spent: bool, settings: DocSettings, default_margins: Option<(Margins, Columns)>, swatches: Vec, @@ -183,6 +195,8 @@ struct Importer<'r> { page_index: HashMap, item_ids: HashMap, assets: BTreeMap>, + /// Content lookup for `assets`, so each placed graphic doesn't compare against every earlier one. + asset_index: designcraft_doc::AssetIndex, /// Assets read from a link path at a page and crop, so later placements of that path, page /// and crop reuse them unread. linked_assets: HashMap<(String, u32, designcraft_doc::PdfCrop), AssetId>, @@ -202,6 +216,8 @@ struct Importer<'r> { /// The file's text variables (`None`: it defines none) and their Self → index. text_vars: Option>, text_var_ids: HashMap, + /// The Self of each cross-reference page number variable (`XrefPageNumberType`). + xref_page_vars: HashSet, /// Creation and modification times (Unix seconds) from the package metadata. created: Option, modified: Option, @@ -225,6 +241,9 @@ impl<'r> Importer<'r> { read_link, previews, next_id: 0, + table_cells: 0, + table_warnings: 0, + table_budget_spent: false, settings: DocSettings::default(), default_margins: None, swatches: specials, @@ -260,6 +279,7 @@ impl<'r> Importer<'r> { page_index: HashMap::new(), item_ids: HashMap::new(), assets: BTreeMap::new(), + asset_index: designcraft_doc::AssetIndex::new(), linked_assets: HashMap::new(), ctx: ItemCtx { threads: Vec::new() }, sections: Vec::new(), @@ -272,6 +292,7 @@ impl<'r> Importer<'r> { index_xrefs: Vec::new(), text_vars: None, text_var_ids: HashMap::new(), + xref_page_vars: HashSet::new(), created: None, modified: None, repaired_tab_lists: 0, @@ -312,11 +333,17 @@ impl<'r> Importer<'r> { self.default_margins = Some((m, c)); } "ViewPreference" => { - if let Some(u) = e.get("HorizontalMeasurementUnits") { - self.settings.horizontal_units = names::unit_in(u); - } - if let Some(u) = e.get("VerticalMeasurementUnits") { - self.settings.vertical_units = names::unit_in(u); + for (attr, ruler) in [("HorizontalMeasurementUnits", "horizontal"), ("VerticalMeasurementUnits", "vertical")] { + let Some(u) = e.get(attr) else { continue }; + let unit = names::unit_in(u).unwrap_or_else(|| { + self.warnings.push(format!("{ruler} ruler units `{u}` are not available; read as points")); + designcraft_geom::Unit::Points + }); + if ruler == "horizontal" { + self.settings.horizontal_units = unit; + } else { + self.settings.vertical_units = unit; + } } if let Some(v) = e.num("CursorKeyIncrement") { self.settings.keyboard_increment = v; @@ -599,6 +626,9 @@ impl<'r> Importer<'r> { { d.settings.zero_point = zp; } + // Cross-reference text the format generates exactly is generated again; any other was + // typed over it and is kept (needs the destinations, resolved above). + d.drop_generated_xref_text(); Ok(d) } } diff --git a/crates/idml/src/import/document.rs b/crates/idml/src/import/document.rs index dce466381..3b78413ef 100644 --- a/crates/idml/src/import/document.rs +++ b/crates/idml/src/import/document.rs @@ -133,7 +133,13 @@ impl<'r> Importer<'r> { pub(super) fn text_variable(&mut self, e: &El) { use designcraft_doc::vars::{TextVariable, Use, VAR_MAX, VarKind}; let ty = e.get("VariableType").unwrap_or(""); - // Cross-reference internals; export writes its own. + // Cross-reference internals; export writes its own. Their page numbers are recognised + // inside cross-reference text. + if ty == "XrefPageNumberType" + && let Some(id) = e.get("Self") + { + self.xref_page_vars.insert(id.to_string()); + } if ty.is_empty() || ty.starts_with("Xref") { return; } @@ -313,10 +319,12 @@ impl<'r> Importer<'r> { let story = top.iter().find(|e| e.local() == "Story" && e.get("AppliedTOCStyle") == Some(id))?; Some((ts, *self.story_ids.get(story.get("Self")?)?)) })?; - let known = |r: Option<&str>| r.map(|r| self.para_style_ref(r)).filter(|n| self.styles.para(n).is_some()); + // Property children are element text: formatting whitespace around a reference is dropped + // unless the reference as written names a style. + let known = |r: Option<&str>| r.map(|r| self.listed_para_style_ref(r)).filter(|n| self.styles.para(n).is_some()); // Character styles the document has; [None] is stored as no style. let known_chars = |r: Option<&str>| { - r.map(|r| self.char_style_ref(r)).filter(|n| n != designcraft_doc::NO_CHAR_STYLE && self.styles.char_style(n).is_some()) + r.map(|r| self.listed_char_style_ref(r)).filter(|n| n != designcraft_doc::NO_CHAR_STYLE && self.styles.char_style(n).is_some()) }; let entries: Vec = ts .elements() @@ -333,9 +341,10 @@ impl<'r> Importer<'r> { _ => TocPageNumber::After, }; let separator = e.get("Separator").map(crate::names::toc_separator_in).filter(|s| s != "\t" && s.chars().count() <= 64); - let entry_style = known(e.get("FormatStyle")).filter(|n| *n != format!("TOC Level {level}")); - let page_number_style = known_chars(e.get("PageNumberStyle")); - let separator_style = known_chars(e.get("SeparatorStyle")); + // InDesign writes these as `Properties` children; attributes are read too. + let entry_style = known(e.prop("FormatStyle").as_deref()).filter(|n| *n != format!("TOC Level {level}")); + let page_number_style = known_chars(e.prop("PageNumberStyle").as_deref()); + let separator_style = known_chars(e.prop("SeparatorStyle").as_deref()); let sort = e.get("Sort").is_some_and(|v| v.trim() == "true"); Some(TocEntry { style, level, entry_style, page_number, separator, page_number_style, separator_style, sort }) }) @@ -346,6 +355,7 @@ impl<'r> Importer<'r> { } let title: String = ts.get("Title").unwrap_or("").chars().filter(|c| !matches!(c, '\n' | '\r' | '\u{2029}')).take(1024).collect(); let title_style = known(ts.get("TitleStyle")).filter(|n| n != "TOC Title"); - Some(designcraft_doc::Toc { story, title, entries, page_numbers: true, title_style }) + let create_bookmarks = ts.get("CreateBookmarks").or_else(|| ts.get("CreatePdfBookmarks")) == Some("true"); + Some(designcraft_doc::Toc { story, title, entries, page_numbers: true, title_style, create_bookmarks }) } } diff --git a/crates/idml/src/import/item.rs b/crates/idml/src/import/item.rs index d25044dc6..ac27a833e 100644 --- a/crates/idml/src/import/item.rs +++ b/crates/idml/src/import/item.rs @@ -371,7 +371,7 @@ impl<'r> Importer<'r> { uri.as_deref().and_then(|p| p.rsplit(['/', '\\']).next()).filter(|n| !n.is_empty()).map(str::to_string).unwrap_or_else(|| "image".into()); let fresh = AssetId(self.alloc()); let link_path = uri.filter(|p| p.contains('/') || p.contains('\\')); - let id = designcraft_doc::insert_asset( + let id = self.asset_index.insert_asset( &mut self.assets, Asset { page, diff --git a/crates/idml/src/import/object_style.rs b/crates/idml/src/import/object_style.rs index bcdaedb66..343e67211 100644 --- a/crates/idml/src/import/object_style.rs +++ b/crates/idml/src/import/object_style.rs @@ -111,6 +111,9 @@ impl<'r> Importer<'r> { size: ds.num("Size").unwrap_or(def.size), spread: ds.num("Spread").unwrap_or(def.spread), global_light: false, + // Without the attribute (earlier DesignCraft exports) the shadow stays Normal, as it + // rendered then; a new shadow's default is Multiply. + mode: ds.get("BlendMode").map_or(designcraft_color::BlendMode::Normal, names::blend_in), }; } if let Some(f) = t.find("FeatherSetting") diff --git a/crates/idml/src/import/spread.rs b/crates/idml/src/import/spread.rs index e2caa6b18..ca9a3d746 100644 --- a/crates/idml/src/import/spread.rs +++ b/crates/idml/src/import/spread.rs @@ -70,7 +70,7 @@ impl<'r> Importer<'r> { } /// A page's margins and columns, and whether they are local (differ from its parent page's). - fn page_margins(&self, p: &El, r: &Rect, master: bool, parent: Option, side: PageSide) -> (Margins, Columns, bool) { + fn page_margins(&self, p: &El, r: &Rect, master: bool, parent: Option, (side, index): (PageSide, usize)) -> (Margins, Columns, bool) { // A document page's margins are local when they differ from its parent page's; a // page without its own takes the parent page's. let from_parent = if master { @@ -78,7 +78,7 @@ impl<'r> Importer<'r> { } else { parent .and_then(|id| self.parents.iter().find(|sp| sp.id == id)) - .and_then(|sp| sp.pages.get(sp.parent_page_for_side(side)?)) + .and_then(|sp| sp.pages.get(sp.parent_page_at(side, index)?)) .map(|q| (q.margins, q.columns.clone())) }; let (margins, columns) = match (p.find("MarginPreference"), &from_parent) { @@ -126,7 +126,7 @@ impl<'r> Importer<'r> { PageSide::Right }; let parent = p.get("AppliedMaster").and_then(|m| self.parent_ids.get(m)).copied(); - let (margins, columns, local_margins) = self.page_margins(p, r, master, parent, side); + let (margins, columns, local_margins) = self.page_margins(p, r, master, parent, (side, i)); out_pages.push(Page { id: pid, width: r.width(), diff --git a/crates/idml/src/import/story.rs b/crates/idml/src/import/story.rs index 3d87fc35a..579b1e004 100644 --- a/crates/idml/src/import/story.rs +++ b/crates/idml/src/import/story.rs @@ -47,10 +47,14 @@ impl<'r> Importer<'r> { anchor_list.push(Arc::new(designcraft_doc::TextAnchor { id: aid, name })); } let mut xref_list = Vec::new(); - for (k, (me, fmt)) in xrefs.into_iter().enumerate() { - self.xref_srcs.insert(me, (id, k)); - let format = self.xref_format_names.get(&fmt).cloned().unwrap_or_else(|| "Full Paragraph & Page Number".into()); - xref_list.push(Arc::new(designcraft_doc::CrossRef { target: 0, format })); + for (k, x) in xrefs.into_iter().enumerate() { + self.xref_srcs.insert(x.me, (id, k)); + let format = self.xref_format_names.get(&x.format).cloned().unwrap_or_else(|| "Full Paragraph & Page Number".into()); + // The text as saved: once the destinations are known, text the format generates + // exactly is dropped to be generated again (`drop_generated_xref_text`), and text + // typed over it is kept, as InDesign shows it until the cross-reference is updated. + let saved = !x.text.is_empty(); + xref_list.push(Arc::new(designcraft_doc::CrossRef { target: 0, format, text: saved.then_some(x.text), page: x.page.filter(|_| saved) })); } let pref = e.find("StoryPreference"); let optical_margin = designcraft_doc::OpticalMargin { @@ -171,8 +175,11 @@ impl<'r> Importer<'r> { b.fresh = true; } "Table" => { - let t = self.table(c); - b.push_table(t, pf, cf); + // `None` once the file-wide cell budget is spent: the table is not read + // at all rather than kept as a 1 x 1 (noted in review). + if let Some(t) = self.table(c) { + b.push_table(t, pf, cf); + } } "HyperlinkTextDestination" | "ParagraphDestination" => { b.anchors.push((c.get("Self").unwrap_or("").to_string(), c.get("Name").unwrap_or("Anchor").to_string())); @@ -192,7 +199,14 @@ impl<'r> Importer<'r> { } "CrossReferenceSource" => { // The generated text is regenerated from the format. - b.xrefs.push((c.get("Self").unwrap_or("").to_string(), c.get("AppliedFormat").unwrap_or("").to_string())); + let (mut text, mut page) = (String::new(), None); + xref_saved_text(c, &self.xref_page_vars, &mut text, &mut page, 0); + b.xrefs.push(XrefSource { + me: c.get("Self").unwrap_or("").to_string(), + format: c.get("AppliedFormat").unwrap_or("").to_string(), + text, + page, + }); // A source at paragraph level wraps a range; inside a range it holds the // text directly and takes that range's format. let f = c.find("CharacterStyleRange").map_or_else(|| cf.clone(), |r| self.range_format(r, pchars, cf)); @@ -399,10 +413,50 @@ struct StoryBuilder { /// Text destinations (Self, name) and cross-reference sources (Self, format Self) in order. anchors: Vec<(String, String)>, index_refs: Vec, - xrefs: Vec<(String, String)>, + xrefs: Vec, objects: Vec, } +/// A cross-reference source as saved: its id, its format's id, and its text with each page number +/// as `` (and the page it last showed). +struct XrefSource { + me: String, + format: String, + text: String, + page: Option, +} + +/// Whether a `TextVariableInstance` shows a cross-reference page number: its +/// `AssociatedTextVariable` is a variable of that type (`page_vars`), or, in a file that doesn't +/// define it, has the name InDesign gives that variable. +fn is_xref_page_number(e: &El, page_vars: &HashSet) -> bool { + e.get("AssociatedTextVariable").is_some_and(|v| page_vars.contains(v) || (page_vars.is_empty() && v.ends_with("TV XRefPageNumber"))) +} + +/// The text a `CrossReferenceSource` holds as shown, with its live page numbers (InDesign's +/// cross-reference page number variable, `page_vars`) as ``. Tracked insertions count; +/// tracked deletions don't. Returns the text and the page shown. +fn xref_saved_text(e: &El, page_vars: &HashSet, text: &mut String, page: &mut Option, depth: usize) { + if depth > 16 { + return; + } + for c in e.elements() { + match c.local() { + "Content" => text.push_str(&c.text_content()), + "TextVariableInstance" if is_xref_page_number(c, page_vars) => { + text.push_str(designcraft_doc::xref::PAGE_NUM_BLOCK); + if page.is_none() { + *page = c.get("ResultText").map(str::to_string); + } + } + "TextVariableInstance" => text.push_str(c.get("ResultText").unwrap_or("")), + "Change" if c.get("ChangeType") == Some("DeletedText") => {} + "CharacterStyleRange" | "Change" => xref_saved_text(c, page_vars, text, page, depth + 1), + _ => {} + } + } +} + impl StoryBuilder { /// A table gets an anchor paragraph of its own. fn push_table(&mut self, t: Table, pf: &ParaFormat, cf: &CharFormat) { diff --git a/crates/idml/src/import/styles.rs b/crates/idml/src/import/styles.rs index 5719393a2..4519d8931 100644 --- a/crates/idml/src/import/styles.rs +++ b/crates/idml/src/import/styles.rs @@ -86,6 +86,7 @@ impl<'r> Importer<'r> { let split = e.get("SplitDocument") == Some("true") || e.elements() .any(|m| m.local() == "StyleExportTagMap" && m.get("ExportType") == Some("EPUB") && m.get("SplitDocument") == Some("true")); + self.export_tag(e, &format!("p:{name}")); if split { self.styles.export_tags.entry(format!("p:{name}")).or_default().split = true; } @@ -99,6 +100,7 @@ impl<'r> Importer<'r> { Some(slot) => *slot = s, None => self.styles.character.push(s), } + self.export_tag(e, &format!("c:{name}")); } // Keep built-ins as well: [Basic Table] is editable, and [No table style] // supplies the defaults used by named styles in ordinary IDML files. @@ -201,6 +203,19 @@ impl<'r> Importer<'r> { } } + /// Export Tagging (tag and class) of style `e` from its EPUB export tag map, under `key`. + fn export_tag(&mut self, e: &El, key: &str) { + let Some(m) = e.elements().find(|m| m.local() == "StyleExportTagMap" && m.get("ExportType") == Some("EPUB")) else { return }; + let tag = m.get("ExportTag").unwrap_or("").to_string(); + let class = m.get("ExportClass").unwrap_or("").to_string(); + if tag.is_empty() && class.is_empty() { + return; + } + let t = self.styles.export_tags.entry(key.to_string()).or_default(); + t.tag = tag; + t.class = class; + } + fn based_on(&self, e: &El, para: bool) -> Option { let b = e.prop("BasedOn")?; let b = b.trim(); @@ -223,6 +238,11 @@ impl<'r> Importer<'r> { self.para_names.get(r).cloned().unwrap_or_else(|| names::style_name_in(PARA_BUILTINS, &unescape_id(r.trim_start_matches("ParagraphStyle/")))) } + /// A paragraph style reference written as an element's text: as written when it names a + /// style, otherwise without surrounding whitespace (see [`Self::listed_char_style_ref`]). + pub(super) fn listed_para_style_ref(&self, r: &str) -> String { + if self.para_names.contains_key(r) { self.para_style_ref(r) } else { self.para_style_ref(r.trim()) } + } /// A character style reference written as an element's text (nested, line and GREP styles). /// A style name may end in a space, so the reference is used as written when it names a /// style; only otherwise is surrounding whitespace dropped. @@ -238,7 +258,12 @@ impl<'r> Importer<'r> { // A style or range can set some of a rule's settings (such as only its colour); the others // come from its paragraph style chain, so the rule is whole. let fields = ["", "LineWeight", "Color", "Tint", "Width", "Offset", "LeftIndent", "RightIndent"]; - if !fields.iter().any(|f| e.prop(&format!("{k}{f}")).is_some()) { + // Any of them, or of the type, gap and Keep In Frame settings, makes it an override (the + // rest then come from the style chain, or the defaults: off, 1 pt). + let more = ["Type", "GapColor", "GapTint"]; + let own = fields.iter().chain(&more).any(|f| e.prop(&format!("{k}{f}")).is_some()) + || (k == "RuleAbove" && e.prop("KeepRuleAboveInFrame").is_some()); + if !own { return None; } let v: Vec> = fields.iter().map(|f| self.para_setting(e, &format!("{k}{f}"))).collect(); @@ -246,7 +271,6 @@ impl<'r> Importer<'r> { let num = |i: usize| get(i).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()); let on = get(0).map(|v| v.trim() == "true"); let weight = num(1); - on.or(weight.map(|_| false))?; let mut r = Rule { on: on.unwrap_or(false), ..Rule::default() }; if let Some(w) = weight { r.weight = w; @@ -258,17 +282,38 @@ impl<'r> Importer<'r> { r.offset = num(5).unwrap_or(0.0); r.left_indent = num(6).unwrap_or(0.0); r.right_indent = num(7).unwrap_or(0.0); - if let Some(c) = get(2) { - let c = c.trim(); - if c == designcraft_doc::TEXT_COLOR { - r.color = c.to_string(); - } else if c.contains('/') { - r.color = self.swatch_ref(c); - } + // Type, gap colour and tint, Keep In Frame (rule above). + if let Some(t) = self.para_setting(e, &format!("{k}Type")) { + r.kind = names::stroke_type_in(t.trim()); + } + if let Some(c) = self.para_setting(e, &format!("{k}GapColor")) + && let Some(c) = self.rule_color_ref(&c) + { + r.gap_color = c; + } + if let Some(t) = tint(self.para_setting(e, &format!("{k}GapTint")).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite())) { + r.gap_tint = t; + } + if k == "RuleAbove" { + r.keep_in_frame = self.para_setting(e, "KeepRuleAboveInFrame").is_some_and(|v| v.trim() == "true"); + } + if let Some(c) = get(2).and_then(|c| self.rule_color_ref(&c)) { + r.color = c; } Some(r) } + /// A rule's colour or gap colour: Text Color (written `Text Color` or `$ID/Text Color`), or + /// a swatch reference; anything else leaves the default. + fn rule_color_ref(&mut self, c: &str) -> Option { + let c = c.trim(); + if c == designcraft_doc::TEXT_COLOR || c.strip_prefix("$ID/") == Some(designcraft_doc::TEXT_COLOR) { + Some(designcraft_doc::TEXT_COLOR.to_string()) + } else { + c.contains('/').then(|| self.swatch_ref(c)) + } + } + /// Paragraph setting `k` of `e` (a paragraph style or a range): its own, else from its /// paragraph style chain (`BasedOn` of a style, the applied style of a range). fn para_setting(&self, e: &El, k: &str) -> Option { diff --git a/crates/idml/src/import/table.rs b/crates/idml/src/import/table.rs index 5b5ca744b..268f1af97 100644 --- a/crates/idml/src/import/table.rs +++ b/crates/idml/src/import/table.rs @@ -1,6 +1,39 @@ use super::values::{span, tint}; use super::{CellRange, El, Importer, RowHeightMode, StoryId, Table, names, swatch}; +/// Cells every table in one import may hold between them. A `Cell` carries a whole story, so +/// it is 1,360 bytes: 400,000 of them is about 520 MB, which is the 512 MiB the package reader +/// already allows one part of the file to decompress to, and the same count the spreadsheet +/// importer collects at most. +const MAX_IMPORT_TABLE_CELLS: usize = 400_000; + +/// Clamp warnings one import writes before it says the rest were the same. +const MAX_TABLE_WARNINGS: usize = 20; + +/// How large a table of `rows` x `cols` may be read when `reserved` cells have already been +/// taken, and whether the file-wide budget is what cut it down -- or `None` when the budget is +/// spent and the table is not read at all. +/// +/// The per-table cap leaves a file free to hold many capped tables, so each is also clamped +/// against what is left. While there is budget, one row and one column are always kept: the table +/// is then still in the document, still says where its text went, and a file pays one cell for a +/// table it really does contain. +/// +/// Past that there is nothing left to pay with. Keeping a 1 x 1 table for every further `
` +/// was unbounded in the one direction the budget was meant to close: `
` is fifteen +/// bytes, so the 512 MiB a part may decompress to holds some 36 million of them, and a `Cell` +/// each is tens of gigabytes with every table inside both caps (noted in review). +fn budgeted_size(rows: usize, cols: usize, reserved: usize) -> Option<(usize, usize, bool)> { + let (mut nr, mut nc) = designcraft_doc::table::clamped_size(rows, cols); + let left = MAX_IMPORT_TABLE_CELLS.checked_sub(reserved).filter(|left| *left > 0)?; + let over_budget = nr.saturating_mul(nc) > left; + if over_budget { + nc = nc.min(left); + nr = nr.min((left / nc).max(1)); + } + Some((nr, nc, over_budget)) +} + /// Preserve the legacy uniform field only when all four sides explicitly specify every /// property. Partial records must stay sparse so they can inherit from a parent style. pub(super) fn uniform_cell_stroke(edges: &[designcraft_doc::CellStrokeAttrs; 4]) -> Option { @@ -21,12 +54,44 @@ pub(super) fn uniform_cell_stroke(edges: &[designcraft_doc::CellStrokeAttrs; 4]) impl<'r> Importer<'r> { /// ``: rows, columns and cells (cell `Name` is `column:row`). - pub(super) fn table(&mut self, e: &El) -> Table { - let id = self.alloc(); + pub(super) fn table(&mut self, e: &El) -> Option
{ let rows: Vec<&El> = e.find_all("Row").collect(); let cols: Vec<&El> = e.find_all("Column").collect(); - let nr = rows.len().max(1); - let nc = cols.len().max(1); + // A file's row and column counts are its claim, and a table of them is allocated whole: a + // story holding one `
` with 5,000 of each asked for 25M cells, around 34 GB, which + // aborts the process instead of failing (#1583). What is read is clamped, and the rest of + // this function walks the clamped slices -- indexing `t.rows`/`t.columns` by the file's + // own counts would be out of bounds. + // + // Reserved before `Table::new`, so a cell's own story cannot spend the budget this table + // is counting on. + let Some((nr, nc, over_budget)) = budgeted_size(rows.len().max(1), cols.len().max(1), self.table_cells) else { + // Said once: the file that reaches this is the one with millions of tables in it. + if !self.table_budget_spent { + self.table_budget_spent = true; + self.warnings.push(format!( + "the tables in this file claim more than {MAX_IMPORT_TABLE_CELLS} cells between them: the rest of them were not read" + )); + } + return None; + }; + self.table_cells = self.table_cells.saturating_add(nr.saturating_mul(nc)); + if nr < rows.len() || nc < cols.len() { + let why = if over_budget { "more than the tables in one file can hold between them" } else { "more than a table can hold" }; + self.table_warnings += 1; + match self.table_warnings.cmp(&MAX_TABLE_WARNINGS) { + std::cmp::Ordering::Less => { + self.warnings.push(format!("a table of {} rows and {} columns was read as {nr} x {nc}: {why}", rows.len(), cols.len())) + } + std::cmp::Ordering::Equal => { + self.warnings.push(format!("more than {MAX_TABLE_WARNINGS} tables were read smaller than this file claims")) + } + std::cmp::Ordering::Greater => {} + } + } + let id = self.alloc(); + let rows = &rows[..nr.min(rows.len())]; + let cols = &cols[..nc.min(cols.len())]; let mut t = Table::new(id, nr, nc, 0, 0, 0.0); t.options.direction = if e.get("TableDirection") == Some("RightToLeftDirection") { designcraft_doc::TextDirection::RightToLeft @@ -113,7 +178,7 @@ impl<'r> Importer<'r> { for rg in regions { let _ = t.merge(rg); } - t + Some(t) } /// A cell's local fill, insets, alignment, rotation and edge strokes. @@ -157,3 +222,64 @@ impl<'r> Importer<'r> { } } } + +#[cfg(test)] +mod budget_tests { + use super::{MAX_IMPORT_TABLE_CELLS, budgeted_size}; + + /// One table is capped, but a file chooses how many tables it has: three tables each at the + /// per-table cap asked for 750,000 cells, about 1 GB of `Cell`, with every table inside its + /// own limit (noted in review of #1583). + #[test] + fn the_tables_in_one_file_are_capped_between_them() { + let mut reserved = 0; + let mut sizes = Vec::new(); + for _ in 0..4 { + let size = budgeted_size(500, 1000, reserved); + if let Some((nr, nc, _)) = size { + reserved += nr * nc; + } + sizes.push(size); + } + assert_eq!(sizes[0], Some((250, 1000, false)), "the first table is held by the per-table cap alone"); + assert_eq!(sizes[1], Some((150, 1000, true)), "the second gets what is left of the file's budget"); + assert_eq!(sizes[2], None, "and the third is not read: there is nothing left to pay for it"); + assert_eq!(sizes[3], None, "nor any after it"); + assert_eq!(reserved, MAX_IMPORT_TABLE_CELLS, "the budget is spent exactly, not overspent"); + } + + /// The budget is a hard limit, not a floor: keeping one cell for every further table was + /// unbounded in the one direction the budget exists to close, since a `
` is + /// fifteen bytes and a part may decompress to 512 MiB (noted in review). + #[test] + fn a_spent_budget_stops_reading_tables_altogether() { + let claims = [(0, 0), (1, 1), (7, 3), (usize::MAX, usize::MAX), (1, usize::MAX), (usize::MAX, 1), (10_000, 1_000), (2, 0)]; + let mut reserved = 0usize; + let mut read = 0usize; + let mut skipped = 0usize; + // Every claim, several times over, in an order that exhausts the budget part way through. + for _ in 0..4 { + for (rows, cols) in claims { + match budgeted_size(rows.max(1), cols.max(1), reserved) { + Some((nr, nc, _)) => { + assert!(nr >= 1 && nc >= 1, "{rows} x {cols} was read as {nr} x {nc}: a table has nowhere to put its text"); + reserved += nr * nc; + read += 1; + } + None => skipped += 1, + } + assert!(reserved <= MAX_IMPORT_TABLE_CELLS, "{read} tables reserved {reserved} cells, past the budget"); + } + } + assert_eq!(reserved, MAX_IMPORT_TABLE_CELLS, "this sequence does reach the budget"); + assert!(skipped > 0, "and does go on asking after it: {skipped} tables were not read"); + } + + /// Below the budget nothing is clamped by it, so an ordinary file is read exactly as before. + #[test] + fn an_ordinary_file_is_not_touched_by_the_budget() { + for (rows, cols) in [(1, 1), (2, 3), (50, 20), (250, 1000)] { + assert_eq!(budgeted_size(rows, cols, 0), Some((rows, cols, false)), "{rows} x {cols}"); + } + } +} diff --git a/crates/idml/src/import/text_attrs.rs b/crates/idml/src/import/text_attrs.rs index 323c14b17..21ad2a13c 100644 --- a/crates/idml/src/import/text_attrs.rs +++ b/crates/idml/src/import/text_attrs.rs @@ -304,6 +304,7 @@ impl<'r> Importer<'r> { a.shading_color = Some(self.swatch_ref(c.trim())); } a.shading_tint = tint(e.num("ParagraphShadingTint")); + a.shading_overprint = e.boolean("ParagraphShadingOverprint"); let sides = |f: &dyn Fn(&str) -> String| -> Option<[f64; 4]> { let v = ["Top", "Left", "Bottom", "Right"].map(|s| e.num(&f(s))); v.iter().any(Option::is_some).then(|| v.map(|x| x.unwrap_or(0.0))) @@ -314,6 +315,7 @@ impl<'r> Importer<'r> { a.border_color = Some(self.swatch_ref(c.trim())); } a.border_tint = tint(e.num("ParagraphBorderTint")); + a.border_overprint = e.boolean("ParagraphBorderOverprint"); a.border_weights = sides(&|s| format!("ParagraphBorder{s}LineWeight")); a.border_offsets = sides(&|s| format!("ParagraphBorder{s}Offset")); a.border_type = e.prop("ParagraphBorderType").map(|v| names::stroke_type_in(v.trim())); @@ -329,6 +331,7 @@ impl<'r> Importer<'r> { a.border_gap_color = Some(self.swatch_ref(c.trim())); } a.border_gap_tint = tint(e.num("ParagraphBorderGapTint")); + a.border_gap_overprint = e.boolean("ParagraphBorderGapOverprint"); a.shading_corners = corners(e, "ParagraphShading"); a.shading_width = e.prop("ParagraphShadingWidth").map(|v| names::box_width_in(v.trim())); a.shading_top = e.prop("ParagraphShadingTopOrigin").map(|v| names::box_top_in(v.trim())); diff --git a/crates/idml/src/names.rs b/crates/idml/src/names.rs index b9102c9ae..4163dd2f5 100644 --- a/crates/idml/src/names.rs +++ b/crates/idml/src/names.rs @@ -655,6 +655,8 @@ pub fn number_style_in(s: &str) -> NumberStyle { } } +/// IDML `MeasurementUnits` of a unit. Q is `Q`; `U` is a different unit, which DesignCraft +/// doesn't have. pub fn unit_out(u: Unit) -> &'static str { match u { Unit::Points => "Points", @@ -666,27 +668,14 @@ pub fn unit_out(u: Unit) -> &'static str { Unit::Ciceros => "Ciceros", Unit::Agates => "Agates", Unit::Pixels => "Pixels", - Unit::Q => "U", + Unit::Q => "Q", Unit::Ha => "Ha", } } -pub fn unit_in(s: &str) -> Unit { - [ - Unit::Points, - Unit::Picas, - Unit::Inches, - Unit::InchesDecimal, - Unit::Millimeters, - Unit::Centimeters, - Unit::Ciceros, - Unit::Agates, - Unit::Pixels, - Unit::Q, - Unit::Ha, - ] - .into_iter() - .find(|u| unit_out(*u) == s) - .unwrap_or(Unit::Points) +/// The unit an IDML `MeasurementUnits` value names, if DesignCraft has it (not `U`, `Bai`, +/// `Mils`, `AmericanPoints` or `Custom`). +pub fn unit_in(s: &str) -> Option { + Unit::ALL.into_iter().find(|u| unit_out(*u) == s) } /// IDML layer colour enumeration names for our layer colour table. @@ -858,28 +847,25 @@ pub fn digits_in(s: &str) -> Option { [Digits::Default, Digits::Arabic, Digits::Hindi, Digits::Farsi, Digits::Native].into_iter().find(|d| digits_out(*d) == s) } -/// A TOC separator in IDML's special-character notation: `^t` is a tab, `^^` a caret. +/// A TOC separator in IDML's special-character notation. pub fn toc_separator_out(s: &str) -> String { - s.replace('^', "^^").replace('\t', "^t") + designcraft_doc::story::toc_separator_notation(s) } -/// IDML TOC separator → text (`^t` and `^y`, the right indent tab, are tabs; other codes stay). +/// IDML may prefix a separator with its localized-string marker. pub fn toc_separator_in(s: &str) -> String { - let mut out = String::new(); - let mut it = s.chars(); - while let Some(c) = it.next() { - if c != '^' { - out.push(c); - continue; - } - match it.next() { - Some('t' | 'y') => out.push('\t'), - Some('^') => out.push('^'), - Some(o) => { - out.push('^'); - out.push(o); - } - None => out.push('^'), - } + designcraft_doc::story::toc_separator_text(s.strip_prefix("$ID/").unwrap_or(s)) +} + +#[cfg(test)] +mod toc_separator_tests { + use super::*; + + #[test] + fn localized_right_indent_tab_is_not_an_ordinary_tab() { + assert_eq!(toc_separator_in("$ID/^y"), "\u{E006}"); + assert_eq!(toc_separator_in("$ID/ ^y "), " \u{E006} "); + assert_eq!(toc_separator_in("^t"), "\t"); + assert_eq!(toc_separator_out("\u{E006}"), "^y"); + assert_eq!(toc_separator_in("^^y ^q ^"), "^y ^q ^"); } - out } diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 0d078c63c..24cec7141 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -330,12 +330,19 @@ fn indesign_text_defaults() -> Vec { "RuleBelow": "false" => false, "true" => true, |r| r.0.rule_below.on; "RuleBelowColor": "Text Color" => designcraft_doc::TEXT_COLOR, "Color/Black" => "[Black]", |r| r.0.rule_below.color; "RuleBelowTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.0.rule_below.tint; + "RuleBelowType": "StrokeStyle/$ID/Solid" => designcraft_doc::StrokeType::Solid, "StrokeStyle/$ID/Wavy" => designcraft_doc::StrokeType::Wavy, |r| r.0.rule_below.kind; + "RuleBelowGapColor": "Swatch/None" => "[None]", "Color/Black" => "[Black]", |r| r.0.rule_below.gap_color; + "RuleBelowGapTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.0.rule_below.gap_tint; + "KeepRuleAboveInFrame": "false" => false, "true" => true, |r| r.0.rule_above.keep_in_frame; "ParagraphShadingOn": "false" => false, "true" => true, |r| r.0.shading_on; "ParagraphShadingColor": "Color/Black" => "[Black]", "Swatch/None" => "[None]", |r| r.0.shading_color; "ParagraphShadingTint": "20" => 0.2f32, "50" => 0.5f32, |r| r.0.shading_tint; "ParagraphBorderOn": "false" => false, "true" => true, |r| r.0.border_on; "ParagraphBorderColor": "Color/Black" => "[Black]", "Swatch/None" => "[None]", |r| r.0.border_color; "ParagraphBorderTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.0.border_tint; + "ParagraphShadingOverprint": "false" => false, "true" => true, |r| r.0.shading_overprint; + "ParagraphBorderOverprint": "false" => false, "true" => true, |r| r.0.border_overprint; + "ParagraphBorderGapOverprint": "false" => false, "true" => true, |r| r.0.border_gap_overprint; "FontStyle": "Regular" => "Regular", "Bold" => "Bold", |r| r.1.font_style; "PointSize": "12" => 12.0, "9" => 9.0, |r| r.1.size; "Leading": "Auto" => Leading::Auto, "14" => Leading::Points(14.0), |r| r.1.leading; @@ -554,6 +561,14 @@ fn basic_paragraph_is_normal_paragraph_style() { assert_eq!(back.styles.paragraph.iter().filter(|p| p.name == st::BASIC_PARAGRAPH).count(), 1); } +/// Like InDesign, the export carries the document's default story preference, which IDML readers expect. +#[test] +fn export_writes_the_document_story_preference() { + let d = import_idml_with(&fixture(), &|_| None).unwrap(); + let prefs = zip_text(&export_idml(&d), "Resources/Preferences.xml"); + assert!(prefs.contains(r#""#), "{prefs}"); +} + /// Page items, frames, tables and preferences an IDML file leaves unset are InDesign's defaults /// for a new document. #[test] @@ -1291,7 +1306,7 @@ fn round_trips_cross_references() { d.add_text_frame(SpreadRef::Doc(0), Rect::new(40.0, 40.0, 400.0, 400.0), lid, "Results: found\nSee .", ParaFormat::default()).unwrap(); let target = d.paragraph_anchor(sid, 0).unwrap(); let at = d.story(sid).unwrap().text.find(" .").unwrap() + 1; - d.story_mut(sid).unwrap().insert_xref(at, designcraft_doc::CrossRef { target, format: "Paragraph Text".into() }); + d.story_mut(sid).unwrap().insert_xref(at, designcraft_doc::CrossRef { target, format: "Paragraph Text".into(), ..Default::default() }); d.xref_formats.push(designcraft_doc::XrefFormat { name: "Mine".into(), definition: "(, p. )".into(), @@ -1303,6 +1318,7 @@ fn round_trips_cross_references() { assert_eq!(s.text, d.story(sid).unwrap().text); assert_eq!(s.xrefs.len(), 1); assert_eq!(s.xrefs[0].format, "Paragraph Text"); + assert_eq!(s.xrefs[0].text, None, "generated text reads back as generated"); let (tsid, tpos) = back.find_anchor(s.xrefs[0].target).expect("target resolves"); assert_eq!((tsid, tpos), (s.id, 0)); let mine = back.xref_format("Mine").unwrap(); @@ -2972,6 +2988,38 @@ fn round_trips_the_zero_point_and_ruler_origin() { assert_eq!(fixture("NaN 4").settings.zero_point, [0.0, 0.0]); } +/// Q is `Q` in IDML, as InDesign writes it (`U` is another unit). Units DesignCraft lacks are +/// read as points, and the import report says so. +#[test] +fn ruler_units_in_q_and_ha_round_trip_and_unknown_units_are_reported() { + use designcraft_geom::Unit; + let mut d = Document::new(&NewDocument::default()); + for (h, v) in [(Unit::Q, Unit::Ha), (Unit::Ha, Unit::Q), (Unit::Millimeters, Unit::Points)] { + (d.settings.horizontal_units, d.settings.vertical_units) = (h, v); + let bytes = export_idml(&d); + let back = import_idml(&bytes).unwrap(); + assert_eq!((back.settings.horizontal_units, back.settings.vertical_units), (h, v)); + } + d.settings.horizontal_units = Unit::Q; + let prefs = zip_text(&export_idml(&d), "Resources/Preferences.xml"); + assert!(prefs.contains(r#"HorizontalMeasurementUnits="Q""#), "{prefs}"); + + let prefs = + PREFS.replace(""#, + r#" + $ID/[No paragraph style] + + + ParagraphStyle/Base + + + $ID/[No paragraph style] + + + $ID/[No paragraph style] + + + $ID/[No paragraph style]Text Color + + "#, + ); + let d = import_idml(&fixture_with_table_styles(&styles, STORY)).unwrap(); + let style = |n: &str| d.styles.paragraph.iter().find(|s| s.name == n).unwrap().para.clone(); + let wavy = style("Wavy").rule_below.expect("the type alone overrides the rule"); + assert_eq!((wavy.on, wavy.weight, wavy.kind), (true, 3.0, designcraft_doc::StrokeType::Wavy), "the rest from the based-on style"); + let gap = style("Gap").rule_below.expect("the gap colour alone, with no on or weight in the chain"); + assert!(!gap.on && gap.gap_color != designcraft_color::swatch::NONE, "{gap:?}"); + assert!(style("Kept").rule_above.expect("Keep In Frame alone").keep_in_frame); + // Text Color, in either spelling, for a gap and a rule colour. + let text = style("TextGap"); + let (below, above) = (text.rule_below.expect("rule below"), text.rule_above.expect("rule above")); + assert_eq!(below.gap_color, designcraft_doc::TEXT_COLOR); + assert_eq!((above.gap_color.as_str(), above.color.as_str()), (designcraft_doc::TEXT_COLOR, designcraft_doc::TEXT_COLOR)); + // And it is written back as Text Color, not as no swatch. + let back = import_idml(&export_idml(&d)).unwrap(); + let below = back.styles.paragraph.iter().find(|s| s.name == "TextGap").unwrap().para.rule_below.clone().unwrap(); + assert_eq!(below.gap_color, designcraft_doc::TEXT_COLOR, "round-trips"); +} + #[test] fn missing_linked_graphics_show_their_preview_and_keep_their_link() { // InDesign's preview of the missing photo (a PNG header is all the importer reads: 4 × 3). @@ -4582,6 +4670,98 @@ fn cell_first_baseline_comes_from_cell_styles_table_regions_and_cells_and_round_ assert_eq!(s2.first_baseline, Some(FirstBaseline::CapHeight)); } +/// A TOC style as InDesign writes it: `$ID/`-prefixed separator (`^y`, the right indent tab) and entry style. +const TOC_STYLES: &str = r#" + + + + + + + + + + + ParagraphStyle/$ID/TOC%3aTOC body text + CharacterStyle/TOC Page Number + $ID/[Same Style] + +"#; + +const TOC_STORY: &str = r#" +Welcome"#; + +#[test] +fn toc_style_entries_read_id_prefixed_separators_and_styles() { + let d = import_idml(&fixture_with_table_styles(TOC_STYLES, TOC_STORY)).unwrap(); + let toc = d.toc.as_ref().expect("the story's TOC style is imported"); + let entry = toc.entries.iter().find(|e| e.style == "Heading 1").unwrap(); + assert_eq!(entry.separator.as_deref(), Some("\u{E006}"), "right-indent tabs must not become ordinary tab stops"); + assert_eq!(entry.entry_style.as_deref(), Some("TOC/TOC body text")); + assert_eq!(entry.page_number_style.as_deref(), Some("TOC Page Number")); + assert_eq!(toc.title_style.as_deref(), Some("TOC/TOC Header")); +} + +#[test] +fn toc_style_entries_fall_back_on_malformed_and_unsupported_values() { + let long = "x".repeat(65); + let entries = format!( + r#" + ParagraphStyle/Missing + CharacterStyle/$ID/[None] + CharacterStyle/Missing + + + + "# + ); + let start = TOC_STYLES.find("").unwrap(); + let styles = format!("{}{entries}\n{}", &TOC_STYLES[..start], &TOC_STYLES[end..]) + .replace(r#"TitleStyle="ParagraphStyle/TOC%3aTOC Header""#, r#"TitleStyle="ParagraphStyle/Missing""#); + let d = import_idml(&fixture_with_table_styles(&styles, TOC_STORY)).unwrap(); + d.check().unwrap(); + let toc = d.toc.as_ref().expect("the TOC style is still imported"); + assert_eq!(toc.title_style, None, "a title style the document lacks is dropped"); + assert_eq!(toc.entries.len(), 3, "an entry without a style name is skipped: {:?}", toc.entries); + let e1 = &toc.entries[0]; + assert_eq!(e1.level, 1, "an unreadable level falls back to 1"); + assert_eq!(e1.page_number, designcraft_doc::TocPageNumber::After, "an unknown page-number position falls back to After"); + assert_eq!(e1.separator.as_deref(), Some("^q"), "an unknown special-character code is kept as written"); + assert_eq!( + (e1.entry_style.as_deref(), e1.page_number_style.as_deref(), e1.separator_style.as_deref()), + (None, None, None), + "undefined and [None] styles are no style" + ); + let e2 = &toc.entries[1]; + assert_eq!(e2.level, 9, "a level past 9 is clamped"); + assert_eq!(e2.separator, None, "an over-long separator is dropped"); + assert_eq!((toc.entries[2].level, toc.entries[2].separator.as_deref()), (2, None), "a plain tab is the default separator"); +} + +#[test] +fn toc_style_references_in_indented_properties_resolve() { + // Property children laid out on their own lines: the whitespace around a reference isn't + // part of the style name. + let styles = TOC_STYLES + .replace( + ">ParagraphStyle/$ID/TOC%3aTOC body text<", + "> + ParagraphStyle/$ID/TOC%3aTOC body text + <", + ) + .replace( + ">CharacterStyle/TOC Page Number<", + "> + CharacterStyle/TOC Page Number + <", + ); + let d = import_idml(&fixture_with_table_styles(&styles, TOC_STORY)).unwrap(); + let entry = d.toc.as_ref().unwrap().entries.iter().find(|e| e.style == "Heading 1").unwrap(); + assert_eq!(entry.entry_style.as_deref(), Some("TOC/TOC body text")); + assert_eq!(entry.page_number_style.as_deref(), Some("TOC Page Number")); +} + /// A graphic placed in a table cell is written into the cell, not dropped. #[test] fn exports_graphic_in_table_cell() { @@ -4746,6 +4926,41 @@ fn split_document_round_trips() { assert!(split(&back, "Part") && split(&back, "Chapter") && !split(&back, "Body")); } +/// Export Tagging (tag and class) of paragraph and character styles comes from the style's EPUB +/// export tag map, not another export type's, and is written back. +#[test] +fn export_tags_round_trip() { + let xml = r#" + + + + + + + + + + + + + + + + +"#; + let d = import_idml_with(&fixture_with_styles(xml), &|_| None).unwrap(); + let tag = |d: &designcraft_doc::Document, name: &str, character: bool| { + d.styles.export_tag(name, character).map(|t| (t.tag.clone(), t.class.clone(), t.split)) + }; + let check = |d: &designcraft_doc::Document| { + assert_eq!(tag(d, "Chapter", false), Some(("h1".into(), "title".into(), false))); + assert_eq!(tag(d, "Lead", true), Some(("em".into(), "lead".into(), false))); + assert_eq!(tag(d, "Body", false), None); + }; + check(&d); + check(&import_idml(&export_idml(&d)).unwrap()); +} + /// EPUB Export Options › Split Document: off, one style (built in or not) or the styles' export /// tags, read from the file's `EPubExportPreference` and written back. #[test] @@ -4777,3 +4992,55 @@ fn epub_split_document_round_trips() { assert_eq!(import_idml(&export_idml(&d)).unwrap().settings.epub_split, want, "{attrs}"); } } + +/// Text typed over a cross-reference's generated text is kept as shown, its page number live +/// (InDesign's cross-reference page number variable, found by its `AssociatedTextVariable`), +/// tracked insertions in and deletions out. The edits survive export and import. +#[test] +fn keeps_edited_cross_reference_text() { + let story = r#" + + +Page : Bove, Vinny. "Advent Horizon." (draft) 1996. + and +"Intro" on page +"#; + let d = import_idml(&fixture_with_story(story)).unwrap(); + d.check().unwrap(); + let s = d.stories.values().find(|s| !s.xrefs.is_empty()).expect("story with cross-references"); + assert_eq!(s.xrefs.len(), 2); + assert_eq!(s.xrefs[0].text.as_deref(), Some("Page : Bove, Vinny. \"Advent Horizon.\" 1996.")); + assert_eq!(s.xrefs[0].page.as_deref(), Some("7")); + // Its destination is gone: the text and page it last showed. + assert_eq!(designcraft_compose::xref::xref_text(&d, &s.xrefs[0], None), "Page 7: Bove, Vinny. \"Advent Horizon.\" 1996."); + assert_eq!(s.xrefs[1].text.as_deref(), Some("\"Intro\" on page "), "with no destination to generate from, the text as shown"); + let back = import_idml(&export_idml(&d)).unwrap(); + back.check().unwrap(); + let b = back.stories.values().find(|s| !s.xrefs.is_empty()).unwrap(); + assert_eq!(b.xrefs[0].text, s.xrefs[0].text); + assert_eq!(b.xrefs[0].page, s.xrefs[0].page); +} + +/// Saved cross-reference text counts as generated only when it is exactly what its format +/// generates for its destination: text added after it (or anywhere else) is an edit, and kept. +#[test] +fn only_exactly_generated_cross_reference_text_is_regenerated() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(40.0, 40.0, 400.0, 400.0), lid, "Results\nSee", ParaFormat::default()).unwrap(); + let target = d.paragraph_anchor(sid, 0).unwrap(); + d.xref_formats.push(designcraft_doc::XrefFormat { name: "Page".into(), definition: "Page ".into() }); + for t in ["Page , revised", "Page ", "Seite "] { + let st = d.story_mut(sid).unwrap(); + let end = st.len(); + st.insert(end, " "); + let x = designcraft_doc::CrossRef { target, format: "Page".into(), text: Some(t.into()), page: Some("1".into()) }; + st.insert_xref(end + 1, x); + } + d.check().unwrap(); + let back = import_idml(&export_idml(&d)).unwrap(); + back.check().unwrap(); + let s = back.stories.values().find(|s| !s.xrefs.is_empty()).unwrap(); + let got: Vec> = s.xrefs.iter().map(|x| x.text.as_deref()).collect(); + assert_eq!(got, [Some("Page , revised"), None, Some("Seite ")]); +} diff --git a/crates/idml/src/tests/facing_ltr.rs b/crates/idml/src/tests/facing_ltr.rs index b1c322ecc..45d1dc065 100644 --- a/crates/idml/src/tests/facing_ltr.rs +++ b/crates/idml/src/tests/facing_ltr.rs @@ -27,6 +27,11 @@ fn story(id: &str, text: &str) -> String { /// right page a guide at 300 and a "Right head". Three pages: 1 alone, then 2–3. Rulers measure /// from each page. fn left_to_right_fixture() -> Vec { + fixture(true) +} + +/// [`left_to_right_fixture`], with facing pages on or off. +fn fixture(facing: bool) -> Vec { let margins = r#""#; let page = |id: &str, x: f64, applied: bool, guide: Option| { let applied = if applied { r#" AppliedMaster="m1""# } else { "" }; @@ -48,7 +53,7 @@ fn left_to_right_fixture() -> Vec { r#""# ); let prefs = format!( - r#""# + r#""# ); zip_files(&[ ("designmap.xml", &map), @@ -109,3 +114,19 @@ fn left_to_right_right_pages_take_mirrored_margins_and_the_right_parent_page() { let again = import_idml(&export_idml(&d)).unwrap(); assert_right_pages_mirror_left_pages(&again); } + +/// Without facing pages, a page of a two-page spread shows the parent page in its place, as in +/// InDesign: page 3, the second page of its spread, shows the parent's second page, not the +/// first page's items again. +#[test] +fn single_sided_pages_show_the_parent_page_in_their_place() { + let d = import_idml(&fixture(false)).unwrap(); + assert!(d.spreads.iter().flat_map(|s| &s.pages).all(|p| p.side == PageSide::Single)); + assert_eq!(shown_heads(&d, 0), ["Left head"], "a page alone shows the parent's first page"); + assert_eq!(shown_heads(&d, 1), ["Left head"]); + assert_eq!(shown_heads(&d, 2), ["Right head"]); + // What the renderer draws behind each page of the second spread. + let (ppi, _) = d.parent_page_for(1).unwrap(); + assert_eq!(d.shown_parent_pages(designcraft_doc::SpreadRef::Doc(1), 0), [(ppi, 0)]); + assert_eq!(d.shown_parent_pages(designcraft_doc::SpreadRef::Doc(1), 1), [(ppi, 1)]); +} diff --git a/crates/images/Cargo.toml b/crates/images/Cargo.toml index 48dc5955d..685c7d947 100644 --- a/crates/images/Cargo.toml +++ b/crates/images/Cargo.toml @@ -14,6 +14,7 @@ designcraft-fonts = { workspace = true } image = { workspace = true } tiff = { workspace = true } resvg = { workspace = true } +skrifa = { workspace = true } quick-xml = { workspace = true } flate2 = "1" log = { workspace = true } diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index 817aa7829..3f9a23d63 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -104,7 +104,10 @@ fn svg_options() -> &'static usvg::Options<'static> { /// family's but a face's PostScript name (as Illustrator's SVG export writes them: /// `font-family="'MyriadPro-Regular'"`) sets the text in that face, whatever font-weight and /// font-style say: the name already picks the weight and style, and a family's other faces would -/// contradict it. Families listed before it come first. +/// contradict it. A name that is a face's legacy family name (name ID 1) or full name (ID 4), as +/// Windows names faces outside the regular/bold/italic quartet (`'Segoe UI Semibold'`, whose +/// typographic family is `Segoe UI`), sets the text in that face (the closest to font-weight and +/// font-style when the legacy family has several). Families listed before it come first. fn select_font() -> usvg::FontSelectionFn<'static> { use usvg::fontdb::{Family, Query, Stretch, Style, Weight}; Box::new(move |font, db| { @@ -112,27 +115,67 @@ fn select_font() -> usvg::FontSelectionFn<'static> { let spelled = |db: &usvg::fontdb::Database, name: &str| { db.faces().flat_map(|f| &f.families).find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(n, _)| n.clone()) }; - for family in font.families() { + // (index in the font-family list, face) of the faces a name names by its legacy names. + let mut legacy: Vec<(usize, usvg::fontdb::ID)> = Vec::new(); + for (i, family) in font.families().iter().enumerate() { if let usvg::FontFamily::Named(name) = family && spelled(db, name).is_none() { if fonts.has_family(name) { for style in fonts.styles(name) { - add_face(db, fonts.face(name, &style)); + let face = fonts.face(name, &style); + let named = has_legacy_name(&face, name); + if let Some(id) = add_face(db, face) + && named + { + legacy.push((i, id)); + } + } + } else if postscript_face(db, name).is_none() { + if let Some((family, style)) = fonts.postscript_face(name) { + add_face(db, fonts.face(&family, &style)); + } else { + // A full name: the family is the name without its last words ("Bold"). + let mut base = name.as_str(); + for _ in 0..3 { + let Some((head, _)) = base.trim_end().rsplit_once(' ') else { break }; + base = head; + if fonts.has_family(base) { + for style in fonts.styles(base) { + let face = fonts.face(base, &style); + if has_legacy_name(&face, name) + && let Some(id) = add_face(db, face) + { + legacy.push((i, id)); + } + } + break; + } + } } - } else if postscript_face(db, name).is_none() - && let Some((family, style)) = fonts.postscript_face(name) - { - add_face(db, fonts.face(&family, &style)); } } } - for family in font.families() { + let want_style = match font.style() { + usvg::FontStyle::Normal => Style::Normal, + usvg::FontStyle::Italic => Style::Italic, + usvg::FontStyle::Oblique => Style::Oblique, + }; + for (i, family) in font.families().iter().enumerate() { match family { usvg::FontFamily::Named(name) if spelled(db, name).is_none() => { if let Some(id) = postscript_face(db, name) { return Some(id); } + let closest = legacy + .iter() + .filter(|(at, _)| *at == i) + .filter_map(|(_, id)| db.face(*id)) + .min_by_key(|f| (f.style != want_style, f.weight.0.abs_diff(font.weight()))) + .map(|f| f.id); + if closest.is_some() { + return closest; + } } // A family (or generic family) that matches: the query below. _ => break, @@ -173,15 +216,22 @@ fn select_font() -> usvg::FontSelectionFn<'static> { usvg::FontStretch::ExtraExpanded => Stretch::ExtraExpanded, usvg::FontStretch::UltraExpanded => Stretch::UltraExpanded, }; - let style = match font.style() { - usvg::FontStyle::Normal => Style::Normal, - usvg::FontStyle::Italic => Style::Italic, - usvg::FontStyle::Oblique => Style::Oblique, - }; - db.query(&Query { families: &families, weight: Weight(font.weight()), stretch, style }) + db.query(&Query { families: &families, weight: Weight(font.weight()), stretch, style: want_style }) }) } +/// Is `name` (ignoring case) a legacy family name (name ID 1) or full name (ID 4) of `face`? +fn has_legacy_name(face: &designcraft_fonts::FontFace, name: &str) -> bool { + use skrifa::MetadataProvider; + use skrifa::string::StringId; + let Some(f) = face.skrifa() else { return false }; + [StringId::FAMILY_NAME, StringId::FULL_NAME] + .into_iter() + .flat_map(|id| f.localized_strings(id)) + .take(256) + .any(|s| s.chars().map(|c| c.to_ascii_lowercase()).eq(name.chars().map(|c| c.to_ascii_lowercase()))) +} + /// The face in the SVG's font database with PostScript name `name` (ignoring case). fn postscript_face(db: &usvg::fontdb::Database, name: &str) -> Option { db.faces().find(|f| f.post_script_name.eq_ignore_ascii_case(name)).map(|f| f.id) @@ -324,10 +374,22 @@ pub fn pixel_size(bytes: &[u8]) -> Option<(u32, u32)> { .or_else(|| tiff_decoder(bytes)?.dimensions().ok()) } +/// The largest decoded raster, in RGBA bytes: 256 Mpx, a 300 ppi scan of an A0 sheet fits. +const MAX_RGBA_BYTES: u64 = 1 << 30; + +/// Whether a raster's header gives it more pixels than [`decode_rgba`] decodes. +pub fn too_large_to_decode(bytes: &[u8]) -> bool { + // `image` limits only its own output, which can be a quarter of the RGBA copy or less. + pixel_size(bytes).is_some_and(|(w, h)| u64::from(w).saturating_mul(u64::from(h)).saturating_mul(4) > MAX_RGBA_BYTES) +} + /// Straight (non-premultiplied) RGBA8 of a raster in display sRGB: an embedded ICC profile is /// applied, and CMYK goes through its own profile or the working CMYK space ([`colour`]). pub fn decode_rgba(bytes: &[u8]) -> Option { use colour::{DisplayColour, Pixels, to_display_rgba}; + if too_large_to_decode(bytes) { + return None; + } if is_psd(bytes) { return psd::decode(bytes); } @@ -520,6 +582,9 @@ pub fn is_cmyk_tiff(bytes: &[u8]) -> bool { /// over the decoder's 256 MiB limit; [`is_cmyk_tiff`] tells those apart from RGB files. pub fn decode_cmyk_tiff(bytes: &[u8]) -> Option { use tiff::tags::Tag; + if too_large_to_decode(bytes) { + return None; + } let mut d = tiff_decoder(bytes)?; let has_alpha = match d.colortype().ok()? { tiff::ColorType::CMYK(8 | 16) => false, diff --git a/crates/images/src/tests.rs b/crates/images/src/tests.rs index ba6e032eb..d69a6f504 100644 --- a/crates/images/src/tests.rs +++ b/crates/images/src/tests.rs @@ -248,6 +248,34 @@ fn svg_text_finds_fonts_by_postscript_name() { assert_eq!(text_face(r#" font-family="NoSuch-Font""#), "SourceSerif4-Regular"); } +#[test] +fn svg_text_matches_legacy_family_names() { + use designcraft_fonts::testing::{font_with_names, with_table_u16}; + // Laid out as Windows names faces outside the regular/bold/italic quartet (seguisb.ttf): the + // weight is in the legacy family (ID 1) and full name (ID 4), the typographic family (ID 16) + // is the base name. + let face = |names: &[(u16, &str)], weight: u16| with_table_u16(font_with_names(names, &['a', 'b']).unwrap(), b"OS/2", 4, weight).unwrap(); + let fonts = designcraft_fonts::FontDb::global(); + fonts.add_font(face(&[(1, "DC Test Legacy"), (2, "Regular"), (4, "DC Test Legacy Regular"), (6, "DCTestLegacy-Regular")], 400)); + fonts.add_font(face(&[(1, "DC Test Legacy"), (2, "Bold"), (4, "DC Test Legacy Bold"), (6, "DCTestLegacy-Bold")], 700)); + for (name, weight) in [("Semibold", 600), ("Light", 300)] { + let legacy = format!("DC Test Legacy {name}"); + let ps = format!("DCTestLegacy-{name}"); + fonts.add_font(face(&[(1, &legacy), (2, "Regular"), (4, &legacy), (6, &ps), (16, "DC Test Legacy"), (17, name)], weight)); + } + assert_eq!(text_face(r#" font-family="DC Test Legacy Semibold""#), "DCTestLegacy-Semibold"); + assert_eq!(text_face(r#" font-family="dc test legacy light""#), "DCTestLegacy-Light"); + assert_eq!(text_face(r#" font-family="'DC Test Legacy Semibold', sans-serif""#), "DCTestLegacy-Semibold"); + assert_eq!(text_face(r#" font-family="NoSuch Font, 'DC Test Legacy Light', serif""#), "DCTestLegacy-Light"); + // The full name (ID 4) of a face of the quartet. + assert_eq!(text_face(r#" font-family="DC Test Legacy Bold""#), "DCTestLegacy-Bold"); + // The typographic family still picks by weight. + assert_eq!(text_face(r#" font-family="DC Test Legacy" font-weight="600""#), "DCTestLegacy-Semibold"); + assert_eq!(text_face(r#" font-family="DC Test Legacy""#), "DCTestLegacy-Regular"); + // A name nobody has still falls to the serif. + assert_eq!(text_face(r#" font-family="DC Test Legacy Nothing""#), "SourceSerif4-Regular"); +} + /// A minimal Photoshop file: 2×1 RGB, no layers, raw merged image. fn tiny_psd() -> Vec { let mut b = Vec::new(); @@ -401,3 +429,97 @@ fn bmp_decodes() { assert_eq!(pixel_size(&bmp), Some((3, 2))); assert_eq!(decode_rgba(&bmp).unwrap().get_pixel(1, 1).0, [0, 0, 255, 255]); } + +/// A 1-bit gray PNG of `side` x `side` blank pixels, written row by row so the test stays small. +fn blank_png(side: u32) -> Vec { + use std::io::Write; + let chunk = |out: &mut Vec, kind: &[u8; 4], data: &[u8]| { + out.extend((data.len() as u32).to_be_bytes()); + let mut crc = flate2::Crc::new(); + crc.update(kind); + crc.update(data); + out.extend(kind); + out.extend(data); + out.extend(crc.sum().to_be_bytes()); + }; + let row = vec![0u8; 1 + side.div_ceil(8) as usize]; + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + for _ in 0..side { + z.write_all(&row).unwrap(); + } + let mut ihdr = [side.to_be_bytes(), side.to_be_bytes()].concat(); + ihdr.extend([1, 0, 0, 0, 0]); + let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); + chunk(&mut png, b"IHDR", &ihdr); + chunk(&mut png, b"IDAT", &z.finish().unwrap()); + chunk(&mut png, b"IEND", &[]); + png +} + +/// A small file that expands past 1 GiB of RGBA is refused from its header (#1586). +#[test] +fn rasters_past_the_rgba_budget_are_refused_before_decoding() { + let png = blank_png(16_385); + assert!(png.len() < 1 << 20, "{} bytes", png.len()); + assert_eq!(pixel_size(&png), Some((16_385, 16_385))); + assert_eq!(natural_size(&png), Some((16_385.0, 16_385.0))); + assert!(too_large_to_decode(&png)); + assert!(decode_rgba(&png).is_none()); + assert!(!too_large_to_decode(&blank_png(16_384))); + let small = decode_rgba(&blank_png(9)).expect("decodes"); + assert_eq!(small.get_pixel(8, 8).0, [0, 0, 0, 255]); +} + +/// A GIF whose logical screen is `side` x `side`, holding one black 1x1 frame. +fn gif_screen(side: u16) -> Vec { + let mut gif = b"GIF89a".to_vec(); + gif.extend(side.to_le_bytes()); + gif.extend(side.to_le_bytes()); + // Two-colour global table: black, white. + gif.extend([0x80, 0, 0, 0, 0, 0, 255, 255, 255]); + // Frame at the origin; LZW minimum code size 2, codes clear, 0, end; trailer. + gif.extend([0x2C, 0, 0, 0, 0, 1, 0, 1, 0, 0, 2, 2, 0x44, 0x01, 0, 0x3B]); + gif +} + +/// The header of an uncompressed 8-bit CMYK TIFF of `side` x `side` pixels, without the pixels. +fn cmyk_tiff_header(side: u32) -> Vec { + let entries: [(u16, u16, u32, u32); 9] = [ + (256, 4, 1, side), // width + (257, 4, 1, side), // height + (258, 3, 4, 122), // bits per sample, stored after the IFD + (259, 3, 1, 1), // no compression + (262, 3, 1, 5), // separated (CMYK) + (273, 4, 1, 130), // strip offset + (277, 3, 1, 4), // samples per pixel + (278, 4, 1, side), // rows per strip + (279, 4, 1, side * side * 4), // strip byte count + ]; + let mut t = b"II*\0".to_vec(); + t.extend(8u32.to_le_bytes()); + t.extend(9u16.to_le_bytes()); + for (tag, kind, count, value) in entries { + t.extend(tag.to_le_bytes()); + t.extend(kind.to_le_bytes()); + t.extend(count.to_le_bytes()); + t.extend(value.to_le_bytes()); + } + t.extend(0u32.to_le_bytes()); + assert_eq!(t.len(), 122); + t.extend([8u16; 4].iter().flat_map(|b| b.to_le_bytes())); + t +} + +/// GIF tones and CMYK inks check the RGBA budget too (#1586). +#[test] +fn gif_tone_and_cmyk_inks_check_the_rgba_budget() { + let gif = gif_screen(16_385); + assert!(too_large_to_decode(&gif)); + assert_eq!(tone(&gif), None); + assert_eq!(tone(&gif_screen(2)), Some(Tone::Bitmap)); + let tiff = cmyk_tiff_header(16_385); + assert!(is_cmyk_tiff(&tiff)); + assert_eq!(pixel_size(&tiff), Some((16_385, 16_385))); + assert!(too_large_to_decode(&tiff)); + assert_eq!(decode_cmyk_tiff(&tiff), None); +} diff --git a/crates/images/src/tone.rs b/crates/images/src/tone.rs index 10be68445..3d0b945ce 100644 --- a/crates/images/src/tone.rs +++ b/crates/images/src/tone.rs @@ -39,6 +39,9 @@ pub fn tone(bytes: &[u8]) -> Option { }, // Indexed: what the palette holds decides. Transparent pixels are left out of a bitmap. F::Gif => { + if crate::too_large_to_decode(bytes) { + return None; + } let img = image::load_from_memory_with_format(bytes, F::Gif).ok()?.into_rgba8(); let mut opaque = true; let mut two_level = true; diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index 2b4275da3..3df877c5c 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -31,4 +31,5 @@ hayro-interpret = { version = "0.7.0", default-features = false, features = ["em hayro-cmap = "0.1" kurbo = { workspace = true } tiff = { workspace = true } +flate2 = "1" designcraft-fonts = { workspace = true, features = ["testing"] } diff --git a/crates/pdf/src/export/images.rs b/crates/pdf/src/export/images.rs index a164a6948..ec7c76fd6 100644 --- a/crates/pdf/src/export/images.rs +++ b/crates/pdf/src/export/images.rs @@ -161,9 +161,22 @@ pub(super) fn cmyk_tiff(data: &[u8], mode: RasterMode) -> Option { Image::from_custom(CmykImage(Arc::new(r)), mode.interpolate).ok() } -/// Opaque raster → JPEG (quality 90). `None` when the image has transparency or can't be decoded. -/// With `tag_rgb`, the JPEG keeps the source's RGB profile, or gets sRGB. +#[cfg(test)] +thread_local! { + /// Rasters `recompress` has decoded on this thread. + static RECOMPRESS_DECODES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Opaque raster → JPEG (quality 90). `None` when the image has transparency, is too large or +/// can't be decoded. With `tag_rgb`, the JPEG keeps the source's RGB profile, or gets sRGB. pub(super) fn recompress(data: &[u8], mode: RasterMode) -> Option { + if designcraft_images::too_large_to_decode(data) { + return None; + } + #[cfg(test)] + { + RECOMPRESS_DECODES.with(|n| n.set(n.get() + 1)); + } let img = image::load_from_memory(data).ok()?; if img.color().has_alpha() && img.to_rgba8().pixels().any(|p| p[3] < 255) { return None; @@ -536,3 +549,20 @@ mod eps_tests { assert_eq!(civil(i64::MIN), (-292277022657, 1, 27, 8, 29, 52)); } } + +#[cfg(test)] +mod recompress_tests { + use super::*; + + /// A raster past the RGBA budget is refused before `recompress` decodes it (#1586). + #[test] + fn a_raster_past_the_budget_is_not_decoded() { + let mode = RasterMode { interpolate: false, tag_rgb: false }; + let decodes = || RECOMPRESS_DECODES.with(std::cell::Cell::get); + let start = decodes(); + assert!(recompress(&crate::tests::blank_png(16_385), mode).is_none()); + assert_eq!(decodes(), start); + assert!(recompress(&crate::tests::blank_png(9), mode).is_some()); + assert_eq!(decodes(), start + 1); + } +} diff --git a/crates/pdf/src/export/metadata.rs b/crates/pdf/src/export/metadata.rs index 5f857c619..766ff1d2b 100644 --- a/crates/pdf/src/export/metadata.rs +++ b/crates/pdf/src/export/metadata.rs @@ -11,6 +11,27 @@ pub(super) fn now_unix() -> Option { None } +/// The date an exported file records, when the caller gives none: `SOURCE_DATE_EPOCH` if it holds +/// a non-negative whole number of seconds, else the clock. Pinning it makes an export repeat its +/// bytes, which is otherwise impossible -- the only thing that differs between two exports of one +/// document is the timestamp, and the instance id krilla derives from the bytes follows it. +/// +/// +pub(super) fn export_time() -> Option { + pinned_or(|name| std::env::var(name).ok(), now_unix) +} + +/// [`export_time`] with the environment and the clock given, so a test can show which wins. +fn pinned_or(get: impl Fn(&str) -> Option, clock: impl Fn() -> Option) -> Option { + let pinned = get("SOURCE_DATE_EPOCH").and_then(|v| v.trim().parse::().ok()).filter(|secs| PDF_DATES.contains(secs)); + pinned.or_else(clock) +} + +/// The dates a PDF can hold, as Unix seconds: `date_time` clamps the year to 0..=9999, so a value +/// outside this would be recorded as some other date rather than the one asked for. A value that +/// cannot be honoured is left to the clock, as a malformed one is. +const PDF_DATES: std::ops::RangeInclusive = 0..=253_402_300_799; + /// The document information from File Info (title, author, description, keywords). `title` and /// `author` from the export options replace File Info's. pub(super) fn file_info_metadata(doc: &Document, title: Option<&str>, author: Option<&str>) -> Metadata { @@ -49,3 +70,42 @@ pub(crate) fn civil(t: i64) -> (i64, u8, u8, u8, u8, u8) { 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) } + +#[cfg(test)] +mod export_time_tests { + use super::pinned_or; + + /// What an export records when the caller gives no date: `SOURCE_DATE_EPOCH` when it holds a + /// non-negative whole number of seconds, the clock whenever it does not. The environment and + /// the clock are given here rather than set, so no test changes this process's environment -- + /// that is `unsafe` under edition 2024 and would reach every test beside it. + #[test] + fn a_pinned_epoch_is_preferred_and_the_clock_answers_otherwise() { + let env = |v: Option<&'static str>| move |_: &str| v.map(str::to_owned); + let clock = || Some(1_000_000); + assert_eq!(pinned_or(env(Some("1791208980")), clock), Some(1_791_208_980), "the variable wins"); + assert_eq!(pinned_or(env(Some("0")), clock), Some(0), "the epoch itself counts"); + assert_eq!(pinned_or(env(Some(" 1791208980 ")), clock), Some(1_791_208_980), "spaces around it are allowed"); + // A date a PDF cannot hold is left to the clock too: `date_time` would clamp the year and + // record some other date. 253402300799 is 9999-12-31T23:59:59Z, the last one it can. + assert_eq!(pinned_or(env(Some("253402300799")), clock), Some(253_402_300_799), "the last date a PDF can hold"); + for ignored in [ + None, + Some(""), + Some(" "), + Some("-1"), + Some("later"), + Some("1791208980.5"), + Some("99999999999999999999"), + Some("253402300800"), + Some("9223372036854775807"), + ] { + assert_eq!(pinned_or(env(ignored), clock), Some(1_000_000), "{ignored:?} leaves it to the clock"); + } + // A platform with no clock and no variable records no date at all. + assert_eq!(pinned_or(env(None), || None), None); + assert_eq!(pinned_or(env(Some("1791208980")), || None), Some(1_791_208_980), "the variable needs no clock"); + // Only that variable is read. + assert_eq!(pinned_or(|name: &str| (name == "TZ").then(|| "1791208980".to_owned()), clock), Some(1_000_000)); + } +} diff --git a/crates/pdf/src/export/pipeline.rs b/crates/pdf/src/export/pipeline.rs index ca9d1a988..ffef45c87 100644 --- a/crates/pdf/src/export/pipeline.rs +++ b/crates/pdf/src/export/pipeline.rs @@ -1,6 +1,6 @@ use super::common::{tf, to_path}; use super::images::ImageContentCache; -use super::metadata::{date_time, file_info_metadata, now_unix}; +use super::metadata::{date_time, export_time, file_info_metadata}; use super::*; /// Export `doc` as PDF bytes. @@ -162,7 +162,7 @@ fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions }; let settings = krilla::SerializeSettings { compress_content_streams: opts.compress, configuration, ..base }; let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); - let created = opts.created.or_else(now_unix); + let created = opts.created.or_else(export_time); let metadata = Metadata { created: created.map(date_time), ..file_info_metadata(doc, opts.title.as_deref(), opts.author.as_deref()) }; let mut writer = Writer::new(settings, metadata); let pdf = writer.document_mut(); diff --git a/crates/pdf/src/export/walk.rs b/crates/pdf/src/export/walk.rs index af6429b23..cf96dac25 100644 --- a/crates/pdf/src/export/walk.rs +++ b/crates/pdf/src/export/walk.rs @@ -308,13 +308,16 @@ impl Exporter<'_> { if frame_art { s.end_tagged(); } - self.draw_leaf_content(s, it, bp, path, page); + // A text frame's anchored objects are drawn under its text when they can be. + let drawn = self.draw_leaf_content(s, it, xf, bp, path, page); self.draw_leaf_stroke(s, it, bp, path, frame_art); s.pop(); for _ in 0..pushes { s.pop(); } - if let Content::Text(tfr) = &it.content { + if let Content::Text(tfr) = &it.content + && !drawn + { let cs = self.composed(tfr.story, page); if let Some(ft) = cs.frame(it.id) { self.frame_objects(s, ft, xf * self.doc.text_local(it), page); @@ -347,8 +350,10 @@ impl Exporter<'_> { } } - fn draw_leaf_content(&mut self, s: &mut Surface, it: &Item, bp: &BezPath, path: Option<&Path>, page: Option) { + /// The item's content; returns whether a text frame's anchored objects were drawn with it. + fn draw_leaf_content(&mut self, s: &mut Surface, it: &Item, xf: Affine, bp: &BezPath, path: Option<&Path>, page: Option) -> bool { let doc = self.doc; + let mut drawn = false; match &it.content { Content::Graphic(g) => { let g = &if it.media.is_some() { it.drawn_graphic(&self.doc.assets).unwrap_or_else(|| g.clone()) } else { g.clone() }; @@ -391,9 +396,12 @@ impl Exporter<'_> { if local != Affine::IDENTITY { s.push_transform(&tf(local)); } - self.text_area = (local.determinant().abs() > 1e-12).then(|| local.inverse().transform_rect_bbox(it.inner_bounds())); - self.frame_text(s, &cs, ft, text); - self.text_area = None; + // Anchored text frames now draw inside `frame_text`: restore the host's area + // after, so an object drawn there doesn't clear it for the host's glyphs. + let area = (local.determinant().abs() > 1e-12).then(|| local.inverse().transform_rect_bbox(it.inner_bounds())); + let outer = std::mem::replace(&mut self.text_area, area); + drawn = self.frame_text(s, &cs, ft, text, Some((xf * local, page))); + self.text_area = outer; if local != Affine::IDENTITY { s.pop(); } @@ -401,6 +409,7 @@ impl Exporter<'_> { } _ => {} } + drawn } fn draw_leaf_stroke(&mut self, s: &mut Surface, it: &Item, bp: &BezPath, path: Option<&Path>, frame_art: bool) { @@ -419,7 +428,7 @@ impl Exporter<'_> { /// Draw embedded page items after restoring the surface, retaining spread-space /// transforms for page culling (also for anonymous note and cell stories). - fn frame_objects(&mut self, s: &mut Surface, ft: &designcraft_compose::FrameText, xf: Affine, page: Option) { + pub(crate) fn frame_objects(&mut self, s: &mut Surface, ft: &designcraft_compose::FrameText, xf: Affine, page: Option) { let mut pending = vec![(ft, xf)]; while let Some((frame, transform)) = pending.pop() { for object in &frame.objects { diff --git a/crates/pdf/src/forms.rs b/crates/pdf/src/forms.rs index 07f4cf04c..524467484 100644 --- a/crates/pdf/src/forms.rs +++ b/crates/pdf/src/forms.rs @@ -10,8 +10,9 @@ use designcraft_doc::{Document, FieldKind, FormField, Item, MediaOptions}; use crate::export::Sheet; use crate::transitions::{bracketed, int_after, object, rfind}; -/// A field placed on an output page: (page index, PDF rectangle, field). -pub(crate) type Placed = (usize, [f64; 4], FormField); +/// A field placed on an output page: (page index, PDF rectangle, field, border width, 0 when the +/// item has no stroke). +pub(crate) type Placed = (usize, [f64; 4], FormField, f64); /// A placed video or sound on an output page: (page index, PDF rectangle, file name, MIME type, /// bytes, options). @@ -37,7 +38,8 @@ pub(crate) fn collect(doc: &Document, sheets: &[Sheet]) -> Vec { let mut out = Vec::new(); visit(doc, sheets, |pi, r, it| { if let Some(f) = &it.form_field { - out.push((pi, r, f.clone())); + let bw = if it.stroke.is_none() || !it.stroke.weight.is_finite() { 0.0 } else { it.stroke.weight }; + out.push((pi, r, f.clone(), bw)); } }); out @@ -56,6 +58,25 @@ pub(crate) fn collect_media(doc: &Document, sheets: &[Sheet]) -> Vec String { + let regular = |b: u8| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.' | b'_' | b'~' | b'*' | b'@' | b'$' | b'&' | b'!' | b'^' | b'|'); + let mut o = String::new(); + for b in s.bytes() { + if regular(b) { + o.push(b as char); + } else { + o.push_str(&format!("#{b:02X}")); + } + } + o +} + /// A PDF text string: literal for ASCII, UTF-16BE otherwise. fn pdf_string(s: &str) -> String { if s.is_ascii() { @@ -77,6 +98,16 @@ fn pdf_string(s: &str) -> String { o } +/// A checkbox appearance's border: the item's stroke weight, inset by half of it so it stays +/// inside the box; nothing for an unstroked item. +fn checkbox_border(bw: f64, w: f64, h: f64) -> String { + if bw > 0.0 { + format!("0.45 G {bw:.2} w {i:.2} {i:.2} {:.2} {:.2} re S", (w - bw).max(0.0), (h - bw).max(0.0), i = bw / 2.0) + } else { + String::new() + } +} + /// Add `fields` and `media` to `pdf` (page indices in output order). `None` when the file's /// structure isn't the simple kind this understands. pub(crate) fn add_fields(pdf: &[u8], fields: &[Placed], media: &[PlacedMedia]) -> Option> { @@ -117,7 +148,7 @@ pub(crate) fn add_fields(pdf: &[u8], fields: &[Placed], media: &[PlacedMedia]) - let Some(&page_ref) = kids.get(*page) else { continue }; let (file, spec, rendition, screen) = (next, next + 1, next + 2, next + 3); next += 4; - let mut body = format!("<>\nstream\n", mime.replace('/', "#2F"), data.len()).into_bytes(); + let mut body = format!("<>\nstream\n", pdf_name(mime), data.len()).into_bytes(); body.extend_from_slice(data); body.extend_from_slice(b"\nendstream"); put(&mut out, file, &body); @@ -158,13 +189,14 @@ pub(crate) fn add_fields(pdf: &[u8], fields: &[Placed], media: &[PlacedMedia]) - put(&mut out, font, b"<>"); } let mut all = Vec::new(); - for (page, r, f) in fields { + for (page, r, f, bw) in fields { let Some(&page_ref) = kids.get(*page) else { continue }; let rect = format!("[{:.2} {:.2} {:.2} {:.2}]", r[0], r[1], r[2], r[3]); let (w, h) = (r[2] - r[0], r[3] - r[1]); let fs = if f.font_size > 0.0 { f.font_size } else { 0.0 }; + let (bc, bs) = if *bw > 0.0 { ("/BC[0.45 0.45 0.45]", format!("/BS<>")) } else { ("", "/BS<>".to_string()) }; let common = format!( - "/Type/Annot/Subtype/Widget/T{}/Rect{rect}/F 4/P {page_ref} 0 R/MK<>/DA(/Helv {fs:.1} Tf 0 g)", + "/Type/Annot/Subtype/Widget/T{}/Rect{rect}/F 4/P {page_ref} 0 R/MK<<{bc}/BG[1 1 1]>>{bs}/DA(/Helv {fs:.1} Tf 0 g)", pdf_string(&f.name) ); let req = if f.required { 2 } else { 0 }; @@ -175,7 +207,7 @@ pub(crate) fn add_fields(pdf: &[u8], fields: &[Placed], media: &[PlacedMedia]) - } FieldKind::CheckBox => { // Appearances: an empty box, and a box with a check mark. - let border = format!("0.45 G 1 w 0.5 0.5 {:.2} {:.2} re S", (w - 1.0).max(0.0), (h - 1.0).max(0.0)); + let border = checkbox_border(*bw, w, h); let check = format!( "{border} 0 g {:.2} w 1 J 1 j {:.2} {:.2} m {:.2} {:.2} l {:.2} {:.2} l S", (w.min(h) * 0.12).max(0.8), @@ -255,3 +287,24 @@ pub(crate) fn add_fields(pdf: &[u8], fields: &[Placed], media: &[PlacedMedia]) - out.extend_from_slice(x.as_bytes()); Some(out) } + +#[cfg(test)] +mod tests { + use super::*; + + /// A media type comes from a placed file's own metadata and was written into a PDF name with + /// only `/` escaped, so a type holding a delimiter ended the name early and left the rest as + /// tokens in the file's structure (#1582). + #[test] + fn a_pdf_name_escapes_every_irregular_byte() { + assert_eq!(pdf_name("video/mp4"), "video#2Fmp4"); + assert_eq!(pdf_name("a b"), "a#20b"); + assert_eq!(pdf_name("x<>"), "x#3C#3C#2FType#2FCatalog#3E#3E"); + assert_eq!(pdf_name("a%comment"), "a#25comment"); + assert_eq!(pdf_name("a#1"), "a#231"); + assert_eq!(pdf_name("a(b)"), "a#28b#29"); + assert_eq!(pdf_name("[1 0 R]"), "#5B1#200#20R#5D"); + assert_eq!(pdf_name("application-x_test.v2"), "application-x_test.v2"); + assert_eq!(pdf_name("\u{e9}"), "#C3#A9"); + } +} diff --git a/crates/pdf/src/gradient_inks.rs b/crates/pdf/src/gradient_inks.rs index e889ee1d2..c92b80158 100644 --- a/crates/pdf/src/gradient_inks.rs +++ b/crates/pdf/src/gradient_inks.rs @@ -87,7 +87,7 @@ impl Exporter<'_> { let inks: Vec> = g.stops.iter().map(|s| self.stop_ink(s)).collect(); let has_spot = inks.iter().any(|i| matches!(i, Some(Ink::Spot { .. }))); let on_paper = g.stops.iter().any(|s| s.swatch.as_deref() == Some(designcraft_color::swatch::PAPER)); - if !has_spot && !overprint && !(on_paper && self.for_print() && !self.rgb_only) { + if !(has_spot || overprint || on_paper && self.for_print() && !self.rgb_only) { return None; } let Some(inks) = inks.into_iter().collect::>>() else { diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 7d6682c6d..37909a1f5 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -51,7 +51,7 @@ mod transitions; pub use export::{ BookletKind, BookletOptions, booklet_pairs, export_booklet, export_pdf, export_pdf_with_report, merge_pdfs, print_color, sheet_spreads, }; -pub use pdfx::{check_pdfx4, has_rgb_groups, make_pdfx4}; +pub use pdfx::{check_pdfx4, has_rgb_groups, make_pdfx4, pdfx_version}; pub use pdfx1a::check_pdfx1a; pub use transitions::{add_blend_space, add_catalog_entries, add_page_entries, add_transitions}; @@ -136,7 +136,8 @@ pub struct PdfOptions { /// Title for the metadata; `None` = the document title. pub title: Option, pub author: Option, - /// Creation date as Unix seconds (UTC); `None` = now (native) / omitted (wasm). + /// Creation date as Unix seconds (UTC). `None` takes `SOURCE_DATE_EPOCH` when it names a date + /// a PDF can hold, else the clock (native) / omitted (wasm). pub created: Option, /// Tagged PDF: stories become paragraphs in reading order, graphics figures with their alt /// text, parent-page items and printer's marks artifacts. diff --git a/crates/pdf/src/optional_content.rs b/crates/pdf/src/optional_content.rs index 7857d36d7..374c0d1f1 100644 --- a/crates/pdf/src/optional_content.rs +++ b/crates/pdf/src/optional_content.rs @@ -58,7 +58,7 @@ fn edited(dict: &Dict<'_>, replacements: &[(&[u8], &[u8])]) -> Result> { /// The trailer of the cross-reference section at `offset`: a table's `trailer` dictionary or an /// xref stream's own. A table is read entry by entry rather than searched for the keyword, so a /// `trailer` in a comment can't stand in for the real one; reading is bounded either way. -fn trailer_at(bytes: &[u8], offset: usize) -> Option> { +pub(crate) fn trailer_at(bytes: &[u8], offset: usize) -> Option> { const MAX_SECTION_BYTES: usize = 4 * 1024 * 1024; let data = bytes.get(offset..)?; let mut reader = Reader::new(data.get(..data.len().min(MAX_SECTION_BYTES))?); diff --git a/crates/pdf/src/pdfx.rs b/crates/pdf/src/pdfx.rs index e20247813..6d7a3714f 100644 --- a/crates/pdf/src/pdfx.rs +++ b/crates/pdf/src/pdfx.rs @@ -8,6 +8,7 @@ use std::collections::HashSet; use hayro_syntax::content::UntypedIter; use hayro_syntax::object::{Array, Dict, Name, Object, ObjectIdentifier, Stream}; use hayro_syntax::page::Resources; +use hayro_syntax::reader::ReaderExt; use craft_pdf::write::WriteError; use craft_pdf::write::backend as krilla; @@ -115,16 +116,18 @@ pub fn has_rgb_groups(pdf: &[u8]) -> bool { pub fn check_pdfx4(pdf: &[u8]) -> Vec { let mut issues = Vec::new(); let text = String::from_utf8_lossy(pdf); + let file = hayro_syntax::Pdf::new(pdf.to_vec()).ok(); if !pdf.starts_with(b"%PDF-1.") || pdf.get(7).is_none_or(|v| *v > b'6') { issues.push("the PDF version must be 1.6 or lower".into()); } - if !text.contains("/S/GTS_PDFX") { + let intents = file.as_ref().map(pdfx_intents).unwrap_or_default(); + if intents.is_empty() { issues.push("no PDF/X output intent".into()); } - if !text.contains("/DestOutputProfile") { + if !intents.iter().any(|oi| oi.contains_key(b"DestOutputProfile")) { issues.push("the output intent has no destination profile".into()); } - if !text.contains("/GTS_PDFXVersion(PDF/X-4)") { + if file.as_ref().and_then(|f| info_text(pdf, f, b"GTS_PDFXVersion")).as_deref() != Some("PDF/X-4") { issues.push("the document information doesn't name PDF/X-4".into()); } if !text.contains("pdfxid:GTS_PDFXVersion>PDF/X-4<") { @@ -150,22 +153,67 @@ pub fn check_pdfx4(pdf: &[u8]) -> Vec { if text.contains("/Subtype/Type3") { issues.push("Type 3 fonts aren't allowed".into()); } - issues.extend(device_rgb(pdf)); + issues.extend(device_rgb(file.as_ref())); issues.extend(fonts::unembedded_fonts(pdf)); issues } +/// The document information's `GTS_PDFXVersion` (`PDF/X-4`, `PDF/X-1:2003`, …), read as an +/// object; `None` when the file can't be read or doesn't name one. +pub fn pdfx_version(pdf: &[u8]) -> Option { + info_text(pdf, &hayro_syntax::Pdf::new(pdf.to_vec()).ok()?, b"GTS_PDFXVersion") +} + +/// The catalog's `GTS_PDFX` output intents, read as objects (whitespace and object streams +/// don't matter). +pub(crate) fn pdfx_intents<'a>(file: &'a hayro_syntax::Pdf) -> Vec> { + let xref = file.xref(); + let Some(catalog) = xref.get::>(xref.root_id()) else { return Vec::new() }; + let Some(intents) = catalog.get::>(b"OutputIntents") else { return Vec::new() }; + intents.iter::>().filter(|oi| oi.get::>(b"S").is_some_and(|s| s.as_ref() == b"GTS_PDFX")).collect() +} + +/// The text entry `key` of the document information dictionary, trimmed: the `/Info` of the +/// newest trailer that has one (classic or cross-reference stream, following `/Prev` a bounded +/// number of times), else of the last `trailer` keyword in the file. +pub(crate) fn info_text(pdf: &[u8], file: &hayro_syntax::Pdf, key: &[u8]) -> Option { + let lookup = |trailer: &Dict<'_>| -> Option { + let info = match trailer.get_ref(b"Info") { + Some(r) => file.xref().get::>(r.into())?, + None => trailer.get::>(b"Info")?, + }; + let value = info.get::>(key)?; + Some(String::from_utf8_lossy(value.as_bytes()).trim().to_string()) + }; + let tail = pdf.get(rfind(pdf, b"startxref")?..).unwrap_or_default(); + let mut offset = int_after(&String::from_utf8_lossy(tail), "startxref"); + let mut seen = HashSet::new(); + for _ in 0..32 { + let Some(at) = offset.filter(|at| seen.insert(*at)) else { break }; + let Some(trailer) = crate::optional_content::trailer_at(pdf, at) else { break }; + if trailer.contains_key(b"Info") { + return lookup(&trailer); + } + offset = trailer.get::(b"Prev"); + } + // A damaged cross-reference offset: the dictionary after the last `trailer` keyword. + let at = rfind(pdf, b"trailer")? + b"trailer".len(); + let mut reader = hayro_syntax::reader::Reader::new(pdf.get(at..)?); + reader.skip_white_spaces_and_comments(); + lookup(&reader.read_without_context::>()?) +} + /// Device-dependent RGB under a CMYK output intent. PDF/X-4 (ISO 15930-7) lets RGB in only when /// it is colour-managed: ICCBased RGB, or DeviceRGB remapped by a `DefaultRGB` colour space in the /// resources. DeviceCMYK and DeviceGray are the output intent's own device values, so they stay. /// This walks every page's content, the images, forms, patterns, shadings and soft masks it uses, /// and the transparency groups, and reports each kind of device RGB it finds with the pages it is /// on. Nothing is reported when the file has no CMYK PDF/X output intent. -fn device_rgb(pdf: &[u8]) -> Vec { - let Ok(file) = hayro_syntax::Pdf::new(pdf.to_vec()) else { +fn device_rgb(file: Option<&hayro_syntax::Pdf>) -> Vec { + let Some(file) = file else { return vec!["the file couldn't be read to check its colour spaces".into()]; }; - if !cmyk_output_intent(&file) { + if !cmyk_output_intent(file) { return Vec::new(); } let mut found: Vec<(String, Vec)> = Vec::new(); @@ -198,13 +246,7 @@ fn device_rgb(pdf: &[u8]) -> Vec { /// Whether the file's PDF/X output intent has a four-channel (CMYK) destination profile. pub(crate) fn cmyk_output_intent(file: &hayro_syntax::Pdf) -> bool { - let xref = file.xref(); - let Some(catalog) = xref.get::>(xref.root_id()) else { return false }; - let Some(intents) = catalog.get::>(b"OutputIntents") else { return false }; - intents.iter::>().any(|oi| { - oi.get::>(b"S").is_some_and(|s| s.as_ref() == b"GTS_PDFX") - && oi.get::>(b"DestOutputProfile").and_then(|p| p.dict().get::(b"N")) == Some(4) - }) + pdfx_intents(file).iter().any(|oi| oi.get::>(b"DestOutputProfile").and_then(|p| p.dict().get::(b"N")) == Some(4)) } /// Whether a colour space is device RGB: `/DeviceRGB` (`/RGB` in inline images), or an diff --git a/crates/pdf/src/pdfx/tests.rs b/crates/pdf/src/pdfx/tests.rs index edb6aec8f..b35e897c2 100644 --- a/crates/pdf/src/pdfx/tests.rs +++ b/crates/pdf/src/pdfx/tests.rs @@ -462,3 +462,106 @@ fn xmp_states_trapped() { assert!(String::from_utf8_lossy(&r.bytes).contains("/Trapped/False")); assert!(r.warnings.is_empty(), "{:?}", r.warnings); } + +/// `objects` (from 1) as a PDF 1.6 file with a classic cross-reference table and `trailer`. +fn assemble_x4(objects: &[&str], trailer: &str) -> Vec { + let mut out = b"%PDF-1.6\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f\r\n", objects.len() + 1).as_bytes()); + for off in offsets { + out.extend_from_slice(format!("{off:010} 00000 n\r\n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n{trailer}\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + out +} + +const NO_INTENT: &str = "no PDF/X output intent"; +const NO_INFO: &str = "the document information doesn't name PDF/X-4"; + +/// The output intent and the document information are read as objects: whitespace between +/// tokens (as qpdf and Ghostscript write them) doesn't hide them. +#[test] +fn x4_checks_ignore_whitespace_between_tokens() { + let pdf = assemble_x4( + &[ + "<< /Type /Catalog /Pages 2 0 R /OutputIntents [ << /Type /OutputIntent /S /GTS_PDFX /DestOutputProfile 4 0 R >> ] >>", + "<< /Type /Pages /Kids [ 3 0 R ] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [ 0 0 100 100 ] /TrimBox [ 0 0 100 100 ] >>", + "<< /N 4 /Length 0 >>\nstream\n\nendstream", + "<< /GTS_PDFXVersion (PDF/X-4) /Trapped /False >>", + ], + "<< /Size 6 /Root 1 0 R /Info 5 0 R >>", + ); + let issues = check_pdfx4(&pdf); + assert!(!issues.iter().any(|i| i == NO_INTENT || i == NO_INFO), "{issues:?}"); + // Without them, both are still reported. + let bare = assemble_x4( + &[ + "<< /Type /Catalog /Pages 2 0 R /OutputIntents [ << /Type /OutputIntent /S /GTS_PDFA1 >> ] >>", + "<< /Type /Pages /Kids [ 3 0 R ] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [ 0 0 100 100 ] /TrimBox [ 0 0 100 100 ] >>", + "<< /N 4 /Length 0 >>\nstream\n\nendstream", + "<< /Title (/S/GTS_PDFX /GTS_PDFXVersion(PDF/X-4)) /GTS_PDFXVersion (PDF/X-3:2002) >>", + ], + "<< /Size 6 /Root 1 0 R /Info 5 0 R >>", + ); + let issues = check_pdfx4(&bare); + assert!(issues.iter().any(|i| i == NO_INTENT) && issues.iter().any(|i| i == NO_INFO), "{issues:?}"); +} + +/// The output intent and the document information are found inside a compressed object stream, +/// through a cross-reference stream. +#[test] +fn x4_checks_read_object_streams() { + let plain = [ + "<>", + "<>", + "<>", + "<>\nstream\n\nendstream", + ]; + let (info, intent) = ("<>", "<>"); + let mut out = b"%PDF-1.6\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in plain.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + // Object 5 is free; 6 and 7 live in object stream 8. + let header = format!("6 0 7 {} ", info.len() + 1); + let body = format!("{header}{info} {intent}"); + let objstm = out.len(); + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + std::io::Write::write_all(&mut z, body.as_bytes()).unwrap(); + let packed = z.finish().unwrap(); + out.extend_from_slice( + format!("8 0 obj\n<>\nstream\n", header.len(), packed.len()).as_bytes(), + ); + out.extend_from_slice(&packed); + out.extend_from_slice(b"\nendstream\nendobj\n"); + let xref = out.len(); + let mut rows = Vec::new(); + let mut row = |kind: u8, field: usize, idx: u16| { + rows.push(kind); + rows.extend_from_slice(&u32::try_from(field).unwrap().to_be_bytes()); + rows.extend_from_slice(&idx.to_be_bytes()); + }; + row(0, 0, 65535); + for off in &offsets { + row(1, *off, 0); + } + row(0, 0, 0); + row(2, 8, 0); + row(2, 8, 1); + row(1, objstm, 0); + row(1, xref, 0); + out.extend_from_slice(format!("9 0 obj\n<>\nstream\n", rows.len()).as_bytes()); + out.extend_from_slice(&rows); + out.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + let issues = check_pdfx4(&out); + assert!(!issues.iter().any(|i| i == NO_INTENT || i == NO_INFO), "{issues:?}"); +} diff --git a/crates/pdf/src/pdfx1a.rs b/crates/pdf/src/pdfx1a.rs index 88759b071..495a389d8 100644 --- a/crates/pdf/src/pdfx1a.rs +++ b/crates/pdf/src/pdfx1a.rs @@ -116,16 +116,18 @@ pub fn check_pdfx1a(pdf: &[u8]) -> Vec { fn checks(pdf: &[u8]) -> (Vec, Vec) { let mut issues = Vec::new(); let text = String::from_utf8_lossy(pdf); + let file = hayro_syntax::Pdf::new(pdf.to_vec()).ok(); if !pdf.starts_with(b"%PDF-1.") || pdf.get(7).is_none_or(|v| *v > b'4') { issues.push("the PDF version must be 1.4 or lower".into()); } - if !text.contains("/S/GTS_PDFX") { + if file.as_ref().is_none_or(|f| crate::pdfx::pdfx_intents(f).is_empty()) { issues.push("no PDF/X output intent".into()); } - if !text.contains(&format!("/GTS_PDFXVersion({VERSION})")) { + let info = |key: &[u8]| file.as_ref().and_then(|f| crate::pdfx::info_text(pdf, f, key)); + if info(b"GTS_PDFXVersion").as_deref() != Some(VERSION) { issues.push(format!("the document information doesn't name {VERSION}")); } - if !text.contains(&format!("/GTS_PDFXConformance({CONFORMANCE})")) { + if info(b"GTS_PDFXConformance").as_deref() != Some(CONFORMANCE) { issues.push(format!("the document information doesn't name the conformance level {CONFORMANCE}")); } if !text.contains("/Trapped/False") && !text.contains("/Trapped/True") && !text.contains("/Trapped /False") && !text.contains("/Trapped /True") { @@ -142,18 +144,18 @@ fn checks(pdf: &[u8]) -> (Vec, Vec) { if pages > 0 && trims < pages { issues.push("every page needs a trim box".into()); } - let (content, fonts) = content(pdf); + let (content, fonts) = content(file.as_ref()); issues.extend(fonts); (content, issues) } /// Transparency and color spaces, then the output intent and fonts, walked through every page's /// content and what it uses. -fn content(pdf: &[u8]) -> (Vec, Vec) { - let Ok(file) = hayro_syntax::Pdf::new(pdf.to_vec()) else { +fn content(file: Option<&hayro_syntax::Pdf>) -> (Vec, Vec) { + let Some(file) = file else { return (vec!["the file couldn't be read to check its transparency and color spaces".into()], Vec::new()); }; - let mut fonts = intent_issues(&file); + let mut fonts = intent_issues(file); let mut found: Vec<(String, Vec)> = Vec::new(); let mut font_found: Vec<(String, Vec)> = Vec::new(); for (i, page) in file.pages().iter().enumerate() { @@ -214,13 +216,9 @@ impl Intent { /// The catalog's `GTS_PDFX` output intents. fn pdfx_intents(file: &hayro_syntax::Pdf) -> Vec { - let xref = file.xref(); - let Some(catalog) = xref.get::>(xref.root_id()) else { return Vec::new() }; - let Some(intents) = catalog.get::>(b"OutputIntents") else { return Vec::new() }; let number = |n: i32| usize::try_from(n).ok(); - intents - .iter::>() - .filter(|oi| oi.get::>(b"S").is_some_and(|s| s.as_ref() == b"GTS_PDFX")) + crate::pdfx::pdfx_intents(file) + .into_iter() .map(|oi| { let text = |key: &[u8]| { oi.get::>(key) diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 2accc79fa..262e19c8e 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -76,6 +76,28 @@ fn exports_valid_pdf_with_one_page_per_page() { assert_eq!(pdf.pages().len(), 2); } +#[test] +fn discretionary_hyphen_pdf_text_follows_visible_breaks() { + let text = "Wir möchten uns ganz besonders herzlich bei folgenden Personen bedanken, die auf unterschied\u{ad}lichste Weise zur Entstehung dieses Lehrbuches beigetragen haben:"; + for composer in [designcraft_doc::Composer::SingleLine, designcraft_doc::Composer::Paragraph] { + for (width, expected) in [(430.0, "unterschied-lichste"), (540.0, "unterschiedlichste")] { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let para = designcraft_doc::ParaAttrs { composer: Some(composer), hyphenate: Some(false), ..Default::default() }; + let (_, sid) = d + .add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 36.0 + width, 400.0), lid, text, ParaFormat { para, ..Default::default() }) + .unwrap(); + d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.size = Some(9.5)); + for tagged in [false, true] { + let bytes = export_pdf(&d, &Cache::new(), &PdfOptions { tagged, ..Default::default() }).unwrap(); + let extracted = extract_text(&bytes).concat(); + assert!(extracted.contains(expected), "{composer:?}, width={width}, tagged={tagged}: {extracted:?}"); + assert!(!extracted.contains('\u{ad}')); + } + } + } +} + #[test] fn text_is_real_text() { let d = doc_with_text("Hello DesignCraft — efficient affine"); @@ -257,6 +279,32 @@ fn cmyk_tiff() -> (Vec, Vec) { (b, px) } +/// A black `side`² 1-bit grey PNG: well under 1 MB, it decodes to `side`² bytes. +pub(crate) fn blank_png(side: u32) -> Vec { + use std::io::Write; + let chunk = |out: &mut Vec, kind: &[u8; 4], data: &[u8]| { + out.extend((data.len() as u32).to_be_bytes()); + let mut crc = flate2::Crc::new(); + crc.update(kind); + crc.update(data); + out.extend(kind); + out.extend(data); + out.extend(crc.sum().to_be_bytes()); + }; + let row = vec![0u8; 1 + side.div_ceil(8) as usize]; + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + for _ in 0..side { + z.write_all(&row).unwrap(); + } + let mut ihdr = [side.to_be_bytes(), side.to_be_bytes()].concat(); + ihdr.extend([1, 0, 0, 0, 0]); + let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); + chunk(&mut png, b"IHDR", &ihdr); + chunk(&mut png, b"IDAT", &z.finish().unwrap()); + chunk(&mut png, b"IEND", &[]); + png +} + fn place(d: &mut Document, data: Vec, px: (u32, u32)) { let asset = designcraft_doc::AssetId(d.alloc()); let mime = designcraft_images::mime(&data).into(); @@ -328,6 +376,19 @@ fn a_cut_off_image_is_skipped_not_fatal() { } } +/// A raster past the RGBA budget is skipped with a warning, also when images are recompressed (#1586). +#[test] +fn an_image_past_the_decode_budget_is_skipped_with_a_warning() { + let side = 16_385; + let mut d = doc_with_text("still here"); + place(&mut d, blank_png(side), (side, side)); + for compress_images in [false, true] { + let r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { compress_images, ..Default::default() }).unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("could not be decoded and was skipped")), "compress_images {compress_images}: {:?}", r.warnings); + assert!(image_xobjects(&r.bytes).is_empty(), "compress_images {compress_images}"); + } +} + /// Every image XObject in a PDF: its /ColorSpace name (`?` when not a name) and decoded samples. fn image_xobjects(bytes: &[u8]) -> Vec<(String, Vec)> { use hayro_syntax::object::Name; @@ -473,13 +534,19 @@ fn drawn(bytes: &[u8]) -> (Vec<(String, kurbo::Rect)>, Vec) { /// The page's paint operations in order: `fill`/`stroke` + ` text`/` path` and the stroke width. fn paint_order(bytes: &[u8]) -> Vec<(String, f32)> { + paint_events(bytes, false) +} + +/// [`paint_order`], with transparency groups when `groups`: `group` (and its opacity) and +/// `end group`. +fn paint_events(bytes: &[u8], groups: bool) -> Vec<(String, f32)> { use hayro_interpret::font::Glyph; use hayro_interpret::{ BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, interpret_page, }; #[derive(Default)] - struct Rec(Vec<(String, f32)>); + struct Rec(Vec<(String, f32)>, bool); impl Device<'_> for Rec { fn set_soft_mask(&mut self, _: Option>) {} fn set_blend_mode(&mut self, _: BlendMode) {} @@ -490,7 +557,11 @@ fn paint_order(bytes: &[u8]) -> Vec<(String, f32)> { }); } fn push_clip_path(&mut self, _: &ClipPath) {} - fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn push_transparency_group(&mut self, opacity: f32, _: Option>, _: BlendMode) { + if self.1 { + self.0.push(("group".into(), opacity)); + } + } fn draw_glyph(&mut self, _: &Glyph<'_>, _: kurbo::Affine, _: kurbo::Affine, _: &Paint<'_>, m: &GlyphDrawMode) { self.0.push(match m { GlyphDrawMode::Fill => ("fill text".into(), 0.0), @@ -501,13 +572,17 @@ fn paint_order(bytes: &[u8]) -> Vec<(String, f32)> { } fn draw_image(&mut self, _: Image<'_, '_>, _: kurbo::Affine) {} fn pop_clip_path(&mut self) {} - fn pop_transparency_group(&mut self) {} + fn pop_transparency_group(&mut self) { + if self.1 { + self.0.push(("end group".into(), 0.0)); + } + } } let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).expect("parse"); let cache = InterpreterCache::new(); let page = &pdf.pages()[0]; let mut ctx = Context::new(kurbo::Affine::IDENTITY, kurbo::Rect::new(0.0, 0.0, 1.0, 1.0), &cache, pdf.xref(), InterpreterSettings::default()); - let mut rec = Rec::default(); + let mut rec = Rec(Vec::new(), groups); interpret_page(page, &mut ctx, &mut rec); rec.0 } @@ -798,6 +873,36 @@ fn only_printed_fields_and_buttons_export() { assert_eq!(all.matches("/Link").count(), shown.matches("/Link").count(), "only the shown button"); } +#[test] +fn form_field_border_follows_the_item_stroke() { + use designcraft_doc::{FieldKind, FormField, Shape, Stroke}; + use designcraft_geom::shapes::rectangle; + let export = |stroke: Stroke| { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let mut it = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, rectangle(Rect::new(72.0, 72.0, 250.0, 96.0))); + it.stroke = stroke; + it.form_field = Some(FormField { + kind: FieldKind::TextField, + name: "name".into(), + value: String::new(), + options: Vec::new(), + multiline: false, + required: false, + font_size: 0.0, + }); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + uncompressed(&d, PdfOptions::default()) + }; + let none = export(Stroke { weight: 0.0, swatch: designcraft_color::swatch::NONE.into(), ..Stroke::default() }); + assert!(none.contains("/Subtype/Widget")); + assert!(none.contains("/BS<>"), "no border width"); + assert!(!none.contains("/BC"), "no border colour"); + let stroked = export(Stroke { weight: 1.0, ..Stroke::default() }); + assert!(stroked.contains("/BS< = ev.iter().enumerate().filter(|(_, (o, _))| o == "fill path").map(|(i, _)| i).collect(); + assert!(fills.len() >= 2, "{ev:?}"); + let the_box = fills[1]; + assert!(group < the_box && the_box < end, "the box is inside the frame's group: {ev:?}"); + assert!(the_box < at("fill text") && at("fill text") < at("stroke path") && at("stroke path") < end, "{ev:?}"); +} + /// The export asks for each story's composition once per page it is shown on: a parent page's /// page number still reads its own page everywhere, and a story threaded across pages shows each /// frame's own lines. diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index ba44cf713..bf32f1f3b 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -262,10 +262,23 @@ impl Exporter<'_> { } } - pub(crate) fn frame_text(&mut self, s: &mut Surface, cs: &ComposedStory, ft: &FrameText, story: &str) { + /// The frame's text. With `objects` (the frame text space → page transform and the page), + /// the frame's anchored objects are drawn too, over its paragraph shading and under its text; + /// returns whether they were. + pub(crate) fn frame_text( + &mut self, + s: &mut Surface, + cs: &ComposedStory, + ft: &FrameText, + story: &str, + objects: Option<(Affine, Option)>, + ) -> bool { // Paragraph shading and rules under the text. self.as_artifact(s, |me, s| { for d in ft.decos.iter().filter(|d| !d.nonprinting) { + if d.overprint { + me.warn("overprint is not exported yet"); + } if let Some(c) = me.swatch_color(&d.color, d.tint) { match &d.path { Some(p) => me.fill_path(s, p, c), @@ -277,6 +290,17 @@ impl Exporter<'_> { me.tables(s, ft); } }); + // Anchored objects under the text (as InDesign draws them): a picture whose frame reaches + // into the next line doesn't hide that line. Objects keep their page-space transforms. + let drawn = match objects { + Some((xf, page)) if xf.determinant().abs() > 1e-12 => { + s.push_transform(&tf(xf.inverse())); + self.frame_objects(s, ft, xf, page); + s.pop(); + true + } + _ => false, + }; // All underlines precede all glyph runs, including ink overlapping from another run. let underlines = text_rules(cs, ft, true); if !underlines.is_empty() { @@ -301,12 +325,13 @@ impl Exporter<'_> { if let Some(nft) = n.text.frames.first() { s.push_transform(&tf(Affine::translate(n.origin.to_vec2()))); let area = std::mem::replace(&mut me.text_area, lines_area(nft)); - me.frame_text(s, &n.text, nft, &n.source); + me.frame_text(s, &n.text, nft, &n.source, None); me.text_area = area; s.pop(); } } }); + drawn } /// One table cell's graphic, or its text composed in the cell's own area. @@ -322,7 +347,7 @@ impl Exporter<'_> { } else if let Some(cft) = c.text.frames.first() { s.push_transform(&tf(Affine::translate(c.origin.to_vec2()))); let area = std::mem::replace(&mut self.text_area, lines_area(cft)); - self.frame_text(s, &c.text, cft, &c.source); + self.frame_text(s, &c.text, cft, &c.source, None); self.text_area = area; s.pop(); } diff --git a/crates/pdf/src/x1a_tests.rs b/crates/pdf/src/x1a_tests.rs index 777d1e2a6..c79aaa172 100644 --- a/crates/pdf/src/x1a_tests.rs +++ b/crates/pdf/src/x1a_tests.rs @@ -318,3 +318,13 @@ fn x1a_dropped_inks_warning_says_they_wont_print() { assert!(warnings.iter().any(|w| w.contains("3 tint(s)") && w.contains("dropped and won't print")), "{warnings:?}"); assert!(warnings.iter().all(|w| !w.contains("converted to process")), "{warnings:?}"); } + +/// The output intent and the identification are read as objects: whitespace between tokens (as +/// qpdf and Ghostscript write them) doesn't hide them. +#[test] +fn x1a_checks_ignore_whitespace_between_tokens() { + let mut objects = tiny_objects("/Resources<<>>", "0 0 0 1 k", "/OutputConditionIdentifier(Custom)/DestOutputProfile 4 0 R", &icc_header(2)); + objects[5] = b"<< /GTS_PDFXVersion (PDF/X-1:2003) /GTS_PDFXConformance (PDF/X-1a:2003) /Trapped /False >>".to_vec(); + objects[6] = b"<< /Type /OutputIntent /S /GTS_PDFX /OutputConditionIdentifier (Custom) /DestOutputProfile 4 0 R >>".to_vec(); + assert_eq!(check_pdfx1a(&assemble(&objects)), Vec::::new()); +} diff --git a/crates/render/src/color.rs b/crates/render/src/color.rs index c4406b73c..84d648128 100644 --- a/crates/render/src/color.rs +++ b/crates/render/src/color.rs @@ -202,7 +202,11 @@ pub(crate) fn overprints(f: &Frame, swatch: &str, tint: f32, flag: bool) -> bool pub(crate) fn spread_blends(doc: &Document, sp: &designcraft_doc::Spread) -> bool { let blends = |it: &Item| { let mut any = false; - it.walk(&mut |i| any |= i.blend != Default::default()); + // A screened glow or a shadow with a blend mode blends with the objects below too, not the paper. + let fx_blends = |e: &designcraft_doc::Effects| { + (e.outer_glow.on && e.outer_glow.mode != Default::default()) || (e.drop_shadow.on && e.drop_shadow.mode != Default::default()) + }; + it.walk(&mut |i| any |= i.blend != Default::default() || fx_blends(&i.effects)); any }; sp.items.iter().any(|i| blends(i)) || doc.parents.iter().any(|p| p.items.iter().any(|i| blends(i))) diff --git a/crates/render/src/damage.rs b/crates/render/src/damage.rs index 3c11a5c20..ff76e0ecb 100644 --- a/crates/render/src/damage.rs +++ b/crates/render/src/damage.rs @@ -2,7 +2,7 @@ //! repaint just those regions (typing in a frame repaints the frame, not the window). //! //! Conservative: anything whose effect can reach beyond the changed items' own bounds (styles, -//! swatches, layers, parent pages, text wrap, effects, text that other stories show through +//! swatches, layers, parent pages, placed files, text wrap, effects, text that other stories show through //! cross-references, variables or footnote numbers) reports "everything" (`None`). use std::collections::HashMap; @@ -33,6 +33,9 @@ pub fn damage_with(old: &Document, new: &Document, cache: Option<&designcraft_co || old.spreads.len() != new.spreads.len() || old.parents.len() != new.parents.len() || old.parents.iter().zip(&new.parents).any(|(a, b)| !Arc::ptr_eq(a, b)) + // A relinked or updated file changes what unchanged items show. + || old.assets.len() != new.assets.len() + || old.assets.iter().zip(&new.assets).any(|((ka, a), (kb, b))| ka != kb || !Arc::ptr_eq(a, b)) { return None; } @@ -228,6 +231,43 @@ mod tests { assert!(dmg.iter().any(|(_, r)| r.contains(designcraft_geom::Point::new(350.0, 100.0)))); } + #[test] + fn relinking_an_asset_damages_the_frame_showing_it() { + use designcraft_doc::{Asset, AssetId, Content, Graphic}; + let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() }); + let lid = d.default_layer(); + let aid = AssetId(d.alloc()); + d.assets.insert(aid, Arc::new(Asset { id: aid, name: "a.png".into(), mime: "image/png".into(), ..Default::default() })); + let mut it = Item::new( + ItemId(d.alloc()), + lid, + designcraft_doc::Shape::Rectangle, + designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)), + ); + it.content = Content::Graphic(Graphic { + layer_options: None, + asset: aid, + size: (100.0, 100.0), + xf: designcraft_geom::Affine::IDENTITY, + clip: None, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }); + Arc::make_mut(&mut d.spreads[0]).items.push(Arc::new(it)); + let old = d.clone(); + // Relink: the same asset id now holds other bytes; the item itself is unchanged. + let fresh = Asset { data: Arc::new(vec![1, 2, 3]), link: Some("/new/a.png".into()), ..(*d.assets[&aid]).clone() }; + d.assets.insert(aid, Arc::new(fresh)); + let dmg = damage(&old, &d); + assert!( + dmg.as_ref().is_none_or(|v| v.iter().any(|(_, r)| r.contains(designcraft_geom::Point::new(150.0, 150.0)))), + "relink reported damage {dmg:?}" + ); + } + #[test] fn editing_a_wrapping_anchored_object_damages_the_text_it_wraps() { let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() }); diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 6147c9cf5..af3c0227c 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -102,19 +102,25 @@ impl Renderer { let mut below = vec![]; if e.outer_glow.on { let g = &e.outer_glow; - below.push((Vec2::ZERO, g.size, g.spread, g.color.clone(), g.opacity)); + below.push((Vec2::ZERO, g.size, g.spread, g.color.clone(), g.opacity, g.mode)); } if e.drop_shadow.on { let d = &e.drop_shadow; - below.push((offset(doc.light_angle(d.angle, d.global_light), d.distance), d.size, d.spread, d.color.clone(), d.opacity)); + below.push((offset(doc.light_angle(d.angle, d.global_light), d.distance), d.size, d.spread, d.color.clone(), d.opacity, d.mode)); } - for (off, size, pct, color, opacity) in below { + for (off, size, pct, color, opacity, mode) in below { let Some(c) = doc.resolve_color(&color, 1.0) else { continue }; let (sigma, grow) = split(size, pct); let filter = shadow(sigma, color_of(&c, opacity.clamp(0.0, 1.0))); // The offset moves the geometry, not the filter: vello drops layer content that lies // entirely outside the viewport before filtering. let r = (reach + off).inflate(grow, grow); + // The effect's blend sees what's behind the object: a layer around the filtered one. + let mix = crate::blend_mode(mode).mix; + if mix != Mix::Normal { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(BlendMode::new(mix, Compose::SrcOver)), None, None, None); + } self.with_filters(ctx, f, r, sigma, None, |me, c, fr| { let moved = Frame { view: fr.view * Affine::translate(off), ..*fr }; c.set_transform(moved.view); @@ -128,6 +134,9 @@ impl Renderer { } c.pop_layer(); }); + if mix != Mix::Normal { + ctx.pop_layer(); + } } // Gradient feather: the object (feathered or not) drawn in its own layer, then kept as // much as the opacity gradient says. @@ -211,6 +220,9 @@ impl Renderer { if !it.path.is_closed() { return; } + // An image's edge is where it is opaque, not the frame: inner shadows, glows and bevels + // follow what is painted when the frame holds a graphic. + let painted = matches!(it.content, designcraft_doc::Content::Graphic(_)).then_some((it, page)); // Inner shadow, clipped to the shape. if e.inner_shadow.on && let Some(c) = doc.resolve_color(&e.inner_shadow.color, 1.0) @@ -218,7 +230,19 @@ impl Renderer { let s = &e.inner_shadow; let (sigma, choke) = split(s.size, s.choke); let off = offset(doc.light_angle(s.angle, s.global_light), s.distance); - self.inner_edge(ctx, f, reach, bp, xf, off, sigma, choke, color_of(&c, s.opacity.clamp(0.0, 1.0)), Mix::Normal); + self.inner_edge( + ctx, + f, + reach, + bp, + xf, + off, + sigma, + choke, + color_of(&c, s.opacity.clamp(0.0, 1.0)), + crate::blend_mode(s.mode).mix, + painted, + ); } // Inner glow: from the edges inwards (or out from the centre), screened over the object. if e.inner_glow.on @@ -248,7 +272,7 @@ impl Renderer { c.pop_layer(); }); } else { - self.inner_edge(ctx, f, reach, bp, xf, Vec2::ZERO, sigma, choke, paint, mix); + self.inner_edge(ctx, f, reach, bp, xf, Vec2::ZERO, sigma, choke, paint, mix, painted); } } // Satin: the shape shifted both ways along the angle, blurred, where exactly one covers. @@ -289,16 +313,18 @@ impl Renderer { let sigma = b.size.max(0.0) / 2.0; if let Some(c) = doc.resolve_color(&b.highlight, 1.0) { // The inverse shape moved away from the light lights the edges facing it. - self.inner_edge(ctx, f, reach, bp, xf, off, sigma, 0.0, color_of(&c, b.highlight_opacity.clamp(0.0, 1.0)), Mix::Screen); + self.inner_edge(ctx, f, reach, bp, xf, off, sigma, 0.0, color_of(&c, b.highlight_opacity.clamp(0.0, 1.0)), Mix::Screen, painted); } if let Some(c) = doc.resolve_color(&b.shadow, 1.0) { - self.inner_edge(ctx, f, reach, bp, xf, -off, sigma, 0.0, color_of(&c, b.shadow_opacity.clamp(0.0, 1.0)), Mix::Multiply); + self.inner_edge(ctx, f, reach, bp, xf, -off, sigma, 0.0, color_of(&c, b.shadow_opacity.clamp(0.0, 1.0)), Mix::Multiply, painted); } } } /// An inner shadow-like edge: everything outside the shape moved by `off`, blurred by - /// `sigma`, clipped to the shape and blended with `mix`. + /// `sigma`, clipped to the shape and blended with `mix`. With `painted` (an object and the + /// page it shows on), the shape is what the object paints, so a transparent part of an image + /// is outside it: the edge runs along the image's opaque pixels, not the frame. #[allow(clippy::too_many_arguments)] fn inner_edge( &mut self, @@ -312,7 +338,33 @@ impl Renderer { choke: f64, paint: peniko::Color, mix: Mix, + painted: Option<(&Item, ShownOn)>, ) { + if let Some((it, page)) = painted { + self.with_blended_filters(ctx, f, reach, sigma, mix, (bp, xf), |me, c, fr| { + let pad = sigma * 4.0 + choke + off.hypot() + 4.0 * fr.px; + let area = reach.inflate(pad, pad); + // The colour everywhere but under the painted pixels moved by `off`, blurred, ... + c.set_transform(fr.view); + c.push_layer(None, None, None, None, None); + c.push_layer(None, None, None, None, Some(blur(sigma))); + c.set_paint(paint); + c.fill_rect(&area); + c.set_transform(Affine::IDENTITY); + c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestOut)), None, None, None); + let moved = Frame { view: fr.view * Affine::translate(off), ..*fr }; + me.draw_body(c, &moved, it, bp, xf, page); + c.pop_layer(); + c.pop_layer(); + // ... kept only where the object paints. + c.set_transform(Affine::IDENTITY); + c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None); + me.draw_body(c, fr, it, bp, xf, page); + c.pop_layer(); + c.pop_layer(); + }); + return; + } self.with_blended_filters(ctx, f, reach, sigma, mix, (bp, xf), |_, c, fr| { c.set_transform(fr.view); c.push_layer(None, None, None, None, Some(blur(sigma))); @@ -458,4 +510,68 @@ mod tests { let wide = Offscreen { x0: -14852.0, y0: -14852.0, scale: 15_002.0 / 1024.0, width: 2062, height: 2062 }; assert_eq!(offscreen(object, 15_002.0, 1440, 900), Some(wide)); } + + /// An inner shadow on a frame holding an image whose left half is transparent: the shadow + /// lines the image's opaque part (its edge in the middle of the frame), not only the frame. + #[test] + fn an_inner_shadow_follows_a_transparent_images_edge() { + use designcraft_doc::build::NewDocument; + use designcraft_doc::{Document, SpreadRef}; + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + // 20 × 20: transparent left half, opaque white right half. + let img = image::RgbaImage::from_fn(20, 20, |x, _| if x < 10 { image::Rgba([0, 0, 0, 0]) } else { image::Rgba([255, 255, 255, 255]) }); + let mut png = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let aid = designcraft_doc::AssetId(d.alloc()); + d.assets.insert( + aid, + std::sync::Arc::new(designcraft_doc::Asset { + id: aid, + name: "half.png".into(), + mime: "image/png".into(), + data: std::sync::Arc::new(png), + pixels: Some((20, 20)), + ..Default::default() + }), + ); + let mut it = Item::new( + designcraft_doc::ItemId(d.alloc()), + lid, + designcraft_doc::Shape::Rectangle, + designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 300.0, 300.0)), + ); + it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + layer_options: None, + asset: aid, + size: (200.0, 200.0), + xf: Affine::translate((100.0, 100.0)), + clip: None, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }); + let s = &mut it.effects.inner_shadow; + s.on = true; + s.color = "[Black]".into(); + s.opacity = 1.0; + s.distance = 0.0; + s.size = 20.0; + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let cache = crate::Cache::new(); + let mut r = Renderer::new(); + r.threads = 0; + let page = r.render_page(&d, &cache, 0, 1.0, false, &crate::RenderOptions::default()).unwrap(); + let lum = |x: u32| { + let p = page.pixel(x, 200); + u32::from(p[0]) + u32::from(p[1]) + u32::from(p[2]) + }; + // The image's opaque part starts at x = 200: just inside it the shadow darkens it; in its + // middle (x = 250, 50 pt from every edge but the frame's right one) it stays light. + assert!(lum(204) + 60 < lum(250), "shadow at the image's own edge: {} vs {}", lum(204), lum(250)); + // The transparent half shows the page: no shadow painted there. + assert!(lum(150) > 700, "nothing over the transparent part: {}", lum(150)); + } } diff --git a/crates/render/src/stroke.rs b/crates/render/src/stroke.rs index c9d8b6acb..a63dd44a3 100644 --- a/crates/render/src/stroke.rs +++ b/crates/render/src/stroke.rs @@ -6,6 +6,14 @@ use super::{ pdf_layers, peniko, vello_cpu, }; +/// `stroke` as round dots `weight` wide, one every `2 × weight`. Kurbo's stroker drops +/// zero-length dashes, so each dot is a dash a hair long, centred where the zero-length dash +/// would be (so the path's ends get dots too). +pub(crate) fn dotted(stroke: kurbo::Stroke, weight: f64) -> kurbo::Stroke { + let dot = weight * 1e-3; + stroke.with_dashes(dot / 2.0, [dot, weight * 2.0 - dot]).with_caps(kurbo::Cap::Round) +} + impl Renderer { pub(crate) fn draw_stroke(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine) { self.draw_stroke_painted(ctx, f, it, bp, xf); @@ -37,9 +45,7 @@ impl Renderer { StrokeType::Dashed { pattern } if StrokeType::expandable_dashes(pattern, length) => { stroke = stroke.with_dashes(0.0, pattern.iter().copied()) } - StrokeType::Dotted if StrokeType::expandable_dashes(&[0.0, st.weight * 2.0], length) => { - stroke = stroke.with_dashes(0.0, [0.0, st.weight * 2.0]).with_caps(kurbo::Cap::Round) - } + StrokeType::Dotted if StrokeType::expandable_dashes(&[0.0, st.weight * 2.0], length) => stroke = dotted(stroke, st.weight), _ => {} } ctx.set_transform(f.view * xf); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 295a0da5f..5f522a1e2 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -669,3 +669,63 @@ fn content_crop_box_is_measured_without_rendering_the_page() { assert!(frac.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-6), "{frac:?} vs {want:?}"); assert!((size.0 - 400.0).abs() < 1e-3 && (size.1 - 497.0).abs() < 1e-3, "{size:?}"); } + +/// An inline object is painted under its frame's text, as InDesign draws it: a picture +/// whose frame reaches down into the next line (a negative Y offset) doesn't hide that line. +#[test] +fn inline_objects_are_painted_under_the_text() { + use designcraft_doc::{AnchorPosition, AnchoredObject, ItemId, Shape, SpreadRef}; + let mut d = Document::new(&NewDocument::default()); + let (fid, sid) = + d.add_text_frame(SpreadRef::Doc(0), Rect::new(20.0, 20.0, 300.0, 300.0), d.default_layer(), "A\nWWWW", ParaFormat::default()).unwrap(); + let mut item = + Item::new(ItemId(d.alloc()), d.default_layer(), Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 250.0, 60.0))); + item.fill = Fill::swatch(designcraft_color::swatch::PAPER); + d.story_mut(sid).unwrap().insert_object(1, AnchoredObject::new(item, AnchorPosition::Inline { y_offset: -40.0 })); + let cache = Cache::new(); + let cs = cache.get(&d, sid, None); + let (ft, obj) = (&cs.frames[0], &cs.frames[0].objects[0]); + let l = ft.lines.iter().find(|l| l.range.start > 1).unwrap(); + // The second line lies inside the white box. + assert!(obj.origin.y + obj.size.1 > l.baseline && obj.origin.y < l.baseline - l.ascent, "{:?} {}", obj.origin, l.baseline); + let m = d.text_xf(d.item(fid).unwrap()); + let image = Renderer::new().render_page(&d, &cache, 0, 1.0, false, &RenderOptions::default()).unwrap(); + let (a, b) = (m * designcraft_geom::Point::new(l.x0, l.baseline - l.ascent), m * designcraft_geom::Point::new(l.end_x, l.baseline)); + let dark = + (a.y as u32..b.y as u32).flat_map(|y| (a.x as u32..b.x as u32).map(move |x| (x, y))).filter(|&(x, y)| image.pixel(x, y)[0] < 100).count(); + assert!(dark > 20, "the line's glyphs show over the box: {dark} dark pixels"); +} + +#[test] +fn dotted_strokes_draw_every_dot() { + // A 4 pt dotted line 96 pt long: dots every 8 pt, 13 of them. + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let id = designcraft_doc::ItemId(d.alloc()); + let path = designcraft_geom::shapes::line(designcraft_geom::Point::new(100.0, 400.0), designcraft_geom::Point::new(196.0, 400.0)); + let mut line = Item::new(id, lid, designcraft_doc::Shape::GraphicLine, path); + line.stroke = designcraft_doc::Stroke { + swatch: designcraft_color::swatch::BLACK.into(), + weight: 4.0, + kind: designcraft_doc::StrokeType::Dotted, + ..Default::default() + }; + d.insert_item(SpreadRef::Doc(0), line, None).unwrap(); + let cache = Cache::new(); + let mut r = Renderer::new(); + r.threads = 0; + let img = r.render_page(&d, &cache, 0, 1.0, false, &RenderOptions::default()).unwrap(); + for i in 0..13 { + let x = 100 + i * 8; + assert!(img.pixel(x, 400)[0] < 128, "dot {i} at x={x}: {:?}", img.pixel(x, 400)); + if i < 12 { + assert_eq!(img.pixel(x + 4, 400), [255, 255, 255, 255], "gap after dot {i}"); + } + } + // Table and paragraph rules: one round dot per period. + let stroke = text::cell_stroke(&designcraft_doc::StrokeType::Dotted, 4.0, 96.0); + let edge = kurbo::Line::new((0.0, 0.0), (96.0, 0.0)); + let outline = kurbo::stroke(kurbo::Shape::path_elements(&edge, 0.01), &stroke, &kurbo::StrokeOpts::default(), 0.01); + let dots = outline.elements().iter().filter(|e| matches!(e, kurbo::PathEl::MoveTo(_))).count(); + assert_eq!(dots, 13); +} diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 3797f508e..51271b577 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -387,6 +387,11 @@ impl Renderer { if !ft.tables.is_empty() { self.draw_tables(ctx, f, ft, xf); } + // Anchored objects ride on their lines, under the frame's text (as InDesign draws them): + // a picture whose frame reaches into the next line doesn't hide that line. + for o in &ft.objects { + self.draw_item(ctx, f, &o.object.item, xf * Affine::translate(o.origin.to_vec2()), ShownOn::Own); + } draw_text_indicators(ctx, f, cs, ft, m); let scale = m.determinant().abs().sqrt(); let greek_px = f.opts.greek_below_px; @@ -461,10 +466,6 @@ impl Renderer { self.draw_text(ctx, f, &n.text, nft, xf * Affine::translate(n.origin.to_vec2())); } } - // Anchored objects ride on their lines. - for o in &ft.objects { - self.draw_item(ctx, f, &o.object.item, xf * Affine::translate(o.origin.to_vec2()), ShownOn::Own); - } } } @@ -612,9 +613,7 @@ pub(crate) fn cell_stroke(kind: &designcraft_doc::StrokeType, weight: f64, lengt StrokeType::Dashed { pattern } if StrokeType::expandable_dashes(pattern, length) => { st.with_caps(kurbo::Cap::Butt).with_dashes(0.0, pattern.iter().copied()) } - StrokeType::Dotted if StrokeType::expandable_dashes(&[0.0, weight * 2.0], length) => { - st.with_dashes(0.0, [0.0, weight * 2.0]).with_caps(kurbo::Cap::Round) - } + StrokeType::Dotted if StrokeType::expandable_dashes(&[0.0, weight * 2.0], length) => super::stroke::dotted(st, weight), _ => st, } } diff --git a/crates/render/tests/golden/inner-shadow.png b/crates/render/tests/golden/inner-shadow.png index c267aa386..5cba47641 100644 Binary files a/crates/render/tests/golden/inner-shadow.png and b/crates/render/tests/golden/inner-shadow.png differ diff --git a/crates/render/tests/it/colorize.rs b/crates/render/tests/it/colorize.rs index 5bf87a54d..a99cc8435 100644 --- a/crates/render/tests/it/colorize.rs +++ b/crates/render/tests/it/colorize.rs @@ -13,7 +13,7 @@ use designcraft_geom::{Affine, Rect, shapes}; use designcraft_render::{RenderOptions, Renderer, color_of}; use image::{ExtendedColorType, ImageEncoder}; -const RED: &str = "C=0 M=100 Y=100 K=0"; +const RED: &str = "C=15 M=100 Y=100 K=0"; const CYAN: &str = "C=100 M=0 Y=0 K=0"; fn encode(px: &[u8], w: u32, ct: ExtendedColorType, gif: bool) -> Vec { diff --git a/crates/render/tests/it/effects.rs b/crates/render/tests/it/effects.rs index 92d04bc44..267d538f9 100644 --- a/crates/render/tests/it/effects.rs +++ b/crates/render/tests/it/effects.rs @@ -141,6 +141,8 @@ fn outer_glow_surrounds_the_object() { g.size = 12.0; g.color = "C=0 M=100 Y=0 K=0".into(); g.opacity = 1.0; + // Normal, so the glow shows on the white page (screened, magenta on white is white). + g.mode = designcraft_color::BlendMode::Normal; let img = render(&d); golden("outer-glow", &img); for (x, y) in [(46, 80), (114, 80), (80, 46), (80, 114)] { @@ -150,6 +152,49 @@ fn outer_glow_surrounds_the_object() { assert_eq!(lum(img.pixel(10, 10)), WHITE); } +/// An effect's blend mode works on what's behind it: a glow screened onto a dark backdrop +/// lightens it in the glow's colour, where Normal paints the glow's colour over it. +#[test] +fn outer_glow_blends_with_its_mode() { + let glow = |mode| { + let mut d = doc(); + rect_item(&mut d, Rect::new(0.0, 0.0, 160.0, 160.0), "C=100 M=90 Y=10 K=0"); + let id = rect_item(&mut d, Rect::new(50.0, 50.0, 110.0, 110.0), "[Black]"); + let g = &mut d.item_mut(id).unwrap().effects.outer_glow; + g.on = true; + g.size = 12.0; + g.color = "C=0 M=0 Y=100 K=0".into(); + g.opacity = 1.0; + g.mode = mode; + render(&d) + }; + let screen = glow(designcraft_color::BlendMode::Screen); + let normal = glow(designcraft_color::BlendMode::Normal); + let backdrop = screen.pixel(10, 10); + let (s, n) = (screen.pixel(46, 80), normal.pixel(46, 80)); + // Screen only lightens: every channel at least the backdrop's, and the navy shows through + // (blue stays high), where Normal paints yellow (blue drops). + assert!(s.iter().zip(backdrop).take(3).all(|(a, b)| *a + 2 >= b), "screen lightens: {s:?} over {backdrop:?}"); + assert!(s[2] > n[2] + 25, "screen keeps the backdrop's blue: {s:?} vs normal {n:?}"); +} + +/// A screened glow over blank paper shows in its colour: it blends with the objects below, and +/// the paper is not one of them. +#[test] +fn screened_outer_glow_shows_on_blank_paper() { + let mut d = doc(); + let id = rect_item(&mut d, Rect::new(50.0, 50.0, 110.0, 110.0), "[Black]"); + let g = &mut d.item_mut(id).unwrap().effects.outer_glow; + g.on = true; + g.size = 12.0; + g.color = "C=0 M=0 Y=100 K=0".into(); + g.opacity = 1.0; + g.mode = designcraft_color::BlendMode::Screen; + let img = render(&d); + let p = img.pixel(46, 80); + assert!(p[2] < 200 && p[0] > p[2] + 40, "yellow glow beside the item: {p:?}"); +} + #[test] fn inner_shadow_darkens_inside_the_lit_edge() { let mut d = doc(); diff --git a/crates/textimport/src/docx.rs b/crates/textimport/src/docx.rs index 94085ca69..5f1624267 100644 --- a/crates/textimport/src/docx.rs +++ b/crates/textimport/src/docx.rs @@ -236,7 +236,17 @@ struct Ctx { para_names: HashMap, char_names: HashMap, footnotes: HashMap, + /// Footnote ids already imported, and how much note text has been built. A reference clones + /// its note and walks it again, so one 1 MB footnote referenced 100k times meant ~100 GB + /// (#1584). Word gives each note its own id and references it once; a second reference to the + /// same id gets the mark without the text. + notes_done: std::collections::HashSet, + note_bytes: usize, warnings: Vec, + /// What has already been said, so saying it again is a lookup rather than a scan of + /// everything said so far. A warning that names a footnote id is one per id, and the + /// document chooses how many ids it has (noted in review). + warned: std::collections::HashSet, next_table: u64, numbering: Numbering, /// A paragraph style's list (`w:numPr` numId and ilvl, through `w:basedOn`), by style id. @@ -249,7 +259,47 @@ struct Ctx { default_para: String, } +/// A note's tables may hold tables of their own. The document model stops nesting at 64, so +/// counting no deeper than that reaches every cell a note can actually have. +const MAX_NOTE_DEPTH: usize = 64; + +/// The text `s` holds, including its tables' cell text. A table's cells each keep their text in +/// a story of their own rather than in `s.text`, which holds only the anchor, so counting +/// `s.text` scored a table of any size as a couple of bytes (noted in review). +fn note_bytes(s: &Story, depth: usize) -> usize { + let mut n = s.text.len(); + if depth == 0 { + return n; + } + for t in s.tables.values() { + for c in &t.cells { + n = n.saturating_add(note_bytes(&c.text, depth - 1)); + } + } + n +} + +/// Warnings one import reports. A document that earns more than this has earned the point. +const MAX_WARNINGS: usize = 100; + impl Ctx { + /// Report `message` once. Beyond [`MAX_WARNINGS`] distinct messages nothing further is kept, + /// not even in the set, so neither the report nor the memory behind it grows with the + /// document -- a footnote warning names an id, and a file can hold as many ids as it likes. + fn warn(&mut self, message: String) { + if self.warnings.len() > MAX_WARNINGS { + return; + } + if !self.warned.insert(message.clone()) { + return; + } + if self.warnings.len() == MAX_WARNINGS { + self.warnings.push(format!("The Word file has more than {MAX_WARNINGS} kinds of problem: the rest are not listed.")); + } else { + self.warnings.push(message); + } + } + fn para_style(&self, ppr: Option<&El>) -> String { ppr.and_then(|p| p.val("pStyle")).and_then(|id| self.para_names.get(&id).cloned()).unwrap_or_else(|| self.default_para.clone()) } @@ -412,11 +462,39 @@ impl Ctx { "noBreakHyphen" => "\u{2011}".into(), "softHyphen" => "\u{AD}".into(), "footnoteReference" if allow_notes => { + const MAX_NOTE_BYTES: usize = 8 << 20; + let id = e.attr("id").unwrap_or_default().to_string(); + // The note's text is built once per id, and only while the budget lasts. A + // repeat reference, or one past the budget, still gets its mark and its + // number -- the reference is part of the text -- with an empty note (#1584). + // + // The id is recorded only once its text has actually been kept, further down: + // recording it here meant a first reference arriving after the budget was + // spent counted as "done" with an empty note, and the next reference to it + // then claimed the text was kept at the first one (noted in review). + let again = !id.is_empty() && self.notes_done.contains(&id); + let spent = self.note_bytes >= MAX_NOTE_BYTES; + if again || spent { + let len = st.len(); + if st.insert_note(len, "", ParaFormat::default()).is_some() { + let message = if again { + format!("Footnote {id} is referenced more than once in the Word file: the text is kept at its first reference.") + } else { + format!("The Word file's footnotes are longer than {} MB: the rest were imported empty.", MAX_NOTE_BYTES / (1 << 20)) + }; + self.warn(message); + } + continue; + } if let Some(note) = e.attr("id").and_then(|id| self.footnotes.get(id).cloned()) { let mut ns = Story::new(StoryId(0)); let counter = std::mem::take(&mut self.model_counter); self.blocks(¬e, &mut ns, false); self.model_counter = counter; + self.note_bytes = self.note_bytes.saturating_add(note_bytes(&ns, MAX_NOTE_DEPTH)); + if !id.is_empty() { + self.notes_done.insert(id.clone()); + } // Word puts the reference mark in the note text: we generate the number. let len = st.len(); match st.insert_note(len, "", ParaFormat::default()) { @@ -425,15 +503,13 @@ impl Ctx { n.text = ns; } } - None => self.warnings.push("A footnote in the Word file could not be imported.".into()), + None => self.warn("A footnote in the Word file could not be imported.".into()), } } continue; } "drawing" | "pict" | "object" => { - if !self.warnings.iter().any(|w| w.starts_with("Images")) { - self.warnings.push("Images and drawings in the Word file were not imported (place them separately).".into()); - } + self.warn("Images and drawings in the Word file were not imported (place them separately).".into()); continue; } _ => continue, @@ -485,7 +561,10 @@ pub fn import(bytes: &[u8]) -> Result { para_names: HashMap::new(), char_names: HashMap::new(), footnotes: HashMap::new(), + notes_done: std::collections::HashSet::new(), + note_bytes: 0, warnings: vec![], + warned: std::collections::HashSet::new(), next_table: 0, numbering: Numbering::default(), style_lists: HashMap::new(), diff --git a/crates/textimport/src/docx/tests.rs b/crates/textimport/src/docx/tests.rs index c70ca851f..7f1e425cf 100644 --- a/crates/textimport/src/docx/tests.rs +++ b/crates/textimport/src/docx/tests.rs @@ -89,6 +89,124 @@ fn a_measure_or_size_a_file_cannot_mean_is_bounded() { assert_eq!(size, None, "a size the engine cannot mean is dropped, so the style's size stands"); } +/// A footnote reference clones its note and walks it again, so one large footnote referenced +/// many times multiplied: the audit's case is a 1 MB note referenced 100k times, around +/// 100 GB, which aborts rather than fails (#1584). The text is kept at the first reference; +/// later ones get the mark and an empty note, and the report says so. +#[test] +fn a_footnote_referenced_many_times_is_imported_once() { + let note_text = "x".repeat(2_000); + let footnotes = format!(r#"{note_text}"#); + // One note, referenced 50 times. + let refs = r#""#.repeat(50); + let doc = format!(r#"{refs}"#); + let i = import(&docx(&doc, "", &footnotes)).unwrap(); + + // Every reference is still in the text: the marks are the document's content. + assert_eq!(i.story.notes.len(), 50, "all 50 references are kept"); + let with_text = i.story.notes.iter().filter(|n| !n.text.text.is_empty()).count(); + assert_eq!(with_text, 1, "only the first reference carries the note's text"); + let total: usize = i.story.notes.iter().map(|n| n.text.text.len()).sum(); + assert!(total <= note_text.len() + 64, "the note text is stored once, not 50 times: {total} bytes"); + assert!(i.warnings.iter().any(|w| w.contains("referenced more than once")), "the report says what it did: {:?}", i.warnings); + + // A document whose notes each have their own id is unaffected. + let many = (2..6).map(|id| format!(r#"note {id}"#)).collect::(); + let footnotes = format!(r#"{many}"#); + let refs = (2..6).map(|id| format!(r#""#)).collect::(); + let doc = format!(r#"{refs}"#); + let i = import(&docx(&doc, "", &footnotes)).unwrap(); + assert_eq!(i.story.notes.len(), 4); + assert!(i.story.notes.iter().all(|n| n.text.text.contains("note")), "each distinct note keeps its text"); + assert!(!i.warnings.iter().any(|w| w.contains("referenced more than once")), "{:?}", i.warnings); +} + +/// A footnote's table keeps its cell text in stories of its own, so the budget counted a +/// table of any size as the couple of bytes of its anchor and the cap did not hold (noted in +/// review of #1584). Eighteen notes of ~600 KB of cell text each are 10.8 MB of table text +/// against an 8 MB budget. +#[test] +fn a_footnote_table_counts_toward_the_note_budget() { + let big = "x".repeat(600_000); + let cell = format!(r#"{big}"#); + let notes: String = (2..20).map(|id| format!(r#"{cell}"#)).collect(); + let footnotes = format!(r#"{notes}"#); + let refs: String = (2..20).map(|id| format!(r#""#)).collect(); + let doc = format!(r#"{refs}"#); + let i = import(&docx(&doc, "", &footnotes)).unwrap(); + + // Every reference is still a mark in the text. + assert_eq!(i.story.notes.len(), 18, "all 18 references are kept"); + // What was retained is the budget plus at most the one note that was already being built + // when it ran out -- not all 10.8 MB. + let held: usize = i.story.notes.iter().map(|n| note_bytes(&n.text, MAX_NOTE_DEPTH)).sum(); + assert!(held <= (8 << 20) + big.len() + 1024, "the cell text is inside the budget: {held} bytes"); + assert!(held > big.len(), "and the notes that fit were imported in full: {held} bytes"); + assert!(i.warnings.iter().any(|w| w.contains("longer than 8 MB")), "the budget warning is reported: {:?}", i.warnings); +} + +/// The report is one line per distinct footnote id, and the file chooses how many ids it +/// has: 400 ids referenced twice each meant 400 warnings, every one of them pushed after a +/// `Vec::contains` scan of all the earlier ones (noted in review of #1584). Both the report +/// and the set behind it stop at the cap. +#[test] +fn the_report_does_not_grow_with_the_document() { + let ids = 2..402; + // Small notes, so the byte budget is not what stops this -- the ids are. + let notes: String = ids.clone().map(|id| format!(r#"note {id}"#)).collect(); + let footnotes = format!(r#"{notes}"#); + let refs: String = ids + .clone() + .flat_map(|id| { + let one = format!(r#""#); + [one.clone(), one] + }) + .collect(); + let doc = format!(r#"{refs}"#); + let i = import(&docx(&doc, "", &footnotes)).unwrap(); + + // The document is imported in full: every reference is a mark in the text. + assert_eq!(i.story.notes.len(), 800, "all 800 references are kept"); + let duplicates = i.warnings.iter().filter(|w| w.contains("referenced more than once")).count(); + assert!(duplicates >= 1, "the repeats are reported: {:?}", i.warnings); + assert!(i.warnings.len() <= MAX_WARNINGS + 1, "400 duplicated ids do not make 400 warnings: {} warnings", i.warnings.len()); + // And the last line says the list was cut, rather than ending without explanation. + assert!(i.warnings.iter().any(|w| w.contains("the rest are not listed")), "{:?}", i.warnings); +} + +/// When the budget runs out before a note's *first* reference, that reference gets an empty +/// note -- and must not be recorded as the one that kept the text, or a later reference to the +/// same id would be told the text is at a reference that has none (noted in review of #1584). +#[test] +fn a_note_whose_first_reference_is_past_the_budget_is_not_recorded_as_kept() { + // Notes totalling more than the 8 MB budget, so the later ids are never built. + let big = "x".repeat(600_000); + let notes: String = (2..20).map(|id| format!(r#"{big}"#)).collect(); + let footnotes = format!(r#"{notes}"#); + // Each id referenced twice, in order. + let refs: String = (2..20) + .flat_map(|id| { + let one = format!(r#""#); + [one.clone(), one] + }) + .collect(); + let doc = format!(r#"{refs}"#); + let i = import(&docx(&doc, "", &footnotes)).unwrap(); + + // Every reference is kept, and the budget is respected. + assert_eq!(i.story.notes.len(), 36, "all 36 references are in the text"); + let total: usize = i.story.notes.iter().map(|n| n.text.text.len()).sum(); + assert!(total <= (8 << 20) + big.len(), "the note text stays inside the budget: {total} bytes"); + + // The "referenced more than once" warning is only about ids whose text was actually kept. + // A note that was never built cannot have kept its text at a first reference. + let kept: std::collections::HashSet<&str> = i.story.notes.iter().filter(|n| !n.text.text.is_empty()).map(|n| n.text.text.as_str()).collect(); + if i.warnings.iter().any(|w| w.contains("referenced more than once")) { + assert!(!kept.is_empty(), "that warning implies some text was kept: {:?}", i.warnings); + } + assert!(i.warnings.iter().any(|w| w.contains("longer than 8 MB")), "the budget warning is the one this document earns: {:?}", i.warnings); +} + #[test] fn imports_styles_runs_footnotes_and_tables() { let styles = format!( diff --git a/crates/textimport/src/export.rs b/crates/textimport/src/export.rs index 8262e4691..7d614f640 100644 --- a/crates/textimport/src/export.rs +++ b/crates/textimport/src/export.rs @@ -15,7 +15,7 @@ fn plain_char(c: char) -> Option<&'static str> { match c { PAGE_NUMBER | NEXT_PAGE_NUMBER | PREV_PAGE_NUMBER => Some("#"), FORCED_LINE_BREAK => Some("\u{2028}"), - '\u{E000}'..='\u{E0FF}' => None, + '\u{E000}'..='\u{E1FF}' => None, _ => Some(""), } } @@ -174,7 +174,7 @@ pub fn rtf(doc: &Document, story: &Story) -> String { } } PAGE_NUMBER | NEXT_PAGE_NUMBER | PREV_PAGE_NUMBER => out.push('#'), - '\u{E000}'..='\u{E0FF}' => {} + '\u{E000}'..='\u{E1FF}' => {} c => rtf_escape(&mut out, c), } } @@ -201,6 +201,14 @@ mod tests { st } + #[test] + fn text_variable_markers_are_dropped() { + let st = Story::with_text(StoryId(1), "Page \u{E100} of", Default::default()); + assert_eq!(plain_text(&st), "Page of"); + let r = rtf(&Document::new(&NewDocument::default()), &st); + assert!(!r.contains("\\u-7936") && r.contains("Page of"), "{r}"); + } + #[test] fn text_only() { assert_eq!(plain_text(&story()), "Plain bold {x}\r\nCafé\ntwo"); diff --git a/crates/textimport/src/lib.rs b/crates/textimport/src/lib.rs index 56eac1a00..eb4189156 100644 --- a/crates/textimport/src/lib.rs +++ b/crates/textimport/src/lib.rs @@ -117,9 +117,12 @@ pub fn import(name: &str, bytes: &[u8]) -> Result { Ok(plain(&tagged::decode(bytes))) } -/// Plain text: line breaks become paragraphs (CRLF / CR normalised). +/// Plain text: line breaks become paragraphs (CRLF / CR normalised). The private-use characters +/// (U+E000–U+E1FF) that stories use as markers are dropped, since a stray one would be read as a +/// marker without its payload. pub fn plain(text: &str) -> Imported { let t = text.trim_start_matches('\u{FEFF}').replace("\r\n", "\n").replace('\r', "\n"); + let t: String = t.chars().filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c)).collect(); let story = Story::with_text(designcraft_doc::StoryId(0), &t, Default::default()); Imported { story, para_styles: vec![], char_styles: vec![], warnings: vec![] } } @@ -137,6 +140,12 @@ mod tests { assert!(matches!(import("x.doc", b"\xD0\xCF"), Err(ImportError::Unsupported(_)))); } + #[test] + fn plain_text_drops_story_markers() { + let i = import("a.txt", "a\u{E009}b\u{E000}c\u{E002}d".as_bytes()).unwrap(); + assert_eq!(i.story.text, "abcd"); + } + #[test] fn plain_text_utf16() { let mut le = vec![0xFF, 0xFE]; diff --git a/crates/textimport/src/rtf.rs b/crates/textimport/src/rtf.rs index f795804bb..f750aa385 100644 --- a/crates/textimport/src/rtf.rs +++ b/crates/textimport/src/rtf.rs @@ -149,13 +149,15 @@ pub fn import(bytes: &[u8]) -> Result { let next = chars.get(i + 1).copied().unwrap_or(' '); if next == '\'' { // \'hh: a Windows-1252 byte. - let hex: String = chars.iter().skip(i + 2).take(2).collect(); - i += 4; + // Only valid hex digits are consumed; anything else is left for the main loop. + let hex: String = chars.iter().skip(i + 2).take(2).take_while(|c| c.is_ascii_hexdigit()).collect(); + i += 2 + hex.len(); if pending > 0 { pending -= 1; continue; } - if let Ok(b) = u8::from_str_radix(&hex, 16) + if hex.len() == 2 + && let Ok(b) = u8::from_str_radix(&hex, 16) && !stack.last().is_some_and(|s| s.skip) { units.reset(); @@ -314,6 +316,15 @@ mod tests { assert_eq!(import(br"{\rtf1 a\u-4294967231?b}").unwrap().story.text, "ab"); } + /// A short or invalid `\'hh` escape consumes only its valid hex digits. + #[test] + fn short_hex_escape_keeps_closing_brace() { + let i = import(br"{\rtf1 {\b A\'4}B}").unwrap(); + assert_eq!(i.story.text, "AB"); + assert_eq!(i.story.format_after(1).over.font_style, None); + assert_eq!(import(br"{\rtf1 a\'+fb}").unwrap().story.text, "a+fb"); + } + #[test] fn imports_rtf_text_and_formatting() { let rtf = br"{\rtf1\ansi{\fonttbl{\f0 Times;}}{\colortbl;\red0\green0\blue0;}{\*\generator Test;} diff --git a/crates/textimport/src/tagged.rs b/crates/textimport/src/tagged.rs index 74806ae4c..fc0d6a58d 100644 --- a/crates/textimport/src/tagged.rs +++ b/crates/textimport/src/tagged.rs @@ -56,9 +56,18 @@ fn unescape_value(v: &str) -> String { out } +/// The first format version whose `cFont` and `cTypeface` values are escaped like style names. +/// Older files hold them raw, so a backslash in them is literal. +const ESCAPED_FONTS_VERSION: u32 = 8; + +/// A `cFont` or `cTypeface` value: decoded in files that escape it, literal in older ones. +fn font_value(v: &str, escaped: bool) -> String { + if escaped { unescape_value(v) } else { v.to_string() } +} + /// `story` as Tagged Text. pub fn tagged_text(doc: &Document, story: &Story) -> String { - let mut out = String::from("\r\n\r\n"); + let mut out = String::from("\r\n\r\n"); let ranges = story.para_ranges(); for (i, r) in ranges.iter().enumerate() { if i > 0 { @@ -80,11 +89,11 @@ pub fn tagged_text(doc: &Document, story: &Story) -> String { open.push("CharStyle"); } if let Some(v) = &f.over.font_family { - let _ = write!(out, ""); + let _ = write!(out, "", escape_value(v)); open.push("cFont"); } if let Some(v) = &f.over.font_style { - let _ = write!(out, ""); + let _ = write!(out, "", escape_value(v)); open.push("cTypeface"); } if let Some(v) = f.over.size { @@ -142,6 +151,7 @@ pub fn import(bytes: &[u8]) -> Result { _ => runs.push((c.to_string(), fmt.clone())), } }; + let mut escaped_fonts = false; let mut units = crate::Utf16Units::default(); let mut i = 0; let mut skipped = 0; @@ -176,7 +186,10 @@ pub fn import(bytes: &[u8]) -> Result { let (name, value) = tag.split_once(':').map_or((tag.as_str(), ""), |(a, b)| (a, b)); match name { n if n.starts_with("0x") => { - if let Ok(u) = u32::from_str_radix(&n[2..], 16) + // `from_str_radix` accepts a leading '+', so check the digits first. + if let Some(digits) = n.get(2..) + && digits.bytes().all(|b| b.is_ascii_hexdigit()) + && let Ok(u) = u32::from_str_radix(digits, 16) && let Some(ch) = units.push(u) { push(&mut paras, ch, &fmt); @@ -184,7 +197,7 @@ pub fn import(bytes: &[u8]) -> Result { } "ParaStyle" => { started = true; - let v = unescape_value(value.trim()); + let v = unescape_value(value); if !v.is_empty() && !para_styles.contains(&v) { para_styles.push(v.clone()); } @@ -193,25 +206,26 @@ pub fn import(bytes: &[u8]) -> Result { } } "CharStyle" => { - let v = unescape_value(value.trim()); + let v = unescape_value(value); fmt.style = if v.is_empty() { NO_CHAR_STYLE.into() } else { v.clone() }; if !v.is_empty() && !char_styles.contains(&v) { char_styles.push(v); } } - "cFont" => fmt.over.font_family = (!value.is_empty()).then(|| value.to_string()), - "cTypeface" => fmt.over.font_style = (!value.is_empty()).then(|| value.to_string()), + "cFont" => fmt.over.font_family = (!value.is_empty()).then(|| font_value(value, escaped_fonts)), + "cTypeface" => fmt.over.font_style = (!value.is_empty()).then(|| font_value(value, escaped_fonts)), // A size the engine cannot mean (0, negative, 1e300) is dropped, so the text // keeps its style's size (#1593). "cSize" => fmt.over.size = value.trim().parse().ok().and_then(designcraft_doc::attrs::imported_type_size), "DefineParaStyle" | "DefineCharStyle" => { - let nm = value.split('=').next().unwrap_or("").trim().to_string(); + let nm = value.split('=').next().unwrap_or("").to_string(); let list = if name == "DefineParaStyle" { &mut para_styles } else { &mut char_styles }; if !nm.is_empty() && !list.contains(&nm) { list.push(nm); } } - n if n.starts_with("ASCII-") || n.starts_with("UNICODE-") || n.starts_with("ANSI-") || n == "Version" => {} + "Version" => escaped_fonts = value.trim().parse::().is_ok_and(|v| v >= ESCAPED_FONTS_VERSION), + n if n.starts_with("ASCII-") || n.starts_with("UNICODE-") || n.starts_with("ANSI-") => {} _ => skipped += 1, } } @@ -278,6 +292,38 @@ mod tests { assert_eq!(import("\r\nA<0xD83D>B<0xDE00>C".as_bytes()).unwrap().story.text, "ABC"); } + /// A style name is read back exactly as the exporter wrote it, spaces included. + #[test] + fn style_names_keep_leading_and_trailing_spaces() { + let i = import("\r\n\r\nHello".as_bytes()).unwrap(); + assert_eq!(i.story.paras[0].style, "Body "); + assert_eq!(i.story.char_format_at(2).style, " Em "); + assert_eq!(i.para_styles[0].name, "Body "); + assert_eq!(i.char_styles[0].name, " Em "); + } + + /// A style defined with spaces in its name keeps them, so a paragraph that uses the name matches. + #[test] + fn defined_style_names_keep_leading_and_trailing_spaces() { + let src = + "\r\n>>Hello"; + let i = import(src.as_bytes()).unwrap(); + assert_eq!(i.para_styles.len(), 1); + assert_eq!(i.para_styles[0].name, "Body "); + assert_eq!(i.char_styles.len(), 1); + assert_eq!(i.char_styles[0].name, " Em "); + } + + /// `from_str_radix` accepts a leading `+`, so `<0x+41>` inserted an 'A'. A signed or empty + /// code is not a code point tag and is skipped. + #[test] + fn a_signed_hex_code_tag_is_skipped() { + for t in ["<0x+41>", "<0x-41>", "<0x>", "<0xZZ>"] { + let src = format!("\r\nA{t}B"); + assert_eq!(import(src.as_bytes()).unwrap().story.text, "AB", "{t}"); + } + } + /// A size does not have to be non-finite to be nonsense: a file can hold `1e300`, `0` or a /// negative, and tagged text took all three and set them on the text (#1593). They are now /// dropped, as RTF and DOCX already dropped them, so the text keeps its style's size. @@ -338,6 +384,31 @@ mod tests { } } + #[test] + fn font_values_with_tag_characters_round_trip() { + let doc = Document::new(&designcraft_doc::build::NewDocument::default()); + for name in ["Weird>Font", "Ac", "x\\y", "end\\"] { + let mut story = Story::new(StoryId(0)); + let mut f = CharFormat::default(); + f.over.font_family = Some(name.into()); + f.over.font_style = Some(name.into()); + story.insert_with(0, "hello", f); + let out = tagged_text(&doc, &story); + let back = import(out.as_bytes()).unwrap(); + assert_eq!(back.story.text, "hello", "{out}"); + assert_eq!(back.story.char_format_at(1).over.font_family.as_deref(), Some(name), "{out}"); + assert_eq!(back.story.char_format_at(1).over.font_style.as_deref(), Some(name), "{out}"); + } + } + + /// Version 7 files hold font values raw, so their backslashes stay literal. + #[test] + fn old_format_font_values_stay_literal() { + let i = import("\r\n\r\nxy".as_bytes()).unwrap(); + assert_eq!(i.story.char_format_at(1).over.font_family.as_deref(), Some("a\\b")); + assert_eq!(i.story.char_format_at(2).over.font_style.as_deref(), Some("c\\d")); + } + /// A backslash that isn't one of our escapes (older files, InDesign's `\:`) stays literal. #[test] fn unknown_backslashes_in_style_names_stay_literal() { diff --git a/crates/textimport/src/xlsx.rs b/crates/textimport/src/xlsx.rs index a26665ecc..7f129cce1 100644 --- a/crates/textimport/src/xlsx.rs +++ b/crates/textimport/src/xlsx.rs @@ -212,19 +212,41 @@ pub fn import(bytes: &[u8]) -> Result { .transpose()? .map(|sst| sst.els().filter(|e| e.name == "si").map(string_item).collect()) .unwrap_or_default(); + // A `t="s"` cell holds an index into the shared strings and each one is materialised as its + // own `String`, so one large shared string referenced by many cells multiplies: a 1 MB string + // and 3M references is terabytes, allocated before the grid cap below ever applies, and an + // allocation that large aborts the process rather than failing (#1584). Collection carries a + // byte budget and a cell count of its own. + const MAX_TEXT_BYTES: usize = 64 << 20; + const MAX_COLLECTED_CELLS: usize = 400_000; let mut cells: Vec<((usize, usize), String)> = Vec::new(); + let mut text_bytes = 0usize; + let mut cut = false; if let Some(data) = sheet.child("sheetData") { - for (ri, row) in data.els().filter(|e| e.name == "row").enumerate() { + 'rows: for (ri, row) in data.els().filter(|e| e.name == "row").enumerate() { for (ci, c) in row.els().filter(|e| e.name == "c").enumerate() { let at = c.attr("r").and_then(cell_ref).unwrap_or((ri, ci)); let v = value(c, &shared); - if !v.is_empty() { - cells.push((at, v)); + if v.is_empty() { + continue; } + if cells.len() >= MAX_COLLECTED_CELLS || text_bytes.saturating_add(v.len()) > MAX_TEXT_BYTES { + cut = true; + break 'rows; + } + text_bytes = text_bytes.saturating_add(v.len()); + cells.push((at, v)); } } } let mut warnings = Vec::new(); + if cut { + warnings.push(format!( + "the worksheet holds more than {} cells or {} MB of text: the rest was not read", + MAX_COLLECTED_CELLS, + MAX_TEXT_BYTES / (1 << 20) + )); + } if cells.is_empty() { warnings.push("the worksheet is empty".into()); } @@ -293,6 +315,50 @@ mod tests { buf.into_inner() } + /// A `t="s"` cell materialises its own copy of the shared string, so one big string with many + /// references multiplies: the audit's case is a 1 MB string and 3M cells, terabytes, built + /// before the grid cap applies (#1584). Collection now carries its own byte budget. + #[test] + fn a_shared_string_referenced_many_times_does_not_multiply() { + // A 64 KB shared string referenced 2,000 times would be 128 MB of cell text; the budget + // is 64 MB, so collection stops and says so. + let big = "y".repeat(64 * 1024); + let cells: String = (1..=2_000).map(|r| format!(r#"0"#)).collect(); + let mut buf = std::io::Cursor::new(Vec::new()); + { + let mut z = zip::ZipWriter::new(&mut buf); + let o = zip::write::SimpleFileOptions::default(); + for (name, body) in [ + ( + "xl/workbook.xml", + r#""#.to_string(), + ), + ( + "xl/_rels/workbook.xml.rels", + r#""#.to_string(), + ), + ("xl/sharedStrings.xml", format!("{big}")), + ("xl/worksheets/sheet1.xml", format!("{cells}")), + ] { + z.start_file(name, o).unwrap(); + z.write_all(body.as_bytes()).unwrap(); + } + z.finish().unwrap(); + } + let imp = import(&buf.into_inner()).unwrap(); + let table = imp.story.tables.values().next().expect("a table"); + let text: usize = (0..table.nrows()) + .flat_map(|r| (0..table.ncols()).map(move |c| (r, c))) + .filter_map(|(r, c)| table.cell(r, c).map(|cell| cell.text.text.len())) + .sum(); + assert!(text <= 64 << 20, "the table holds at most the budget: {text} bytes"); + assert!(imp.warnings.iter().any(|w| w.contains("was not read")), "the report says the sheet was cut: {:?}", imp.warnings); + + // An ordinary workbook is unaffected and reports nothing of the sort. + let plain = import(&workbook()).unwrap(); + assert!(!plain.warnings.iter().any(|w| w.contains("was not read")), "{:?}", plain.warnings); + } + #[test] fn first_sheet_used_range_becomes_a_table() { let imp = import(&workbook()).unwrap(); diff --git a/crates/tools/src/frame.rs b/crates/tools/src/frame.rs index ff65e7ce7..86f621c61 100644 --- a/crates/tools/src/frame.rs +++ b/crates/tools/src/frame.rs @@ -405,6 +405,9 @@ impl Tool for FrameTool { snap_free_line(cx, sr, sa, b, &mut self.guides) }; } + // The readout measures the line as drawn (Shift and snapping included), in + // spread space. + self.last = Some((spread_json(sr), Rect::from_points(sa, b))); out.push(Action::Preview("line.create".into(), json!({"spread": spread_json(sr), "a": [sa.x, sa.y], "b": [b.x, b.y]}))); } else { let end = cx.layout.to_spread(sr, ev.pos); @@ -458,11 +461,18 @@ impl Tool for FrameTool { } fn overlays(&self, cx: &ToolContext) -> Vec { - if let (true, Some(a)) = (self.active, self.start) { - let r = Rect::from_points(a, self.cur); - let _ = cx; + // The frame or line being drawn (Shift, Alt and snapping included, in spread space), in the + // document's units to two decimals, on two lines: InDesign's readout ("W: 3.13 cm", + // "H: 3.13 cm"). Nothing before the first drag step. + if self.active + && self.start.is_some() + && let Some((_, r)) = self.last.as_ref() + { + let units = &cx.doc.settings; + let w = designcraft_geom::units::format_measure_prec(r.width().abs(), units.horizontal_units, 2); + let h = designcraft_geom::units::format_measure_prec(r.height().abs(), units.vertical_units, 2); let mut v = self.guides.clone(); - v.push(Overlay::Measure { p: self.cur, text: format!("W: {:.0} pt H: {:.0} pt", r.width(), r.height()) }); + v.push(Overlay::Measure { p: self.cur, text: format!("W: {w}\nH: {h}") }); return v; } vec![] diff --git a/crates/tools/src/layout.rs b/crates/tools/src/layout.rs index 9f000fcd7..d5ea24b85 100644 --- a/crates/tools/src/layout.rs +++ b/crates/tools/src/layout.rs @@ -1,9 +1,10 @@ //! Where spreads sit on the canvas (the pasteboard): stacked vertically, spines aligned at x = 0, //! parents shown on their own canvas when a parent spread is being edited. -use designcraft_doc::{Document, SpreadRef}; +use designcraft_doc::{Document, Selection, SpreadRef}; use designcraft_geom::{Affine, Point, Rect, Vec2}; use serde::Serialize; +use serde_json::{Value, json}; #[derive(Clone, Copy, Debug, PartialEq, Serialize)] pub struct SpreadSlot { @@ -100,6 +101,31 @@ impl CanvasLayout { (m * Point::new(d.x, d.y)) - (m * Point::ORIGIN) } + /// The Page tool selection on the canvas currently shown. + pub fn selected_page(&self, doc: &Document, selection: &Selection) -> Option<(SpreadRef, usize)> { + let (sr, pi) = if self.slots.first().is_some_and(|s| matches!(s.spread, SpreadRef::Parent(_))) { + let (id, pi) = selection.parent_page?; + (SpreadRef::Parent(doc.parent_index(id)?), pi) + } else { + let (si, pi) = doc.page_loc(selection.page?)?; + (SpreadRef::Doc(si), pi) + }; + self.slot(sr)?; + doc.spread(sr)?.pages.get(pi)?; + Some((sr, pi)) + } + + /// A page on the visible canvas, including parent pages. + pub fn page_at(&self, doc: &Document, p: Point) -> Option<(SpreadRef, usize)> { + self.slots + .iter() + .find_map(|slot| doc.spread(slot.spread)?.pages.iter().position(|pg| pg.bounds().contains(slot.to_spread(p))).map(|pi| (slot.spread, pi))) + } + + pub fn spread_page_rect(&self, doc: &Document, sr: SpreadRef, pi: usize) -> Option { + Some(self.slot(sr)?.xf.transform_rect_bbox(doc.spread(sr)?.pages.get(pi)?.bounds())) + } + /// Canvas rect of a document page. pub fn page_rect(&self, doc: &Document, abs: usize) -> Option { let (si, pi) = doc.page_loc(abs)?; @@ -107,6 +133,24 @@ impl CanvasLayout { } } +/// The same resize contract for panels, handles and the exact-size dialog. +pub fn page_size_command(doc: &Document, sr: SpreadRef, page: usize, mut params: Value) -> Option<(&'static str, Value)> { + let sp = doc.spread(sr)?; + sp.pages.get(page)?; + let command = match sr { + SpreadRef::Doc(si) => { + params["pages"] = json!([doc.first_page_of_spread(si) + page + 1]); + "layout.pageSize" + } + SpreadRef::Parent(_) => { + params["parent"] = json!(sp.id.0); + params["page"] = json!(page); + "layout.parentPageSize" + } + }; + Some((command, params)) +} + fn dist_y(r: Rect, y: f64) -> f64 { if y < r.y0 - SPREAD_GAP / 2.0 { r.y0 - y diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index bb6b9b90c..57872cff9 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -16,7 +16,7 @@ mod frame; mod gradient; pub mod layout; mod nav; -pub use nav::{LOCK_OFFSET_PX, PIN_OFFSET_PX}; +pub use nav::{LOCK_OFFSET_PX, PIN_OFFSET_PX, PLACE_DRAG_MIN}; mod pen; mod pencil; pub mod select; @@ -232,6 +232,8 @@ pub struct ToolContext<'a> { pub unit: Unit, /// The toolbox's Stroke box is in front: sampled colours go to strokes (the Eyedropper). pub stroke_in_front: bool, + /// Natural size, in points, of the graphic loaded in the place cursor. + pub loaded: Option<(f64, f64)>, } impl ToolContext<'_> { diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index f77c5d0bb..e65daec8f 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -133,7 +133,8 @@ impl Tool for PlaceGun { let Some(a) = self.start.take() else { return vec![] }; let Some((sr, sa)) = cx.layout.spread_at(a) else { return vec![] }; let b = cx.layout.to_spread(sr, ev.pos); - if (ev.pos - a).hypot() < cx.tol(3.0) + let click = (ev.pos - a).hypot() < cx.tol(3.0); + if click && let Some((_, id)) = cx.hit(a) && cx .doc @@ -142,7 +143,12 @@ impl Tool for PlaceGun { { return vec![Action::Exec("place.drop".into(), json!({"frame": id.0}))]; } - let r = designcraft_geom::Rect::from_points(sa, b); + // Under the drag threshold it's a click, as the overlay shows: actual size there. + if click { + return vec![Action::Exec("place.drop".into(), json!({"spread": crate::spread_json(sr), "x": sa.x, "y": sa.y}))]; + } + // The frame the drag drew: at the graphic's proportions from where it started. + let r = cx.loaded.map_or_else(|| designcraft_geom::Rect::from_points(sa, b), |size| proportional(sa, b, size).0); vec![Action::Exec( "place.drop".into(), json!({"spread": crate::spread_json(sr), "x": sa.x, "y": sa.y, "rect": [r.x0, r.y0, r.x1, r.y1]}), @@ -151,11 +157,29 @@ impl Tool for PlaceGun { _ => vec![], } } - fn overlays(&self, _cx: &ToolContext) -> Vec { - match self.start { - Some(a) => vec![crate::Overlay::Marquee(designcraft_geom::Rect::from_points(a, self.cur))], - None => vec![], + fn overlays(&self, cx: &ToolContext) -> Vec { + let Some(a) = self.start else { return vec![] }; + let Some(size) = cx.loaded else { + return vec![crate::Overlay::Marquee(designcraft_geom::Rect::from_points(a, self.cur))]; + }; + // Under the drag threshold the release is a click (actual size where it was pressed): + // no frame is drawn yet, the loaded cursor shows what lands. + if (self.cur - a).hypot() < cx.tol(3.0) { + return vec![]; } + // As InDesign: the frame the graphic will fill, and its scale beside the pointer. + let (r, k) = drawn_frame(a, self.cur, size); + let pct = format!("{:.2}%", k * 100.0); + vec![ + crate::Overlay::Marquee(r), + crate::Overlay::Measure { + p: self.cur, + text: format!( + "X: {pct} +Y: {pct}" + ), + }, + ] } fn key(&mut self, _cx: &ToolContext, key: crate::ToolKey, _mods: Mods) -> Vec { match key { @@ -164,11 +188,43 @@ impl Tool for PlaceGun { _ => vec![], } } - fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { - Cursor::LoadedGraphic + fn cursor(&self, cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { + // Drawing the frame: the crosshair (the loaded graphic's thumbnail would hide the frame). + // Under the drag threshold no frame is drawn yet (the release is a click), so the + // thumbnail stays, as the overlay does. With no graphic loaded there's no thumbnail to + // show: the crosshair throughout. + let drawing = cx.loaded.is_none() || self.start.is_some_and(|a| (self.cur - a).hypot() >= cx.tol(3.0)); + if drawing { Cursor::Crosshair } else { Cursor::LoadedGraphic } } } +/// A dragged frame `place.drop` takes only when it is wider and taller than this, in points; +/// a thinner one places the graphic at its natural size instead. +pub const PLACE_DRAG_MIN: f64 = 2.0; + +/// The frame `place.drop` makes for a place-cursor drag from `a` to `b`: the proportional frame +/// (see [`proportional`]), or the graphic at natural size from `a` when that frame is at most +/// [`PLACE_DRAG_MIN`] in either direction. With its scale. +fn drawn_frame(a: Point, b: Point, size: (f64, f64)) -> (designcraft_geom::Rect, f64) { + let (r, k) = proportional(a, b, size); + if r.width() > PLACE_DRAG_MIN && r.height() > PLACE_DRAG_MIN { + (r, k) + } else { + (designcraft_geom::Rect::new(a.x, a.y, a.x + size.0, a.y + size.1), 1.0) + } +} + +/// The frame a place-cursor drag from `a` to `b` draws for a graphic of natural `size`: the +/// graphic's proportions, from `a` towards `b`, as large as fits the drag. With its scale. +fn proportional(a: Point, b: Point, size: (f64, f64)) -> (designcraft_geom::Rect, f64) { + let (w, h) = (size.0.max(1e-6), size.1.max(1e-6)); + let (dx, dy) = (b.x - a.x, b.y - a.y); + let k = (dx.abs() / w).min(dy.abs() / h); + let k = if k.is_finite() { k } else { 0.0 }; + let c = Point::new(a.x + (w * k).copysign(dx), a.y + (h * k).copysign(dy)); + (designcraft_geom::Rect::from_points(a, c), k) +} + /// Page tool (Shift+P): click a page to select it; drag one of its handles to preview its liquid /// page rule (the page springs back on release), Alt-drag to resize it, Shift to keep its /// proportions; double-click a page for exact sizes. @@ -249,7 +305,9 @@ fn liquid_control_points(cx: &ToolContext, r: designcraft_geom::Rect) -> [(Liqui /// A page handle being dragged. #[derive(Clone, Copy, Debug)] struct PageDrag { - /// Absolute page index. + spread: designcraft_doc::SpreadRef, + parent: Option, + /// Page index within the spread. page: usize, /// Reference-point index of the handle (0–8, row-major; 4 is the centre and has none). handle: usize, @@ -297,6 +355,9 @@ impl PageTool { fn liquid_target( cx: &ToolContext, ) -> Option<(designcraft_doc::ItemId, designcraft_doc::ObjectLiquid, designcraft_geom::Rect, designcraft_geom::Rect)> { + if cx.layout.slots.first().is_some_and(|s| matches!(s.spread, designcraft_doc::SpreadRef::Parent(_))) { + return None; + } let page = cx.selection.page?; let [id] = cx.selection.items.as_slice() else { return None }; let (si, _) = cx.doc.page_loc(page)?; @@ -316,17 +377,16 @@ impl PageTool { } /// The selected page's handle under `p`. - fn handle_at(&self, cx: &ToolContext, p: Point) -> Option<(usize, usize)> { - let page = cx.selection.page?; - let r = cx.layout.page_rect(cx.doc, page)?; - page_handles(r).find(|(_, h)| (h.x - p.x).abs() <= cx.tol(5.0) && (h.y - p.y).abs() <= cx.tol(5.0)).map(|(i, _)| (page, i)) + fn handle_at(&self, cx: &ToolContext, p: Point) -> Option<(designcraft_doc::SpreadRef, usize, usize)> { + let (sr, page) = cx.layout.selected_page(cx.doc, cx.selection)?; + let r = cx.layout.spread_page_rect(cx.doc, sr, page)?; + page_handles(r).find(|(_, h)| (h.x - p.x).abs() <= cx.tol(5.0) && (h.y - p.y).abs() <= cx.tol(5.0)).map(|(i, _)| (sr, page, i)) } /// The new page size for the drag at `p`: the handle's edges follow the pointer (Shift: in /// proportion). fn size_at(cx: &ToolContext, d: &PageDrag, p: Point, shift: bool) -> (f64, f64) { - let si = cx.doc.page_loc(d.page).map_or(0, |l| l.0); - let v = cx.layout.delta_to_spread(designcraft_doc::SpreadRef::Doc(si), p - d.start); + let v = cx.layout.delta_to_spread(d.spread, p - d.start); let (col, row) = (d.handle % 3, d.handle / 3); let dx = match col { 0 => -v.x, @@ -361,10 +421,21 @@ impl Tool for PageTool { fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { match ev.kind { PointerKind::Down => { - if let Some((page, handle)) = self.handle_at(cx, ev.pos) { - let Some((si, pi)) = cx.doc.page_loc(page) else { return vec![] }; - let pg = &cx.doc.spreads[si].pages[pi]; - self.drag = Some(PageDrag { page, handle, start: ev.pos, cur: ev.pos, size: (pg.width, pg.height), alt: ev.mods.alt }); + if let Some((spread, page, handle)) = self.handle_at(cx, ev.pos) { + let Some(pg) = cx.doc.spread(spread).and_then(|sp| sp.pages.get(page)) else { return vec![] }; + self.drag = Some(PageDrag { + spread, + parent: match spread { + designcraft_doc::SpreadRef::Parent(si) => cx.doc.parents.get(si).map(|sp| sp.id), + _ => None, + }, + page, + handle, + start: ev.pos, + cur: ev.pos, + size: (pg.width, pg.height), + alt: ev.mods.alt, + }); return vec![Action::Begin("Resize Page".into())]; } // An object-based page's object: a click on a pin or lock toggles it. @@ -373,7 +444,18 @@ impl Tool for PageTool { } // A click selects the page under the pointer (none on the pasteboard), and on an // object-based page the object clicked too. - let page = (0..cx.doc.page_count()).find(|p| cx.layout.page_rect(cx.doc, *p).is_some_and(|r| r.contains(ev.pos))); + let target = cx.layout.page_at(cx.doc, ev.pos); + if let Some((designcraft_doc::SpreadRef::Parent(si), page)) = target { + let Some(sp) = cx.doc.parents.get(si) else { return vec![] }; + return vec![Action::Exec("layout.selectParentPage".into(), json!({"parent": sp.id.0, "page": page}))]; + } + if cx.layout.slots.first().is_some_and(|s| matches!(s.spread, designcraft_doc::SpreadRef::Parent(_))) { + return vec![Action::Exec("layout.selectParentPage".into(), json!({"parent": null}))]; + } + let page = target.and_then(|(sr, pi)| match sr { + designcraft_doc::SpreadRef::Doc(si) => Some(cx.doc.first_page_of_spread(si) + pi), + _ => None, + }); let object = page .filter(|p| object_based(cx.doc, *p)) .and_then(|p| cx.hit(ev.pos).map(|(_, id)| id).filter(|id| cx.doc.page_of_item(*id) == Some(p))); @@ -387,9 +469,21 @@ impl Tool for PageTool { let Some(d) = self.drag.as_mut() else { return vec![] }; d.cur = ev.pos; d.alt |= ev.mods.alt; - let d = *d; + let mut d = *d; + if let Some(id) = d.parent { + let Some(si) = cx.doc.parent_index(id) else { + self.drag = None; + return vec![Action::Cancel]; + }; + d.spread = designcraft_doc::SpreadRef::Parent(si); + } let (w, h) = Self::size_at(cx, &d, ev.pos, ev.mods.shift); - vec![Action::Preview("layout.pageSize".into(), json!({"pages": [d.page + 1], "width": w, "height": h, "anchor": 8 - d.handle}))] + let Some((cmd, params)) = + crate::layout::page_size_command(cx.doc, d.spread, d.page, json!({"width": w, "height": h, "anchor": 8 - d.handle})) + else { + return vec![]; + }; + vec![Action::Preview(cmd.into(), params)] } PointerKind::Up => match self.drag.take() { // Alt-drag resizes; a plain drag previews the liquid rule and springs back. @@ -399,12 +493,12 @@ impl Tool for PageTool { }, PointerKind::DoubleClick => { // Exact sizes: the Page Size dialog for the page. - let Some(page) = (0..cx.doc.page_count()).find(|p| cx.layout.page_rect(cx.doc, *p).is_some_and(|r| r.contains(ev.pos))) else { + let Some((sr, pi)) = cx.layout.page_at(cx.doc, ev.pos) else { return vec![] }; + let Some(pg) = cx.doc.spread(sr).and_then(|sp| sp.pages.get(pi)) else { return vec![] }; + let Some((cmd, params)) = crate::layout::page_size_command(cx.doc, sr, pi, json!({"width": pg.width, "height": pg.height})) else { return vec![]; }; - let Some((si, pi)) = cx.doc.page_loc(page) else { return vec![] }; - let pg = &cx.doc.spreads[si].pages[pi]; - vec![Action::Dialog("cmd:layout.pageSize".into(), json!({"pages": [page + 1], "width": pg.width, "height": pg.height}))] + vec![Action::Dialog(format!("cmd:{cmd}"), params)] } PointerKind::Move => { self.hover = Self::control_at(cx, ev.pos).map(|(_, _, c)| (c, ev.pos)); @@ -413,10 +507,9 @@ impl Tool for PageTool { } } fn overlays(&self, cx: &ToolContext) -> Vec { - let Some(page) = cx.selection.page else { return vec![] }; - let Some(r) = cx.layout.page_rect(cx.doc, page) else { return vec![] }; - let Some((si, pi)) = cx.doc.page_loc(page) else { return vec![] }; - let pg = &cx.doc.spreads[si].pages[pi]; + let Some((sr, pi)) = cx.layout.selected_page(cx.doc, cx.selection) else { return vec![] }; + let Some(r) = cx.layout.spread_page_rect(cx.doc, sr, pi) else { return vec![] }; + let Some(pg) = cx.doc.spread(sr).and_then(|sp| sp.pages.get(pi)) else { return vec![] }; let mut v = vec![Overlay::PageHandles(r), Overlay::Tag { p: Point::new(r.x0, r.y0 - cx.tol(6.0)), text: rule_label(pg.liquid).into() }]; if let Some((_, l, object, page)) = Self::liquid_target(cx) { v.push(Overlay::LiquidPins { @@ -445,7 +538,7 @@ impl Tool for PageTool { if self.drag.is_none() && Self::control_at(cx, p).is_some() { return Cursor::Hand; } - let handle = self.drag.map(|d| d.handle).or_else(|| self.handle_at(cx, p).map(|(_, h)| h)); + let handle = self.drag.map(|d| d.handle).or_else(|| self.handle_at(cx, p).map(|(_, _, h)| h)); match handle { Some(1 | 7) => Cursor::ResizeV, Some(3 | 5) => Cursor::ResizeH, diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index 21e85f010..753e6d650 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -33,6 +33,9 @@ pub struct PenTool { /// Continuing from the subpath's first anchor: `anchors` run in drawing order, the reverse /// of the path's, and new anchors go before its first one. from_start: bool, + /// The anchor the continued end point is joined to (drawing order): the segment a drag of the + /// end point's direction lines reshapes runs from it, not from the path's other end. + joined: Option<(Point, Point, Point)>, } /// An end point of an open path under canvas point `p`: (item, subpath, whether it is the last @@ -120,6 +123,7 @@ impl PenTool { self.editing_end = false; self.end_moved = false; self.from_start = false; + self.joined = None; vec![] } @@ -137,6 +141,13 @@ impl PenTool { // From the first anchor the anchors run backwards (handles swapped); new ones are added // before the first, so the path keeps its direction (and its arrowheads their ends). let (other, end) = if at_end { (at(first, false), at(last, false)) } else { (at(last, true), at(first, true)) }; + // The anchor next to the end point, in drawing order too (the other end when there are two). + let joined = if at_end { + sp.anchors.len().checked_sub(2).and_then(|i| sp.anchors.get(i)).map(|a| at(a, false)) + } else { + sp.anchors.get(1).map(|a| at(a, true)) + }; + self.joined = joined; self.path = Some(id.0); self.subpath = Some(si); self.spread = Some(loc.spread); @@ -299,21 +310,40 @@ impl Tool for PenTool { return out; }; let xf = cx.layout.xf(sr); + let blue = [79, 153, 255]; if let Some(last) = self.anchors.last() && !self.dragging { - // Rubber band to the pointer. - out.push(Overlay::Line { a: xf * last.0, b: self.hover, color: [79, 153, 255], dashed: true }); + // Rubber band to the pointer: the segment the next click would make, straight from + // a corner, curved out of a smooth point's direction line (#1438). + if (last.2 - last.0).hypot() > 1e-9 { + let mut path = BezPath::new(); + path.move_to(xf * last.0); + path.curve_to(xf * last.2, self.hover, self.hover); + out.push(Overlay::Path { path, color: blue, dashed: true }); + } else { + out.push(Overlay::Line { a: xf * last.0, b: self.hover, color: blue, dashed: true }); + } } if self.path.is_none() && self.anchors.len() == 1 { let a = self.anchors[0]; - out.push(Overlay::Line { a: xf * a.1, b: xf * a.2, color: [79, 153, 255], dashed: false }); + out.push(Overlay::Line { a: xf * a.1, b: xf * a.2, color: blue, dashed: false }); } if self.dragging && let Some(a) = self.anchors.last() { - out.push(Overlay::Line { a: xf * a.1, b: xf * a.2, color: [79, 153, 255], dashed: false }); - let _ = BezPath::new(); + // The direction line being dragged out, and the segment it shapes: from the previous + // anchor's out handle to the new anchor's in handle, live under the pointer (#1438). A + // continued end point being reshaped is joined to its neighbour in the path, not to + // the other end the anchors start with. + out.push(Overlay::Line { a: xf * a.1, b: xf * a.2, color: blue, dashed: false }); + let prev = if self.editing_end { self.joined } else { self.anchors.len().checked_sub(2).and_then(|i| self.anchors.get(i)).copied() }; + if let Some(prev) = prev { + let mut path = BezPath::new(); + path.move_to(xf * prev.0); + path.curve_to(xf * prev.2, xf * a.1, xf * a.0); + out.push(Overlay::Path { path, color: blue, dashed: false }); + } } // Guides are already canvas. Do not run them through the spread transform again. out.extend(self.guides.iter().cloned()); diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 033e1a97c..1fb30e6f7 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -92,6 +92,8 @@ enum Drag { /// None when that point could not be read. The live document during a drag is the /// previous preview, so the snap must not read the anchor from it again. at: Option<(SpreadRef, Point)>, + /// The other selected anchor points, which move with it: (item, subpath, anchor). + others: Vec<(u64, usize, usize)>, }, } @@ -144,21 +146,42 @@ pub fn anchor_at(cx: &ToolContext, p: Point) -> Option<(u64, usize, usize, Optio anchor_at_in(cx, &cx.selection.items, p) } -/// [`anchor_at`] over the given items. +/// Whether the Direct Selection tool shows (and lets you drag) a direction handle of anchor `ai` +/// of a sub-path of `n` anchors, as InDesign: both handles of a selected anchor, and on its +/// neighbours the handle that shapes the segment to it (`out`: the outgoing handle). With no +/// anchor selected no handle shows. +pub fn handle_shown(n: usize, closed: bool, ai: usize, out: bool, chosen: impl Fn(usize) -> bool) -> bool { + if chosen(ai) { + return true; + } + let neighbour = if out { + if ai + 1 < n { Some(ai + 1) } else { closed.then_some(0) } + } else if ai > 0 { + Some(ai - 1) + } else { + closed.then(|| n.saturating_sub(1)) + }; + neighbour.is_some_and(chosen) +} + +/// [`anchor_at`] over the given items. Of the direction handles, only those [`handle_shown`] +/// shows are found. pub fn anchor_at_in(cx: &ToolContext, ids: &[designcraft_doc::ItemId], p: Point) -> Option<(u64, usize, usize, Option<&'static str>)> { let tol = cx.tol(5.0); for id in ids { let (Some(it), Some(xf)) = (cx.doc.item(*id), cx.item_canvas_xf(*id)) else { continue }; let m = xf * it.xf; for (si, sp) in it.path.subpaths.iter().enumerate() { + let chosen = |a: usize| cx.selection.anchors.contains(&(*id, si, a)); for (ai, a) in sp.anchors.iter().enumerate() { if ((m * a.p) - p).hypot() <= tol { return Some((id.0, si, ai, None)); } - if a.has_in() && ((m * a.h_in) - p).hypot() <= tol { + let shown = |out: bool| handle_shown(sp.anchors.len(), sp.closed, ai, out, chosen); + if a.has_in() && shown(false) && ((m * a.h_in) - p).hypot() <= tol { return Some((id.0, si, ai, Some("in"))); } - if a.has_out() && ((m * a.h_out) - p).hypot() <= tol { + if a.has_out() && shown(true) && ((m * a.h_out) - p).hypot() <= tol { return Some((id.0, si, ai, Some("out"))); } } @@ -181,6 +204,37 @@ fn spread_delta_to_canvas(xf: Affine, d: Vec2) -> Vec2 { (xf * Point::new(d.x, d.y)) - (xf * Point::ORIGIN) } +/// The anchor points inside `r` (canvas) of the unlocked, visible paths, groups' members included. +fn anchors_in(cx: &ToolContext, r: Rect) -> Vec<(ItemId, usize, usize)> { + fn walk(cx: &ToolContext, items: &[std::sync::Arc], r: Rect, out: &mut Vec<(ItemId, usize, usize)>) { + for it in items { + let locked = it.locked || cx.doc.layer(it.layer).is_some_and(|l| l.locked || !l.visible); + if locked || it.hidden { + continue; + } + if let Content::Group { items } = &it.content { + walk(cx, items, r, out); + continue; + } + let Some(xf) = cx.item_canvas_xf(it.id) else { continue }; + let m = xf * it.xf; + for (si, ai, a) in it.path.anchors() { + let q = m * a.p; + if q.x >= r.x0 && q.x <= r.x1 && q.y >= r.y0 && q.y <= r.y1 { + out.push((it.id, si, ai)); + } + } + } + } + let mut out = Vec::new(); + for slot in &cx.layout.slots { + if let Some(sp) = cx.doc.spread(slot.spread) { + walk(cx, &sp.items, r, &mut out); + } + } + out +} + /// 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 { @@ -217,6 +271,43 @@ fn guides_crossing(cx: &ToolContext, r: Rect) -> Vec { out } +/// `selection.set` params selecting `points`: their paths, and those points. +/// `keep` (Shift): the objects already selected stay selected; otherwise only the paths of +/// `points` are. +fn anchor_selection(cx: &ToolContext, points: &[(ItemId, usize, usize)], keep: bool) -> Value { + let mut ids: Vec = if keep { cx.selection.items.iter().map(|i| i.0).collect() } else { Vec::new() }; + for (i, _, _) in points { + if !ids.contains(&i.0) { + ids.push(i.0); + } + } + let anchors: Vec = points.iter().map(|(i, s, a)| json!([i.0, s, a])).collect(); + json!({"ids": ids, "anchors": anchors}) +} + +/// `path.moveAnchors` points for `points` moved `d` on the canvas: each with its own movement in +/// its spread (a rotated spread view turns it). +fn moved_points(cx: &ToolContext, points: impl IntoIterator, d: Vec2) -> Vec { + points + .into_iter() + .map(|(i, s, a)| { + let v = cx.doc.find(i).map_or(d, |loc| cx.layout.delta_to_spread(loc.spread, d)); + json!([i.0, s, a, v.x, v.y]) + }) + .collect() +} + +/// `d` held to the nearest multiple of 45°, as Shift does. +fn constrain_45(d: Vec2) -> Vec2 { + if d.hypot() < 1e-9 { + return d; + } + let step = std::f64::consts::FRAC_PI_4; + let a = (d.y.atan2(d.x) / step).round() * step; + let proj = d.x * a.cos() + d.y * a.sin(); + Vec2::new(proj * a.cos(), proj * a.sin()) +} + pub struct SelectionTool { direct: bool, drag: Drag, @@ -224,6 +315,8 @@ pub struct SelectionTool { guides: Vec, /// Selected item to toggle if this Shift press never becomes a drag. shift_release: Option, + /// Selected anchor point to take away if this Shift press never becomes a drag. + anchor_release: Option<(ItemId, usize, usize)>, /// One of several selected items to make (or stop being) the key object for Align if this /// press never becomes a drag. key_release: Option, @@ -235,7 +328,17 @@ pub struct SelectionTool { impl SelectionTool { pub fn new(direct: bool) -> Self { - Self { direct, drag: Drag::None, hover_handle: None, guides: vec![], shift_release: None, key_release: None, loaded: None, live: None } + Self { + direct, + drag: Drag::None, + hover_handle: None, + guides: vec![], + shift_release: None, + key_release: None, + anchor_release: None, + loaded: None, + live: None, + } } /// [`bracket_at`] for this tool. With Direct Selection an anchor under the pointer wins: the @@ -689,6 +792,7 @@ impl SelectionTool { self.shift_release = None; self.key_release = None; + self.anchor_release = None; // Type on a path: a bracket moves where the text starts or ends. if !ev.mods.shift && let Some((id, end)) = self.bracket_at(cx, p) @@ -700,8 +804,51 @@ impl SelectionTool { && let Some((id, si, ai, handle)) = anchor_at(cx, p) { let at = anchor_spread_point(cx, id, si, ai); - self.drag = Drag::Anchor { id, si, ai, handle, start: p, at }; - return vec![Action::Begin(if handle.is_some() { "Move Direction Handle".into() } else { "Move Anchor".into() })]; + let mut out = vec![]; + let mut others = vec![]; + if handle.is_none() { + // InDesign's anchor point selection: a click selects the point, Shift+click + // adds it or takes it away, and dragging a selected point moves all of them. + let me = (ItemId(id), si, ai); + let mut points = cx.selection.anchors.clone(); + let was = points.contains(&me); + if ev.mods.shift && was { + // Kept selected so a Shift-drag can constrain it; a click still takes it + // away, on release, as for objects. + self.anchor_release = Some(me); + } else if ev.mods.shift { + points.push(me); + } else if !was { + points = vec![me]; + } + if points != cx.selection.anchors { + out.push(Action::Exec("selection.set".into(), anchor_selection(cx, &points, ev.mods.shift))); + } + others = points.iter().filter(|a| **a != me).map(|(i, s, a)| (i.0, *s, *a)).collect(); + } + let label = if handle.is_some() { + "Move Direction Handle" + } else if others.is_empty() { + "Move Anchor" + } else { + "Move Anchors" + }; + self.drag = Drag::Anchor { id, si, ai, handle, start: p, at, others }; + out.push(Action::Begin(label.into())); + return out; + } + // As in InDesign, the Direct Selection tool pressed on a corner or anchor of an object + // that isn't selected selects it and moves just that point (Chris Nielsen's "The + // InDesign Toolbox", 10:10: click outside, then pull a frame's corner). + if self.direct + && !ev.mods.shift + && let Some((_, hit)) = cx.hit(p) + && !cx.selection.contains(hit) + && let Some((id, si, ai, None)) = anchor_at_in(cx, &[hit], p) + { + let at = anchor_spread_point(cx, id, si, ai); + self.drag = Drag::Anchor { id, si, ai, handle: None, start: p, at, others: vec![] }; + return vec![Action::Exec("selection.set".into(), json!({"ids": [id], "content": false})), Action::Begin("Move Anchor".into())]; } // A selected placed graphic's own handles resize it inside its frame (the // frame's anchors, under Direct Selection, come first). @@ -776,7 +923,11 @@ impl SelectionTool { } else { out.push(Action::Exec("selection.toggle".into(), json!({"id": id.0}))); } - } else if !cx.selection.contains(id) || (self.direct && !cx.selection.content) { + } else if !cx.selection.contains(id) + || (self.direct && !cx.selection.content && (cx.selection.items.len() > 1 || cx.doc.item(id).is_some_and(holds_content))) + { + // Direct Selection on a selected frame selects its content (a graphic or pasted-in items), + // and on one of several selected objects just that object. out.push(Action::Exec("selection.set".into(), json!({"ids": [id.0], "content": self.direct}))); } else if !self.direct && cx.selection.items.len() > 1 { // InDesign: a click on one of several selected objects makes it the @@ -1040,8 +1191,18 @@ impl SelectionTool { // Screen y points down: a positive screen angle is clockwise. vec![Action::Preview("transform.rotate".into(), json!({"angle": -a}))] } - Drag::Anchor { id, si, ai, handle, start, at } => { - let d = p - start; + Drag::Anchor { id, si, ai, handle, start, at, others } => { + let mut d = p - start; + if d.hypot() >= cx.tol(3.0) { + self.anchor_release = None; + } else if self.anchor_release.is_some() { + // A Shift-press on a selected point is still a click: nothing moves yet. + return vec![]; + } + // Shift: horizontal, vertical or at 45°. + if ev.mods.shift { + d = constrain_45(d); + } let mut dx = d.x; let mut dy = d.y; self.guides.clear(); @@ -1069,18 +1230,25 @@ impl SelectionTool { }, ); // dx and dy stay in canvas space. Snap.delta is spread space. - let corr = spread_delta_to_canvas(cx.layout.xf(spread), hit.delta); - if corr.x.is_finite() { - dx += corr.x; - } - if corr.y.is_finite() { - dy += corr.y; + let mut corr = spread_delta_to_canvas(cx.layout.xf(spread), hit.delta); + corr = Vec2::new(if corr.x.is_finite() { corr.x } else { 0.0 }, if corr.y.is_finite() { corr.y } else { 0.0 }); + // Shift: the snap moves the points along the constrained direction only. + if ev.mods.shift && d.hypot() > 1e-9 { + let dir = d / d.hypot(); + corr = dir * corr.dot(dir); } + dx += corr.x; + dy += corr.y; self.guides = hit.guides; } - let mut params = json!({"id": id, "anchors": [[si, ai]], "dx": dx, "dy": dy}); + // The command moves in spread space: each point by the drag in its own spread. + let own = at.map_or(Vec2::new(dx, dy), |(spread, _)| cx.layout.delta_to_spread(spread, Vec2::new(dx, dy))); + let mut params = json!({"id": id, "anchors": [[si, ai]], "dx": own.x, "dy": own.y}); if let Some(h) = handle { params["handle"] = json!(h); + } else if !others.is_empty() { + let points = std::iter::once((id, si, ai)).chain(others).map(|(i, s, a)| (ItemId(i), s, a)); + params = json!({"points": moved_points(cx, points, Vec2::new(dx, dy))}); } vec![Action::Preview("path.moveAnchors".into(), params)] } @@ -1147,9 +1315,15 @@ impl Tool for SelectionTool { PointerKind::Up => { self.guides.clear(); let release = self.shift_release.take(); + let anchor_release = self.anchor_release.take(); let key = self.key_release.take(); let d = std::mem::replace(&mut self.drag, Drag::None); match d { + // A Shift-click on a selected point that never became a drag takes it away. + Drag::Anchor { .. } if anchor_release.is_some() => { + let points: Vec<_> = cx.selection.anchors.iter().copied().filter(|a| Some(*a) != anchor_release).collect(); + vec![Action::Commit, Action::Exec("selection.set".into(), anchor_selection(cx, &points, true))] + } Drag::Move { .. } | Drag::Resize { .. } | Drag::ContentResize { .. } @@ -1166,6 +1340,21 @@ impl Tool for SelectionTool { }, Drag::Marquee { start, cur } => { let r = Rect::from_points(start, cur); + // Direct Selection: the anchor points inside the marquee, on every path it + // reaches (InDesign's way to select points of several frames at once). + if self.direct { + let mut points = anchors_in(cx, r); + if !points.is_empty() { + if ev.mods.shift { + for a in &cx.selection.anchors { + if !points.contains(a) { + points.push(*a); + } + } + } + return vec![Action::Exec("selection.set".into(), anchor_selection(cx, &points, ev.mods.shift))]; + } + } // Items whose bounds intersect the marquee. let mut ids: Vec = Vec::new(); for slot in &cx.layout.slots { @@ -1202,7 +1391,20 @@ impl Tool for SelectionTool { if key == ToolKey::Escape && self.live.take().is_some_and(|id| cx.selection.items == [id]) { return vec![Action::Handled]; } - let mv = |dx: f64, dy: f64| vec![Action::Exec("transform.move".into(), json!({"dx": dx, "dy": dy, "copy": mods.alt}))]; + let direct_points = self.direct && !cx.selection.anchors.is_empty(); + let mv = |dx: f64, dy: f64| { + // Direct Selection with anchor points selected: the arrow keys move the points, each + // the way the arrow points on screen. + if direct_points { + let points = moved_points(cx, cx.selection.anchors.iter().copied(), Vec2::new(dx, dy)); + return vec![Action::Exec("path.moveAnchors".into(), json!({"points": points}))]; + } + vec![Action::Exec("transform.move".into(), json!({"dx": dx, "dy": dy, "copy": mods.alt}))] + }; + // Selected points: Delete and Backspace delete them, not their paths. + if direct_points && matches!(key, ToolKey::Delete | ToolKey::Backspace) { + return vec![Action::Exec("path.deletePoints".into(), json!({}))]; + } if cx.selection.items.is_empty() { // Selected ruler guides: the arrows nudge them, Delete deletes them. if cx.selection.guides.is_empty() { @@ -1347,3 +1549,26 @@ fn edit_text_at(cx: &ToolContext, p: Point) -> Option> { let (_, sp) = cx.layout.spread_at(p).unwrap_or((SpreadRef::Doc(0), p)); Some(vec![Action::SwitchTool("type".into()), Action::Exec("text.placeCaret".into(), json!({"frame": id.0, "point": [sp.x, sp.y]}))]) } + +#[cfg(test)] +mod handle_tests { + use super::handle_shown; + + /// InDesign's Direct Selection shows a selected anchor's two handles, and on its neighbours + /// the handle shaping the segment to it; nothing with no anchor selected. + #[test] + fn handles_show_around_the_selected_anchors() { + let shown = |closed: bool, chosen: &[usize]| { + (0..4) + .flat_map(|ai| [false, true].map(|out| (ai, out))) + .filter(|(ai, out)| handle_shown(4, closed, *ai, *out, |a| chosen.contains(&a))) + .collect::>() + }; + assert_eq!(shown(true, &[]), vec![], "no anchor selected"); + assert_eq!(shown(true, &[2]), vec![(1, true), (2, false), (2, true), (3, false)]); + // Closed, the first anchor's neighbours wrap round. + assert_eq!(shown(true, &[0]), vec![(0, false), (0, true), (1, false), (3, true)]); + // Open, the end has no neighbour beyond it. + assert_eq!(shown(false, &[0]), vec![(0, false), (0, true), (1, false)]); + } +} diff --git a/crates/tools/src/snap/tests.rs b/crates/tools/src/snap/tests.rs index 2dfccafae..f97c637c1 100644 --- a/crates/tools/src/snap/tests.rs +++ b/crates/tools/src/snap/tests.rs @@ -64,6 +64,7 @@ fn ctx_on<'a>(doc: &'a Document, sel: &'a Selection, cache: &'a Cache, layout: & snap: SnapView::FACTORY, unit: Unit::Points, stroke_in_front: false, + loaded: None, } } diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 2a9bd0444..3970f1910 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -19,6 +19,7 @@ fn ctx<'a>(d: &'a Document, s: &'a Selection, c: &'a Cache, l: &'a CanvasLayout) snap: SnapView::OFF, unit: Unit::Points, stroke_in_front: false, + loaded: None, } } @@ -70,6 +71,56 @@ fn frame_tool_drag_emits_begin_preview_commit() { assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 110.0, 60.0)), vec![Action::Commit]); } +/// While a frame is drawn, its size shows in the document's units, as InDesign's readout does +/// ("W: 6.08 cm" over "H: 6.08 cm" in Zero2Graphic's page layout tutorial, 0:43). +#[test] +fn frame_tool_readout_shows_the_size_in_the_document_units() { + let mut d = Document::new(&NewDocument::default()); + d.settings.horizontal_units = Unit::Centimeters; + d.settings.vertical_units = Unit::Centimeters; + let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let mut t = create("rectangleFrame"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + // 172.35 pt square: 6.08 cm. + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 182.35, 182.35)); + let texts: Vec = t + .overlays(&cx) + .into_iter() + .filter_map(|o| match o { + Overlay::Measure { text, .. } => Some(text), + _ => None, + }) + .collect(); + assert_eq!(texts, vec!["W: 6.08 cm\nH: 6.08 cm".to_string()]); +} + +/// The Line tool's readout measures the line as drawn: Shift makes it level, so it has no +/// height, wherever the pointer is. +#[test] +fn line_tool_readout_measures_the_line_as_drawn() { + let mut d = Document::new(&NewDocument::default()); + d.settings.horizontal_units = Unit::Centimeters; + d.settings.vertical_units = Unit::Centimeters; + let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let mut t = create("line"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + let mut drag = PointerEvent::new(PointerKind::Drag, 182.35, 15.0); + drag.mods.shift = true; + t.pointer(&cx, &drag); + let texts: Vec = t + .overlays(&cx) + .into_iter() + .filter_map(|o| match o { + Overlay::Measure { text, .. } => Some(text), + _ => None, + }) + .collect(); + assert_eq!(texts.len(), 1, "{texts:?}"); + assert_eq!(texts[0], "W: 6.08 cm\nH: 0 cm", "level, as drawn, not 5 pt down to the pointer"); +} + #[test] fn selection_click_and_marquee() { let mut d = Document::new(&NewDocument::default()); @@ -837,6 +888,62 @@ fn pen_click_snaps_to_a_guide() { assert!((x - 100.0).abs() < 1e-6, "anchor x {x}"); } +/// #1438: dragging a smooth point shows the segment it shapes under the pointer (from the +/// previous anchor's out handle to the new anchor's in handle), and after a smooth point the +/// rubber band to the pointer curves out of its direction line; a corner keeps the straight one. +#[test] +fn pen_previews_the_curve_while_dragging_and_after_a_smooth_point() { + use designcraft_geom::{BezPath, PathEl}; + let d = Document::new(&NewDocument::default()); + let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let (sr, _) = cx.layout.spread_at(Point::new(100.0, 100.0)).expect("the page is under the pointer"); + let xf = cx.layout.xf(sr); + let sp = |x: f64, y: f64| cx.layout.to_spread(sr, Point::new(x, y)); + let near = |a: Point, b: Point| (a - b).hypot() < 1e-6; + let paths = |t: &dyn Tool, dashed: bool| -> Vec { + t.overlays(&cx) + .into_iter() + .filter_map(|o| match o { + Overlay::Path { path, dashed: d, .. } if d == dashed => Some(path), + _ => None, + }) + .collect() + }; + let mut t = create("pen"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 100.0, 100.0)); + // A corner's rubber band is a straight line: no curve. + t.pointer(&cx, &PointerEvent::new(PointerKind::Move, 150.0, 120.0)); + assert!(paths(&*t, true).is_empty() && paths(&*t, false).is_empty(), "a corner's rubber band is a line"); + // Dragging the second anchor out: the segment from the first anchor is previewed live. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 100.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 230.0, 130.0)); + let [p] = &paths(&*t, false)[..] else { panic!("one solid curve preview: {:?}", t.overlays(&cx)) }; + let in_handle = sp(200.0, 100.0) - (sp(230.0, 130.0) - sp(200.0, 100.0)); + match p.elements() { + [PathEl::MoveTo(a), PathEl::CurveTo(c1, c2, b)] => { + assert!(near(*a, xf * sp(100.0, 100.0)) && near(*c1, xf * sp(100.0, 100.0)), "from the corner, whose out handle is itself: {a:?} {c1:?}"); + assert!(near(*c2, xf * in_handle) && near(*b, xf * sp(200.0, 100.0)), "to the dragged anchor by its in handle: {c2:?} {b:?}"); + } + other => panic!("{other:?}"), + } + // Let go: the anchor is committed and the segment preview goes; the rubber band now curves + // out of the smooth point's direction line towards the pointer. + let up = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 230.0, 130.0)); + assert!(matches!(&up[..], [Action::Exec(cmd, _)] if cmd == "path.create"), "{up:?}"); + assert!(paths(&*t, false).is_empty(), "no segment preview once let go"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Move, 300.0, 100.0)); + let [band] = &paths(&*t, true)[..] else { panic!("a curved rubber band: {:?}", t.overlays(&cx)) }; + match band.elements() { + [PathEl::MoveTo(a), PathEl::CurveTo(c1, c2, b)] => { + assert!(near(*a, xf * sp(200.0, 100.0)) && near(*c1, xf * sp(230.0, 130.0)), "{a:?} {c1:?}"); + assert!(near(*c2, Point::new(300.0, 100.0)) && near(*b, Point::new(300.0, 100.0)), "{c2:?} {b:?}"); + } + other => panic!("{other:?}"), + } +} + /// A text frame with overset text (selected), an empty frame and a threaded pair x → y, on page 1. struct ThreadDoc { d: Document, @@ -1085,6 +1192,33 @@ fn double_click_on_a_port_breaks_its_link() { } } +/// With the Type tool, a drag that starts in an empty rectangle draws a new text frame over it +/// (the rectangle stays as it is), as InDesign's does; a click still turns the rectangle into a +/// text frame with the caret in it. +#[test] +fn type_tool_drag_over_an_empty_frame_draws_a_new_text_frame() { + let mut d = Document::new(&NewDocument::default()); + let id = add_rect(&mut d, Rect::new(100.0, 100.0, 300.0, 300.0)); + let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let off = l.xf(SpreadRef::Doc(0)).translation(); + let at = |x: f64, y: f64| (x + off.x, y + off.y); + let mut t = create("type"); + let (x, y) = at(120.0, 120.0); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y)).is_empty()); + let (x1, y1) = at(280.0, 200.0); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x1, y1)); + assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(cmd, p)] if cmd == "frame.create" && p["content"] == "text"), "{a:?}"); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x1, y1)), vec![Action::Commit]); + // A click: the caret goes into the rectangle, which becomes a text frame. + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y)).is_empty()); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x, y)); + assert!(matches!(&a[..], [Action::Exec(cmd, p)] if cmd == "text.placeCaret" && p["frame"] == id.0), "{a:?}"); + // A release away from the press with no drag in between isn't a click: no caret, no conversion. + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y)).is_empty()); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x1, y1)).is_empty()); +} + #[test] fn clicks_in_a_row_edit_and_select_text() { let mut d = Document::new(&NewDocument::default()); @@ -1480,6 +1614,32 @@ fn table_frame(degrees: f64) -> (Document, ItemId) { (d, id) } +/// In a table cell Shift+Tab goes back a cell and Esc toggles cell selection, as InDesign; out of +/// a table Tab types a tab and Esc leaves the text. +#[test] +fn type_tool_keys_in_a_table_cell() { + let (d, id) = table_frame(0.0); + let sid = d.item(id).unwrap().text_frame().unwrap().story; + let table = *d.story(sid).unwrap().tables.keys().next().unwrap(); + let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); + let cell = designcraft_doc::CellAddr { table, row: 1, col: 1 }; + let s = Selection::text(designcraft_doc::TextSel { cell: Some(cell), ..designcraft_doc::TextSel::caret(sid, 0) }); + let cx = ctx(&d, &s, &c, &l); + let mut t = create("type"); + let shift = Mods { shift: true, ..Default::default() }; + let exec = |c: &str, p: serde_json::Value| vec![Action::Exec(c.into(), p)]; + assert_eq!(t.key(&cx, ToolKey::Tab, shift), exec("table.prevCell", serde_json::json!({}))); + assert_eq!(t.key(&cx, ToolKey::Tab, Mods::default()), exec("text.insert", serde_json::json!({"text": "\t"}))); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), exec("table.toggleSelection", serde_json::json!({}))); + // Out of a table, and in footnote text (addressed as a cell too). + for cell in [None, Some(designcraft_doc::CellAddr::footnote(1))] { + let s = Selection::text(designcraft_doc::TextSel { cell, ..designcraft_doc::TextSel::caret(sid, 0) }); + let cx = ctx(&d, &s, &c, &l); + assert_eq!(t.key(&cx, ToolKey::Tab, shift), exec("text.insert", serde_json::json!({"text": "\t"})), "{cell:?}"); + assert!(matches!(t.key(&cx, ToolKey::Escape, Mods::default()).first(), Some(Action::SwitchTool(_))), "{cell:?}"); + } +} + #[test] fn type_tool_drags_a_table_column_border() { for degrees in [0.0, 90.0] { @@ -1746,6 +1906,52 @@ fn pen_over_a_selected_open_paths_end_point_continues_it() { assert!(t.busy(), "the path is being continued"); } +/// Continuing a path from an end point and dragging its direction lines previews the segment the +/// drag reshapes: from the anchor joined to that end point, not from the path's other end, from +/// either end (#1438). +#[test] +fn pen_previews_the_segment_joined_to_a_continued_end_point() { + use designcraft_geom::{BezPath, PathEl}; + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let id = ItemId(d.alloc()); + let anchors = vec![ + designcraft_geom::Anchor::corner(Point::new(100.0, 100.0)), + designcraft_geom::Anchor::corner(Point::new(200.0, 100.0)), + designcraft_geom::Anchor::corner(Point::new(200.0, 200.0)), + ]; + let mut it = Item::new(id, lid, Shape::Path, designcraft_geom::PathData::single(designcraft_geom::SubPath::new(anchors, false))); + it.stroke = Stroke::default(); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let (s, c, l) = (Selection::items(vec![id]), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let xf = l.xf(SpreadRef::Doc(0)); + let near = |a: Point, b: Point| (a - b).hypot() < 1e-6; + let solid = |t: &dyn Tool| -> Vec { + t.overlays(&cx) + .into_iter() + .filter_map(|o| match o { + Overlay::Path { path, dashed: false, .. } => Some(path), + _ => None, + }) + .collect() + }; + for (end, joined, far) in [((200.0, 200.0), (200.0, 100.0), (100.0, 100.0)), ((100.0, 100.0), (200.0, 100.0), (200.0, 200.0))] { + let mut t = create("pen"); + let p = xf * Point::new(end.0, end.1); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, p.x + 30.0, p.y + 30.0)); + let [path] = &solid(&*t)[..] else { panic!("one segment preview from {end:?}: {:?}", t.overlays(&cx)) }; + match path.elements() { + [PathEl::MoveTo(a), PathEl::CurveTo(_, _, b)] => { + assert!(near(*a, xf * Point::new(joined.0, joined.1)), "from the anchor joined to {end:?}, not the other end {far:?}: {a:?}"); + assert!(near(*b, p), "to the end point being reshaped: {b:?}"); + } + other => panic!("{other:?}"), + } + } +} + #[test] fn the_selection_tool_drags_a_path_type_bracket_along_its_path() { let mut d = Document::new(&NewDocument::default()); @@ -1821,7 +2027,13 @@ fn direct_selection_drags_the_end_anchors_of_type_on_a_path() { let p = Point::new(x + off.x, 300.0 + off.y); let mut direct = create("directSelection"); assert_ne!(direct.cursor(&cx, p, Mods::default()), Cursor::PathBracket, "anchor at {x}"); - assert_eq!(direct.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y)), vec![Action::Begin("Move Anchor".into())]); + // The press selects that anchor point and starts moving it. + let a = direct.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y)); + assert!( + matches!(a.first(), Some(Action::Exec(cmd, p)) if cmd == "selection.set" && p["anchors"].as_array().map(Vec::len) == Some(1)), + "{a:?}" + ); + assert_eq!(a.last(), Some(&Action::Begin("Move Anchor".into()))); } } @@ -1966,3 +2178,177 @@ fn loaded_text_cursor_threads_into_a_frame_inside_a_group() { assert_eq!(cx2.hit_text_frame(q).map(|(_, i)| i), None, "no text frame in that group"); assert_eq!(t2.cursor(&cx2, q, Mods::default()), Cursor::NotAllowed); } + +#[test] +fn parent_resize_page_tool_targets_the_visible_parent() { + let d = Document::new(&NewDocument { width: 400.0, height: 600.0, pages: 3, facing_pages: true, ..Default::default() }); + let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, true)); + let cx = ctx(&d, &s, &c, &l); + let p = l.to_canvas(SpreadRef::Parent(0), d.parents[0].pages[0].bounds().center()); + let mut t = create("page"); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::DoubleClick, p.x, p.y)); + assert_eq!( + a, + vec![Action::Dialog( + "cmd:layout.parentPageSize".into(), + serde_json::json!({"parent": d.parents[0].id.0, "page": 0, "width": 400.0, "height": 600.0}) + )], + "double click must target the visible parent instead of document page zero" + ); +} + +#[test] +fn parent_resize_selection_tracks_ids_and_never_crosses_canvas_modes() { + let mut d = Document::new(&NewDocument::default()); + let second = d.add_parent("B", "Parent", 1, 12.0, designcraft_doc::Margins::uniform(36.0)); + let selection = Selection { page: Some(0), parent_page: Some((second, 1)), ..Default::default() }; + let l = CanvasLayout::new(&d, true); + assert_eq!(l.selected_page(&d, &selection), Some((SpreadRef::Parent(1), 1))); + d.parents.swap(0, 1); + let l = CanvasLayout::new(&d, true); + assert_eq!(l.selected_page(&d, &selection), Some((SpreadRef::Parent(0), 1))); + assert_eq!(CanvasLayout::new(&d, false).selected_page(&d, &selection), Some((SpreadRef::Doc(0), 0))); + d.parents.remove(0); + assert_eq!(CanvasLayout::new(&d, true).selected_page(&d, &selection), None); + let selection = Selection { page: Some(0), ..Default::default() }; + assert_eq!(CanvasLayout::new(&d, true).selected_page(&d, &selection), None); +} + +#[test] +fn direct_selection_click_on_one_of_several_selected_frames_selects_only_it() { + let mut d = Document::new(&NewDocument::default()); + let a = add_rect(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0)); + let b = add_rect(&mut d, Rect::new(300.0, 100.0, 400.0, 200.0)); + let (s, c, l) = (Selection::items(vec![a, b]), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let p = spread_pt(&l, 150.0, 150.0); + let mut t = create("directSelection"); + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y)); + assert_eq!(acts, vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": [a.0], "content": true}))]); +} + +#[test] +fn pulling_a_corner_of_an_unselected_frame_selects_the_frame_not_its_content() { + let mut d = Document::new(&NewDocument::default()); + let a = add_rect(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0)); + let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let p = spread_pt(&l, 200.0, 100.0); + let mut t = create("directSelection"); + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y)); + assert_eq!( + acts, + vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": [a.0], "content": false})), Action::Begin("Move Anchor".into())] + ); +} + +#[test] +fn anchor_drag_on_a_rotated_spread_sends_a_spread_space_delta() { + let mut d = Document::new(&NewDocument::default()); + let a = add_rect(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0)); + Arc::make_mut(&mut d.spreads[0]).pages[0].view_rotation = 1; + let (s, c, l) = (Selection::items(vec![a]), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let p = l.to_canvas(SpreadRef::Doc(0), Point::new(200.0, 100.0)); + let mut t = create("directSelection"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y)); + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, p.x + 50.0, p.y)); + let [Action::Preview(cmd, params)] = acts.as_slice() else { panic!("{acts:?}") }; + assert_eq!(cmd, "path.moveAnchors"); + let want = l.delta_to_spread(SpreadRef::Doc(0), Vec2::new(50.0, 0.0)); + assert!(want.y.abs() > 1.0, "the view rotation turns a canvas x move into a spread y move: {want:?}"); + let (dx, dy) = (params["dx"].as_f64().unwrap(), params["dy"].as_f64().unwrap()); + assert!((dx - want.x).abs() < 1e-6 && (dy - want.y).abs() < 1e-6, "{params} want {want:?}"); +} + +/// Direct Selection selects anchor points with a marquee across several frames, and dragging one of +/// them moves them all, as InDesign does when the top points of a row of frames are dragged to slant +/// them (Zero2Graphic's page layout tutorial, 1:15). +#[test] +fn direct_selection_marquee_selects_points_of_several_frames_and_drags_them_together() { + let mut d = Document::new(&NewDocument::default()); + let a = add_rect(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0)); + let b = add_rect(&mut d, Rect::new(200.0, 100.0, 300.0, 200.0)); + let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); + let none = Selection::default(); + let mut t = create("directSelection"); + // A marquee around the top edges: the four top points, not the frames' bottom ones. + let (p0, p1) = (spread_pt(&l, 80.0, 90.0), spread_pt(&l, 320.0, 110.0)); + t.pointer(&ctx(&d, &none, &c, &l), &PointerEvent::new(PointerKind::Down, p0.x, p0.y)); + t.pointer(&ctx(&d, &none, &c, &l), &PointerEvent::new(PointerKind::Drag, p1.x, p1.y)); + let up = t.pointer(&ctx(&d, &none, &c, &l), &PointerEvent::new(PointerKind::Up, p1.x, p1.y)); + let Some(Action::Exec(cmd, p)) = up.last() else { panic!("{up:?}") }; + assert_eq!(cmd, "selection.set"); + assert_eq!(p["ids"], serde_json::json!([a.0, b.0])); + let mut got: Vec<(u64, u64, u64)> = + p["anchors"].as_array().unwrap().iter().map(|v| (v[0].as_u64().unwrap(), v[1].as_u64().unwrap(), v[2].as_u64().unwrap())).collect(); + got.sort(); + assert_eq!(got, vec![(a.0, 0, 0), (a.0, 0, 1), (b.0, 0, 0), (b.0, 0, 1)]); + // With another frame selected before, a marquee without Shift selects only the paths whose + // points it takes; with Shift the other frame stays selected. + let other = add_rect(&mut d, Rect::new(400.0, 400.0, 450.0, 450.0)); + let l = CanvasLayout::new(&d, false); + let was = Selection { items: vec![other], ..Default::default() }; + let shift = Mods { shift: true, ..Default::default() }; + for (mods, want) in [(Mods::default(), serde_json::json!([a.0, b.0])), (shift, serde_json::json!([other.0, a.0, b.0]))] { + t.pointer(&ctx(&d, &was, &c, &l), &PointerEvent::new(PointerKind::Down, p0.x, p0.y).with_mods(mods)); + t.pointer(&ctx(&d, &was, &c, &l), &PointerEvent::new(PointerKind::Drag, p1.x, p1.y).with_mods(mods)); + let up = t.pointer(&ctx(&d, &was, &c, &l), &PointerEvent::new(PointerKind::Up, p1.x, p1.y).with_mods(mods)); + let Some(Action::Exec(_, p)) = up.last() else { panic!("{up:?}") }; + assert_eq!(p["ids"], want, "{mods:?}"); + } + // Those points selected: dragging one moves all four, in one preview. + let sel = Selection { items: vec![a, b], anchors: vec![(a, 0, 0), (a, 0, 1), (b, 0, 0), (b, 0, 1)], ..Default::default() }; + let cx = ctx(&d, &sel, &c, &l); + let at = spread_pt(&l, 200.0, 100.0); + let down = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y)); + assert_eq!(down, vec![Action::Begin("Move Anchors".into())]); + let drag = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, at.x + 36.0, at.y + 3.0).with_mods(shift)); + let Some(Action::Preview(cmd, p)) = drag.last() else { panic!("{drag:?}") }; + assert_eq!(cmd, "path.moveAnchors"); + assert_eq!(p["points"].as_array().map(Vec::len), Some(4), "{p}"); + // Shift keeps the move horizontal. + // Each point carries its own movement (in its spread): here all the same. + let moves = |p: &serde_json::Value| -> Vec<(f64, f64)> { + p["points"].as_array().unwrap().iter().map(|v| (v[3].as_f64().unwrap(), v[4].as_f64().unwrap())).collect() + }; + assert!(moves(p).iter().all(|&(x, y)| (x - 36.0).abs() < 1e-9 && y.abs() < 1e-9), "{p}"); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x + 36.0, at.y)), vec![Action::Commit]); + // A Shift-drag that starts on a selected point keeps it: all four move, constrained. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(shift)); + let drag = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, at.x + 3.0, at.y + 30.0).with_mods(shift)); + let Some(Action::Preview(_, p)) = drag.last() else { panic!("{drag:?}") }; + assert_eq!(p["points"].as_array().map(Vec::len), Some(4), "{p}"); + assert!(moves(p).iter().all(|&(x, y)| x.abs() < 1e-9 && (y - 30.0).abs() < 1e-9), "vertical: {p}"); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x, at.y + 30.0).with_mods(shift)), vec![Action::Commit]); + // Shift+click on a selected point takes it out of the selection, on release; the arrow keys + // move the points. + let out = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(shift)); + assert!(!out.iter().any(|a| matches!(a, Action::Exec(..))), "kept while it may become a drag: {out:?}"); + // A jiggle inside the drag tolerance moves nothing: it is still a click. + let jiggle = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, at.x + 1.0, at.y + 1.0).with_mods(shift)); + assert!(jiggle.is_empty(), "{jiggle:?}"); + let out = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x + 1.0, at.y + 1.0).with_mods(shift)); + let Some(Action::Exec(cmd, p)) = out.last() else { panic!("{out:?}") }; + assert_eq!(cmd, "selection.set"); + assert_eq!(p["anchors"].as_array().map(Vec::len), Some(3), "{p}"); + let keys = t.key(&cx, ToolKey::Right, Mods::default()); + assert!( + matches!(keys.as_slice(), [Action::Exec(cmd, p)] if cmd == "path.moveAnchors" && p["points"].as_array().map(Vec::len) == Some(4)), + "{keys:?}" + ); + // Delete deletes the selected points, not their paths. + assert_eq!(t.key(&cx, ToolKey::Delete, Mods::default()), vec![Action::Exec("path.deletePoints".into(), serde_json::json!({}))]); + // On a spread turned a quarter on screen, → moves the points right on screen: up or down in + // the spread, not across it. + let mut turned = d.clone(); + std::sync::Arc::make_mut(&mut turned.spreads[0]).pages[0].view_rotation = 1; + let lt = CanvasLayout::new(&turned, false); + let cxt = ctx(&turned, &sel, &c, <); + let keys = t.key(&cxt, ToolKey::Right, Mods::default()); + let [Action::Exec(_, p)] = keys.as_slice() else { panic!("{keys:?}") }; + let inc = turned.settings.keyboard_increment; + let want = lt.delta_to_spread(SpreadRef::Doc(0), designcraft_geom::Vec2::new(inc, 0.0)); + assert!(want.x.abs() < 1e-9 && (want.y.abs() - inc).abs() < 1e-9, "a quarter turn: {want:?}"); + assert!(moves(p).iter().all(|&(x, y)| (x - want.x).abs() < 1e-9 && (y - want.y).abs() < 1e-9), "{p}"); +} diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index b879760f0..c03c32670 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -32,6 +32,9 @@ pub struct TypeTool { start: Option, selecting: Option, drawing: bool, + /// A press on an empty frame: (frame, press point in spread space). A click turns it into a + /// text frame with the caret in it; a drag draws a new text frame over it, as in InDesign. + convert: Option<(u64, Point)>, /// A press on a table whose whole rows or columns are selected: (frame, press point in /// spread space) — a drag moves them, a click places the caret. cell_drag: Option<(u64, Point)>, @@ -228,6 +231,7 @@ impl TypeTool { self.cell_drag = None; self.unit = None; self.border = None; + self.convert = None; if ev.kind == PointerKind::Down && let Some(b) = border_at(cx, ev.pos) { @@ -248,6 +252,11 @@ impl TypeTool { self.cell_drag = Some((id.0, sp)); return vec![]; } + // An empty frame: wait to see whether this is a click or a drag. + if clicks == 1 && !ev.mods.shift && !it.is_text_frame() { + self.convert = Some((id.0, sp)); + return vec![]; + } self.selecting = Some(id.0); // Two presses select a word (or a run of spaces), three a line, four a // paragraph, five the story. @@ -299,6 +308,7 @@ impl TypeTool { let mut out = vec![]; if !self.drawing { self.drawing = true; + self.convert = None; out.push(Action::Begin("Create Text Frame".into())); } out.push(Action::Preview( @@ -327,6 +337,14 @@ impl TypeTool { self.selecting = None; return vec![Action::Commit]; } + // A click in an empty frame: it becomes a text frame with the caret in it. A release + // away from the press (with no drag in between) isn't a click: nothing changes. + if let Some((fid, at)) = self.convert.take() { + if start.is_some_and(|a| (ev.pos - a).hypot() >= cx.tol(3.0)) { + return vec![]; + } + return vec![Action::Exec("text.placeCaret".into(), json!({"frame": fid, "point": [at.x, at.y]}))]; + } // Ends a press in text: drops dragged text, or is ignored by the engine. if let Some(fid) = self.selecting.take() { let sp = cx.layout.spread_at(ev.pos).map(|(_, p)| p).unwrap_or(ev.pos); @@ -372,6 +390,8 @@ impl Tool for TypeTool { let steps = if mods.cmd || mods.ctrl { 5.0 } else { 1.0 }; vec![Action::Exec("type.kernStep".into(), json!({"steps": sign * steps}))] }; + // A table cell's text, not a footnote's (which is addressed as a cell too). + let in_cell = cx.selection.text.is_some_and(|t| t.cell.is_some_and(|c| c.footnote_id().is_none())); match key { ToolKey::Left if mods.alt && !mods.shift => kern(-1.0), ToolKey::Right if mods.alt && !mods.shift => kern(1.0), @@ -384,7 +404,11 @@ impl Tool for TypeTool { ToolKey::Backspace => vec![Action::Exec("text.delete".into(), json!({"forward": false, "word": mods.alt}))], ToolKey::Delete => vec![Action::Exec("text.delete".into(), json!({"forward": true, "word": mods.alt}))], ToolKey::Enter => vec![Action::Exec("text.insert".into(), json!({"text": if mods.shift { "\u{2028}" } else { "\n" }}))], + // In a table cell Shift+Tab goes back a cell (Tab goes on through `text.insert`). + ToolKey::Tab if mods.shift && in_cell => vec![Action::Exec("table.prevCell".into(), json!({}))], ToolKey::Tab => vec![Action::Exec("text.insert".into(), json!({"text": "\t"}))], + // In a table Esc toggles between the cell's text and the cell, as InDesign. + ToolKey::Escape if in_cell => vec![Action::Exec("table.toggleSelection".into(), json!({}))], ToolKey::Escape => vec![Action::SwitchTool("selection".into()), Action::Exec("text.exitToFrame".into(), json!({}))], _ => vec![], } diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index f40007fb2..3116b01db 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -1,8 +1,7 @@ -//! Bake the contributor credits into the binary as static Rust tables (craftrules -//! standards/contributors.md). Nothing is read at run time. +//! Bake the contributor credits into the binary as static Rust tables. Nothing is read at run time. //! -//! - contributors/contributors.json: commit stats per GitHub username, generated by craftrules -//! scripts/contributors.py, which also marks whose people.toml entry it verified. +//! - contributors/contributors.json: commit stats per GitHub username, with a mark for whose people.toml +//! entry is verified. //! - contributors/people.toml: the names people chose for themselves. A name is used only when the //! JSON says the entry was verified (`people_entry`). //! diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 44e35ef15..bb75c102e 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -83,6 +83,7 @@ impl DesignApp { let was_find = self.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange"); match r { UiRequest::Dialog { id, params } if id == "colorTheme" => dialogs::open_color_theme(self, params), + UiRequest::Dialog { id, params } if id == "frameSize" => self.ui.dialog = Some(dialogs::frame_size_dialog(self, params)), UiRequest::Dialog { id, params } => self.ui.dialog = Some(dialogs::Dialog::new(&id, params)), UiRequest::View { params } => canvas::apply_view_request(self, ¶ms), UiRequest::Pick { purpose, params } => { diff --git a/crates/ui-egui/src/app/file_dialogs.rs b/crates/ui-egui/src/app/file_dialogs.rs index 0d3a2b4da..9f1271389 100644 --- a/crates/ui-egui/src/app/file_dialogs.rs +++ b/crates/ui-egui/src/app/file_dialogs.rs @@ -337,6 +337,7 @@ fn open_filters(purpose: &str) -> &'static [OpenFilter] { "script" => &[OpenFilter { name: "Script", extensions: &["dcscript", "txt", "json"] }], "shortcuts" => &[OpenFilter { name: "Shortcut set (JSON, InDesign text)", extensions: &["json", "txt"] }], "icc" => &[OpenFilter { name: "ICC profile", extensions: &["icc", "icm"] }], + "coverImage" => &[OpenFilter { name: "Cover image (PNG, JPEG)", extensions: &["png", "jpg", "jpeg"] }], "book" => &[OpenFilter { name: "Book", extensions: &["dcbook"] }], "xml" => &[OpenFilter { name: "XML", extensions: &["xml"] }], "dtd" => &[OpenFilter { name: "DTD", extensions: &["dtd"] }], diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 49d2bd757..cef32eae9 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -161,8 +161,7 @@ impl DesignApp { } else if place { self.open_file("file.place", json!({"name": file.name, "base64": b64})) } else { - let title = file.name.rsplit_once('.').map_or(file.name.as_str(), |(stem, _)| stem); - self.open_file("file.openBytes", json!({"name": title, "base64": b64})) + self.open_file("file.openBytes", json!({"name": file.name, "base64": b64})) } } @@ -258,21 +257,27 @@ impl DesignApp { } self.ui.dialog = Some(dialogs::Dialog::new("alert", json!({"title": title, "file": file, "message": e}))); } else if let Ok(result) = &r { - let warnings: Vec<&str> = result + // Each warning with the frame it is about (`warningItems`), if any. + let items = result.get("warningItems").and_then(Value::as_array); + let (warnings, lines): (Vec<&str>, Vec) = result .get("warnings") .and_then(Value::as_array) .into_iter() .flatten() - .filter_map(Value::as_str) - .filter(|warning| !warning.is_empty()) - .collect(); + .enumerate() + .filter_map(|(i, w)| Some((i, w.as_str()?))) + .filter(|(_, warning)| !warning.is_empty()) + .map(|(i, w)| (w, json!({"text": w, "item": items.and_then(|a| a.get(i)).cloned().unwrap_or(Value::Null)}))) + .unzip(); if !warnings.is_empty() { self.status(format!("{file}: {}", warnings.join("; "))); // Find Font, offered for the document's missing fonts, stays up: the status line // keeps the warnings. if self.ui.dialog.as_ref().is_none_or(|d| d.id != "findFont") { - self.ui.dialog = - Some(dialogs::Dialog::new("alert", json!({"title": "Import Warnings", "file": file, "message": warnings.join("\n")}))); + self.ui.dialog = Some(dialogs::Dialog::new( + "alert", + json!({"title": "Import Warnings", "file": file, "message": warnings.join("\n"), "lines": lines}), + )); } } } @@ -326,6 +331,7 @@ mod import_warning_tests { assert_eq!(dialog.id, "alert"); assert_eq!(dialog.fields["title"], "Import Warnings"); assert_eq!(dialog.fields["file"], file); + assert!(dialog.fields["lines"].as_array().unwrap().iter().all(|l| l["item"].is_null()), "missing parts name no frame"); for part in ["Spreads/missing.xml", "Stories/missing.xml"] { assert!(dialog.fields["message"].as_str().unwrap().contains(part), "every missing part is named"); assert!(app.ui.status.contains(part), "the status line retains the warning"); @@ -370,7 +376,7 @@ mod import_warning_tests { let request = app.import_request("open"); app.services.inbox.as_ref().unwrap().lock().unwrap().push(ImportedFile { request, name: "partial.idml".into(), bytes: idml(true) }); app.drain_inbox(); - assert_partial_import_warning(&mut app, "partial"); + assert_partial_import_warning(&mut app, "partial.idml"); } #[test] @@ -417,6 +423,42 @@ pub(crate) mod browser_import_tests { app.services.inbox.as_ref().unwrap().lock().unwrap().push(ImportedFile { request, name: name.into(), bytes }); app.drain_inbox(); } + + /// A document chosen or dropped in the browser is titled by its file name less one extension, + /// as on the desktop: the extension comes off once, in the engine. + #[test] + fn browser_opens_title_a_document_by_its_name_without_one_extension() { + let mut app = app(); + let native = designcraft_engine::cmd::base64_decode(app.run("file.serialize", json!({})).unwrap()["base64"].as_str().unwrap()); + for purpose in ["open", "drop"] { + for (name, title) in [ + ("Brochure.designcraft", "Brochure"), + ("Brochure.designcraft.designcraft", "Brochure.designcraft"), + ("Brochure.v2.designcraft", "Brochure.v2"), + ("Brochure.indd", "Brochure.indd"), + ("Brochure.INDT", "Brochure.INDT"), + ("日本.DESIGNCRAFT", "日本"), + (".designcraft", ".designcraft"), + ("Brochure", "Brochure"), + ] { + let before = app.session.documents().len(); + let request = app.import_request(purpose); + deliver(&mut app, request, name, native.clone()); + assert_eq!(app.session.documents().len(), before + 1, "{purpose}: {name}"); + assert_eq!(app.session.doc().unwrap().doc.title, title, "{purpose}: {name}"); + } + } + let idml = designcraft_idml::export_idml(&app.session.doc().unwrap().doc); + for purpose in ["open", "drop"] { + for name in ["Brochure.idml", "Brochure.indd", "Brochure.INDT"] { + let before = app.session.documents().len(); + let request = app.import_request(purpose); + deliver(&mut app, request, name, idml.clone()); + assert_eq!(app.session.documents().len(), before + 1, "{purpose}: {name}"); + assert_eq!(app.session.doc().unwrap().doc.title, "Brochure", "{purpose}: {name}"); + } + } + } fn image() -> Vec { br##""##.to_vec() } diff --git a/crates/ui-egui/src/app/input.rs b/crates/ui-egui/src/app/input.rs index 8d248561c..e889c3287 100644 --- a/crates/ui-egui/src/app/input.rs +++ b/crates/ui-egui/src/app/input.rs @@ -23,6 +23,11 @@ impl DesignApp { /// Inject synthetic events (one pointer event per host iteration), retaining control /// provenance until a settled UI pass has actually taken the batch. pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + // An undecorated window gets its caption back when the toolkit changes its flags + // (maximize, restore, …): take it off again before the frame is laid out (#1295). + if let Some(window_frame) = self.services.window_frame.as_mut() { + window_frame(); + } if !self.injected_settle_hook { let pending = self.injected_pending.clone(); let settled = self.injected_settled.clone(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 84b3cd0d6..8d552c80c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -44,8 +44,8 @@ use input::{press_count, release_clicks}; #[cfg(test)] use page_decor::{MAX_BASELINES, baseline_grid_lines}; use page_decor::{dashed, draw_flattener_preview, draw_guides, shown_area}; -pub use render::DisplayKey; use render::render_texture; +pub use render::{DisplayKey, color_at}; #[cfg(test)] use render::{c32, invalidate_on_display_change, screen_options}; pub(crate) use rulers::fmt_tick; @@ -56,13 +56,13 @@ use scroll::{mods, navigation_space, scrollbars, spring_zoom_mode}; pub use text_overlay::selected_text; use text_overlay::{draw_dynamic_spelling, draw_hidden_characters}; #[cfg(test)] -use text_overlay::{draw_text_selection, hidden_marks}; +use text_overlay::{draw_text_selection, frame_hidden_marks, hidden_marks}; use tool_overlays::draw_tool_overlays; pub(crate) use view::page_in_view; use view::present_fit; pub use view::{ - apply_view_request, can_go_through_history, current_page, current_parent_page, current_slot, current_spread, current_spread_page, drop_point, - fit, go_through_history, go_to_page, set_zoom, show_spread, step_spread, view_centre, zoom_at, + PageBox, apply_view_request, can_go_through_history, current_page, current_parent_page, current_slot, current_spread, current_spread_page, + drop_point, fit, go_through_history, go_to_page, page_box, page_box_go, page_target, set_zoom, show_spread, step_spread, view_centre, zoom_at, }; pub const RULER: f32 = 15.0; @@ -327,38 +327,13 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } // Cursor. if let Some(p) = resp.hover_pos().filter(|p| screen.contains(*p) && !elsewhere) { - let wants_text = app.session.wants_text(); - let space = navigation_space(app, ui.ctx()); - let zoom = spring_zoom_mode(app, ui.ctx(), space); - let middle = ui.input(|i| i.pointer.middle_down()); - let alt_hand: bool = ui.data(|d| d.get_temp(egui::Id::unique(("canvas_alt_hand", app.pane)))).unwrap_or(false); - let tool_drag: bool = ui.data(|d| d.get_temp(egui::Id::unique(("canvas_pointer_down", app.pane)))).unwrap_or(false); - let alt_text = wants_text && !tool_drag && ui.input(|i| i.modifiers.alt); - let c = if middle || alt_hand { - Cursor::HandGrab - } else if zoom { - if ui.input(|i| i.modifiers.alt) { Cursor::ZoomOut } else { Cursor::ZoomIn } - } else if (space && !ui.input(|i| i.modifiers.command)) || alt_text { - Cursor::Hand - } 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::unique((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 c = pointer_cursor(app, ui, &xf, p); 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); + if c == Cursor::LoadedGraphic { + draw_loaded_thumbnail(app, &painter, p, &t); + } } // A second viewport has no panels, dialogs or Quick Apply after its canvas: observe here, // once, as the end of its pass. The root viewport observes at the end of `DesignApp::ui`. @@ -367,6 +342,100 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } } +/// The cursor under the pointer at `p` over the canvas: the navigation hands, then the guide +/// resize cursors, then the active tool's. +fn pointer_cursor(app: &mut DesignApp, ui: &egui::Ui, xf: &Xf, p: Pos2) -> Cursor { + let wants_text = app.session.wants_text(); + let space = navigation_space(app, ui.ctx()); + let zoom = spring_zoom_mode(app, ui.ctx(), space); + let middle = ui.input(|i| i.pointer.middle_down()); + let alt_hand: bool = ui.data(|d| d.get_temp(egui::Id::unique(("canvas_alt_hand", app.pane)))).unwrap_or(false); + let tool_drag: bool = ui.data(|d| d.get_temp(egui::Id::unique(("canvas_pointer_down", app.pane)))).unwrap_or(false); + let alt_text = wants_text && !tool_drag && ui.input(|i| i.modifiers.alt); + let c = if middle || alt_hand { + Cursor::HandGrab + } else if zoom { + if ui.input(|i| i.modifiers.alt) { Cursor::ZoomOut } else { Cursor::ZoomIn } + } else if (space && !ui.input(|i| i.modifiers.command)) || alt_text { + Cursor::Hand + } 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::unique((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, + }; + // A ruler guide being dragged keeps the cursor across it until it is dropped, over the + // canvas too (the tool's cursor took over as soon as the pointer left the ruler). Only + // while the button is down: a drag state left behind by a skipped release doesn't stick. + let dragging = ui.input(|i| i.pointer.primary_down()); + match ui.data(|d| d.get_temp::(egui::Id::unique(GUIDE_DRAG))).filter(|_| dragging).map(|g| g.orientation) { + Some(designcraft_doc::Orientation::Horizontal) => Cursor::ResizeV, + Some(designcraft_doc::Orientation::Vertical) => Cursor::ResizeH, + None => c, + } +} + +/// Longest side of the loaded place cursor's thumbnail, in points. +const LOADED_THUMB: f32 = 128.0; + +/// The loaded place cursor's thumbnail, as InDesign shows it: the graphic, at most 128 pt on its +/// longer side, hanging from the pointer, with an image badge in its corner. +fn draw_loaded_thumbnail(app: &DesignApp, painter: &egui::Painter, p: Pos2, t: &Tokens) { + let (Some(l), Ok(st)) = (app.session.loaded.as_ref(), app.session.doc()) else { return }; + let (w, h) = (l.size.0 as f32, l.size.1 as f32); + if !(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) { + return; + } + let k = LOADED_THUMB / w.max(h); + let r = Rect::from_min_size(p + vec2(2.0, 2.0), vec2(w * k, h * k)); + if let Some(tex) = loaded_thumb_texture(painter.ctx(), &st.doc, l.asset, l.size) { + painter.image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::from_white_alpha(225)); + } else { + // Not drawable here (an unreadable file): its frame only. + painter.rect_stroke(r, 0.0, Stroke::new(1.0, t.measure_text), StrokeKind::Inside); + } + let badge = Rect::from_min_size(r.min + vec2(6.0, 6.0), vec2(16.0, 12.0)); + painter.rect_filled(badge, 2.0, t.measure_bg); + let ink = Stroke::new(1.2, t.measure_text); + let inner = badge.shrink2(vec2(3.0, 2.5)); + painter.rect_stroke(inner, 0.5, ink, StrokeKind::Middle); + painter.add(egui::Shape::line( + vec![pos2(inner.min.x, inner.max.y), pos2(inner.min.x + 3.5, inner.min.y + 2.5), pos2(inner.max.x, inner.max.y)], + ink, + )); +} + +/// The loaded graphic's thumbnail texture, cached per image data. +fn loaded_thumb_texture(ctx: &egui::Context, doc: &Document, asset: designcraft_doc::AssetId, size: (f64, f64)) -> Option { + let a = doc.assets.get(&asset)?; + let key = egui::Id::unique(("loaded_thumb", std::sync::Arc::as_ptr(&a.data) as usize, a.data.len(), a.page)); + if let Some(t) = ctx.data(|d| d.get_temp::(key)) { + return Some(t); + } + // Twice the thumbnail's size in pixels, for high-density screens. + let scale = (2.0 * f64::from(LOADED_THUMB) / size.0.max(size.1)).min(1.0); + let pm = designcraft_render::images::mip(&a.data, a.page, size.0, scale)?; + let (pw, ph) = (pm.width() as usize, pm.height() as usize); + if pw == 0 || ph == 0 { + return None; + } + let ci = egui::ColorImage::from_rgba_premultiplied([pw, ph], pm.data_as_u8_slice()); + let max = ctx.input(|i| i.max_texture_side).min(1024); + let t = ctx.load_texture("loaded_thumb", crate::widgets::fit_texture(ci, max), egui::TextureOptions::LINEAR); + ctx.data_mut(|d| d.insert_temp(key, t.clone())); + Some(t) +} + /// 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)] diff --git a/crates/ui-egui/src/canvas/column_guides.rs b/crates/ui-egui/src/canvas/column_guides.rs index 34d995fda..c15595442 100644 --- a/crates/ui-egui/src/canvas/column_guides.rs +++ b/crates/ui-egui/src/canvas/column_guides.rs @@ -106,6 +106,7 @@ fn snap_gutter(app: &DesignApp, g: &ColumnDrag, want: f64, shift: bool, zoom: f6 snap: designcraft_tools::SnapView { smart_dimensions: false, smart_spacing: false, ..app.ui.snap_view() }, unit: app.ui.units, stroke_in_front: false, + loaded: None, }; let rect = DRect::new(want, m.y0, want + width, m.y1); let s = designcraft_tools::snap::snap( diff --git a/crates/ui-egui/src/canvas/frames.rs b/crates/ui-egui/src/canvas/frames.rs index 4578b933b..e897d689f 100644 --- a/crates/ui-egui/src/canvas/frames.rs +++ b/crates/ui-egui/src/canvas/frames.rs @@ -10,7 +10,10 @@ use super::{ /// Spread-space → canvas transform for an item at `loc`. pub(super) fn item_canvas_xf(doc: &Document, layout: &CanvasLayout, id: designcraft_doc::ItemId) -> Option<(Affine, &'static str)> { let loc = doc.find(id)?; - Some((layout.xf(loc.spread) * doc.parent_xf(&loc), "")) + // Items on spreads that aren't laid out (document pages while parents are being edited, or + // the reverse) have no place on the canvas. + let slot = layout.slot(loc.spread)?; + Some((slot.xf * doc.parent_xf(&loc), "")) } pub(super) fn path_screen(it: &Item, xf: &Xf, a: Affine) -> Vec> { @@ -188,14 +191,16 @@ pub(super) fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, } let mut union: Option = None; let mut color = Color32::from_rgb(79, 153, 255); + let direct = matches!(app.session.tool_id(), "directSelection" | "pen" | "addAnchor" | "deleteAnchor" | "convertDirection"); for id in &sel.items { let (Some(it), Some((a, _))) = (doc.item(*id), item_canvas_xf(doc, layout, *id)) else { continue }; color = layer_color(doc, it); for poly in path_screen(it, xf, a) { painter.add(egui::Shape::line(poly, Stroke::new(1.0, color))); } - if sel.items.len() == 1 && sel.text.is_none() { - // InDesign draws a 1 pt bounding box around the selection. + // InDesign draws a 1 pt bounding box around the selection; with the Direct Selection and + // pen tools, only the path and its points. + if sel.items.len() == 1 && sel.text.is_none() && !direct { painter.rect_stroke(xf.rect(a.transform_rect_bbox(it.bounds())), 0.0, Stroke::new(1.0, color), StrokeKind::Middle); } else if sel.key == Some(*id) { // The key object for Align: a thicker bounding box, as in InDesign. @@ -226,16 +231,26 @@ pub(super) fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, } } } - let direct = matches!(app.session.tool_id(), "directSelection" | "pen" | "addAnchor" | "deleteAnchor" | "convertDirection"); + // Direct Selection shows only the handles around the selected anchors, as InDesign; the pen + // and the anchor tools show every handle they work on. + let some_handles = app.session.tool_id() == "directSelection"; if direct { + // Looked up once per anchor drawn: a large marquee selection stays linear. + let chosen_set: std::collections::HashSet<(designcraft_doc::ItemId, usize, usize)> = sel.anchors.iter().copied().collect(); for id in &sel.items { let (Some(it), Some((a, _))) = (doc.item(*id), item_canvas_xf(doc, layout, *id)) else { continue }; let m = a * it.xf; let col = layer_color(doc, it); - for sp in &it.path.subpaths { - for an in &sp.anchors { + for (si, sp) in it.path.subpaths.iter().enumerate() { + for (ai, an) in sp.anchors.iter().enumerate() { let p = xf.to_screen(m * an.p); - for (h, has) in [(an.h_in, an.has_in()), (an.h_out, an.has_out())] { + // A selected anchor point is solid, the others hollow (InDesign). + let chosen = chosen_set.contains(&(*id, si, ai)); + let shown = |out: bool| { + !some_handles + || designcraft_tools::select::handle_shown(sp.anchors.len(), sp.closed, ai, out, |a| chosen_set.contains(&(*id, si, a))) + }; + for (h, has) in [(an.h_in, an.has_in() && shown(false)), (an.h_out, an.has_out() && shown(true))] { if has { let hp = xf.to_screen(m * h); painter.line_segment([p, hp], Stroke::new(1.0, col)); @@ -243,7 +258,7 @@ pub(super) fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, } } let r = Rect::from_center_size(p, vec2(5.0, 5.0)); - painter.rect_filled(r, 0.0, Color32::WHITE); + painter.rect_filled(r, 0.0, if chosen { col } else { Color32::WHITE }); painter.rect_stroke(r, 0.0, Stroke::new(1.0, col), StrokeKind::Inside); } } diff --git a/crates/ui-egui/src/canvas/guides.rs b/crates/ui-egui/src/canvas/guides.rs index eebd41208..4689b5fb6 100644 --- a/crates/ui-egui/src/canvas/guides.rs +++ b/crates/ui-egui/src/canvas/guides.rs @@ -143,6 +143,7 @@ pub(super) fn guide_spot(app: &DesignApp, xf: &Xf, g: &GuideDrag) -> Option bool { snap: app.ui.snap_view(), unit: app.ui.units, stroke_in_front: false, + loaded: None, }; cx.hit(xf.to_canvas(p)).is_some() } diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 3e2f32659..4616ca11d 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -337,9 +337,12 @@ pub(super) fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui:: events.push(PointerEvent { kind: PointerKind::Down, pos: pos(o), mods: m, clicks: n }); down = true; } + // The place cursor draws a frame from 3 points (its own threshold), before egui starts a + // drag (at `max_click_dist`): follow the pointer from there, so a release between the two + // places the frame that was previewed. + let place_drag = app.session.tool_id() == "placeGun" && origin.zip(latest).is_some_and(|(o, p)| (o - p).length() >= 3.0); if down - && resp.dragged() - && resp.drag_delta() != egui::Vec2::ZERO + && ((resp.dragged() && resp.drag_delta() != egui::Vec2::ZERO) || place_drag) && let Some(p) = latest { events.push(PointerEvent { kind: PointerKind::Drag, pos: pos(p), mods: m, clicks: 1 }); diff --git a/crates/ui-egui/src/canvas/render.rs b/crates/ui-egui/src/canvas/render.rs index 642694b8a..07983944e 100644 --- a/crates/ui-egui/src/canvas/render.rs +++ b/crates/ui-egui/src/canvas/render.rs @@ -1,9 +1,40 @@ -use super::{Affine, CanvasLayout, Color32, DRect, DesignApp, Point, Rect, Xf}; +use super::{Affine, CanvasLayout, Color32, DRect, DesignApp, Point, Pos2, Rect, ScreenMode, Xf}; pub(super) fn c32(c: [u8; 3]) -> Color32 { Color32::from_rgb(c[0], c[1], c[2]) } +/// The colour the canvas shows at screen point `pos` (the Color Picker's eyedropper): one device +/// pixel rendered as the page is, over its paper. None off the pages. +pub fn color_at(app: &mut DesignApp, ctx: &egui::Context, pos: Pos2) -> Option<[u8; 3]> { + let rect = app.canvas_rect.filter(|r| r.contains(pos))?; + let v = *app.view()?; + let xf = Xf::new(rect, &v); + let at = xf.to_canvas(pos); + let st = app.session.active()?; + let doc = st.doc.clone(); + let layout = CanvasLayout::new(&doc, st.editing_parents); + let on_page = layout + .slots + .iter() + .any(|slot| doc.spread(slot.spread).is_some_and(|sp| sp.pages.iter().any(|page| slot.xf.transform_rect_bbox(page.bounds()).contains(at)))); + if !on_page { + return None; + } + let placed: Vec = layout.slots.iter().map(|s| designcraft_render::Placed { spread: s.spread, xf: s.xf }).collect(); + let ppp = ctx.pixels_per_point() as f64; + let view = Affine::scale(ppp) * Affine::scale(xf.zoom) * Affine::translate((-at.x, -at.y)); + let opts = screen_options(app, &layout, app.ui.screen_mode != ScreenMode::Normal, ppp); + let mut img = app.canvas.patcher.render(&doc, &app.session.cache, &placed, (1, 1), view, &opts); + // The colour as the canvas shows it (proofed). + screen_post(app, &mut img); + let [r, g, b, a] = <[u8; 4]>::try_from(img.pixels.get(..4)?).ok()?; + // Premultiplied over the paper. + let paper = doc.resolve_color(designcraft_color::swatch::PAPER, 1.0).unwrap_or(designcraft_color::Color::WHITE).to_rgba8(1.0); + let over = |c: u8, p: u8| (u32::from(c) + u32::from(p) * (255 - u32::from(a)) / 255).min(255) as u8; + Some([over(r, paper[0]), over(g, paper[1]), over(b, paper[2])]) +} + /// Render options for the canvas texture. pub(super) fn screen_options(app: &DesignApp, layout: &CanvasLayout, preview: bool, ppp: f64) -> designcraft_render::RenderOptions { designcraft_render::RenderOptions { @@ -268,10 +299,16 @@ fn render_worker_spawn(ctx: &egui::Context) -> Option Some(r), + _ => None, + }) + .expect("the frame is previewed"); + h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(3); + let placed = h.state().session.doc().unwrap().doc.spreads[0].items.last().expect("placed").bounds(); + let shown = slot.inverse().transform_rect_bbox(preview); + for (a, b) in [(placed.x0, shown.x0), (placed.y0, shown.y0), (placed.x1, shown.x1), (placed.y1, shown.y1)] { + assert!((a - b).abs() < 0.01, "placed {placed:?}, previewed {shown:?}"); + } +} + #[test] fn a_press_after_a_drag_is_not_a_double_click() { let (delay, dist) = (0.3, 6.0); @@ -1013,6 +1064,31 @@ fn overset_frames_are_found_inside_groups() { assert!(out.is_empty()); } +/// While parents are edited, a document page's frames have no place on the canvas (they fell back +/// to the identity transform and their ¶ marks and other overlays piled onto the right parent +/// page), and the reverse. +#[test] +fn document_pages_have_no_place_on_the_parent_canvas() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 2})).unwrap(); + let s = &mut app.session; + let body = + s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "one\ntwo"})).unwrap()["id"].as_u64().unwrap(); + let head = s + .execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [36, 10, 300, 30], "content": "text", "text": "head"})) + .unwrap()["id"] + .as_u64() + .unwrap(); + let doc = app.session.doc().unwrap().doc.clone(); + let (body, head) = (designcraft_doc::ItemId(body), designcraft_doc::ItemId(head)); + let pages = CanvasLayout::new(&doc, false); + assert!(item_canvas_xf(&doc, &pages, body).is_some()); + assert!(item_canvas_xf(&doc, &pages, head).is_none()); + let parents = CanvasLayout::new(&doc, true); + assert!(item_canvas_xf(&doc, &parents, body).is_none(), "a page frame is not drawn over the parent"); + assert!(item_canvas_xf(&doc, &parents, head).is_some()); +} + /// The always-on overset mark sits at the frame's out port, a step along its own right edge /// (so a rotated frame gets it inside its edge, not straight up from the corner), and a /// selected overset frame gets only the mark its ports draw. @@ -1832,6 +1908,7 @@ fn document_grid_is_drawn_where_points_snap_to_it() { snap: SnapView { snap_to_document_grid: true, zone_px: 20.0, ..SnapView::OFF }, unit: Unit::Points, stroke_in_front: false, + loaded: None, }; let (snapped, _) = designcraft_tools::snap::snap_point(&cx, sr, Point::new(page.x0 + 31.3, page.y0 + 47.9)); assert_eq!((snapped.x - page.x0, snapped.y - page.y0), (27.0, 45.0)); @@ -2897,3 +2974,49 @@ fn trailing_spaces_show_dots_before_the_paragraph_mark() { assert!(para_x >= space.x + space.adv, "{mark} at {para_x} overlaps the last space"); } } + +/// As InDesign: an end-of-story # in every cell (after its text), a ¶ where a cell paragraph +/// ends, and the table paragraph's ¶ just after the table's bottom right corner. +#[test] +fn table_cells_show_their_hidden_characters() { + let mut d = Document::new(&designcraft_doc::build::NewDocument::default()); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), DRect::new(0.0, 0.0, 300.0, 300.0), lid, "", Default::default()).unwrap(); + let data = vec![vec!["Name:".to_string(), "".into()], vec!["a\nb".into(), "".into()]]; + let t = designcraft_doc::Table::from_strings(d.alloc(), &data, 200.0, &Default::default(), &Default::default()); + d.story_mut(sid).unwrap().insert_table(0, t); + let cs = compose::compose_story(&d, sid, &compose::ComposeOptions::default()); + let frag = &cs.frames[0].tables[0]; + // The whole frame: the table's own line adds no second ¶; the story's last paragraph + // (after the table) has its #. + let story = d.story(sid).unwrap().text.clone(); + let all = frame_hidden_marks(&story, &cs.frames[0]); + let marks: Vec<_> = all.iter().filter(|m| frag.rect.inflate(2.0, 2.0).contains(m.0)).cloned().collect(); + let ends = |m: &str| marks.iter().filter(|x| x.1 == m).count(); + assert_eq!(ends("#"), 4, "one per cell: {marks:?}"); + assert_eq!(ends("¶"), 2, "the cell's first paragraph and the table's, once: {marks:?}"); + assert_eq!(all.iter().filter(|m| m.1 == "¶" && m.3 > 20.0).count(), 0, "no ¶ the size of the table: {all:?}"); + // Each # is inside its cell, after the cell's text. + for c in &frag.cells { + let inside = marks.iter().filter(|m| m.1 == "#" && c.rect.contains(m.0)).count(); + assert_eq!(inside, 1, "cell {},{}: {marks:?}", c.row, c.col); + } + let name = frag.cell(0, 0).unwrap(); + assert!(marks.iter().any(|m| m.1 == "#" && name.rect.contains(m.0) && m.0.x > name.origin.x + 20.0), "after Name:"); + let last = marks.last().unwrap(); + assert_eq!((last.0, last.1), (designcraft_geom::Point::new(frag.rect.x1 + 1.0, frag.rect.y1), "¶")); +} + +#[test] +fn leaving_parent_editing_for_the_left_page_of_a_spread_keeps_that_page_selected() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 4, "facingPages": true})).unwrap(); + let mut h = harness(app); + h.state_mut().run("layout.parents.edit", json!({"on": true, "parent": "A-Parent"})).unwrap(); + h.run_steps(2); + let b = page_box(h.state()).unwrap(); + // Page 2 is the left page of the second spread. + page_box_go(h.state_mut(), &b, 1); + h.run_steps(3); + assert_eq!(current_page(h.state()), Some(1)); +} diff --git a/crates/ui-egui/src/canvas/text_overlay.rs b/crates/ui-egui/src/canvas/text_overlay.rs index dc48ae720..521759eb0 100644 --- a/crates/ui-egui/src/canvas/text_overlay.rs +++ b/crates/ui-egui/src/canvas/text_overlay.rs @@ -266,6 +266,8 @@ fn draw_inverse_highlight( } /// View → Show Hidden Characters: ¶ paragraph ends, » tabs, · spaces, ¬ forced line breaks, # end of story. +/// As InDesign, every table cell shows its own marks (an empty cell its #), and a table's +/// paragraph return sits after the table's bottom right corner. pub(super) fn draw_hidden_characters(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout) { for story in doc.stories.values() { let cs = app.session.cache.get(doc, story.id, None); @@ -273,19 +275,41 @@ pub(super) fn draw_hidden_characters(app: &DesignApp, painter: &egui::Painter, x let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { continue }; let m = a * doc.text_xf(it); let col = layer_color(doc, it); - 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 (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); - } + for (p, mark, centered, ascent) in frame_hidden_marks(&story.text, ft) { + let size = (ascent * 0.75 * xf.zoom).clamp(6.0, 40.0) as f32; + let align = if centered { egui::Align2::CENTER_BOTTOM } else { egui::Align2::LEFT_BOTTOM }; + painter.text(xf.to_screen(m * p), align, mark, egui::FontId::proportional(size), col); } } } } +/// A frame's hidden-character marks in frame space (position at the baseline, mark, centered, +/// the line's ascent): its lines' marks, then its tables' — each text cell's own marks (nested +/// tables included) with its # where its text ends, and a table paragraph's ¶ after the last +/// row's bottom right corner. A table's own glyph-less line has no marks: its ¶ is that one. +pub(super) fn frame_hidden_marks(text: &str, ft: &compose::FrameText) -> Vec<(Point, &'static str, bool, f64)> { + let table_lines: Vec = ft.tables.iter().map(|t| t.line).collect(); + let mut out: Vec<_> = ft + .lines + .iter() + .enumerate() + .filter(|(i, _)| !table_lines.contains(i)) + .flat_map(|(_, l)| hidden_marks(text, l).into_iter().map(move |(x, mark, c)| (Point::new(x, l.baseline), mark, c, l.ascent))) + .collect(); + for t in &ft.tables { + for c in t.cells.iter().filter(|c| c.graphic.is_none()) { + if let Some(cf) = c.text.frames.first() { + out.extend(frame_hidden_marks(&c.source, cf).into_iter().map(|(p, mark, centered, a)| (p + c.origin.to_vec2(), mark, centered, a))); + } + } + if t.last { + out.push((Point::new(t.rect.x1 + 1.0, t.rect.y1), "¶", false, 12.0)); + } + } + out +} + /// 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. pub(super) fn hidden_marks(text: &str, l: &compose::Line) -> Vec<(f64, &'static str, bool)> { @@ -346,17 +370,31 @@ pub(super) fn draw_dynamic_spelling(app: &DesignApp, ctx: &egui::Context, painte }; let y = l.baseline + l.descent * 0.5; let (p0, p1) = (xf.to_screen(m * Point::new(x0, y)), xf.to_screen(m * Point::new(x1, y))); - // A zigzag 2 px high, 4 px per wave. - let n = ((p1.x - p0.x) / 2.0).max(1.0) as usize; - let pts: Vec = - (0..=n).map(|k| pos2(p0.x + (p1.x - p0.x) * k as f32 / n as f32, p0.y + if k % 2 == 0 { 0.0 } else { 2.0 })).collect(); - painter.add(egui::Shape::line(pts, red)); + // Screen direction of the text's own "down", so the wave hangs below the baseline in rotated, flipped and vertical frames. + let down = xf.to_screen(m * Point::new(x0, y + 1.0)) - p0; + painter.add(egui::Shape::line(squiggle_points(p0, p1, down), red)); } } } } } +/// Zigzag points for a spelling squiggle from `p0` to `p1` (screen space): 2 px high, 4 px per wave, +/// offset along `down` (the screen direction of the text's own "down") so it follows the text frame's +/// rotation and flips. +pub(super) fn squiggle_points(p0: Pos2, p1: Pos2, down: egui::Vec2) -> Vec { + let d = p1 - p0; + let n = (d.length() / 2.0).max(1.0) as usize; + let len = down.length(); + let off = if len > 1e-6 { down / len } else { egui::vec2(0.0, 1.0) }; + (0..=n) + .map(|k| { + let wave = if k % 2 == 0 { 0.0 } else { 2.0 }; + p0 + d * (k as f32 / n as f32) + off * wave + }) + .collect() +} + /// Plain text of the current text selection (story markers resolved to readable characters). pub fn selected_text(app: &DesignApp) -> Option { let st = app.session.active()?; @@ -368,3 +406,38 @@ pub fn selected_text(app: &DesignApp) -> Option { } Some(s.replace(designcraft_doc::story::FORCED_LINE_BREAK, "\n")) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn squiggle_horizontal_offsets_down() { + let pts = squiggle_points(pos2(0.0, 10.0), pos2(20.0, 10.0), egui::vec2(0.0, 1.0)); + assert_eq!(pts.len(), 11); + assert!((pts[0].y - 10.0).abs() < 1e-4); + assert!((pts[1].y - 12.0).abs() < 1e-4); + assert!((pts[1].x - 2.0).abs() < 1e-4); + } + + #[test] + fn squiggle_follows_rotation() { + // Frame rotated 90 degrees: the line runs down the screen and the text's "down" points left, + // so the wave must move sideways, not along y. + let pts = squiggle_points(pos2(10.0, 0.0), pos2(10.0, 20.0), egui::vec2(-3.0, 0.0)); + assert_eq!(pts.len(), 11); + assert!((pts[1].y - 2.0).abs() < 1e-4); + assert!((pts[1].x - 8.0).abs() < 1e-4); + assert!((pts[0].x - 10.0).abs() < 1e-4); + } + + #[test] + fn squiggle_follows_horizontal_flip() { + // A horizontally flipped frame runs the word right to left; the wave still hangs below it. + let pts = squiggle_points(pos2(20.0, 10.0), pos2(0.0, 10.0), egui::vec2(0.0, 1.0)); + assert_eq!(pts.len(), 11); + assert!((pts[1].x - 18.0).abs() < 1e-4); + assert!((pts[1].y - 12.0).abs() < 1e-4); + assert!(pts[10].x.abs() < 1e-4); + } +} diff --git a/crates/ui-egui/src/canvas/tool_overlays.rs b/crates/ui-egui/src/canvas/tool_overlays.rs index 5720ba030..e6beb1e65 100644 --- a/crates/ui-egui/src/canvas/tool_overlays.rs +++ b/crates/ui-egui/src/canvas/tool_overlays.rs @@ -31,10 +31,10 @@ pub(super) fn draw_tool_overlays(app: &mut DesignApp, painter: &egui::Painter, x } Overlay::Measure { p, text } => { let s = xf.to_screen(p) + vec2(14.0, 14.0); - let g = painter.layout_no_wrap(text, egui::FontId::proportional(11.0), tok.measure_text); + let g = painter.layout_no_wrap(text, egui::FontId::proportional(11.0), tok.readout_text); let r = Rect::from_min_size(s, g.size() + vec2(10.0, 6.0)); - painter.rect_filled(r, 3.0, tok.measure_bg); - painter.galley(r.min + vec2(5.0, 3.0), g, tok.measure_text); + painter.rect_filled(r, 3.0, tok.readout_bg); + painter.galley(r.min + vec2(5.0, 3.0), g, tok.readout_text); } Overlay::Line { a, b, color, dashed: d } => { let st = Stroke::new(1.0, c32(color)); diff --git a/crates/ui-egui/src/canvas/view.rs b/crates/ui-egui/src/canvas/view.rs index db3eebcc8..f00a005f5 100644 --- a/crates/ui-egui/src/canvas/view.rs +++ b/crates/ui-egui/src/canvas/view.rs @@ -113,7 +113,7 @@ pub fn current_page(app: &DesignApp) -> Option { let layout = CanvasLayout::new(&st.doc, st.editing_parents); let i = current_slot(app, &layout)?; let slot = layout.slots.get(i)?; - let SpreadRef::Doc(si) = slot.spread else { return Some(0) }; + let SpreadRef::Doc(si) = slot.spread else { return None }; let v = app.view()?; let r = app.canvas_rect?; let c = slot.to_spread(Point::new(v.origin.x + r.width() as f64 / 2.0 / v.zoom, slot.bounds.center().y)); @@ -121,6 +121,16 @@ pub fn current_page(app: &DesignApp) -> Option { Some(st.doc.first_page_of_spread(si) + pi) } +/// The Page tool selection, or the page in view, without crossing canvas modes. +pub fn page_target(app: &DesignApp) -> Option<(SpreadRef, usize)> { + let st = app.session.active()?; + let selected = CanvasLayout::new(&st.doc, st.editing_parents).selected_page(&st.doc, &st.selection); + if st.editing_parents && st.selection.parent_page.is_some() { + return selected; + } + selected.or_else(|| current_spread_page(app)) +} + /// The spread (document or parent) under the view centre and the page index within it: where /// commands that act "on the page I am working on" should go. pub fn current_spread_page(app: &DesignApp) -> Option<(SpreadRef, usize)> { @@ -135,22 +145,58 @@ pub fn current_spread_page(app: &DesignApp) -> Option<(SpreadRef, usize)> { Some((slot.spread, pg)) } -/// While parent spreads are being edited: (parent spread index, page index within it) under the -/// view centre. `None` when document pages are shown. +/// The selected parent page, or the parent page under the view centre. pub fn current_parent_page(app: &DesignApp) -> Option<(usize, usize)> { + let (SpreadRef::Parent(si), pi) = page_target(app)? else { return None }; + Some((si, pi)) +} + +/// The page box's entries (the status bar's, and Properties › Page), as InDesign's: the +/// document's pages by name, then the parents by label ("A-Parent"). +pub struct PageBox { + pub names: Vec, + /// The entry in view: the parent shown while parents are edited, else the page. + pub current: usize, + /// How many of `names` are pages; the parents follow. + pub pages: usize, + parents: Vec, +} + +impl PageBox { + /// The name of the entry in view ("3", "A-Parent"). + pub fn current_name(&self) -> &str { + self.names.get(self.current).map_or("", String::as_str) + } +} + +pub fn page_box(app: &DesignApp) -> Option { let st = app.session.active()?; - if !st.editing_parents { - return None; + let pages = st.doc.page_count(); + let mut names: Vec = (0..pages).map(|i| app.session.page_label(i)).collect(); + names.extend(st.doc.parents.iter().map(|p| p.parent.as_ref().map(|i| i.label()).unwrap_or_default())); + let current = match current_parent_page(app) { + Some((pi, _)) => pages + pi, + None => current_page(app).unwrap_or(0).min(pages.saturating_sub(1)), + }; + Some(PageBox { names, current, pages, parents: st.doc.parents.iter().map(|p| p.id).collect() }) +} + +/// Shows entry `i` of `b`: a page (leaving the parents, if they are shown) or a parent. +pub fn page_box_go(app: &mut DesignApp, b: &PageBox, i: usize) { + if i < b.pages { + if app.session.active().is_some_and(|st| st.editing_parents) { + let _ = app.run("layout.parents.edit", serde_json::json!({"on": false})); + // Fit the page's spread first, then centre the page: a later refit would centre the + // spread, and the page box would then show its other page. + let spread = app.session.active().and_then(|st| st.doc.page_loc(i)).map(|(si, _)| SpreadRef::Doc(si)); + if let Some(r) = spread { + show_spread(app, r); + } + } + go_to_page(app, i); + } else if let Some(id) = b.parents.get(i - b.pages) { + let _ = app.run("layout.parents.edit", serde_json::json!({"parent": id.0})); } - let layout = CanvasLayout::new(&st.doc, true); - let i = current_slot(app, &layout)?; - let slot = layout.slots.get(i)?; - let SpreadRef::Parent(pi) = slot.spread else { return None }; - let v = app.view()?; - let r = app.canvas_rect?; - let c = slot.to_spread(Point::new(v.origin.x + r.width() as f64 / 2.0 / v.zoom, slot.bounds.center().y)); - let qi = st.doc.parents.get(pi)?.page_at_x(c.x).unwrap_or(0); - Some((pi, qi)) } /// Scroll so absolute page `abs` is centred (keeps zoom). A jump Go Back can return from. diff --git a/crates/ui-egui/src/chrome/control_bar.rs b/crates/ui-egui/src/chrome/control_bar.rs index 36a7521fe..d4dab9cc6 100644 --- a/crates/ui-egui/src/chrome/control_bar.rs +++ b/crates/ui-egui/src/chrome/control_bar.rs @@ -34,25 +34,34 @@ pub fn control_bar(app: &mut DesignApp, ui: &mut egui::Ui) { } /// Page-size preset menu and the portrait / landscape toggles of the Control panel. -fn page_preset_and_orientation(app: &mut DesignApp, ui: &mut egui::Ui, lang: &str, size: (f64, f64), n: usize, anchor: u8) { +fn page_preset_and_orientation( + app: &mut DesignApp, + ui: &mut egui::Ui, + lang: &str, + size: (f64, f64), + anchor: u8, + resize: impl Fn(&mut DesignApp, serde_json::Value), +) { let landscape = size.0 > size.1; ui.vertical(|ui| { let cur = panels::properties::preset_name(size.0, size.1); - craft_ui::ComboBox::from_id_salt("ctl_page_preset").selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, cur))).width(130.0).show_ui( - ui, - |ui| { + craft_ui::ComboBox::from_id_salt("ctl_page_preset") + .selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, cur))) + .width(130.0) + .show_ui(ui, |ui| { for (name, w, h) in panels::properties::PAGE_PRESETS { if ui.selectable_label(cur == *name, crate::rtl::widget(ui, crate::i18n::tr(lang, name))).clicked() { let (w, h) = if landscape { (h, w) } else { (w, h) }; - let _ = app.run("layout.pageSize", json!({"pages": [n], "width": w, "height": h, "anchor": anchor})); + resize(app, json!({"width": w, "height": h, "anchor": anchor})); } } - }, - ); + }) + .response + .accessible_name(crate::i18n::tr(lang, "Page Size")); ui.horizontal(|ui| { for (icon, tip, want) in [("orient-portrait", "Portrait", false), ("orient-landscape", "Landscape", true)] { if crate::widgets::icon_toggle(ui, icon, landscape == want, crate::i18n::tr(lang, tip)).clicked() && landscape != want { - let _ = app.run("layout.pageSize", json!({"pages": [n], "width": size.1, "height": size.0, "anchor": anchor})); + resize(app, json!({"width": size.1, "height": size.0, "anchor": anchor})); } } }); @@ -62,16 +71,24 @@ fn page_preset_and_orientation(app: &mut DesignApp, ui: &mut egui::Ui, lang: &st /// The Control panel with the Page tool, as InDesign's: reference point, X/Y, W/H, page-size /// preset, orientation, Liquid Page Rule and Show Parent Page Overlay, for /// the page the Page tool selected (else the page in view). W/H keep the reference point in place. -fn control_page(app: &mut DesignApp, ui: &mut egui::Ui) { +pub(super) fn control_page(app: &mut DesignApp, ui: &mut egui::Ui) { let lang = app.ui.language.clone(); let Some(st) = app.session.active() else { return }; let units = st.doc.settings.horizontal_units; - let Some(page) = st.selection.page.or_else(|| crate::canvas::current_page(app)) else { return }; + let Some((spread, page)) = crate::canvas::page_target(app) else { return }; let Some(st) = app.session.active() else { return }; let d = st.doc.clone(); - let Some((si, pi)) = d.page_loc(page) else { return }; - let pg = d.spreads[si].pages[pi].clone(); - let n = page + 1; + let Some(sp) = d.spread(spread) else { return }; + let Some(pg) = sp.pages.get(page).cloned() else { return }; + let n = match spread { + designcraft_doc::SpreadRef::Doc(si) => Some(d.first_page_of_spread(si) + page + 1), + _ => None, + }; + let resize = |app: &mut DesignApp, params| { + if let Some((cmd, params)) = designcraft_tools::layout::page_size_command(&d, spread, page, params) { + let _ = app.run(cmd, params); + } + }; // Reference point: W/H edits keep it in place. let (r, _) = ui.allocate_exact_size(vec2(30.0, 44.0), Sense::hover()); if let Some(p) = panels::properties::ref_point_proxy(ui, egui::Rect::from_center_size(r.center(), vec2(27.0, 27.0)), app.ui.ref_point) { @@ -80,46 +97,53 @@ fn control_page(app: &mut DesignApp, ui: &mut egui::Ui) { let anchor = app.ui.ref_point.min(8); let (fx, fy) = ((anchor % 3) as f64 / 2.0, (anchor / 3) as f64 / 2.0); // The reference point's position: from the spine across, from the top down. - let (x, y) = (pg.x - d.spreads[si].spine_x() + fx * pg.width, fy * pg.height); + let (x, y) = (pg.x - sp.spine_x() + fx * pg.width, fy * pg.height); egui::Grid::new("ctl_page_xywh").num_columns(4).spacing(vec2(4.0, 4.0)).show(ui, |ui| { caption(ui, crate::i18n::tr(&lang, "X:")); // Pages don't move within their spread yet. ui.add_enabled_ui(false, |ui| measure(ui, "cpx", Some(x), units, 64.0)); caption(ui, crate::i18n::tr(&lang, "W:")); if let Some(v) = measure(ui, "cpw", Some(pg.width), units, 64.0) { - let _ = app.run("layout.pageSize", json!({"pages": [n], "width": v, "anchor": anchor})); + resize(app, json!({"width": v, "anchor": anchor})); } ui.end_row(); caption(ui, crate::i18n::tr(&lang, "Y:")); ui.add_enabled_ui(false, |ui| measure(ui, "cpy", Some(y), units, 64.0)); caption(ui, crate::i18n::tr(&lang, "H:")); if let Some(v) = measure(ui, "cph", Some(pg.height), units, 64.0) { - let _ = app.run("layout.pageSize", json!({"pages": [n], "height": v, "anchor": anchor})); + resize(app, json!({"height": v, "anchor": anchor})); } ui.end_row(); }); vsep(ui); // Page-size preset and orientation. - page_preset_and_orientation(app, ui, &lang, (pg.width, pg.height), n, anchor); + page_preset_and_orientation(app, ui, &lang, (pg.width, pg.height), anchor, resize); vsep(ui); - ui.vertical(|ui| { + ui.add_enabled_ui(n.is_some(), |ui| { caption(ui, crate::i18n::tr(&lang, "Liquid Page Rule:")); let label = panels::interactive::liquid_rule_label(pg.liquid); - craft_ui::ComboBox::from_id_salt("ctl_page_rule").selected_text(crate::rtl::widget(ui, crate::i18n::tr(&lang, label))).width(150.0).show_ui( - ui, - |ui| { + craft_ui::ComboBox::from_id_salt("ctl_page_rule") + .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&lang, label))) + .width(150.0) + .show_ui(ui, |ui| { for (r, l) in panels::interactive::LIQUID_RULES { - if ui.selectable_label(pg.liquid == r, crate::rtl::widget(ui, crate::i18n::tr(&lang, l))).clicked() && pg.liquid != r { + if ui.selectable_label(pg.liquid == r, crate::rtl::widget(ui, crate::i18n::tr(&lang, l))).clicked() + && pg.liquid != r + && let Some(n) = n + { let _ = app.run("liquid.pageRule", json!({"rule": r, "pages": [n]})); } } - }, - ); + }) + .response + .accessible_name(crate::i18n::tr(&lang, "Liquid Page Rule:")); }); vsep(ui); - ui.vertical(|ui| { + ui.add_enabled_ui(n.is_some(), |ui| { let mut overlay = pg.show_parent_overlay; - if ui.checkbox(&mut overlay, crate::rtl::widget(ui, crate::i18n::tr(&lang, "Show Parent Page Overlay"))).changed() { + if ui.checkbox(&mut overlay, crate::rtl::widget(ui, crate::i18n::tr(&lang, "Show Parent Page Overlay"))).changed() + && let Some(n) = n + { let _ = app.run("layout.pageOptions", json!({"pages": [n], "showParentOverlay": overlay})); } }); diff --git a/crates/ui-egui/src/chrome/status_bar.rs b/crates/ui-egui/src/chrome/status_bar.rs index e0a9d27e2..5fde929be 100644 --- a/crates/ui-egui/src/chrome/status_bar.rs +++ b/crates/ui-egui/src/chrome/status_bar.rs @@ -114,22 +114,46 @@ pub fn status_bar(app: &mut DesignApp, ui: &mut egui::Ui) { if icons::button(ui, "prev", 14.0, false, crate::i18n::tr(&app.ui.language, "Previous Spread")).clicked() { crate::canvas::go_to_page(app, cur.saturating_sub(1)); } - let name = app.session.page_label(cur); + // The page box, as InDesign's: the page in view, or the parent while one is shown + // ("A-Parent"); its menu lists the pages, then the parents. + let pb = crate::canvas::page_box(app); + let name = pb.as_ref().map_or_else(String::new, |b| b.current_name().to_string()); let (fr, fresp) = ui.allocate_exact_size(vec2(80.0, 14.0), Sense::click()); ui.painter().rect(fr, 0.0, t.input, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); - ui.painter().text(fr.left_center() + vec2(5.0, 0.0), egui::Align2::LEFT_CENTER, &name, small.clone(), t.text); + // A long parent name is cut at the arrow. + ui.painter().with_clip_rect(egui::Rect::from_min_max(fr.min, egui::pos2(fr.max.x - 14.0, fr.max.y))).text( + fr.left_center() + vec2(5.0, 0.0), + egui::Align2::LEFT_CENTER, + &name, + small.clone(), + t.text, + ); ui.painter().text(fr.right_center() - vec2(5.0, 0.0), egui::Align2::RIGHT_CENTER, "▾", small.clone(), t.text); - egui::Popup::menu(&fresp).show(|ui| { - let names: Vec = (0..n).map(|i| app.session.page_label(i)).collect(); - egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { - for (i, nm) in names.iter().enumerate() { - if ui.button(nm).clicked() { - crate::canvas::go_to_page(app, i); - ui.close(); - } - } - }); + let label = crate::i18n::tr(&app.ui.language, "Page"); + fresp.widget_info(|| { + let mut info = egui::WidgetInfo::labeled(egui::Role::ComboBox, true, label); + info.current_text_value = Some(name.clone()); + info }); + let mut picked = None; + if let Some(b) = &pb { + egui::Popup::menu(&fresp).show(|ui| { + egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { + for (i, nm) in b.names.iter().enumerate() { + if i == b.pages && i > 0 { + ui.separator(); + } + if ui.selectable_label(i == b.current, nm).clicked() { + picked = Some(i); + ui.close(); + } + } + }); + }); + } + if let (Some(b), Some(i)) = (&pb, picked) { + crate::canvas::page_box_go(app, b, i); + } if icons::button(ui, "next", 14.0, false, crate::i18n::tr(&app.ui.language, "Next Spread")).clicked() { crate::canvas::go_to_page(app, (cur + 1).min(n - 1)); } diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index 5e420de98..f0449c2d0 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -180,6 +180,51 @@ fn status_bar_error_count_opens_the_preflight_panel() { assert!(crate::panel_docking::showing(&h.state().app, "preflight")); } +/// The course's chapter 25: while a parent is shown, the status bar's page box names it +/// ("A-Parent"), as InDesign's. Its menu lists the pages, then the parents: a parent shows +/// that parent, a page leaves the parents for that page. +#[test] +fn the_page_box_names_the_parent_in_view_and_lists_the_parents() { + use egui::accesskit::Role; + let mut app = app(); + app.run("file.new", serde_json::json!({"pages": 4, "facingPages": true})).unwrap(); + app.run("layout.parents.new", serde_json::json!({"prefix": "B", "name": "Cover"})).unwrap(); + app.ui.dock_tab = "properties".into(); + let mut h = test_window::open(app, vec2(1200.0, 700.0)); + let shown = |h: &egui_kittest::Harness<'static, test_window::Window>| { + let b = h.get_by(|n| n.role() == Role::ComboBox && n.label().as_deref() == Some("Page")); + b.value().unwrap_or_default() + }; + assert_eq!(shown(&h), "1"); + h.state_mut().app.run("layout.parents.edit", serde_json::json!({"on": true})).unwrap(); + h.run_steps(6); + assert_eq!(shown(&h), "A-Parent"); + // The menu: a parent. + h.get_by(|n| n.role() == Role::ComboBox && n.label().as_deref() == Some("Page")).click(); + h.run_steps(3); + for name in ["1", "2", "3", "4", "A-Parent"] { + // The canvas or a panel may show the name too: the menu lists it. + assert!(h.query_all_by_label(name).next().is_some(), "{name} is listed"); + } + // The menu, drawn last, holds the last match. + h.get_all_by_label("B-Cover").last().unwrap().click(); + h.run_steps(6); + assert_eq!(shown(&h), "B-Cover"); + assert_eq!(crate::canvas::current_parent_page(&h.state().app).map(|(i, _)| i), Some(1)); + // A page: the parents are left. + h.get_by(|n| n.role() == Role::ComboBox && n.label().as_deref() == Some("Page")).click(); + h.run_steps(3); + h.get_all_by_label("3").last().unwrap().click(); + h.run_steps(6); + assert!(!h.state().app.session.active().unwrap().editing_parents); + assert_eq!(crate::canvas::current_page(&h.state().app), Some(2)); + assert_eq!(shown(&h), "3"); + // Its accessible name is in the interface's language. + h.state_mut().app.ui.language = "de".into(); + h.run_steps(3); + assert!(h.query_by(|n| n.role() == Role::ComboBox && n.label().as_deref() == Some("Seite")).is_some(), "the page box is Seite"); +} + /// As in InDesign, double-clicking the preflight count (or its light) shows the panel. #[test] fn double_clicking_the_status_bar_error_count_opens_the_preflight_panel() { @@ -470,7 +515,7 @@ fn the_page_tool_has_its_own_control_panel() { app.run("file.new", serde_json::json!({"width": 252, "height": 144, "facingPages": false})).unwrap(); app.session.set_tool("page"); let mut h = test_window::open(app, vec2(1800.0, 600.0)); - let _ = h.get_by_label("Liquid Page Rule:"); + let _ = h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Liquid Page Rule:"); // Pages don't move within their spread, so there is no "Objects Move with Page" option, and // no "Controlled by Parent" rule until parent pages have rules of their own. assert!(h.query_by_label("Objects Move with Page").is_none()); @@ -866,3 +911,144 @@ fn caption_buttons_fit_the_minimum_window_width() { assert!(!h.click(p).is_empty(), "{id} remains clickable"); } } + +#[test] +fn parent_resize_properties_keep_size_and_inline_parent_margins() { + parent_resize_panel(true); +} + +#[test] +fn parent_resize_control_changes_the_parent_not_document_page_zero() { + parent_resize_panel(false); +} + +fn parent_resize_panel(properties: bool) { + use egui_kittest::Harness; + { + let mut app = app(); + app.run("file.new", serde_json::json!({"width": 400, "height": 600, "pages": 3, "facingPages": true})).unwrap(); + app.run("layout.parents.new", serde_json::json!({"prefix": "B"})).unwrap(); + app.run("layout.pages.applyParent", serde_json::json!({"pages": [0], "parent": "A"})).unwrap(); + if properties { + app.run("layout.marginsAndColumns", json!({"parent": 1, "pages": [0], "margins": 24})).unwrap(); + app.run( + "layout.marginsAndColumns", + json!({"parent": 1, "pages": [1], "margins": {"top": 36, "bottom": 48, "inside": 60, "outside": 72}}), + ) + .unwrap(); + app.run("layout.pages.applyParent", json!({"pages": [0, 1, 2], "parent": "B"})).unwrap(); + app.run("layout.marginsAndColumns", json!({"pages": [0], "margins": 90})).unwrap(); + } + app.run("layout.parents.edit", serde_json::json!({"on": true, "parent": "A"})).unwrap(); + app.session.set_tool("page"); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(800.0, 600.0))); + *app.view_mut().unwrap() = crate::View { zoom: 1.0, origin: designcraft_geom::Point::new(-600.0, 0.0), fitted: true, rotation: 0 }; + let before = app.session.doc().unwrap().doc.clone(); + let mut h = Harness::builder().with_size(vec2(1600.0, 900.0)).build_ui_state( + move |ui, app: &mut DesignApp| { + let id = egui::Id::unique("parent_resize_fonts"); + if ui.data(|d| d.get_temp::(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + if properties { + panels::properties::show(app, ui); + } else { + super::control_bar::control_page(app, ui); + } + }, + app, + ); + h.run_steps(3); + edit_parent_resize_field(&mut h, "33p4", "500 pt"); + let d = &h.state().session.doc().unwrap().doc; + assert_eq!(d.parents[0].pages[0].width, 500.0, "the parent page in view must change"); + assert_eq!(d.spreads, before.spreads); + assert_eq!(d.parents[1], before.parents[1]); + let b = before.parents[1].id.0; + h.state_mut().run("layout.selectParentPage", json!({"parent": b, "page": 1})).unwrap(); + h.run_steps(3); + assert_eq!(crate::canvas::current_parent_page(h.state()), Some((1, 1))); + if properties { + h.state_mut().session.set_tool("selection"); + } + h.run_steps(3); + edit_parent_resize_field(&mut h, "50p0", "700 pt"); + edit_parent_resize_field(&mut h, "33p4", "560 pt"); + let d = &h.state().session.doc().unwrap().doc; + assert_eq!((d.parents[1].pages[1].width, d.parents[1].pages[1].height), (560.0, 700.0)); + assert_eq!(d.parents[1].pages[0].width, 400.0); + assert_eq!(d.parents[0].pages[0].width, 500.0); + assert_eq!(d.spreads, before.spreads); + let parents = d.parents.clone(); + if properties { + let saved = d.clone(); + check_parent_margin_edits(&mut h); + h.get_by_label("Back").click(); + h.run_steps(3); + assert!(!h.state().session.doc().unwrap().editing_parents, "Back returns to document properties"); + assert_eq!(h.state().session.tool_id(), "selection"); + assert!(h.query_by_label("Back").is_none(), "the next frame must leave the page properties screen"); + assert!(std::sync::Arc::ptr_eq(&saved, &h.state().session.doc().unwrap().doc)); + } + h.state_mut().run("layout.parents.edit", json!({"on": false})).unwrap(); + h.state_mut().session.set_tool("page"); + h.state_mut().run("layout.selectPage", json!({"page": 1})).unwrap(); + h.run_steps(3); + edit_parent_resize_field(&mut h, "33p4", "530 pt"); + let d = &h.state().session.doc().unwrap().doc; + assert_eq!(d.spreads[0].pages[0].width, 530.0); + assert_eq!(d.parents, parents); + if properties { + let saved = d.clone(); + h.get_by_label("Back").click(); + h.run_steps(3); + assert_eq!(h.state().session.tool_id(), "selection"); + assert!(h.query_by_label("Back").is_none()); + assert_eq!(h.state().session.doc().unwrap().selection.page, None); + assert!(std::sync::Arc::ptr_eq(&saved, &h.state().session.doc().unwrap().doc)); + } + } +} + +fn check_parent_margin_edits(h: &mut egui_kittest::Harness<'_, DesignApp>) { + let saved = h.state().session.doc().unwrap().doc.clone(); + assert_eq!(h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Top").value().as_deref(), Some("3p0")); + edit_parent_resize_field(h, "3p0", "72 pt"); + let edited = &h.state().session.doc().unwrap().doc; + assert_eq!(edited.parents[1].pages[1].margins, designcraft_doc::Margins::uniform(72.0)); + assert_eq!(edited.parents[1].pages[0], saved.parents[1].pages[0]); + assert_eq!(edited.parents[0], saved.parents[0]); + assert_eq!(edited.page(0), saved.page(0), "local document margins stay unchanged"); + assert_eq!(edited.page(1), saved.page(1), "the other parent page's inheritor stays unchanged"); + assert_eq!(edited.page(2).unwrap().margins, designcraft_doc::Margins::uniform(72.0)); + assert_eq!(edited.settings, saved.settings); + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().session.doc().unwrap().doc, saved); + h.get_by_label("Make all settings the same").click(); + h.run_steps(3); + edit_parent_resize_field(h, "3p0", "84 pt"); + let edited = &h.state().session.doc().unwrap().doc; + let margins = designcraft_doc::Margins { top: 84.0, bottom: 48.0, inside: 60.0, outside: 72.0 }; + assert_eq!(edited.parents[1].pages[1].margins, margins); + assert_eq!(edited.parents[1].pages[0], saved.parents[1].pages[0]); + assert_eq!(edited.parents[0], saved.parents[0]); + assert_eq!(edited.page(0), saved.page(0)); + assert_eq!(edited.page(1), saved.page(1)); + assert_eq!(edited.page(2).unwrap().margins, margins); + assert_eq!(edited.settings, saved.settings); + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().session.doc().unwrap().doc, saved); +} + +fn edit_parent_resize_field(h: &mut egui_kittest::Harness<'_, DesignApp>, value: &str, input: &str) { + h.query_all_by(|n| n.role() == egui::accesskit::Role::TextInput && n.value().as_deref() == Some(value)).next().expect("page size field").click(); + h.run_steps(2); + h.event(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }); + h.event(egui::Event::Text(input.into())); + h.key_press(egui::Key::Enter); + h.run_steps(3); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index e4c6fe723..d70a79695 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -389,7 +389,8 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest if id == "findChange" || app.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange") { app.session.reset_find_scope(); } - let d = crate::dialogs::Dialog::new(id, p.get("fields").cloned().unwrap_or(Value::Null)); + let fields = p.get("fields").cloned().unwrap_or(Value::Null); + let d = if id == "frameSize" { crate::dialogs::frame_size_dialog(app, fields) } else { crate::dialogs::Dialog::new(id, fields) }; let v = serde_json::to_value(&d).unwrap_or_default(); app.ui.dialog = Some(d); ok(v) @@ -418,7 +419,12 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest } "ui.render" => { let Some(st) = app.session.active() else { return err("no document") }; - let page = match page_index(p, crate::canvas::current_page(app).unwrap_or(0), st.doc.page_count()) { + let current = match crate::canvas::current_page(app) { + Some(page) => page, + None if !st.editing_parents || p.get("page").is_some() => 0, + None => return err("select a document page or give an explicit page"), + }; + let page = match page_index(p, current, st.doc.page_count()) { Ok(page) => page, Err(e) => return err(e), }; @@ -688,6 +694,83 @@ mod automation_contract_tests { assert_eq!(app.ui.palette.as_deref(), Some("field")); assert_eq!(app.session.execute("story.get", &json!({"story":f["story"]})).unwrap()["text"], "Hello"); } + #[test] + fn parent_resize_no_canvas_render_keeps_document_page_zero_default() { + let mut app = app(); + let ctx = egui::Context::default(); + app.run("layout.pageSize", json!({"pages": [2], "width": 700})).unwrap(); + assert!(app.canvas_rect.is_none()); + let before = app.session.doc().unwrap().doc.clone(); + let omitted = call(&mut app, &ctx, "ui.render", json!({"scale": 0.1})); + assert_eq!(omitted["ok"], true, "{omitted}"); + let first = call(&mut app, &ctx, "ui.render", json!({"page": 0, "scale": 0.1})); + let second = call(&mut app, &ctx, "ui.render", json!({"page": 1, "scale": 0.1})); + assert_eq!(omitted, first); + assert_ne!(omitted, second); + assert_eq!(app.session.doc().unwrap().doc, before); + } + + #[test] + fn parent_resize_no_canvas_delete_uses_first_document_page() { + let mut app = app(); + assert!(app.canvas_rect.is_none()); + let before = app.session.doc().unwrap().doc.clone(); + app.run("app.deletePage", json!({})).unwrap(); + let after = &app.session.doc().unwrap().doc; + assert_eq!(after.page_count(), 1); + assert_eq!(after.page(0).unwrap().id, before.page(1).unwrap().id); + assert_eq!(after.parents, before.parents); + assert!(app.ui.dialog.is_none()); + } + + #[test] + fn parent_resize_no_canvas_delete_keeps_the_object_warning() { + let mut app = app(); + app.run("frame.create", json!({"rect": [36, 36, 100, 100]})).unwrap(); + assert!(app.canvas_rect.is_none()); + let before = app.session.doc().unwrap().doc.clone(); + app.run("app.deletePage", json!({})).unwrap(); + let dialog = app.ui.dialog.as_ref().unwrap(); + assert_eq!(dialog.id, "deletePages"); + assert_eq!(dialog.fields["pages"], json!([0])); + assert_eq!(app.session.doc().unwrap().doc, before); + } + + #[test] + fn parent_resize_no_canvas_duplicate_uses_first_document_spread() { + let mut app = app(); + assert!(app.canvas_rect.is_none()); + let before = app.session.doc().unwrap().doc.clone(); + let ids: Vec<_> = before.spreads.iter().flat_map(|sp| sp.pages.iter().map(|p| p.id)).collect(); + let result = app.run("app.duplicateSpread", json!({})).unwrap(); + let after = &app.session.doc().unwrap().doc; + let first = result["page"].as_u64().unwrap() as usize; + assert_eq!(first, before.spreads[0].pages.len()); + assert_eq!(after.page_count(), before.page_count() + first); + for i in first..first * 2 { + assert!(!ids.contains(&after.page(i).unwrap().id)); + } + let retained: Vec<_> = after.spreads.iter().flat_map(|sp| sp.pages.iter().map(|p| p.id)).filter(|id| ids.contains(id)).collect(); + assert_eq!(retained, ids); + assert_eq!(after.parents, before.parents); + } + + #[test] + fn parent_resize_mode_does_not_default_document_actions_to_page_zero() { + for canvas in [None, Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0)))] { + let mut app = app(); + let ctx = egui::Context::default(); + app.canvas_rect = canvas; + app.run("layout.parents.edit", json!({"on":true,"parent":"A"})).unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + assert!(app.run("app.deletePage", json!({})).is_err()); + assert!(app.run("app.duplicateSpread", json!({})).is_err()); + assert_eq!(call(&mut app, &ctx, "ui.render", json!({"scale":0.1}))["ok"], false); + assert_eq!(call(&mut app, &ctx, "ui.render", json!({"page":0,"scale":0.1}))["ok"], true); + assert_eq!(app.session.doc().unwrap().doc, before); + } + } + #[test] fn control_pages_validate_before_writer_and_preserve_omission() { let mut app = app(); diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 45f821b09..a5a6b7fa6 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -1,6 +1,6 @@ //! The About window's credits: every contributor (GitHub login always; display and real names only //! with recorded consent) and the AI models that helped. The tables are baked into the binary by -//! build.rs from contributors/contributors.json (craftrules standards/contributors.md); nothing is +//! build.rs from contributors/contributors.json; nothing is //! read at run time. use std::cmp::Ordering; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index bb3077103..9516801f6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -22,6 +22,10 @@ pub struct Dialog { /// Set from the active document when the dialog is shown or confirmed (picas without one). #[serde(skip)] pub unit: Unit, + /// The vertical ruler unit, for a dialog with a height field (the Rectangle dialog): kept + /// from when it opened, so a bare `50` means what it did then. `None` falls back to `unit`. + #[serde(skip)] + pub vertical_unit: Option, } impl Dialog { @@ -33,9 +37,11 @@ impl Dialog { let defaults = match id { "newDocument" => crate::new_document::defaults(), "goToPage" => json!({"page": "1"}), - "epubExport" => json!({"split": false, "mode": "style", "style": designcraft_doc::BASIC_PARAGRAPH}), + "epubExport" => json!({"split": false, "mode": "style", "style": designcraft_doc::BASIC_PARAGRAPH, "coverImage": ""}), "qrCode" => json!({"type": "url", "content": "https://", "color": "[Black]"}), "insertTable" => json!({"bodyRows": 4, "columns": 4, "headerRows": 0, "footerRows": 0}), + "convertTextToTable" => json!({"columnSeparator": "tab", "rowSeparator": "paragraph", "columns": 1}), + "convertTableToText" => json!({"columnSeparator": "tab", "rowSeparator": "paragraph"}), "insertXref" => json!({"linkTo": "paragraph", "style": "", "target": "", "format": ""}), "findChange" => json!({"find": "", "change": "", "grep": false, "caseSensitive": false, "wholeWord": false, "scope": "document"}), "documentSetup" => json!({}), @@ -79,14 +85,7 @@ impl Dialog { if id == "ruby" { fields.entry("section".to_string()).or_insert(json!("cjk.rubyPlacement")); } - if id == "frameSize" { - for k in ["width", "height"] { - if let Some(v) = fields.get(k).and_then(Value::as_f64) { - fields.insert(k.into(), json!(format_measure(v, Unit::Picas))); - } - } - } - Dialog { id: id.into(), fields, unit: Unit::Picas } + Dialog { id: id.into(), fields, unit: Unit::Picas, vertical_unit: None } } /// Set a field and report a genuine Find domain/mode transition to the caller. pub(crate) fn set_field(&mut self, key: &str, value: Value) -> bool { @@ -106,9 +105,13 @@ impl Dialog { } } fn m(&self, k: &str) -> Option { + self.m_in(k, self.unit) + } + /// A measure where a bare number is in `unit`. + fn m_in(&self, k: &str, unit: Unit) -> Option { match self.fields.get(k) { Some(Value::Number(n)) => n.as_f64(), - Some(Value::String(s)) => parse_measure(s, self.unit).ok(), + Some(Value::String(s)) => parse_measure(s, unit).ok(), _ => None, } } @@ -149,6 +152,218 @@ fn text_field_named(ui: &mut egui::Ui, d: &mut Dialog, key: &str, w: f32, name: } } +/// Insert Pages…, after page `abs` and with that page's parent, as InDesign's. +pub(crate) fn open_insert_pages(app: &mut DesignApp, abs: usize) { + let Some(st) = app.session.active() else { return }; + let abs = abs.min(st.doc.page_count().saturating_sub(1)); + let parent = st.doc.page(abs).and_then(|p| p.parent).map_or_else(String::new, |id| id.0.to_string()); + app.ui.dialog = Some(Dialog::new("insertPages", json!({"count": 1, "insert": "after", "page": abs, "parent": parent}))); +} + +/// Insert Pages, as InDesign's: how many, where (after or before a page, at the start or the end +/// of the document) and with which parent. +fn insert_pages_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let lang = app.ui.language.clone(); + let Some(st) = app.session.active() else { return }; + let pages: Vec = (0..st.doc.page_count()).map(|i| app.session.page_label(i)).collect(); + let mut parents = vec![(String::new(), crate::i18n::tr(&lang, "[None]").to_string())]; + parents.extend(st.doc.parents.iter().map(|p| (p.id.0.to_string(), p.parent.as_ref().map(|i| i.label()).unwrap_or_default()))); + egui::Grid::new("insert_pages").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + let name = crate::rtl::label(ui, crate::i18n::tr(&lang, "Pages:")); + let mut n = d.n("count").unwrap_or(1.0).clamp(1.0, 9999.0) as u32; + if ui.add(egui::DragValue::new(&mut n).range(1..=9999)).labelled_by(name.id).changed() { + d.fields.insert("count".into(), json!(n)); + } + ui.end_row(); + let name = crate::rtl::label(ui, crate::i18n::tr(&lang, "Insert:")); + ui.horizontal(|ui| { + let wheres = [("after", "After Page"), ("before", "Before Page"), ("start", "At Start of Document"), ("end", "At End of Document")]; + translated_combo(&lang, ui, d, "insert", &wheres).labelled_by(name.id); + // The page, by name: At Start and At End need none. + ui.add_enabled_ui(matches!(d.s("insert").as_str(), "after" | "before"), |ui| { + let cur = (d.n("page").unwrap_or(0.0).max(0.0) as usize).min(pages.len().saturating_sub(1)); + craft_ui::ComboBox::from_id_salt("insert_page") + .selected_text(pages.get(cur).cloned().unwrap_or_default()) + .width(64.0) + .show_ui(ui, |ui| { + for (i, name) in pages.iter().enumerate() { + if ui.selectable_label(i == cur, name).clicked() { + d.fields.insert("page".into(), json!(i)); + } + } + }) + .response + .accessible_name(crate::i18n::tr(&lang, "Page").to_owned()); + }); + }); + ui.end_row(); + let name = crate::rtl::label(ui, crate::i18n::tr(&lang, "Parent:")); + let opts: Vec<(&str, &str)> = parents.iter().map(|(v, l)| (v.as_str(), l.as_str())).collect(); + combo(ui, d, "parent", &opts).labelled_by(name.id); + ui.end_row(); + }); +} +/// The temporary-data key the Printing Instructions dialog sets, each frame, while one of its +/// multi-line fields has focus: Enter then makes a new line there, and the footer doesn't take it +/// for OK. No other dialog changes how Enter works. +const MULTILINE_FOCUS: &str = "dialog_multiline_focus"; + +pub(crate) fn set_multiline_focus(ctx: &egui::Context) { + ctx.data_mut(|m| m.insert_temp(egui::Id::unique(MULTILINE_FOCUS), true)); +} + +/// Whether Enter in dialog `id` goes to a multi-line field this frame. The flag is read (and cleared) +/// for every dialog, but only the Printing Instructions dialog honours it. +fn take_multiline_focus(ctx: &egui::Context, id: &str) -> bool { + let set = ctx.data_mut(|m| m.remove_temp::(egui::Id::unique(MULTILINE_FOCUS))).unwrap_or(false); + set && id == crate::package_dialog::INSTRUCTIONS +} + +/// Separators the Convert dialogs offer by name; anything else typed in the field is the +/// separator's own characters. +const SEPARATORS: [(&str, &str); 3] = [("tab", "Tab"), ("comma", "Comma"), ("paragraph", "Paragraph")]; + +/// The characters a separator field stands for (`table.convertFromText` resolves it the same +/// way), so "comma" and a typed "," are the same separator. +fn resolved_separator(v: &str) -> &str { + match v { + "tab" => "\t", + "comma" => ",", + "paragraph" => "\n", + other => other, + } +} + +/// Whether the Convert dialog's two separators are the same (Number of Columns then applies). +fn same_separators(d: &Dialog) -> bool { + resolved_separator(&d.s("columnSeparator")) == resolved_separator(&d.s("rowSeparator")) +} + +/// A separator field, as InDesign's: type any characters, or pick Tab, Comma or Paragraph from +/// its menu. Stored as `tab`/`comma`/`paragraph` or the typed text. +fn separator_field(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, key: &str) { + let cur = d.s(key); + let name = |v: &str| SEPARATORS.iter().find(|s| s.0 == v).map(|s| crate::i18n::tr(lang, s.1).to_string()); + let mut shown = name(&cur).unwrap_or_else(|| cur.clone()); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + if ui.add(egui::TextEdit::singleline(&mut shown).id_salt(("sep", key)).desired_width(150.0)).changed() { + let v = SEPARATORS.iter().find(|s| crate::i18n::tr(lang, s.1) == shown).map_or(shown.clone(), |s| s.0.to_string()); + d.fields.insert(key.into(), json!(v)); + } + ui.menu_button("▾", |ui| { + for (v, label) in SEPARATORS { + if ui.selectable_label(cur == v, crate::i18n::tr(lang, label)).clicked() { + d.fields.insert(key.into(), json!(v)); + ui.close(); + } + } + }); + }); +} + +/// Table › Convert Text to Table and Convert Table to Text, laid out as InDesign's: Column and +/// Row Separator; converting text to a table also has Number of Columns (only when both +/// separators are the same: the cells then fill rows of that many) and Table Style. +fn convert_table_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let lang = &app.ui.language; + let to_table = d.id == "convertTextToTable"; + let label = |ui: &mut egui::Ui, text: &str, on: bool| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_enabled(on, egui::Label::new(crate::rtl::widget(ui, format!("{}:", crate::i18n::tr(lang, text))))); + }); + }; + egui::Grid::new(("convert_table", to_table)).num_columns(2).min_col_width(120.0).spacing([8.0, 6.0]).show(ui, |ui| { + label(ui, "Column Separator", true); + separator_field(lang, ui, d, "columnSeparator"); + ui.end_row(); + label(ui, "Row Separator", true); + separator_field(lang, ui, d, "rowSeparator"); + ui.end_row(); + if !to_table { + return; + } + let same = same_separators(d); + label(ui, "Number of Columns", same); + let max = designcraft_engine::cmd::table::MAX_COLUMNS as f64; + let field = crate::widgets::NumField::number("columns", d.n("columns"), "", 0).width(70.0).range(1.0, max).spinner().enabled(same); + if let Some(v) = field.show(ui) { + d.fields.insert("columns".into(), json!(v.round())); + } + ui.end_row(); + label(ui, "Table Style", true); + let names: Vec = app.session.active().map(|st| st.doc.styles.table.iter().map(|s| s.name.clone()).collect()).unwrap_or_default(); + let cur = table_style_field(d).unwrap_or_else(|| designcraft_doc::BASIC_TABLE.to_string()); + craft_ui::ComboBox::from_id_salt("convert_table_style").selected_text(&cur).width(170.0).show_ui(ui, |ui| { + for n in &names { + if ui.selectable_label(*n == cur, n).clicked() { + d.fields.insert("tableStyle".into(), json!(n)); + } + } + }); + ui.end_row(); + }); +} + +/// The dialog's chosen table style: none when unset, null or empty (a new table's [Basic Table]). +fn table_style_field(d: &Dialog) -> Option { + d.fields.get("tableStyle").and_then(Value::as_str).filter(|s| !s.is_empty()).map(str::to_string) +} + +/// The dialog's `tableStyle` as the table commands take it: null for none, the style's name, or +/// a value that is neither (set through `ui.dialog.set`) as it is, for the command to refuse. +fn table_style_value(d: &Dialog) -> Value { + match d.fields.get("tableStyle") { + None | Some(Value::String(_)) => table_style_field(d).map_or(Value::Null, Value::String), + Some(v) => v.clone(), + } +} + +/// Table › Insert Table, laid out as InDesign's: Table Dimensions (body rows and columns, then +/// header and footer rows, each a number with a spinner, labels on the right edge of their +/// column) and the Table Style to apply. +fn insert_table_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let lang = &app.ui.language; + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "Table Dimensions")).font(semibold(12.0))); + use designcraft_engine::cmd::table::{MAX_BODY_ROWS, MAX_COLUMNS, MAX_HEADER_FOOTER_ROWS}; + // `table.insert`'s limits, so the table made is the one the fields show. + let rows = [ + ("bodyRows", crate::i18n::tr(lang, "Body Rows"), 1.0, MAX_BODY_ROWS), + ("columns", crate::i18n::tr_context(lang, "Columns", "table"), 1.0, MAX_COLUMNS), + ("headerRows", crate::i18n::tr(lang, "Header Rows"), 0.0, MAX_HEADER_FOOTER_ROWS), + ("footerRows", crate::i18n::tr(lang, "Footer Rows"), 0.0, MAX_HEADER_FOOTER_ROWS), + ]; + // Body rows and columns, then (after a gap) header and footer rows; one label width for both. + for (g, pair) in rows.chunks(2).enumerate() { + if g > 0 { + ui.add_space(8.0); + } + egui::Grid::new(("ins_table", g)).num_columns(2).min_col_width(100.0).spacing([8.0, 6.0]).show(ui, |ui| { + for (key, label, min, max) in pair { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| crate::rtl::label(ui, format!("{label}:"))); + let field = crate::widgets::NumField::number(key, d.n(key), "", 0).width(70.0).range(*min, *max as f64).spinner(); + if let Some(v) = field.show(ui) { + d.fields.insert((*key).into(), json!(v.round())); + } + ui.end_row(); + } + }); + } + ui.add_space(8.0); + let names: Vec = app.session.active().map(|st| st.doc.styles.table.iter().map(|s| s.name.clone()).collect()).unwrap_or_default(); + let cur = table_style_field(d).unwrap_or_else(|| designcraft_doc::BASIC_TABLE.to_string()); + ui.horizontal(|ui| { + crate::rtl::label(ui, format!("{}:", crate::i18n::tr(lang, "Table Style"))); + craft_ui::ComboBox::from_id_salt("ins_table_style").selected_text(&cur).width(160.0).show_ui(ui, |ui| { + for n in &names { + if ui.selectable_label(*n == cur, n).clicked() { + d.fields.insert("tableStyle".into(), json!(n)); + } + } + }); + }); +} + /// Extract once when opened. Cancel is read-only; confirmation adds exactly these colours. pub(crate) fn open_color_theme(app: &mut DesignApp, params: Value) { match app.run("color.extractTheme", params) { @@ -186,6 +401,195 @@ fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod color_theme_tests { use super::*; + /// Enter in a multi-line field makes a new line in the Printing Instructions only; every other + /// dialog keeps Enter as OK, and the flag does not outlive the frame. + #[test] + fn enter_in_a_multiline_field_is_local_to_the_printing_instructions() { + let ctx = egui::Context::default(); + set_multiline_focus(&ctx); + assert!(take_multiline_focus(&ctx, crate::package_dialog::INSTRUCTIONS)); + assert!(!take_multiline_focus(&ctx, crate::package_dialog::INSTRUCTIONS), "cleared once read"); + set_multiline_focus(&ctx); + assert!(!take_multiline_focus(&ctx, "insertPages")); + } + + /// A page past the end shows as the last page in the dialog, and Before Page puts the new + /// page before that one rather than at the end. + #[test] + fn insert_pages_before_a_page_past_the_end_goes_before_the_last() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 4, "facingPages": false})).unwrap(); + let ids = |app: &DesignApp| { + let d = &app.session.doc().unwrap().doc; + (0..d.page_count()).map(|i| d.page(i).unwrap().id).collect::>() + }; + let old = ids(&app); + app.ui.dialog = Some(Dialog::new("insertPages", json!({"count": 1, "insert": "before", "page": 100, "parent": ""}))); + confirm(&mut app).unwrap(); + let now = ids(&app); + assert_eq!(now.len(), 5); + assert_eq!((now[..3].to_vec(), now[4]), (old[..3].to_vec(), old[3]), "the new page is fourth, before the old last page"); + } + + /// The course's chapter 25: Insert Pages as InDesign's. It opens after the page in view with + /// that page's parent; At Start of Document with B-Cover puts a B page first, and Before Page + /// with [None] puts a page without a parent before the page chosen. + #[test] + fn insert_pages_goes_before_a_page_or_at_the_start_with_the_chosen_parent() { + use egui::accesskit::Role; + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + // Facing: [1] [2 3] [4]. + app.run("file.new", json!({"pages": 4})).unwrap(); + let b = app.run("layout.parents.new", json!({"prefix": "B", "name": "Cover"})).unwrap()["id"].as_u64().unwrap(); + let ids = |app: &DesignApp| -> Vec { + let d = &app.session.doc().unwrap().doc; + (0..d.page_count()).map(|i| d.page(i).unwrap().id.0).collect() + }; + let old = ids(&app); + crate::menus::activate(&mut app, "layout.pages.insert", &Value::Null); + assert_eq!(app.ui.dialog.as_ref().unwrap().id, "insertPages"); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::unique("insert_pages_test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(3); + for l in ["Pages:", "Insert:", "Parent:"] { + assert!(h.query_by_label(l).is_some(), "{l}"); + } + // The dropdown showing `shown`, then its item `choice`. + let pick = |h: &mut Harness<'_, DesignApp>, shown: &str, choice: &str| { + h.get_by(|n| n.role() == Role::ComboBox && n.value().as_deref() == Some(shown)).click(); + h.run_steps(2); + h.get_by_label(choice).click(); + h.run_steps(2); + }; + // After page 1 with A, at first; then At Start of Document, with B-Cover. + pick(&mut h, "After Page", "At Start of Document"); + pick(&mut h, "A-Parent", "B-Cover"); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert!(h.state().ui.dialog.is_none()); + let now = ids(h.state()); + assert_eq!(now[1..], old[..], "the new page is first"); + let parent = |app: &DesignApp, i: usize| app.session.doc().unwrap().doc.page(i).unwrap().parent.map(|p| p.0); + assert_eq!(parent(h.state(), 0), Some(b)); + // Before page 4, with [None]. + crate::dialogs::open_insert_pages(h.state_mut(), 0); + h.run_steps(3); + pick(&mut h, "After Page", "Before Page"); + pick(&mut h, "1", "4"); + pick(&mut h, "B-Cover", "[None]"); + h.get_by_label(" OK ").click(); + h.run_steps(3); + let then = ids(h.state()); + assert_eq!(then.len(), 6); + assert_eq!(then[..3], now[..3], "the pages before it stay"); + assert_eq!(then[4..], now[3..], "page 4 and those after it follow the new page"); + assert_eq!(parent(h.state(), 3), None); + } + + /// The course's chapter 18: Text Frame Options with Preview on shows the columns and the + /// inset on the page while the dialog is open; Cancel puts the frame back, OK sets them as one + /// undo step. The Paragraph Spacing Limit is set from the dialog too. + #[test] + fn text_frame_options_preview_shows_the_change_until_ok_or_cancel() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let r = app.run("frame.create", json!({"rect": [36, 36, 336, 236], "content": "text", "text": "Hello"})).unwrap(); + let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); + app.run("selection.set", json!({"ids": [id.0]})).unwrap(); + let opts = |app: &DesignApp| app.session.doc().unwrap().doc.item(id).unwrap().text_frame().unwrap().options.clone(); + let open = |app: &mut DesignApp| { + let mut d = Dialog::new("textFrameOptions", json!({})); + seed_text_frame_options(app, &mut d); + d + }; + let mut d = open(&mut app); + d.fields.insert("columns".into(), json!("3")); + d.fields.insert("preview".into(), json!(true)); + text_frame_preview(&mut app, &mut d); + assert_eq!(opts(&app).columns, 3, "previewed on the page"); + d.fields.insert("insetTop".into(), json!("1p0")); + text_frame_preview(&mut app, &mut d); + assert_eq!((opts(&app).columns, opts(&app).inset[0]), (3, 12.0), "redone when a field changes"); + d.fields.insert("preview".into(), json!(false)); + text_frame_preview(&mut app, &mut d); + assert_eq!(opts(&app).columns, 1, "Preview off: the page as it was"); + // A value OK would refuse: the page goes back while the status says why. + d.fields.insert("preview".into(), json!(true)); + text_frame_preview(&mut app, &mut d); + assert_eq!(opts(&app).columns, 3); + let gutter = d.fields.get("gutter").cloned().unwrap_or_default(); + d.fields.insert("gutter".into(), json!("wide")); + text_frame_preview(&mut app, &mut d); + assert_eq!(opts(&app).columns, 1, "the page as it was"); + assert!(!d.fields.contains_key(PREVIEWED) && !d.s("status").is_empty()); + d.fields.insert("gutter".into(), gutter); + d.fields.remove("status"); + // Cancel after a preview: back as it was. + d.fields.insert("preview".into(), json!(true)); + text_frame_preview(&mut app, &mut d); + app.ui.dialog = Some(d); + cancel(&mut app); + assert_eq!(opts(&app).columns, 1); + // OK after a preview: set once, one undo step. + let mut d = open(&mut app); + d.fields.insert("columns".into(), json!("2")); + d.fields.insert("verticalJustification".into(), json!("justify")); + d.fields.insert("paragraphSpacingLimit".into(), json!("2p0")); + d.fields.insert("preview".into(), json!(true)); + text_frame_preview(&mut app, &mut d); + app.ui.dialog = Some(d); + confirm(&mut app).unwrap(); + let o = opts(&app); + assert_eq!((o.columns, o.vj_paragraph_spacing_limit), (2, 24.0)); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(opts(&app).columns, 1, "one undo step"); + } + + #[test] + fn toc_right_indent_separator_survives_the_dialog_and_update() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.newSample", json!({})).unwrap(); + open_toc(&mut app); + app.ui.dialog.as_mut().unwrap().fields.insert( + "included".into(), + json!([ + {"style": "Headline", "level": 1, "separator": " ^y "} + ]), + ); + confirm(&mut app).unwrap(); + if app.session.tool_id() == "tocPlace" { + app.run("toc.place", json!({"x":36,"y":36})).unwrap(); + } + let separator = format!(" {} ", designcraft_doc::story::RIGHT_INDENT_TAB); + let d = &app.session.doc().unwrap().doc; + let toc = d.toc.as_ref().unwrap(); + assert_eq!(toc.entries[0].separator.as_deref(), Some(separator.as_str())); + assert!(d.story(toc.story).unwrap().text.contains(&separator)); + app.run("toc.update", json!({})).unwrap(); + open_toc(&mut app); + assert_eq!(toc_rows(app.ui.dialog.as_ref().unwrap())[0].separator, " ^y "); + for text in ["^y", "^t^y", "^m^>", "unknown ^q and trailing ^", "a\t\u{E006}\u{2003}\u{2002}z"] { + assert_eq!(toc_separator_typed(&toc_separator_shown(text)), text); + } + // The picker after a literal caret: the caret stays, and the code is the code. + for (field, typed) in [("^", "^\u{E006}"), ("^^", "^\u{E006}"), ("a", "a\u{E006}"), ("^^^", "^^\u{E006}")] { + let mut f = field.to_string(); + toc_separator_append(&mut f, "^y"); + assert_eq!(toc_separator_typed(&f), typed, "{field:?} → {f:?}"); + } + } + #[test] fn color_theme_cancel_is_read_only_and_confirm_adds_the_preview() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); @@ -299,8 +703,9 @@ fn epub_export_split(d: &Dialog) -> designcraft_doc::EpubSplit { } } -fn epub_export(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { - let lang = app.ui.language.as_str(); +fn epub_export(app: &mut crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let lang = app.ui.language.clone(); + let lang = lang.as_str(); let styles: Vec = app .session .active() @@ -329,6 +734,27 @@ fn epub_export(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } }); }); + ui.add_space(8.0); + // The cover: the first page rendered, or an image file (PNG or JPEG) named here (#1934). + crate::rtl::label(ui, crate::i18n::tr(lang, "Cover Image:")); + ui.horizontal(|ui| { + text_field(ui, d, "coverImage", 240.0); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(lang, "Choose…"))).clicked() + && let Err(e) = app.ask_open("coverImage", Bound::Document, set_epub_cover) + { + app.status_error("EPUB Export Options", e); + } + }); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "A PNG or JPEG file; left empty, the first page is the cover.")).small()); +} + +/// EPUB Export Options › Choose…: the image file picked goes into the dialog's Cover Image field +/// while it is open. +fn set_epub_cover(app: &mut DesignApp, path: String) -> Result { + if let Some(d) = app.ui.dialog.as_mut().filter(|d| d.id == "epubExport") { + d.fields.insert("coverImage".into(), json!(path)); + } + Ok(Value::Null) } fn print_dialog(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { @@ -385,6 +811,41 @@ fn print_dialog(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { const PDF_EXPORT_PRESETS: &[&str] = &["Desktop Printing", "Commercial Printing", "Screen and Email", "PDF/X-1a:2003", "PDF/X-4", "PDF/A-2b", "Custom"]; +/// The `file.exportPdf` options the Export PDF dialog's fields stand for (pages and title aside). +fn pdf_export_params(d: &Dialog) -> Value { + let mut params = json!({ + "standard": d.s("standard"), + "compressImages": d.b("compressImages"), + "spreads": d.b("spreads"), + "bleed": d.b("bleed"), + "tagged": d.b("tagged"), + "marks": { + "crop": d.b("marksCrop"), + "bleed": d.b("marksBleed"), + "pageInfo": d.b("marksPageInfo"), + "weight": d.pt("marksWeight").unwrap_or(0.25), + "offset": d.pt("marksOffset").unwrap_or(6.0), + }, + }); + let flatten = d.s("flatten"); + if !flatten.is_empty() { + params["flatten"] = json!(flatten); + } + params +} + +/// The PDF presets a document can be exported with, by name (Custom aside). +pub(crate) fn pdf_presets() -> impl Iterator { + PDF_EXPORT_PRESETS.iter().copied().filter(|p| *p != "Custom") +} + +/// The `file.exportPdf` options of PDF preset `preset`, as the Export PDF dialog sets them. +pub(crate) fn pdf_preset_params(preset: &str) -> Value { + let mut d = Dialog::new("pdfExport", json!({})); + apply_pdf_preset(&mut d, preset); + pdf_export_params(&d) +} + /// Apply a preset's dependent fields. Flatten is only touched by "Screen and Email" and /// "PDF/X-1a:2003" (High Resolution); "Custom" (and any unknown value) changes nothing. fn apply_pdf_preset(d: &mut Dialog, preset: &str) { @@ -536,6 +997,7 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) { "firstBaseline": kind(serde_json::to_value(o.first_baseline).unwrap_or_default()), "firstBaselineMin": fm(o.first_baseline_min), "insetTop": fm(o.inset[0]), "insetLeft": fm(o.inset[1]), "insetBottom": fm(o.inset[2]), "insetRight": fm(o.inset[3]), "verticalJustification": serde_json::to_value(o.vertical_justification).unwrap_or(Value::Null), + "paragraphSpacingLimit": fm(o.vj_paragraph_spacing_limit), "columnRule": o.column_rule, "columnRuleWeight": format_measure(o.column_rule_weight, Unit::Points), "columnRuleColor": o.column_rule_color, "columnRuleTint": percent(o.column_rule_tint), "columnRuleTopInset": fm(o.column_rule_top_inset), "columnRuleBottomInset": fm(o.column_rule_bottom_inset), "columnRuleOffset": fm(o.column_rule_offset), @@ -553,6 +1015,10 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) { } } d.fields.entry("insetLinked".to_string()).or_insert_with(|| json!(o.inset.iter().all(|v| (v - o.inset[0]).abs() < 1e-9))); + // The frame's width: Fixed Number shows each column's width from it as the fields change. + if let Some(w) = selected_frame_width(app) { + d.fields.insert("_frameWidth".into(), json!(w)); + } d.fields.insert("_shown".into(), shown); } @@ -578,7 +1044,7 @@ fn text_frame_options_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) ui, d, false, - &[("general", "General"), ("baseline", "Baseline Options"), ("autosize", "Auto-Size"), ("rules", "Column Rules")], + &[("general", "General"), ("rules", "Column Rules"), ("baseline", "Baseline Options"), ("autosize", "Auto-Size")], ); ui.separator(); ui.vertical(|ui| match d.s("section").as_str() { @@ -625,49 +1091,146 @@ fn text_frame_options_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) _ => general(&lang, ui, d), }); }); + ui.add_space(6.0); + check(ui, d, "preview", tr("Preview")); + if let Some(st) = d.fields.get("status").and_then(Value::as_str) { + crate::rtl::label(ui, egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); + } } -/// Text Frame Options › General: columns, inset spacing, vertical justification, text wrap. +/// A measure field of the dialog with InDesign's spinner (stored as text in the document's unit). +fn spin_measure(ui: &mut egui::Ui, d: &mut Dialog, key: &str, w: f32, enabled: bool) { + let id = format!("tfo_{key}"); + if let Some(v) = crate::widgets::NumField::measure(&id, d.m(key), d.unit).width(w).spinner().range(0.0, 15552.0).enabled(enabled).show(ui) { + d.fields.insert(key.into(), json!(format_measure(v, d.unit))); + } +} + +/// A titled group in a thin frame, as InDesign's Text Frame Options draws them. +fn framed(ui: &mut egui::Ui, title: Option<&str>, add: impl FnOnce(&mut egui::Ui)) { + let t = crate::theme::Tokens::get(ui.ctx()); + ui.add_space(4.0); + egui::Frame::NONE.stroke(egui::Stroke::new(1.0, t.divider)).inner_margin(egui::Margin::symmetric(10, 8)).corner_radius(2.0).show(ui, |ui| { + ui.set_width(ui.available_width()); + if let Some(title) = title { + crate::rtl::label(ui, egui::RichText::new(title).font(semibold(12.0))); + } + add(ui); + }); +} + +/// A caption right-aligned in `w` (the dialog's fields line up after their captions). +fn caption_at(ui: &mut egui::Ui, text: &str, w: f32) { + ui.allocate_ui_with_layout(egui::vec2(w, crate::widgets::FIELD_H), egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.set_min_width(w); + ui.add(egui::Label::new(crate::rtl::widget(ui, text)).wrap_mode(egui::TextWrapMode::Extend)); + }); +} + +/// Preview on: the options show on the page (redone when a field changes) without an undo step; +/// off, the page goes back. +fn text_frame_preview(app: &mut DesignApp, d: &mut Dialog) { + let shown = d.fields.get(PREVIEWED).cloned(); + if !d.b("preview") { + if shown.is_some() { + app.session.cancel_interaction(); + d.fields.remove(PREVIEWED); + d.fields.remove("status"); + } + return; + } + let params = match text_frame_params(app, d) { + Ok(p) => Value::Object(p), + Err(e) => { + // Values OK would refuse don't stay on the page: it goes back to how it was. + if shown.is_some() { + app.session.cancel_interaction(); + d.fields.remove(PREVIEWED); + } + d.fields.insert("status".into(), json!(e)); + return; + } + }; + if shown.as_ref() == Some(¶ms) { + return; + } + // Nothing edited (yet, or any more): the page as it was. + if params.as_object().is_some_and(|m| m.keys().all(|k| k == "ids")) { + if shown.is_some() { + app.session.cancel_interaction(); + d.fields.remove(PREVIEWED); + } + return; + } + if shown.is_none() + && let Err(e) = app.session.begin_interaction("Text Frame Options") + { + d.fields.insert("status".into(), json!(e.to_string())); + return; + } + match app.session.preview("object.textFrameOptions", params.clone()) { + Ok(()) => d.fields.remove("status"), + Err(e) => d.fields.insert("status".into(), json!(e.to_string())), + }; + d.fields.insert(PREVIEWED.into(), params); +} + +/// Text Frame Options › General, as InDesign's: Columns (kind, number and width, gutter and +/// maximum, Balance Columns), Inset Spacing (top and bottom, the link, left and right), Vertical +/// Justification (align, paragraph spacing limit) and Ignore Text Wrap. fn general(lang: &str, ui: &mut egui::Ui, d: &mut Dialog) { let tr = |s: &'static str| crate::i18n::tr(lang, s); - crate::rtl::label(ui, egui::RichText::new(tr("Columns")).font(semibold(12.0))); - egui::Grid::new("tfo_cols").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, tr("Columns:")); - translated_combo(lang, ui, d, "columnsKind", &[("fixedNumber", "Fixed Number"), ("fixedWidth", "Fixed Width")]); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr_context(lang, "Number:", "count")); - text_field(ui, d, "columns", 70.0); - // Fixed Number: the width follows from the frame; Fixed Width: it's set here. - crate::rtl::label(ui, tr("Width:")); - ui.add_enabled_ui(d.s("columnsKind") == "fixedWidth", |ui| text_field(ui, d, "columnWidth", 70.0)); - ui.end_row(); - crate::rtl::label(ui, tr("Gutter:")); - text_field(ui, d, "gutter", 70.0); - ui.end_row(); - }); - check(ui, d, "balanceColumns", tr("Balance Columns")); - ui.add_space(6.0); - crate::rtl::label(ui, egui::RichText::new(tr("Inset Spacing")).font(semibold(12.0))); - // While linked, a side typed in sets all four ("Make all settings the same"). - let sides = ["insetTop", "insetLeft", "insetBottom", "insetRight"]; - let before: Vec = sides.iter().map(|k| d.s(k)).collect(); - // Two rows, Top | Left and Bottom | Right, the link between the columns (fixed-width - // captions; the second column starts where the first row's does). - let mut col2_x: Option = None; - for (r, row) in [[("Top:", "insetTop"), ("Left:", "insetLeft")], [("Bottom:", "insetBottom"), ("Right:", "insetRight")]].into_iter().enumerate() { + let (cap, field, cap2) = (64.0, 110.0, 70.0); + framed(ui, None, |ui| { ui.horizontal(|ui| { - for (k, (label, key)) in row.into_iter().enumerate() { - if k == 1 { - match col2_x { - Some(x) => ui.add_space((x - ui.cursor().min.x).max(0.0)), - None => col2_x = Some(ui.cursor().min.x), - } - } - let x0 = ui.cursor().min.x; - crate::rtl::label(ui, tr(label)); - ui.add_space(((if k == 0 { 56.0 } else { 44.0 }) - (ui.cursor().min.x - x0)).max(0.0)); - text_field(ui, d, key, 70.0); - if k == 0 && r == 0 { + caption_at(ui, tr("Columns:"), cap); + translated_combo(lang, ui, d, "columnsKind", &[("fixedNumber", "Fixed Number"), ("fixedWidth", "Fixed Width")]); + }); + ui.add_space(4.0); + ui.horizontal(|ui| { + caption_at(ui, crate::i18n::tr_context(lang, "Number:", "count"), cap); + let n = d.n("columns"); + if let Some(v) = crate::widgets::NumField::number("tfo_columns", n, "", 0).width(field).spinner().range(1.0, 40.0).show(ui) { + d.fields.insert("columns".into(), json!(v.round().to_string())); + } + // Fixed Number: the width follows from the frame; Fixed Width: it's set here. + caption_at(ui, tr("Width:"), cap2); + if d.s("columnsKind") == "fixedWidth" { + spin_measure(ui, d, "columnWidth", field, true); + } else { + // Fixed Number: each column's width, from the frame, the insets and the gutter. + let frame = d.fields.get("_frameWidth").and_then(Value::as_f64); + let n = d.n("columns").unwrap_or(1.0).max(1.0); + let width = frame.map(|w| { + let inner = w - d.m("insetLeft").unwrap_or(0.0) - d.m("insetRight").unwrap_or(0.0); + ((inner - d.m("gutter").unwrap_or(0.0) * (n - 1.0)) / n).max(0.0) + }); + let _ = crate::widgets::NumField::measure("tfo_auto_width", width, d.unit).width(field).spinner().enabled(false).show(ui); + } + }); + ui.horizontal(|ui| { + caption_at(ui, tr("Gutter:"), cap); + spin_measure(ui, d, "gutter", field, true); + // Maximum is for Flexible Width columns, which aren't in the document model yet. + caption_at(ui, tr("Maximum:"), cap2); + ui.add_enabled_ui(false, |ui| { + let _ = crate::widgets::NumField::number("tfo_max", None, "", 0).width(field).spinner().enabled(false).show(ui); + }); + }); + check(ui, d, "balanceColumns", tr("Balance Columns")); + }); + framed(ui, Some(tr("Inset Spacing")), |ui| { + // While linked, a side typed in sets all four ("Make all settings the same"). + let sides = ["insetTop", "insetLeft", "insetBottom", "insetRight"]; + let before: Vec = sides.iter().map(|k| d.s(k)).collect(); + for (r, [(l1, k1), (l2, k2)]) in + [[("Top:", "insetTop"), ("Left:", "insetLeft")], [("Bottom:", "insetBottom"), ("Right:", "insetRight")]].into_iter().enumerate() + { + ui.horizontal(|ui| { + caption_at(ui, tr(l1), cap); + spin_measure(ui, d, k1, field, true); + ui.add_space(8.0); + if r == 0 { let linked = d.b("insetLinked"); let tip = tr("Make all settings the same"); if crate::widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, tip, egui::vec2(22.0, 21.0)) @@ -675,26 +1238,40 @@ fn general(lang: &str, ui: &mut egui::Ui, d: &mut Dialog) { { d.fields.insert("insetLinked".into(), json!(!linked)); } + } else { + ui.add_space(22.0 + ui.spacing().item_spacing.x); } + caption_at(ui, tr(l2), cap2 - 30.0); + spin_measure(ui, d, k2, field, true); + }); + } + if d.b("insetLinked") + && let Some(k) = sides.iter().zip(&before).find(|(k, b)| d.s(k) != **b).map(|(k, _)| *k) + { + let v = d.fields.get(k).cloned().unwrap_or_default(); + for side in sides { + d.fields.insert(side.into(), v.clone()); } - }); - } - if d.b("insetLinked") - && let Some(k) = sides.iter().zip(&before).find(|(k, b)| d.s(k) != **b).map(|(k, _)| *k) - { - let v = d.fields.get(k).cloned().unwrap_or_default(); - for side in sides { - d.fields.insert(side.into(), v.clone()); } - } - ui.add_space(6.0); - crate::rtl::label(ui, egui::RichText::new(tr("Vertical Justification")).font(semibold(12.0))); - egui::Grid::new("tfo_vj").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, tr("Align:")); - translated_combo(lang, ui, d, "verticalJustification", &[("top", "Top"), ("center", "Center"), ("bottom", "Bottom"), ("justify", "Justify")]); - ui.end_row(); }); - ui.add_space(6.0); + framed(ui, Some(tr("Vertical Justification")), |ui| { + ui.horizontal(|ui| { + caption_at(ui, tr("Align:"), 170.0); + translated_combo( + lang, + ui, + d, + "verticalJustification", + &[("top", "Top"), ("center", "Center"), ("bottom", "Bottom"), ("justify", "Justify")], + ); + }); + ui.horizontal(|ui| { + caption_at(ui, tr("Paragraph Spacing Limit:"), 170.0); + let justify = d.s("verticalJustification") == "justify"; + spin_measure(ui, d, "paragraphSpacingLimit", field, justify); + }); + }); + ui.add_space(4.0); check(ui, d, "ignoreWrap", tr("Ignore Text Wrap")); } @@ -829,7 +1406,19 @@ fn text_frame_baseline_grid(d: &Dialog, bad: &dyn Fn(&str) -> String) -> Result< fn confirm_text_frame_options(app: &mut DesignApp, d: &mut Dialog) -> Result { seed_text_frame_options(app, d); - let d = &*d; + // The preview goes; the options are set once, as one undo step. + if d.fields.remove(PREVIEWED).is_some() { + app.session.cancel_interaction(); + } + let p = text_frame_params(app, d)?; + if p.keys().all(|k| k == "ids") { + return Ok(Value::Null); + } + app.run("object.textFrameOptions", Value::Object(p)) +} + +/// The `object.textFrameOptions` params of what the dialog changed. +fn text_frame_params(app: &DesignApp, d: &Dialog) -> Result, String> { let mut p = Map::new(); let bad = |what: &str| format!("Text Frame Options: {what}"); if edited(d, "columns") { @@ -915,8 +1504,11 @@ fn confirm_text_frame_options(app: &mut DesignApp, d: &mut Dialog) -> Result= 0.0).ok_or_else(|| bad("the paragraph spacing limit is not a measure"))?), + ); } // Typing in a frame: the options apply to that frame. if let Some(st) = app.session.active() @@ -925,7 +1517,7 @@ fn confirm_text_frame_options(app: &mut DesignApp, d: &mut Dialog) -> Result Unit { app.session.active().map(|s| s.doc.settings.horizontal_units).unwrap_or(Unit::Picas) } -/// Layout › Create Guides…: guides go on the page in view (or the parent page being edited), not -/// on the first page, and the gutters are typed in the document's ruler unit. +/// The document's vertical ruler unit: what a bare number means in a height field. +fn doc_vertical_unit(app: &DesignApp) -> Unit { + app.session.active().map(|s| s.doc.settings.vertical_units).unwrap_or(Unit::Picas) +} + +/// The Rectangle dialog a click with a frame tool opens: the width (points in `params`) shown in +/// the horizontal ruler unit, the height in the vertical one, as Preferences › Units & +/// Increments and the rulers' menus set them (#1964). +pub fn frame_size_dialog(app: &DesignApp, params: Value) -> Dialog { + let mut d = Dialog::new("frameSize", params); + for (k, unit) in [("width", doc_unit(app)), ("height", doc_vertical_unit(app))] { + if let Some(v) = d.fields.get(k).and_then(Value::as_f64) { + d.fields.insert(k.into(), json!(format_measure(v, unit))); + } + } + d.unit = doc_unit(app); + d.vertical_unit = Some(doc_vertical_unit(app)); + d +} + +/// Layout › Create Guides…: guides go on every page of the spread in view (or of the parent +/// spread being edited), as InDesign's do; "This page" narrows them to +/// the page in view. The gutters are typed in the document's ruler unit. pub fn open_create_guides(app: &mut DesignApp) { let unit = doc_unit(app); let Some((spread, page)) = crate::canvas::current_spread_page(app) else { return }; - let editing_parents = matches!(spread, SpreadRef::Parent(_)); let gutter = json!(format_measure(12.0, unit)); let mut d = Dialog::new( "createGuides", json!({"rows": 0, "columns": 0, "rowGutter": gutter, "columnGutter": gutter, "fitTo": "margins", "removeExisting": false, "spread": serde_json::to_value(spread).unwrap_or(json!(0)), "page": page, - // A parent spread is edited as a whole (both pages of a facing spread), so the guides show on every page that uses it. - "target": if editing_parents { "spread" } else { "page" }, + // InDesign puts the guides on both pages of a facing spread (and a parent spread is + // edited as a whole, so they show on every page that uses it). + "target": "spread", "where": target_description(app, spread, page)}), ); d.unit = unit; @@ -1720,14 +2333,16 @@ fn confirm_document_setup(app: &mut DesignApp, d: &Dialog) -> Result { + let lang = app.ui.language.clone(); + let t = crate::theme::Tokens::get(ui.ctx()); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "Appearance")).font(semibold(12.0))); + // Color Theme: a swatch per theme, as InDesign's. A click shows it at once; + // Cancel goes back to the theme Preferences opened with. + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&lang, "Color Theme:")); + let cur = d.s("brightness"); + for b in crate::theme::Brightness::ALL { + let (r, resp) = ui.allocate_exact_size(egui::vec2(24.0, 24.0), egui::Sense::click()); + let on = cur == b.id(); + let swatch = r.shrink(4.0); + ui.painter().rect_filled(swatch, 1.0, crate::theme::Tokens::for_brightness(b).panel); + ui.painter().rect_stroke(swatch, 1.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); + if on || resp.hovered() { + let ring = if on { t.accent_strong } else { t.field_border }; + ui.painter().rect_stroke(r.shrink(1.0), 2.0, egui::Stroke::new(2.0, ring), egui::StrokeKind::Inside); + } + let name = crate::i18n::tr(&lang, b.label()).to_string(); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, true, on, &name)); + if resp.on_hover_text(&name).clicked() && !on { + d.fields.insert("brightness".into(), json!(b.id())); + d.fields.insert("applyBrightness".into(), json!(b.id())); + } + } + }); + ui.add_space(8.0); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "Panels")).font(semibold(12.0))); + ui.horizontal(|ui| { + let name = crate::rtl::label(ui, crate::i18n::tr(&lang, "Tools Panel:")); + translated_combo(&lang, ui, d, "toolsColumns", &[("single", "Single Column"), ("double", "Double Column")]) + .labelled_by(name.id); + }); + } + "uiScaling" => { crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "UI Scaling")).font(semibold(12.0))); ui.horizontal(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "UI Size:")); @@ -2186,6 +2841,36 @@ fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { } } +/// An alert's message. Import warnings (`lines`: `{text, item}`) list one per line, scrolling +/// when there are many, with Show beside each that names a frame (sets `show`). +fn alert_message(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let lines: Vec<(String, Option)> = d + .fields + .get("lines") + .and_then(Value::as_array) + .into_iter() + .flatten() + .map(|l| (l.get("text").and_then(Value::as_str).unwrap_or_default().to_string(), l.get("item").and_then(Value::as_u64))) + .collect(); + if lines.iter().all(|(_, item)| item.is_none()) { + crate::rtl::label(ui, d.s("message")); + return; + } + let show = crate::i18n::tr(&app.ui.language, "Show"); + egui::ScrollArea::vertical().max_height(360.0).auto_shrink([false, true]).show(ui, |ui| { + for (text, item) in &lines { + ui.horizontal_wrapped(|ui| { + if let Some(item) = item + && ui.small_button(crate::rtl::widget(ui, show)).clicked() + { + d.fields.insert("show".into(), json!(item)); + } + crate::rtl::label(ui, text.as_str()); + }); + } + }); +} + /// Draw the active localized dialog; open popups own Enter/Escape, and result handling drives confirm or cancel. pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let shortcuts = app.ui.dialog.as_ref().is_some_and(|d| d.id == crate::shortcuts_dialog::DIALOG_ID); @@ -2198,9 +2883,10 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let _ = crate::shortcuts_dialog::close(app, false); } let Some(mut d) = app.ui.dialog.clone() else { return }; - // Enter and Escape belong to an open popup (a menu, a name field) rather than the dialog. - // Read before the dialog draws: a popup closed by this frame's Escape is still open here. - let popup_open = egui::Popup::is_any_open(ctx); + // Enter and Escape belong to an open popup (a menu, a name field) or to Find/Change's Change + // Format Settings rather than the dialog. Read before the dialog draws: a popup closed by this + // frame's Escape is still open here. + let popup_open = egui::Popup::is_any_open(ctx) || d.fields.get(FORMAT_EDIT).is_some_and(Value::is_object); let mut result: Option = None; let alert_title = d.s("title"); let title = match d.id.as_str() { @@ -2209,7 +2895,11 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "newDocument" => crate::i18n::tr(&app.ui.language, "New Document"), "frameSize" => crate::i18n::tr(&app.ui.language, "Rectangle"), "goToPage" => crate::i18n::tr(&app.ui.language, "Go to Page"), - "insertTable" => crate::i18n::tr(&app.ui.language, "Create Table"), + "insertPages" => crate::i18n::tr(&app.ui.language, "Insert Pages"), + "insertTable" => crate::i18n::tr(&app.ui.language, "Insert Table…").trim_end_matches('…').trim_end(), + // The menu items' translations (every language has them), without the ellipsis. + "convertTextToTable" => crate::i18n::tr(&app.ui.language, "Convert Text to Table…").trim_end_matches('…').trim_end(), + "convertTableToText" => crate::i18n::tr(&app.ui.language, "Convert Table to Text…").trim_end_matches('…').trim_end(), "cellOptions" => crate::i18n::tr(&app.ui.language, "Cell Options"), "createGuides" => crate::i18n::tr(&app.ui.language, "Create Guides"), "marginsAndColumns" => crate::i18n::tr(&app.ui.language, "Margins and Columns"), @@ -2217,10 +2907,14 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "colorTheme" => crate::i18n::tr(&app.ui.language, "Color Theme"), "documentSetup" => crate::i18n::tr(&app.ui.language, "Document Setup"), "fileInfo" => crate::i18n::tr(&app.ui.language, "File Info"), + // On a page that doesn't start a section yet, InDesign titles it New Section. + "numberingSection" if d.b("newSection") => crate::i18n::tr(&app.ui.language, "New Section"), "numberingSection" => crate::i18n::tr(&app.ui.language, "Numbering & Section Options…").trim_end_matches('…'), "findChange" => crate::i18n::tr(&app.ui.language, "Find/Change"), + "paragraphStyleOptions" if d.b("new") => crate::i18n::tr(&app.ui.language, "New Paragraph Style"), "paragraphStyleOptions" => crate::i18n::tr(&app.ui.language, "Paragraph Style Options"), "deleteParagraphStyle" => crate::i18n::tr(&app.ui.language, "Delete Paragraph Style"), + "characterStyleOptions" if d.b("new") => crate::i18n::tr(&app.ui.language, "New Character Style"), "characterStyleOptions" => crate::i18n::tr(&app.ui.language, "Character Style Options"), "deleteCharacterStyle" => crate::i18n::tr(&app.ui.language, "Delete Character Style"), "footnoteOptions" => crate::i18n::tr(&app.ui.language, "Footnote Options"), @@ -2264,6 +2958,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "colorGroupOptions" => crate::i18n::tr(&app.ui.language, "Color Group Options"), "layerSettings" => crate::i18n::tr(&app.ui.language, "Layer Options"), crate::effects_dialog::ID => crate::i18n::tr(&app.ui.language, "Effects"), + crate::package_dialog::ID => crate::i18n::tr(&app.ui.language, "Package"), + crate::package_dialog::INSTRUCTIONS => crate::i18n::tr(&app.ui.language, "Printing Instructions"), id => match id.strip_prefix("cmd:").and_then(designcraft_engine::find_command) { Some(c) => crate::i18n::tr(&app.ui.language, c.label.trim_end_matches('…')), None => crate::i18n::tr(&app.ui.language, "Dialog"), @@ -2277,11 +2973,16 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let available = (ctx.content_rect().size() - frame_margin - egui::vec2(16.0, 16.0)).max(egui::Vec2::splat(1.0)); let preferred_width: f32 = match d.id.as_str() { "alert" => 440.0, - "closeDocument" | "updateLink" | "deletePages" | "newWorkspace" | "pagesPanelOptions" => 380.0, + "closeDocument" | "updateLink" | "deletePages" | "newWorkspace" | "pagesPanelOptions" | "insertTable" | "convertTableToText" => 380.0, + "insertPages" => 440.0, + "convertTextToTable" => 420.0, "newDocument" => 960.0, "hyphenation" => 560.0, "numberingSection" => 560.0, crate::effects_dialog::ID => 720.0, + "colorPicker" => 580.0, + crate::package_dialog::ID => 720.0, + crate::package_dialog::INSTRUCTIONS => 520.0, _ => 640.0, }; let width = preferred_width.min(available.x); @@ -2337,9 +3038,16 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { result = Some(false); } } - // A centred window only offers its body last frame's height: let this one grow to its - // options' height (the screen still caps it). - let min_body = if side_actions { body_height.min(crate::section_options::BODY_H) } else { 1.0 }; + // A centred window only offers its body last frame's height: let these grow to their + // content's height (the screen still caps it). Numbering & Section Options has its + // options beside its buttons; the Color Picker lays itself out on a fixed canvas, which + // otherwise shows only its top strip (the field cut off, no values, no hex) behind a + // scrollbar. + let min_body = match d.id.as_str() { + "numberingSection" => body_height.min(crate::section_options::BODY_H), + "colorPicker" => body_height.min(picker_layout::SIZE.y + 2.0 * ui.spacing().item_spacing.y), + _ => 1.0, + }; egui::ScrollArea::both() .id_salt(("dialog_body", &d.id)) .min_scrolled_width(1.0) @@ -2458,40 +3166,93 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }); ui.label(d.s("status")); } else { + let (find_formatted, formatted) = (format_set(&d, FIND_FORMAT), format_set(&d, CHANGE_FORMAT)); egui::Grid::new("fc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Find what:")); - text_field(ui, &mut d, "find", 260.0); + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Find what:")); + // InDesign's ⓘ: a Find Format narrows what is found. + if find_formatted { + info_icon( + ui, + crate::i18n::tr( + &app.ui.language, + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + ), + ); + } + }); + text_field_named(ui, &mut d, "find", 260.0, crate::i18n::tr(&app.ui.language, "Find what:")); ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Change to:")); - text_field(ui, &mut d, "change", 260.0); + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Change to:")); + // InDesign's ⓘ: with a Change Format, an empty Change to keeps the text. + if formatted { + info_icon( + ui, + crate::i18n::tr( + &app.ui.language, + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + ), + ); + } + }); + text_field_named(ui, &mut d, "change", 260.0, crate::i18n::tr(&app.ui.language, "Change to:")); ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Search:")); + let search_label = crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Search:")); let cur = d.s("scope"); + // InDesign's names for the scopes. + let scope_name = |v: &str| match v { + "story" => "Story", + "selection" => "Selection", + _ => "Document", + }; craft_ui::ComboBox::from_id_salt("fcscope") - .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, &cur))) + .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, scope_name(&cur)))) .show_ui(ui, |ui| { for v in ["document", "story", "selection"] { - if ui.selectable_label(cur == v, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, v))).clicked() + if ui + .selectable_label(cur == v, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, scope_name(v)))) + .clicked() && d.set_field("scope", json!(v)) { app.session.reset_find_scope(); } } - }); + }) + .response + .labelled_by(search_label.id); ui.end_row(); }); ui.horizontal(|ui| { check(ui, &mut d, "caseSensitive", crate::i18n::tr(&app.ui.language, "Case sensitive")); check(ui, &mut d, "wholeWord", crate::i18n::tr(&app.ui.language, "Whole word")); }); + format_box(&app.ui.language, ui, &mut d, true); + format_box(&app.ui.language, ui, &mut d, false); ui.horizontal(|ui| { let params0 = find_change_params(&d); let params = || params0.clone(); + // Change and Change/Find act on the text Find Next found. + let mut found = params0.clone(); + found["found"] = json!(true); + let selected = app.session.active().and_then(|st| st.selection.text).is_some_and(|t| !t.is_caret()); + let next_status = |app: &mut DesignApp, r: Result| match r { + Ok(Value::Null) => crate::i18n::tr(&app.ui.language, "No matches").to_string(), + Ok(_) => crate::i18n::tr(&app.ui.language, "Found").to_string(), + Err(e) => e, + }; if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Find Next"))).clicked() { let r = app.run("find.next", params()); - let msg = match r { - Ok(Value::Null) => crate::i18n::tr(&app.ui.language, "No matches").to_string(), - Ok(_) => crate::i18n::tr(&app.ui.language, "Found").to_string(), + let msg = next_status(app, r); + d.fields.insert("status".into(), json!(msg)); + } + if ui + .add_enabled(selected, egui::Button::new(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Change")))) + .clicked() + { + let msg = match app.run("find.change", found.clone()) { + Ok(v) => crate::i18n::tr(&app.ui.language, "{count} replacement(s) made") + .replace("{count}", &v["count"].to_string()), Err(e) => e, }; d.fields.insert("status".into(), json!(msg)); @@ -2504,6 +3265,17 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }; d.fields.insert("status".into(), json!(msg)); } + if ui + .add_enabled( + selected, + egui::Button::new(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Change/Find"))), + ) + .clicked() + { + let r = app.run("find.change", found.clone()).and_then(|_| app.run("find.next", params())); + let msg = next_status(app, r); + d.fields.insert("status".into(), json!(msg)); + } if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Count"))).clicked() { let msg = match app.run("find.find", params()) { Ok(v) => crate::i18n::tr(&app.ui.language, "{count} match(es)") @@ -2549,11 +3321,13 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } "insertXref" => insert_xref(app, ui, &mut d), "findFont" => find_font(app, ui, &mut d), - "colorPicker" => color_picker(ui, &mut d), + "colorPicker" => color_picker(app, ui, &mut d), "swatchOptions" => options::swatch_options(app, ui, &mut d), "colorGroupOptions" => options::color_group_options(app, ui, &mut d), "layerSettings" => options::layer_options(app, ui, &mut d), crate::effects_dialog::ID => crate::effects_dialog::show(app, ui, &mut d), + crate::package_dialog::ID => crate::package_dialog::show(app, ui, &mut d), + crate::package_dialog::INSTRUCTIONS => crate::package_dialog::show_instructions(app, ui, &mut d), "preferences" => preferences(app, ui, &mut d, body_height), "print" => print_dialog(app, ui, &mut d), "pdfImport" => { @@ -2950,8 +3724,10 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }); } STEP_AND_REPEAT => step_and_repeat_dialog(app, ui, &mut d), + CORNER_OPTIONS => corner_options_dialog(app, ui, &mut d), TOC => toc_dialog(app, ui, &mut d), id if id.starts_with("cmd:") => command_form(app, ui, &mut d), + "insertPages" => insert_pages_dialog(app, ui, &mut d), "goToPage" => { ui.horizontal(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Page")); @@ -2959,27 +3735,15 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }); } "ruby" => style_cjk::ruby_dialog(app, ui, &mut d), - "insertTable" => { - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Table Dimensions")).font(semibold(12.0))); - egui::Grid::new("ins_table").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Body Rows")); - text_field(ui, &mut d, "bodyRows", 60.0); - crate::rtl::label(ui, crate::i18n::tr_context(&app.ui.language, "Columns", "table")); - text_field(ui, &mut d, "columns", 60.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Header Rows")); - text_field(ui, &mut d, "headerRows", 60.0); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Footer Rows")); - text_field(ui, &mut d, "footerRows", 60.0); - ui.end_row(); - }); - } + "insertTable" => insert_table_dialog(app, ui, &mut d), + "convertTextToTable" | "convertTableToText" => convert_table_dialog(app, ui, &mut d), "cellOptions" => cell_options(app, ui, &mut d), "createGuides" => create_guides_dialog(app, ui, &mut d), "marginsAndColumns" => margins_and_columns_dialog(app, ui, &mut d), "textFrameOptions" => { seed_text_frame_options(app, &mut d); text_frame_options_dialog(app, ui, &mut d); + text_frame_preview(app, &mut d); } "colorTheme" => color_theme(app, ui, &mut d), "documentSetup" => { @@ -2999,11 +3763,19 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { crate::rtl::label(ui, egui::RichText::new(crate::rtl::isolate(&file)).font(semibold(13.0))); ui.add_space(4.0); } - crate::rtl::label(ui, d.s("message")); + alert_message(app, ui, &mut d); } _ => {} } }); + if d.id == "colorPicker" { + match d.fields.remove("_close").as_ref().and_then(Value::as_str) { + Some("ok") => result = Some(true), + Some("cancel") => result = Some(false), + _ => {} + } + return; + } if side_actions { return; } @@ -3014,6 +3786,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "updateLink" => crate::i18n::tr(&app.ui.language, " Update ").to_string(), "findFont" => crate::i18n::tr(&app.ui.language, "Replace All").to_string(), "newDocument" => format!(" {} ", crate::i18n::tr(&app.ui.language, "Create")), + crate::package_dialog::ID => crate::i18n::tr(&app.ui.language, "Package…").to_string(), + crate::package_dialog::INSTRUCTIONS => crate::i18n::tr(&app.ui.language, "Continue").to_string(), _ => crate::i18n::tr(&app.ui.language, " OK ").to_string(), }; let cancel_label = match d.id.as_str() { @@ -3027,6 +3801,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { app.ui.style_preview = d.b("preview"); style_options_preview(app, &mut d); } + // A multi-line field with focus (set by the dialog this frame) takes Enter as a new line. + let multiline = take_multiline_focus(ui.ctx(), &d.id); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui .add( @@ -3034,7 +3810,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { .fill(crate::theme::Tokens::get(ui.ctx()).accent_strong), ) .clicked() - || (!popup_open && ui.input(|i| i.key_pressed(egui::Key::Enter))) + || (!popup_open && !multiline && ui.input(|i| i.key_pressed(egui::Key::Enter))) { result = Some(true); } @@ -3058,15 +3834,33 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }); }); }); + // Show on an import warning: close the warnings (the status line keeps them) and select the + // frame the warning is about, in view, so it can be fixed. + if let Some(item) = d.fields.remove("show").and_then(|v| v.as_u64()) { + app.ui.dialog = None; + if app.run("selection.set", json!({"ids": [item]})).is_ok() { + let _ = app.run("view.fitSelection", json!({})); + } + return; + } // Preferences › General › Interface Language takes effect at once, like any other app setting. if let Some(lang) = d.fields.remove("applyLanguage") && let Err(e) = app.run("app.language", json!({"lang": lang})) { d.fields.insert("status".into(), json!(e)); } + // So does Preferences › Interface › Color Theme, as InDesign's. + if let Some(b) = d.fields.remove("applyBrightness") + && let Err(e) = app.run("window.brightness", json!({"brightness": b})) + { + d.fields.insert("status".into(), json!(e)); + } if d.id == "newDocument" && result.is_none() { crate::new_document::preview(app, &mut d, ctx); } + if d.id == "findChange" && result.is_none() && d.fields.get(FORMAT_EDIT).is_some_and(Value::is_object) { + format_settings(app, ctx, &mut d); + } app.ui.dialog = Some(d); match result { Some(true) => { @@ -3210,10 +4004,18 @@ pub fn cancel(app: &mut DesignApp) { app.cancel_pdf_import(); if let Some(d) = d && d.id == "preferences" - && let Some(lang) = d.fields.get("openedLanguage").and_then(Value::as_str) - && lang != app.ui.language { - let _ = app.run("app.language", json!({"lang": lang})); + if let Some(lang) = d.fields.get("openedLanguage").and_then(Value::as_str) + && lang != app.ui.language + { + let _ = app.run("app.language", json!({"lang": lang})); + } + // The Color Theme shown while choosing goes back too. + if let Some(b) = d.fields.get("openedBrightness").and_then(Value::as_str) + && b != app.ui.brightness.id() + { + let _ = app.run("window.brightness", json!({"brightness": b})); + } } } @@ -3225,9 +4027,243 @@ fn find_change_params(d: &Dialog) -> Value { if let Some(story) = d.fields.get("story") { p["story"] = story.clone(); } + // Find Format: only text with it is found (with nothing in Find what, all text with it). + if let Some(f) = d.fields.get(FIND_FORMAT).and_then(Value::as_object).filter(|f| !f.is_empty()) { + p["findFormat"] = Value::Object(f.clone()); + } + // Change Format: find.change applies it to the changed text (the other commands ignore it). + if let Some(f) = d.fields.get(CHANGE_FORMAT) { + for key in ["charStyle", "paraStyle"] { + if let Some(v) = f.get(key).filter(|v| v.is_string()) { + p[key] = v.clone(); + } + } + if let Some(chars) = f.get("chars").filter(|c| c.as_object().is_some_and(|o| !o.is_empty())) { + p["attrs"] = chars.clone(); + } + } p } +/// Find/Change's Find Format and Change Format ({charStyle?, paraStyle?, chars?}), as their +/// settings set them; absent when there is none. +const FIND_FORMAT: &str = "findFormat"; +const CHANGE_FORMAT: &str = "changeFormat"; +/// The fields of Find or Change Format Settings while it is open (`for`: find|change, its +/// section, `charStyle`, `paraStyle` and `c.`), until OK makes them the format. +const FORMAT_EDIT: &str = "formatEdit"; + +/// Whether the dialog has a Find (`FIND_FORMAT`) or Change Format. +fn format_set(d: &Dialog, key: &str) -> bool { + d.fields.get(key).and_then(Value::as_object).is_some_and(|f| !f.is_empty()) +} + +/// The sections of Find and Change Format Settings: Style Options, then those of Character Style +/// Options. +const FORMAT_SECTIONS: &[(&str, &str)] = &[ + ("style", "Style Options"), + ("chars", "Basic Character Formats"), + ("advanced", "Advanced Character Formats"), + ("color", "Character Color"), + ("openType", "OpenType Features"), + ("underline", "Underline Options"), + ("strikethrough", "Strikethrough Options"), +]; + +/// A small ⓘ whose tooltip is `tip`. +fn info_icon(ui: &mut egui::Ui, tip: &str) { + let (r, resp) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::hover()); + crate::icons::paint(ui.painter(), r, "info", crate::theme::Tokens::get(ui.ctx()).icon); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, tip)); + resp.on_hover_text(tip); +} + +/// What a Find or Change Format box shows: "Character Style: eggs + …". +fn format_summary(lang: &str, format: Option<&Value>) -> String { + let Some(f) = format else { return String::new() }; + let mut parts = Vec::new(); + for (key, label) in [("charStyle", "Character Style:"), ("paraStyle", "Paragraph Style:")] { + if let Some(name) = f.get(key).and_then(Value::as_str) { + parts.push(format!("{} {}", crate::i18n::tr(lang, label), crate::i18n::style_name(lang, name))); + } + } + parts.extend( + f.get("chars").and_then(Value::as_object).into_iter().flatten().map(|(k, v)| format!("{}: {}", setting_label(lang, k), setting_text(v))), + ); + parts.join(" + ") +} + +/// Find/Change's Find Format (`find`) or Change Format, as InDesign's: a box with what the found +/// text must have or what Change All also gives the changed text, a button that opens its +/// settings and one that clears it. +fn format_box(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, find: bool) { + let (key, label, specify) = if find { + (FIND_FORMAT, "Find Format:", "Specify attributes to find") + } else { + (CHANGE_FORMAT, "Change Format:", "Specify attributes to change") + }; + ui.add_space(6.0); + crate::rtl::label(ui, crate::i18n::tr(lang, label)); + ui.horizontal_top(|ui| { + let t = crate::theme::Tokens::get(ui.ctx()); + egui::Frame::new().fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(6.0).show(ui, |ui| { + ui.set_width(340.0); + ui.set_min_height(40.0); + let summary = format_summary(lang, d.fields.get(key)); + ui.add(egui::Label::new(egui::RichText::new(summary).color(t.text)).wrap()); + }); + ui.vertical(|ui| { + if crate::icons::button(ui, "find-format", 22.0, false, crate::i18n::tr(lang, specify)).clicked() { + let f = d.fields.get(key).cloned().unwrap_or_default(); + let mut edit = Map::new(); + edit.insert("for".into(), json!(if find { "find" } else { "change" })); + edit.insert("section".into(), json!("style")); + for key in ["charStyle", "paraStyle"] { + if let Some(v) = f.get(key) { + edit.insert(key.into(), v.clone()); + } + } + for (k, v) in f.get("chars").and_then(Value::as_object).into_iter().flatten() { + edit.insert(format!("c.{k}"), v.clone()); + } + d.fields.insert(FORMAT_EDIT.into(), Value::Object(edit)); + } + if crate::icons::button(ui, "trash", 22.0, false, crate::i18n::tr(lang, "Clear specified attributes")).clicked() { + d.fields.remove(key); + } + }); + }); +} + +/// Find/Change › Find or Change Format Settings, over Find/Change as InDesign's: Style Options +/// (the character and paragraph style the found text has or the changed text gets, [Any Style] +/// for none), then the character sections of Character Style Options with every field blank (any +/// value, or left as it is). OK makes it the Find or Change Format. +fn format_settings(app: &mut DesignApp, ctx: &egui::Context, d: &mut Dialog) { + let lang = app.ui.language.clone(); + let Some(doc) = app.session.active().map(|st| st.doc.clone()) else { + d.fields.remove(FORMAT_EDIT); + return; + }; + let mut fd = Dialog { + id: "changeFormat".into(), + fields: d.fields.get(FORMAT_EDIT).and_then(Value::as_object).cloned().unwrap_or_default(), + unit: d.unit, + vertical_unit: None, + }; + let base = json!({}); + let cf = CharFields { base: &base, sparse: true }; + let find = fd.s("for") == "find"; + let popup_open = egui::Popup::is_any_open(ctx); + let mut result = None; + let host = egui::Id::unique("format_settings"); + crate::modal_drag::modal(ctx, host, host).backdrop_color(egui::Color32::TRANSPARENT).show(ctx, |ui| { + let title_top = ui.cursor().top(); + let title = if find { "Find Format Settings" } else { "Change Format Settings" }; + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, title)).font(semibold(16.0))); + let margin = egui::Frame::popup(ui.style()).inner_margin; + let title_row = egui::Rect::from_min_max( + egui::pos2(ui.max_rect().left() - margin.leftf(), title_top - margin.topf()), + egui::pos2(ui.max_rect().right() + margin.rightf(), ui.min_rect().bottom()), + ); + crate::modal_drag::title_drag(ui, host, title_row); + ui.add_space(10.0); + ui.horizontal_top(|ui| { + // A fixed height: the separator would otherwise stretch the dialog to the window. + ui.set_min_height(380.0); + ui.set_max_height(380.0); + section_list(&lang, ui, &mut fd, false, FORMAT_SECTIONS); + ui.separator(); + ui.vertical(|ui| { + ui.set_min_width(440.0); + match fd.s("section").as_str() { + "chars" => basic_character_formats(app, ui, &mut fd, &cf), + "advanced" => advanced_character_formats(&lang, ui, &mut fd, &cf), + "color" => character_color(&lang, ui, &mut fd, &cf, &doc), + "openType" => open_type_features(&lang, ui, &mut fd, &cf), + "underline" => line_options(&lang, ui, &mut fd, &cf, &doc, "underline"), + "strikethrough" => line_options(&lang, ui, &mut fd, &cf, &doc, "strikethrough"), + _ => format_style_options(&lang, ui, &mut fd, &doc.styles), + } + }); + }); + ui.add_space(12.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .add( + egui::Button::new(crate::rtl::widget(ui, egui::RichText::new(crate::i18n::tr(&lang, " OK ")).color(egui::Color32::WHITE))) + .fill(crate::theme::Tokens::get(ui.ctx()).accent_strong), + ) + .clicked() + || (!popup_open && ui.input(|i| i.key_pressed(egui::Key::Enter))) + { + result = Some(true); + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&lang, "Cancel"))).clicked() + || (!popup_open && ui.input(|i| i.key_pressed(egui::Key::Escape))) + { + result = Some(false); + } + }); + }); + match result { + Some(true) => { + d.fields.remove(FORMAT_EDIT); + let mut f = Map::new(); + for key in ["charStyle", "paraStyle"] { + if let Some(v) = fd.fields.get(key).filter(|v| v.is_string()) { + f.insert(key.into(), v.clone()); + } + } + let chars: Map = + fd.fields.iter().filter(|(_, v)| !v.is_null()).filter_map(|(k, v)| Some((k.strip_prefix("c.")?.to_string(), v.clone()))).collect(); + if !chars.is_empty() { + f.insert("chars".into(), Value::Object(chars)); + } + let key = if find { FIND_FORMAT } else { CHANGE_FORMAT }; + if f.is_empty() { + d.fields.remove(key); + } else { + d.fields.insert(key.into(), Value::Object(f)); + } + } + Some(false) => { + d.fields.remove(FORMAT_EDIT); + } + None => { + d.fields.insert(FORMAT_EDIT.into(), Value::Object(fd.fields)); + } + } +} + +/// Find or Change Format Settings › Style Options: the character and paragraph style the found +/// text has or the changed text gets ([Any Style]: any, or as it is). +fn format_style_options(lang: &str, ui: &mut egui::Ui, fd: &mut Dialog, styles: &designcraft_doc::Styles) { + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "Style Options")).font(semibold(13.0))); + ui.add_space(6.0); + let any = crate::i18n::tr(lang, "[Any Style]"); + let chars: Vec<&str> = styles.character.iter().map(|s| s.name.as_str()).collect(); + let paras: Vec<&str> = styles.paragraph.iter().map(|s| s.name.as_str()).collect(); + egui::Grid::new("format_style_options").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + for (key, label, names) in [("charStyle", "Character Style:", &chars), ("paraStyle", "Paragraph Style:", ¶s)] { + crate::rtl::label(ui, crate::i18n::tr(lang, label)); + let cur = fd.fields.get(key).and_then(Value::as_str).map(str::to_string); + let shown = cur.as_deref().map_or_else(|| any.to_string(), |n| crate::i18n::style_name(lang, n).to_string()); + craft_ui::ComboBox::from_id_salt(("format_style", key)).selected_text(crate::rtl::widget(ui, shown)).width(240.0).show_ui(ui, |ui| { + if ui.selectable_label(cur.is_none(), crate::rtl::widget(ui, any)).clicked() { + fd.fields.remove(key); + } + for n in names.iter() { + if ui.selectable_label(cur.as_deref() == Some(*n), crate::rtl::widget(ui, crate::i18n::style_name(lang, n))).clicked() { + fd.fields.insert(key.into(), json!(n)); + } + } + }); + ui.end_row(); + } + }); +} + /// Apply the open dialog. pub fn confirm(app: &mut DesignApp) -> Result { let Some(d) = app.ui.dialog.take() else { return Err("no dialog open".into()) }; @@ -3277,13 +4313,29 @@ fn confirm_part1(app: &mut DesignApp, d: Dialog) -> Result { let x = d.fields.get("x").and_then(Value::as_f64).unwrap_or(0.0); let y = d.fields.get("y").and_then(Value::as_f64).unwrap_or(0.0); let w = d.m("width").unwrap_or(72.0); - let h = d.m("height").unwrap_or(72.0); + // In the units the dialog showed, not ones changed while it was open. + let h = d.m_in("height", d.vertical_unit.unwrap_or(d.unit)).unwrap_or(72.0); app.run( "frame.create", json!({"spread": d.fields.get("spread").cloned().unwrap_or(json!(0)), "shape": d.s("shape"), "content": d.s("content"), "rect": [x, y, x + w, y + h]}), ) } "ruby" => app.run("type.ruby", style_cjk::ruby_params(&d)), + "insertPages" => { + let n = app.session.active().map_or(1, |st| st.doc.page_count()); + // The page the menu shows: a page past the end is the last. + let page = (d.n("page").unwrap_or(0.0).max(0.0) as usize).min(n.saturating_sub(1)); + let mut p = json!({"count": d.n("count").unwrap_or(1.0).clamp(1.0, 9999.0) as u64}); + match d.s("insert").as_str() { + "before" => p["before"] = json!(page), + "start" => p["before"] = json!(0), + "end" => p["after"] = json!(n.saturating_sub(1)), + _ => p["after"] = json!(page), + } + // A parent's id, or [None]. + p["parent"] = d.s("parent").parse::().map_or(Value::Null, |id| json!(id)); + app.run("layout.pages.insert", p) + } "goToPage" => { // A page name ("iv", "A-3"), a number, or "+n" for an absolute position. let typed = match d.fields.get("page") { @@ -3299,11 +4351,41 @@ fn confirm_part1(app: &mut DesignApp, d: Dialog) -> Result { None => Err(format!("no page \"{typed}\"")), } } - "insertTable" => app.run( - "table.insert", - json!({"rows": d.n("bodyRows").unwrap_or(4.0).max(1.0) as u64, "cols": d.n("columns").unwrap_or(4.0).max(1.0) as u64, - "headerRows": d.n("headerRows").unwrap_or(0.0).max(0.0) as u64, "footerRows": d.n("footerRows").unwrap_or(0.0).max(0.0) as u64}), - ), + "convertTextToTable" | "convertTableToText" => { + let r = if d.id == "convertTextToTable" { + let same = same_separators(&d); + let style = table_style_value(&d); + app.run( + "table.convertFromText", + json!({"columnSeparator": d.s("columnSeparator"), "rowSeparator": d.s("rowSeparator"), + "columns": if same { d.fields.get("columns").cloned().unwrap_or(json!(1)) } else { Value::Null }, "tableStyle": style}), + ) + } else { + app.run("table.convertToText", json!({"columnSeparator": d.s("columnSeparator"), "rowSeparator": d.s("rowSeparator")})) + }; + // On an error (an emptied separator field) the dialog stays open to be corrected. + if let Err(e) = &r { + app.status(e.clone()); + app.ui.dialog = Some(d); + } + r + } + "insertTable" => { + // The table and its style in one step; a style that is gone leaves the document alone, + // and the dialog stays open to be corrected. + let style = table_style_value(&d); + let r = app.run( + "table.insert", + json!({"rows": d.n("bodyRows").unwrap_or(4.0).max(1.0) as u64, "cols": d.n("columns").unwrap_or(4.0).max(1.0) as u64, + "headerRows": d.n("headerRows").unwrap_or(0.0).max(0.0) as u64, "footerRows": d.n("footerRows").unwrap_or(0.0).max(0.0) as u64, + "tableStyle": style}), + ); + if let Err(e) = &r { + app.status(e.clone()); + app.ui.dialog = Some(d); + } + r + } "numberingSection" => { // On an error the dialog stays open to be corrected. let r = crate::section_options::confirm(app, &d); @@ -3442,7 +4524,13 @@ fn confirm_part2(app: &mut DesignApp, d: Dialog) -> Result { if let Some(b) = based.filter(|b| b != designcraft_doc::NO_CHAR_STYLE) { params["basedOn"] = json!(b); } - app.run("style.character.create", params) + // Apply Style to Selection. + app.run("style.character.create", params).and_then(|r| { + if d.b("apply") && app.session.active().is_some_and(|st| st.selection.text.is_some()) { + app.run("style.character.apply", json!({"name": r["name"]}))?; + } + Ok(r) + }) } else { let mut params = json!({"name": name, "chars": chars}); if let Some(b) = based { @@ -3572,7 +4660,11 @@ fn confirm_part3(app: &mut DesignApp, d: Dialog) -> Result { let v = serde_json::to_value(&split).map_err(|e| e.to_string())?; app.run("document.epubSplit", json!({"split": v}))?; } - crate::menus::export_epub(app, &json!({})) + // A cover image file named in the dialog; empty, the first page is the cover. + let cover = d.s("coverImage"); + let cover = cover.trim(); + let params = if cover.is_empty() { json!({}) } else { json!({"coverImage": cover}) }; + crate::menus::export_epub(app, ¶ms) } "pdfExport" => { // Remember the last-used options; `pages` and `title` are per-job and not persisted. @@ -3589,24 +4681,7 @@ fn confirm_part3(app: &mut DesignApp, d: Dialog) -> Result { app.ui.pdf_export.marks_offset = d.s("marksOffset"); app.ui.pdf_export.tagged = d.b("tagged"); - let mut params = json!({ - "standard": d.s("standard"), - "compressImages": d.b("compressImages"), - "spreads": d.b("spreads"), - "bleed": d.b("bleed"), - "tagged": d.b("tagged"), - "marks": { - "crop": d.b("marksCrop"), - "bleed": d.b("marksBleed"), - "pageInfo": d.b("marksPageInfo"), - "weight": d.pt("marksWeight").unwrap_or(0.25), - "offset": d.pt("marksOffset").unwrap_or(6.0), - }, - }); - let flatten = d.s("flatten"); - if !flatten.is_empty() { - params["flatten"] = json!(flatten); - } + let mut params = pdf_export_params(&d); let pages = d.s("pages"); if !pages.trim().is_empty() && pages != "all" { params["pages"] = json!(pages); @@ -3659,7 +4734,23 @@ fn confirm_part4(app: &mut DesignApp, d: Dialog) -> Result { if d.b("cjkFeatures") != d.b("cjkFeatures.initial") { prefs["cjkFeatures"] = json!(d.b("cjkFeatures")); } - app.run("prefs.set", prefs)?; + // No document open: the Units & Increments new documents start with, saved with the + // rest in one `prefs.set` (which checks everything before it changes anything). A value + // that doesn't read, or that it refuses, keeps the dialog open to be fixed, with no + // preference changed. + let saved = default_units_of(&d).and_then(|du| { + if let Some(du) = du { + prefs["defaultUnits"] = du; + } + app.run("prefs.set", prefs) + }); + if let Err(e) = saved { + let mut d = d.clone(); + d.fields.insert("status".into(), json!(e)); + app.status(e.clone()); + app.ui.dialog = Some(d); + return Err(e); + } app.ui.dynamic_spelling = d.b("dynamicSpelling"); if d.fields.contains_key("userWords") { let words: Vec = d.s("userWords").lines().map(str::to_string).collect(); @@ -3678,6 +4769,13 @@ fn confirm_part4(app: &mut DesignApp, d: Dialog) -> Result { }; app.run(view, json!({}))?; app.run("window.richBlack", json!({"on": d.b("richBlack")}))?; + // Interface: the theme (already showing, unless set without a click) and the Tools panel. + if let Some(b) = d.fields.get("brightness").and_then(Value::as_str).filter(|b| *b != app.ui.brightness.id()) { + app.run("window.brightness", json!({"brightness": b}))?; + } + if d.fields.contains_key("toolsColumns") { + app.run("window.toolsDoubleColumn", json!({"on": d.s("toolsColumns") == "double"}))?; + } if let Some(v) = d.n("uiScale") { app.run("window.uiScale", json!({"scale": v / 100.0}))?; } @@ -3696,6 +4794,10 @@ fn confirm_part4(app: &mut DesignApp, d: Dialog) -> Result { } app.run("view.snapPreferences", snap)?; } + if d.b("defaults") { + // The default units went with the preferences above. + return Ok(Value::Null); + } let Some(horizontal) = d.fields.get("horizontalUnits") else { return Ok(Value::Null) }; let mut doc = json!({"horizontalUnits": horizontal}); // Fields can come from `ui.dialog.open`; rulers it doesn't name keep their units. @@ -3897,12 +4999,44 @@ fn confirm_part6(app: &mut DesignApp, d: Dialog) -> Result { } "alert" => Ok(Value::Null), crate::effects_dialog::ID => crate::effects_dialog::confirm(app, &d), + crate::package_dialog::ID | crate::package_dialog::INSTRUCTIONS => { + let r = if d.id == crate::package_dialog::ID { + crate::package_dialog::confirm(app, &d) + } else { + crate::package_dialog::confirm_instructions(app, &d) + }; + // Packaging failed: the dialog stays open with its options and instructions, and the + // status line says why. + if let Err(e) = &r + && app.ui.dialog.is_none() + { + app.status(e.clone()); + app.ui.dialog = Some(d); + } + r + } "findChange" => { let result = if d.b("objectMode") { Ok(Value::Null) } else { app.run("find.change", find_change_params(&d)) }; // Confirmation consumes retained bounds before closing, even on an error. app.session.reset_find_scope(); result } + CORNER_OPTIONS => { + // The preview goes; the corners change once, as one undo step. + if d.fields.contains_key(PREVIEWED) { + app.session.cancel_interaction(); + } + let mut d = d; + seed_corner_options(app, &mut d); + link_corners(&mut d); + let r = corner_options_params(&d).and_then(|p| app.run("object.cornerOptions", p)); + if let Err(e) = &r { + d.fields.remove(PREVIEWED); + d.fields.insert("status".into(), json!(e)); + app.ui.dialog = Some(d); + } + r + } STEP_AND_REPEAT => { // The preview goes; the copies are made once, as one undo step. if d.fields.contains_key(PREVIEWED) { @@ -3967,12 +5101,47 @@ fn grep_error(p: &str) -> Option { designcraft_compose::grep::Grep::new(p, Default::default()).err().map(|e| e.to_string()) } +/// What paragraph style `style` resolves to when based on `based` (the Based On chosen in its +/// dialog, which may not be its own yet): `based`'s chain with the style's own settings over it. +fn style_over( + styles: &designcraft_doc::Styles, + based: &str, + style: &designcraft_doc::ParagraphStyle, +) -> (designcraft_doc::ParaProps, designcraft_doc::CharProps) { + styles.resolve_para(&designcraft_doc::ParaFormat { + style: based.into(), + para: style.para.clone(), + chars: style.chars.clone(), + ..Default::default() + }) +} + /// The Justification spacing check of Paragraph Style Options, against what the section showed: /// the style's own values, or a new style's Based On. fn check_paragraph_style_justification(app: &DesignApp, d: &Dialog) -> Result<(), String> { - let shown = if d.b("new") { d.s("basedOn") } else { d.s("name") }; - let base = - app.session.active().map(|st| serde_json::to_value(st.doc.styles.resolve_para_style(&shown).0).unwrap_or_default()).unwrap_or_default(); + // After Reset To Base the style starts from its Based On (the one chosen here, if any). + let base_of = |st: &designcraft_engine::DocState| { + let chosen = d.s("basedOn"); + if chosen.is_empty() { st.doc.styles.para(&d.s("name")).and_then(|s| s.based_on.clone()).unwrap_or_default() } else { chosen } + }; + let base = app + .session + .active() + .map(|st| { + let pp = if d.b("new") { + st.doc.styles.resolve_para_style(&d.s("basedOn")).0 + } else if d.b("resetToBase") { + st.doc.styles.resolve_para_style(&base_of(st)).0 + } else { + // The style's own settings over the Based On chosen in the dialog. + match st.doc.styles.para(&d.s("name")) { + Some(style) => style_over(&st.doc.styles, &base_of(st), style).0, + None => st.doc.styles.resolve_para_style(&d.s("name")).0, + } + }; + serde_json::to_value(pp).unwrap_or_default() + }) + .unwrap_or_default(); check_justification(d, "p.", &base) } @@ -4010,16 +5179,26 @@ fn confirm_paragraph_style_options(app: &mut DesignApp, d: &mut Dialog) -> Resul let (para, chars) = style_option_maps(d); let based = d.s("basedOn"); let rename = d.s("rename").trim().to_string(); + let next = d.fields.get("nextStyle").cloned(); let (r, style) = if d.b("new") { let mut params = json!({"name": if rename.is_empty() { "Paragraph Style 1" } else { rename.as_str() }, "para": para, "chars": chars}); if !based.is_empty() && based != designcraft_doc::NO_PARA_STYLE { params["basedOn"] = json!(based); } + if let Some(Value::String(n)) = &next { + params["nextStyle"] = json!(n); + } let r = app.run("style.paragraph.create", params)?; let style = r["name"].as_str().unwrap_or_default().to_string(); (r, style) } else { let mut params = json!({"name": name, "para": para, "chars": chars}); + if let Some(n) = next { + params["nextStyle"] = n; + } + if d.b("resetToBase") { + params["resetToBase"] = json!(true); + } if !based.is_empty() { params["basedOn"] = if based == designcraft_doc::NO_PARA_STYLE { Value::Null } else { json!(based) }; } @@ -4037,9 +5216,14 @@ fn confirm_paragraph_style_options(app: &mut DesignApp, d: &mut Dialog) -> Resul }; // The style now exists under `style`: if a later step fails, the dialog stays open on it and // a second OK edits it rather than creating another one. + let created = d.b("new"); d.fields.insert("new".into(), json!(false)); d.fields.insert("name".into(), json!(&style)); d.fields.insert("rename".into(), json!(&style)); + // Apply Style to Selection. + if created && d.b("apply") && app.session.active().is_some_and(|st| st.selection.text.is_some()) { + app.run("style.paragraph.apply", json!({"name": style}))?; + } if d.fields.contains_key("x.tag") { let tag = d.s("x.tag"); let tag = if tag == "[Automatic]" { String::new() } else { tag }; @@ -4104,6 +5288,10 @@ fn style_options_preview(app: &mut DesignApp, d: &mut Dialog) { _ => based.clone(), }; } + // Reset To Base shows on the page too. + if paragraph && d.b("resetToBase") { + params["resetToBase"] = json!(true); + } if shown.as_ref() == Some(¶ms) { return; } @@ -4145,9 +5333,17 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo if new && !d.fields.contains_key("rename") { d.fields.insert("rename".into(), json!(next_style_name(&doc.styles, true))); } + let mut style = style; + let reset = !new && d.b("resetToBase"); + if reset { + style.para = Default::default(); + style.chars = Default::default(); + } let names: Vec = doc.styles.paragraph.iter().map(|p| p.name.clone()).filter(|n| new || *n != name).collect(); - // A new style shows what it would inherit from its Based On. - let (pp, cp) = doc.styles.resolve_para_style(if new { d.fields.get("basedOn").and_then(Value::as_str).unwrap_or("") } else { &name }); + // A new style shows what it would inherit from its Based On (as does one reset to it). + let based_name = d.fields.get("basedOn").and_then(Value::as_str).map(str::to_string).or(style.based_on.clone()).unwrap_or_default(); + // An existing style shows its own settings over the Based On chosen here. + let (pp, cp) = if new || reset { doc.styles.resolve_para_style(&based_name) } else { style_over(&doc.styles, &based_name, &style) }; let units = doc.settings.horizontal_units; let pv = serde_json::to_value(&pp).unwrap_or_default(); let cv = serde_json::to_value(&cp).unwrap_or_default(); @@ -4288,8 +5484,13 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo "openType" => open_type_features(&app.ui.language, ui, d, &CharFields { base: &cv, sparse: false }), "underline" => line_options(&app.ui.language, ui, d, &CharFields { base: &cv, sparse: false }, &doc, "underline"), "strikethrough" => line_options(&app.ui.language, ui, d, &CharFields { base: &cv, sparse: false }, &doc, "strikethrough"), - "border" => border_shading(&app.ui.language, ui, d, &pv, &doc, true), - "shading" => border_shading(&app.ui.language, ui, d, &pv, &doc, false), + "border" | "shading" => { + let border = d.s("section") == "border"; + let title = if border { "Paragraph Border" } else { "Paragraph Shading" }; + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, title)).size(14.0)); + ui.add_space(6.0); + border_shading(&app.ui.language, ui, d, &pv, &doc, border); + } "keep" => keep_options(&app.ui.language, ui, d, &pv), "span" => span_columns(&app.ui.language, ui, d, &pv, &doc), "bullets" => { @@ -4333,13 +5534,59 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo }); let _ = crate::widgets::name_combo(combo, crate::i18n::tr(&app.ui.language, "Based On")); ui.end_row(); + // Next Style: the style of the paragraph that follows when Enter ends this one. + let next_name = crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Next Style:")); + let next = match d.fields.get("nextStyle") { + Some(Value::String(n)) => Some(n.clone()), + Some(_) => None, + None => style.next_style.clone().filter(|n| *n != name), + }; + let same = crate::i18n::tr(&app.ui.language, "[Same style]").to_string(); + let shown = next.as_deref().map_or(same.clone(), |n| crate::i18n::style_name(&app.ui.language, n).to_string()); + craft_ui::ComboBox::from_id_salt("psnext") + .selected_text(crate::rtl::widget(ui, shown)) + .width(220.0) + .show_ui(ui, |ui| { + if ui.selectable_label(next.is_none(), crate::rtl::widget(ui, &same)).clicked() { + d.fields.insert("nextStyle".into(), Value::Null); + } + for n in names.iter().filter(|n| *n != designcraft_doc::NO_PARA_STYLE) { + if ui + .selectable_label( + next.as_deref() == Some(n), + crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, n)), + ) + .clicked() + { + d.fields.insert("nextStyle".into(), json!(n)); + } + } + }) + .response + .labelled_by(next_name.id); + ui.end_row(); }); + // Reset To Base: the style keeps nothing of its own (the edits made here stay). + if !new + && ui + .add_enabled( + !based_name.is_empty() && based_name != designcraft_doc::NO_PARA_STYLE, + egui::Button::new(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Reset To Base"))), + ) + .clicked() + { + d.fields.insert("resetToBase".into(), json!(true)); + } ui.add_space(8.0); crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Style Settings:")).font(semibold(12.0))); let summary = paragraph_style_summary(&app.ui.language, d, &style, &doc.styles); - egui::ScrollArea::vertical().id_salt("paragraph_style_settings").max_height(220.0).show(ui, |ui| { + egui::ScrollArea::vertical().id_salt("paragraph_style_settings").max_height(180.0).show(ui, |ui| { ui.add(egui::Label::new(egui::RichText::new(summary).color(crate::theme::Tokens::get(ui.ctx()).text_dim)).wrap()); }); + if new { + ui.add_space(8.0); + check(ui, d, "apply", crate::i18n::tr(&app.ui.language, "Apply Style to Selection")); + } } }); }); @@ -4560,9 +5807,14 @@ fn para_measure(ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: &str, unit: /// A paragraph attribute chosen from `opts` (serialized value, label). fn para_choice(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: &str, opts: &[(&str, &str)]) { + para_choice_width(lang, ui, d, pv, key, opts, 200.0); +} + +/// [`para_choice`] `width` wide. +fn para_choice_width(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: &str, opts: &[(&str, &str)], width: f32) { let cur = para_value(d, pv, key); let shown = opts.iter().find(|o| cur == o.0).map_or("", |o| crate::i18n::tr(lang, o.1)); - craft_ui::ComboBox::from_id_salt(("pschoice", key)).selected_text(crate::rtl::widget(ui, shown)).width(200.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(("pschoice", key)).selected_text(crate::rtl::widget(ui, shown)).width(width).show_ui(ui, |ui| { for (v, label) in opts { if ui.selectable_label(cur == *v, crate::rtl::widget(ui, crate::i18n::tr(lang, label))).clicked() { para_set(d, key, json!(v)); @@ -4575,18 +5827,32 @@ fn para_choice(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: & /// edited side stores the whole array. fn para_sides(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: &str, field: &dyn Fn(&mut egui::Ui, &str, Option) -> Option) { let mut sides: [f64; 4] = serde_json::from_value(para_value(d, pv, key)).unwrap_or_default(); + let linked = linked(d, key, sides.iter().all(|v| (v - sides[0]).abs() < 1e-9)); let mut changed = false; - egui::Grid::new(("pssides", key)).num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { + // As InDesign's: Top and Bottom, the link, Left and Right. + egui::Grid::new(("pssides", key)).num_columns(5).spacing([8.0, 6.0]).show(ui, |ui| { for (i, label) in ["Top:", "Left:", "Bottom:", "Right:"].into_iter().enumerate() { crate::rtl::label(ui, crate::i18n::tr(lang, label)); - if let Some(side) = sides.get_mut(i) - && let Some(v) = field(ui, &format!("ps{key}{i}"), Some(*side)) - { - *side = v; + if let Some(v) = field(ui, &format!("ps{key}{i}"), sides.get(i).copied()) { + // Linked, a side typed in sets all four. + if linked { + sides = [v; 4]; + } else if let Some(side) = sides.get_mut(i) { + *side = v; + } changed = true; } - if i % 2 == 1 { - ui.end_row(); + match i { + // Linked again, the four take Top's value. + 0 if link_toggle(lang, ui, d, key, linked) => { + sides = [sides[0]; 4]; + changed = true; + } + 0 => {} + 2 => { + ui.label(""); + } + _ => ui.end_row(), } } }); @@ -4595,11 +5861,50 @@ fn para_sides(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: &s } } +/// Whether the four values of `key` are linked ("Make all settings the same"): as the user left +/// the link, else when the four are the same, as InDesign starts it. +fn linked(d: &Dialog, key: &str, same: bool) -> bool { + d.fields.get(&format!("_linked.{key}")).and_then(Value::as_bool).unwrap_or(same) +} + +/// The link button between a border's or shading's four sides or corners; true when it was +/// clicked to link them, and the caller makes the four the same. +fn link_toggle(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, key: &str, linked: bool) -> bool { + let tip = crate::i18n::tr(lang, "Make all settings the same"); + let clicked = crate::widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, tip, egui::vec2(22.0, 21.0)).clicked(); + if clicked { + d.fields.insert(format!("_linked.{key}"), json!(!linked)); + } + clicked && !linked +} + /// A swatch menu with a colour chip per swatch (hidden swatches left out); returns the swatch /// picked. fn swatch_menu(ui: &mut egui::Ui, doc: &designcraft_doc::Document, id: impl egui::AsIdSalt, current: &str, width: f32) -> Option { + swatch_menu_with(ui, doc, id, current, width, None) +} + +/// [`swatch_menu`] with, given its translated label, Text Color first (a paragraph rule's +/// colour: the text's own), as InDesign's Paragraph Rules › Color. +fn swatch_menu_with( + ui: &mut egui::Ui, + doc: &designcraft_doc::Document, + id: impl egui::AsIdSalt, + current: &str, + width: f32, + text_color: Option<&str>, +) -> Option { let mut picked = None; - craft_ui::ComboBox::from_id_salt(id).selected_text(current).width(width).show_ui(ui, |ui| { + let shown = match text_color { + Some(label) if current == designcraft_doc::TEXT_COLOR => label, + _ => current, + }; + craft_ui::ComboBox::from_id_salt(id).selected_text(shown).width(width).show_ui(ui, |ui| { + if let Some(label) = text_color + && ui.selectable_label(current == designcraft_doc::TEXT_COLOR, label).clicked() + { + picked = Some(designcraft_doc::TEXT_COLOR.to_string()); + } for w in doc.swatches.iter().filter(|w| !w.hidden) { let (c, g) = crate::widgets::swatch_colors(doc, &w.name, 1.0); ui.horizontal(|ui| { @@ -4645,49 +5950,95 @@ fn border_type_menu(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, d } /// Corner size and shape of a border or shading (`key` holds top left, top right, bottom right, -/// bottom left); an edited corner stores all four. +/// bottom left), as InDesign lays them out: Top Left and Top Right over Bottom Left and Bottom +/// Right, each a corner icon, a size and a shape menu showing the shapes' glyphs, with the link in +/// the middle. An edited corner stores all four. fn para_corners(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: &str, units: Unit) { use designcraft_geom::corners::{Corner, CornerShape}; let mut corners: [Corner; 4] = serde_json::from_value(para_value(d, pv, key)).unwrap_or_default(); - let mut changed = false; - egui::Grid::new(("pscorners", key)).num_columns(3).spacing([8.0, 6.0]).show(ui, |ui| { - for (i, label) in ["Top Left:", "Top Right:", "Bottom Right:", "Bottom Left:"].into_iter().enumerate() { - let Some(c) = corners.get_mut(i) else { continue }; - crate::rtl::label(ui, crate::i18n::tr(lang, label)); - if let Some(v) = crate::widgets::measure(ui, &format!("ps{key}{i}"), Some(c.size), units, 70.0) { - c.size = if v.is_finite() { v.clamp(0.0, 10_000.0) } else { 0.0 }; - changed = true; - } - craft_ui::ComboBox::from_id_salt(("pscornershape", key, i)) - .selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, c.shape.label()))) - .width(130.0) - .show_ui(ui, |ui| { - for shape in CornerShape::ALL { - if ui.selectable_label(c.shape == shape, crate::rtl::widget(ui, crate::i18n::tr(lang, shape.label()))).clicked() { - c.shape = shape; - changed = true; - } - } + let linked = linked(d, key, corners.iter().all(|c| *c == corners[0])); + let mut changed = None; + let mut relinked = false; + let mut corner = |ui: &mut egui::Ui, i: usize, icon: &str, tip: &str, changed: &mut Option| { + let Some(c) = corners.get_mut(i) else { return }; + let tip = crate::i18n::tr(lang, tip); + let (r, resp) = ui.allocate_exact_size(egui::vec2(18.0, 18.0), egui::Sense::hover()); + crate::icons::paint(ui.painter(), r, icon, crate::theme::Tokens::get(ui.ctx()).icon); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, tip)); + resp.on_hover_text(tip); + let id = format!("ps{key}{i}"); + if let Some(v) = crate::widgets::NumField::measure(&id, Some(c.size), units).width(84.0).spinner().range(0.0, 10_000.0).show(ui) { + c.size = if v.is_finite() { v.clamp(0.0, 10_000.0) } else { 0.0 }; + *changed = Some(i); + } + let shape_label = crate::i18n::tr(lang, "Corner Shape"); + let shown = c.shape; + let resp = crate::panels::properties::corner_shape_button(ui, shown, 52.0); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, shape_label)); + egui::Popup::menu(&resp).show(|ui| { + for shape in CornerShape::ALL { + let row = crate::panels::properties::preview_row(ui, c.shape == shape, crate::i18n::tr(lang, shape.label()), 22.0, |p, r, ink| { + crate::panels::properties::paint_corner_shape(p, r, shape, ink) }); + if row.clicked() { + c.shape = shape; + *changed = Some(i); + ui.close(); + } + } + }); + }; + egui::Grid::new(("pscorners", key)).num_columns(3).spacing([14.0, 6.0]).show(ui, |ui| { + for (row, [(li, licon, ltip), (ri, ricon, rtip)]) in [ + [(0, "corner-tl", "Top Left Corner"), (1, "corner-tr", "Top Right Corner")], + [(3, "corner-bl", "Bottom Left Corner"), (2, "corner-br", "Bottom Right Corner")], + ] + .into_iter() + .enumerate() + { + ui.horizontal(|ui| corner(ui, li, licon, ltip, &mut changed)); + // Linked again, the four take Top Left's size and shape. + if row == 0 { + if link_toggle(lang, ui, d, key, linked) { + relinked = true; + } + } else { + ui.label(""); + } + ui.horizontal(|ui| corner(ui, ri, ricon, rtip, &mut changed)); ui.end_row(); } }); - if changed { + if relinked { + corners = [corners[0]; 4]; + changed = Some(0); + } + if let Some(i) = changed { + // Linked, the corner changed sets all four. + if linked { + corners = [corners[i]; 4]; + } para_set(d, key, serde_json::to_value(corners).unwrap_or_default()); } } -/// The colour and tint rows of a border, its gap or shading (`keys`: colour, tint), in a grid. +/// Tint presets (%), as InDesign's tint menus list them. +const TINTS: &[f64] = &[0.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 100.0]; + +/// The colour and tint rows of a border, its gap or shading (`keys`: colour, tint, overprint), in +/// a grid; Overprint follows the tint, as InDesign's. +#[allow(clippy::too_many_arguments)] fn para_color_tint( + lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, doc: &designcraft_doc::Document, - keys: (&str, &str), + keys: (&str, &str, &str), labels: (&str, &str), enabled: bool, ) { - let (color_key, tint_key) = keys; + let (color_key, tint_key, overprint_key) = keys; crate::rtl::label(ui, labels.0); let color = para_value(d, pv, color_key).as_str().unwrap_or("").to_string(); if let Some(w) = ui.add_enabled_ui(enabled, |ui| swatch_menu(ui, doc, ("pscolor", color_key), &color, 160.0)).inner { @@ -4695,10 +6046,18 @@ fn para_color_tint( } ui.end_row(); crate::rtl::label(ui, labels.1); - let tint = para_value(d, pv, tint_key).as_f64().map(|t| t * 100.0); - if let Some(v) = ui.add_enabled_ui(enabled, |ui| crate::widgets::number(ui, &format!("ps{tint_key}"), tint, "%", 60.0, 0)).inner { - para_set(d, tint_key, json!(if v.is_finite() { (v / 100.0).clamp(0.0, 1.0) } else { 1.0 })); - } + let tint = para_value(d, pv, tint_key).as_f64().map(|t| (t * 1000.0).round() / 10.0); + ui.add_enabled_ui(enabled, |ui| { + ui.horizontal(|ui| { + let id = format!("ps{tint_key}"); + let field = crate::widgets::NumField::number(&id, tint, "%", 0).width(96.0).spinner().presets(TINTS).step(10.0).range(0.0, 100.0); + if let Some(v) = field.show(ui) { + para_set(d, tint_key, json!(if v.is_finite() { (v / 100.0).clamp(0.0, 1.0) } else { 1.0 })); + } + ui.add_space(8.0); + para_check(ui, d, pv, overprint_key, crate::i18n::tr(lang, "Overprint")); + }); + }); ui.end_row(); } @@ -4716,12 +6075,17 @@ fn border_shading(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, doc crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, t)).font(semibold(12.0))); }; ui.add_space(4.0); - egui::ScrollArea::vertical().id_salt(("psbsscroll", border)).max_height(330.0).show(ui, |ui| { + egui::ScrollArea::vertical().id_salt(("psbsscroll", border)).max_height(400.0).show(ui, |ui| { ui.add_enabled_ui(on, |ui| { if border { head(ui, "Stroke"); para_sides(lang, ui, d, pv, &key("Weights"), &|ui: &mut egui::Ui, id: &str, v: Option| { - crate::widgets::number(ui, id, v, " pt", 60.0, 2).map(|w| if w.is_finite() { w.clamp(0.0, 1000.0) } else { 0.0 }) + crate::widgets::NumField::number(id, v, " pt", 2) + .width(84.0) + .spinner() + .range(0.0, 1000.0) + .show(ui) + .map(|w| if w.is_finite() { w.clamp(0.0, 1000.0) } else { 0.0 }) }); ui.add_space(4.0); } else { @@ -4734,12 +6098,12 @@ fn border_shading(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, doc ui.end_row(); } let (color, tint) = (crate::i18n::tr(lang, "Color:"), crate::i18n::tr(lang, "Tint:")); - para_color_tint(ui, d, pv, doc, (&key("Color"), &key("Tint")), (color, tint), true); + para_color_tint(lang, ui, d, pv, doc, (&key("Color"), &key("Tint"), &key("Overprint")), (color, tint), true); if border { // The gap colour shows between the dashes, dots or stripes. let solid = para_value(d, pv, "borderType").get("kind").and_then(Value::as_str).is_none_or(|k| k == "solid"); let (gap, gap_tint) = (crate::i18n::tr(lang, "Gap Color:"), crate::i18n::tr(lang, "Gap Tint:")); - para_color_tint(ui, d, pv, doc, ("borderGapColor", "borderGapTint"), (gap, gap_tint), !solid); + para_color_tint(lang, ui, d, pv, doc, ("borderGapColor", "borderGapTint", "borderGapOverprint"), (gap, gap_tint), !solid); crate::rtl::label(ui, crate::i18n::tr(lang, "Cap:")); para_choice(lang, ui, d, pv, "borderCap", &[("butt", "Butt"), ("round", "Round"), ("projecting", "Projecting")]); ui.end_row(); @@ -4750,28 +6114,45 @@ fn border_shading(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, doc }); head(ui, "Corner Size and Shape"); para_corners(lang, ui, d, pv, &key("Corners"), units); - head(ui, "Offsets"); - para_sides(lang, ui, d, pv, &key("Offsets"), &|ui: &mut egui::Ui, id: &str, v: Option| { - crate::widgets::measure(ui, id, v, units, 60.0) - }); - ui.add_space(4.0); - egui::Grid::new(("psbsedges", border)).num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, crate::i18n::tr(lang, "Top Edge:")); - para_choice( - lang, - ui, - d, - pv, - &key("Top"), - &[("ascent", "Ascent"), ("baseline", "Baseline"), ("emBox", "Em Box Top"), ("leading", "Leading")], - ); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(lang, "Bottom Edge:")); - para_choice(lang, ui, d, pv, &key("Bottom"), &[("descent", "Descent"), ("baseline", "Baseline"), ("emBox", "Em Box Bottom")]); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(lang, "Width:")); - para_choice(lang, ui, d, pv, &key("Width"), &[("column", "Column"), ("text", "Text")]); - ui.end_row(); + // The offsets, and beside them the edges and width the box follows, as InDesign's. + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + head(ui, "Offsets"); + para_sides(lang, ui, d, pv, &key("Offsets"), &|ui: &mut egui::Ui, id: &str, v: Option| { + crate::widgets::spin_measure(ui, id, v, units, 72.0) + }); + }); + ui.add_space(12.0); + ui.vertical(|ui| { + head(ui, ""); + egui::Grid::new(("psbsedges", border)).num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(lang, "Top Edge:")); + para_choice_width( + lang, + ui, + d, + pv, + &key("Top"), + &[("ascent", "Ascent"), ("baseline", "Baseline"), ("emBox", "Em Box Top"), ("leading", "Leading")], + 92.0, + ); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(lang, "Bottom Edge:")); + para_choice_width( + lang, + ui, + d, + pv, + &key("Bottom"), + &[("descent", "Descent"), ("baseline", "Baseline"), ("emBox", "Em Box Bottom")], + 92.0, + ); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(lang, "Width:")); + para_choice_width(lang, ui, d, pv, &key("Width"), &[("column", "Column"), ("text", "Text")], 92.0); + ui.end_row(); + }); + }); }); ui.add_space(4.0); if border { @@ -5510,6 +6891,29 @@ fn section_list(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cjk: bool, sectio }); } +/// Preferences with no document open: the Units & Increments new documents start with, as +/// `prefs.set`'s `defaultUnits` (None with a document open). An increment that doesn't read is an +/// error naming the field. +fn default_units_of(d: &Dialog) -> Result, String> { + if !d.b("defaults") { + return Ok(None); + } + let mut du = json!({}); + for k in ["horizontalUnits", "verticalUnits", "rulerOrigin"] { + if let Some(v) = d.fields.get(k).filter(|v| v.is_string()) { + du[k] = v.clone(); + } + } + if let Some(t) = d.fields.get("keyboardIncrement").and_then(Value::as_str) { + du["keyboardIncrement"] = json!(parse_measure(t, Unit::Points).map_err(|_| format!("Cursor Key: `{t}` is not a measurement"))?); + } + if let Some(v) = d.fields.get("kerningIncrement").filter(|v| !v.is_null()) { + let n = d.n("kerningIncrement").filter(|v| v.is_finite()); + du["kerningIncrement"] = json!(n.ok_or_else(|| format!("Kerning/Tracking: `{}` is not a number", v.as_str().unwrap_or_default()))?); + } + Ok(Some(du)) +} + /// "Paragraph Style n" / "Character Style n": the first name no style has. fn next_style_name(styles: &designcraft_doc::Styles, para: bool) -> String { let (base, count) = if para { ("Paragraph Style", styles.paragraph.len()) } else { ("Character Style", styles.character.len()) }; @@ -5533,8 +6937,39 @@ pub fn open_style_options(app: &mut DesignApp, para: bool, name: &str) { pub fn open_new_style(app: &mut DesignApp, para: bool) { let Some(st) = app.session.active() else { return }; let name = next_style_name(&st.doc.styles, para); + let has_text = st.selection.text.is_some(); + let (bp, bc) = st.doc.styles.resolve_para_style(designcraft_doc::NO_PARA_STYLE); let id = if para { "paragraphStyleOptions" } else { "characterStyleOptions" }; - app.ui.dialog = Some(Dialog::new(id, json!({"new": true, "rename": name}))); + let mut fields = json!({"new": true, "rename": name}); + // New Paragraph Style with text selected starts from it, as InDesign's: Based On [No Paragraph + // Style] and every setting of the text that differs, applied to the selection on OK. + if para + && has_text + && let Some(sel) = app.session.execute("type.selectionAttrs", &json!({})).ok().filter(|v| !v.is_null()) + { + for (prefix, now, base) in + [("p.", &sel["para"], serde_json::to_value(bp).unwrap_or_default()), ("c.", &sel["chars"], serde_json::to_value(bc).unwrap_or_default())] + { + for (k, v) in now.as_object().into_iter().flatten() { + if !v.is_null() && base.get(k) != Some(v) && base.get(k).is_some() { + fields[format!("{prefix}{k}")] = v.clone(); + } + } + } + fields["apply"] = json!(true); + } + // New Character Style with text selected starts from what the text has of its own over its + // paragraph ("[None] + Bold + color"), applied to the selection on OK, as InDesign's. + if !para + && has_text + && let Some(sel) = app.session.execute("type.selectionAttrs", &json!({})).ok().filter(|v| !v.is_null()) + { + for (k, v) in sel["ownChars"].as_object().into_iter().flatten().filter(|(_, v)| !v.is_null()) { + fields[format!("c.{k}")] = v.clone(); + } + fields["apply"] = json!(true); + } + app.ui.dialog = Some(Dialog::new(id, fields)); } /// The General section of the Character Style Options dialog: name, Based On, and a summary of @@ -5651,7 +7086,13 @@ fn character_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo "underline" => line_options(&lang, ui, d, &cf, &doc, "underline"), "strikethrough" => line_options(&lang, ui, d, &cf, &doc, "strikethrough"), s if style_cjk::is_section(s) => style_cjk::section_ui(app, ui, d, s, &cf, None, &doc), - _ => character_style_general(&lang, ui, d, styles, &style, &based), + _ => { + character_style_general(&lang, ui, d, styles, &style, &based); + if new { + ui.add_space(8.0); + check(ui, d, "apply", crate::i18n::tr(&lang, "Apply Style to Selection")); + } + } }); }); } @@ -5712,7 +7153,7 @@ fn char_number(ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, key: &str, su fn char_choice(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, key: &str, opts: &[(Value, &str)]) { let cur = cf.get(d, key); let shown = opts.iter().find(|o| o.0 == cur).map_or("", |o| crate::i18n::tr(lang, o.1)); - craft_ui::ComboBox::from_id_salt(("cs", key)).selected_text(crate::rtl::widget(ui, shown)).width(200.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(("cs", key)).selected_text(crate::rtl::widget(ui, shown)).width(140.0).show_ui(ui, |ui| { if cf.sparse && ui.selectable_label(cur.is_null(), " ").clicked() { cf.set(d, key, Value::Null); } @@ -5755,6 +7196,7 @@ fn basic_character_formats(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo let lang = lang.as_str(); let fonts = crate::panels::fonts(app); let menu = crate::panels::font_menu(app); + // Two columns, as InDesign's: the font, then Size | Leading, Kerning | Tracking, Case | Position. egui::Grid::new("psc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { crate::rtl::label(ui, crate::i18n::tr(lang, "Font Family:")); let fam = cf.get(d, "fontFamily").as_str().unwrap_or("").to_string(); @@ -5804,9 +7246,10 @@ fn basic_character_formats(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo }); } ui.end_row(); + }); + egui::Grid::new("psc2").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { crate::rtl::label(ui, crate::i18n::tr(lang, "Size:")); char_number(ui, d, cf, "size", " pt", 1.0, (0.1, 1296.0)); - ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(lang, "Leading:")); let lv = match cf.get(d, "leading") { v if v["kind"] == "points" => v["value"].as_f64(), @@ -5827,7 +7270,6 @@ fn basic_character_formats(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo "kerning", &[(json!({"kind": "metrics"}), "Metrics"), (json!({"kind": "optical"}), "Optical"), (json!({"kind": "none"}), "0")], ); - ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(lang, "Tracking:")); char_number(ui, d, cf, "tracking", "", 1.0, (-1000.0, 10000.0)); ui.end_row(); @@ -5845,7 +7287,6 @@ fn basic_character_formats(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo (json!("openTypeAllSmallCaps"), "OpenType All Small Caps"), ], ); - ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(lang, "Position:")); char_choice( lang, @@ -6074,22 +7515,26 @@ fn delete_character_style(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog }); } -fn combo(ui: &mut egui::Ui, d: &mut Dialog, key: &str, opts: &[(&str, &str)]) { +fn combo(ui: &mut egui::Ui, d: &mut Dialog, key: &str, opts: &[(&str, &str)]) -> egui::Response { let cur = d.s(key); let shown = opts.iter().find(|o| o.0 == cur).map_or(cur.as_str(), |o| o.1).to_string(); - craft_ui::ComboBox::from_id_salt(key).selected_text(crate::rtl::widget(ui, &shown)).width(170.0).show_ui(ui, |ui| { - for (v, label) in opts { - if ui.selectable_label(cur == *v, crate::rtl::widget(ui, *label)).clicked() { - d.fields.insert(key.into(), json!(v)); + craft_ui::ComboBox::from_id_salt(key) + .selected_text(crate::rtl::widget(ui, &shown)) + .width(170.0) + .show_ui(ui, |ui| { + for (v, label) in opts { + if ui.selectable_label(cur == *v, crate::rtl::widget(ui, *label)).clicked() { + d.fields.insert(key.into(), json!(v)); + } } - } - }); + }) + .response } /// Translate enum captions without changing serialized values or user-defined names. -fn translated_combo(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, key: &str, opts: &[(&str, &str)]) { +fn translated_combo(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, key: &str, opts: &[(&str, &str)]) -> egui::Response { let opts: Vec<_> = opts.iter().map(|(value, label)| (*value, crate::i18n::tr(lang, label))).collect(); - combo(ui, d, key, &opts); + combo(ui, d, key, &opts) } /// Style name picker (paragraph or character styles of the active document). @@ -6436,22 +7881,65 @@ fn paragraph_rules(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, prefix: & }); ui.add_space(6.0); let on = rule["on"].as_bool().unwrap_or(false); + let kind: designcraft_doc::StrokeType = serde_json::from_value(rule["type"].clone()).unwrap_or_default(); + let doc = app.session.active().map(|st| &st.doc); + // As InDesign's: Weight and Type, Color and Tint, Gap Color and Gap Tint, Width and Offset, + // Left and Right Indent, then Keep In Frame (rule above). ui.add_enabled_ui(on, |ui| { egui::Grid::new(("rules", which)).num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { crate::rtl::label(ui, crate::i18n::tr(lang, "Weight:")); - if let Some(v) = sized(ui, &mut |ui| crate::widgets::number(ui, &key("weight"), rule["weight"].as_f64(), " pt", 80.0, 2)) { + if let Some(v) = sized(ui, &mut |ui| { + crate::widgets::NumField::number(&key("weight"), rule["weight"].as_f64(), " pt", 2).width(80.0).spinner().range(0.0, 1000.0).show(ui) + }) { d.fields.insert(key("weight"), json!(v.clamp(0.0, 1000.0))); } + crate::rtl::label(ui, crate::i18n::tr(lang, "Type:")); + let custom: Vec = doc.map_or_else(Vec::new, |doc| doc.stroke_styles.iter().map(|s| s.name.clone()).collect()); + let shown = match &kind { + designcraft_doc::StrokeType::Style { name } => name.clone(), + k => crate::i18n::tr(lang, k.label()).to_string(), + }; + craft_ui::ComboBox::from_id_salt(("rule_type", which)).selected_text(crate::rtl::widget(ui, shown)).width(130.0).show_ui(ui, |ui| { + for k in rule_types().into_iter().chain(custom.into_iter().map(|name| designcraft_doc::StrokeType::Style { name })) { + let label = match &k { + designcraft_doc::StrokeType::Style { name } => name.clone(), + k => crate::i18n::tr(lang, k.label()).to_string(), + }; + if ui.selectable_label(k == kind, crate::rtl::widget(ui, label)).clicked() { + d.fields.insert(key("type"), serde_json::to_value(&k).unwrap_or_default()); + } + } + }); + ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(lang, "Color:")); let color = rule["color"].as_str().unwrap_or("").to_string(); - if let Some(w) = app.session.active().and_then(|st| swatch_menu(ui, &st.doc, ("rule_color", which), &color, 130.0)) { + let text_color = crate::i18n::tr(lang, "Text Color"); + if let Some(w) = doc.and_then(|doc| swatch_menu_with(ui, doc, ("rule_color", which), &color, 130.0, Some(text_color))) { d.fields.insert(key("color"), json!(w)); } - ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(lang, "Tint:")); if let Some(v) = sized(ui, &mut |ui| crate::widgets::number(ui, &key("tint"), rule["tint"].as_f64().map(|t| t * 100.0), "%", 80.0, 0)) { d.fields.insert(key("tint"), json!((v / 100.0).clamp(0.0, 1.0))); } + ui.end_row(); + // A solid rule has no gaps. + let gaps = !matches!(kind, designcraft_doc::StrokeType::Solid); + crate::rtl::label(ui, crate::i18n::tr(lang, "Gap Color:")); + let gap = rule["gapColor"].as_str().unwrap_or(designcraft_color::swatch::NONE).to_string(); + ui.add_enabled_ui(gaps, |ui| { + if let Some(w) = doc.and_then(|doc| swatch_menu(ui, doc, ("rule_gap", which), &gap, 130.0)) { + d.fields.insert(key("gapColor"), json!(w)); + } + }); + crate::rtl::label(ui, crate::i18n::tr(lang, "Gap Tint:")); + ui.add_enabled_ui(gaps && gap != designcraft_color::swatch::NONE, |ui| { + // A full gap tint is left out of the rule's JSON: it reads 100%. + let gap_tint = rule["gapTint"].as_f64().unwrap_or(1.0) * 100.0; + if let Some(v) = sized(ui, &mut |ui| crate::widgets::number(ui, &key("gapTint"), Some(gap_tint), "%", 80.0, 0)) { + d.fields.insert(key("gapTint"), json!((v / 100.0).clamp(0.0, 1.0))); + } + }); + ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(lang, "Width:")); let column = rule["columnWidth"].as_bool().unwrap_or(true); craft_ui::ComboBox::from_id_salt(("rule_width", which)) @@ -6464,23 +7952,50 @@ fn paragraph_rules(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, prefix: & } } }); - ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(lang, "Offset:")); - if let Some(v) = sized(ui, &mut |ui| crate::widgets::measure(ui, &key("offset"), rule["offset"].as_f64(), v_units, 80.0)) { + if let Some(v) = sized(ui, &mut |ui| { + crate::widgets::NumField::measure(&key("offset"), rule["offset"].as_f64(), v_units).width(80.0).spinner().show(ui) + }) { d.fields.insert(key("offset"), json!(v)); } ui.end_row(); for (label, field) in [("Left Indent:", "leftIndent"), ("Right Indent:", "rightIndent")] { crate::rtl::label(ui, crate::i18n::tr(lang, label)); - if let Some(v) = sized(ui, &mut |ui| crate::widgets::measure(ui, &key(field), rule[field].as_f64(), h_units, 80.0)) { + if let Some(v) = + sized(ui, &mut |ui| crate::widgets::NumField::measure(&key(field), rule[field].as_f64(), h_units).width(80.0).spinner().show(ui)) + { d.fields.insert(key(field), json!(v)); } } ui.end_row(); }); + if which == "ruleAbove" { + let mut keep = rule["keepInFrame"].as_bool().unwrap_or(false); + if ui.checkbox(&mut keep, crate::rtl::widget(ui, crate::i18n::tr(lang, "Keep In Frame"))).changed() { + d.fields.insert(key("keepInFrame"), json!(keep)); + } + } }); } +/// The built-in stroke types a paragraph rule can have (Paragraph Rules › Type). +fn rule_types() -> Vec { + use designcraft_doc::StrokeType as T; + vec![ + T::Solid, + T::ThickThick, + T::ThickThin, + T::ThickThinThick, + T::ThinThick, + T::ThinThickThin, + T::ThinThin, + T::Dashed { pattern: vec![12.0, 4.0] }, + T::Dotted, + T::Hashed, + T::Wavy, + ] +} + /// Document Footnote Options: Numbering and Formatting / Layout tabs. fn footnote_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.horizontal(|ui| { @@ -6749,6 +8264,7 @@ fn command_form(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } const STEP_AND_REPEAT: &str = "cmd:edit.stepAndRepeat"; +pub(crate) const CORNER_OPTIONS: &str = "cmd:object.cornerOptions"; const TOC: &str = "cmd:toc.generate"; /// One included style of the Table of Contents dialog, as `toc.generate` takes it, with the @@ -6768,11 +8284,20 @@ struct TocRow { } fn toc_separator_shown(s: &str) -> String { - s.replace('\t', "^t").replace('\u{2003}', "^m").replace('\u{2002}', "^>") + designcraft_doc::story::toc_separator_notation(s) } fn toc_separator_typed(s: &str) -> String { - s.replace("^t", "\t").replace("^m", "\u{2003}").replace("^>", "\u{2002}") + designcraft_doc::story::toc_separator_text(s) +} + +/// Appends the special-character `code` (such as `^y`) to the separator field's notation. A +/// trailing unescaped caret is a literal caret: it's escaped first, so it doesn't swallow the code. +fn toc_separator_append(field: &mut String, code: &str) { + if field.chars().rev().take_while(|&c| c == '^').count() % 2 == 1 { + field.push('^'); + } + field.push_str(code); } fn toc_rows(d: &Dialog) -> Vec { @@ -6817,6 +8342,7 @@ pub fn open_toc(app: &mut DesignApp) { "charStyles": char_styles, "hasToc": toc.is_some(), "replace": toc.is_some(), + "createBookmarks": toc.is_none_or(|t| t.create_bookmarks), }); app.ui.dialog = Some(Dialog::new(TOC, fields)); } @@ -6845,7 +8371,7 @@ fn toc_params(app: &DesignApp, d: &Dialog) -> Value { e }) .collect(); - let mut p = json!({"title": d.s("title"), "entries": entries, "replace": d.b("replace")}); + let mut p = json!({"title": d.s("title"), "entries": entries, "replace": d.b("replace"), "createBookmarks": d.b("createBookmarks")}); let title_style = d.s("titleStyle"); if !title_style.is_empty() { p["titleStyle"] = json!(title_style); @@ -6858,21 +8384,26 @@ fn toc_params(app: &DesignApp, d: &Dialog) -> Value { /// A style menu whose first choice, stored as "", is `auto` (the generated paragraph style, or /// [None] for a character style). -fn toc_style_combo(ui: &mut egui::Ui, salt: &str, styles: &[String], auto: &str, cur: &mut String) -> bool { +fn toc_style_combo(ui: &mut egui::Ui, salt: &str, styles: &[String], auto: &str, cur: &mut String, name: egui::Id) -> bool { let shown = if cur.is_empty() { auto.to_string() } else { cur.clone() }; let mut changed = false; - craft_ui::ComboBox::from_id_salt(("toc_style", salt)).selected_text(shown).width(200.0).show_ui(ui, |ui| { - if ui.selectable_label(cur.is_empty(), auto).clicked() { - cur.clear(); - changed = true; - } - for n in styles.iter().filter(|n| *n != auto) { - if ui.selectable_label(cur == n, n.as_str()).clicked() { - *cur = n.clone(); + craft_ui::ComboBox::from_id_salt(("toc_style", salt)) + .selected_text(shown) + .width(200.0) + .show_ui(ui, |ui| { + if ui.selectable_label(cur.is_empty(), auto).clicked() { + cur.clear(); changed = true; } - } - }); + for n in styles.iter().filter(|n| *n != auto) { + if ui.selectable_label(cur == n, n.as_str()).clicked() { + *cur = n.clone(); + changed = true; + } + } + }) + .response + .labelled_by(name); changed } @@ -6884,19 +8415,40 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); let styles: Vec = d.fields.get("styles").cloned().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default(); let char_styles: Vec = d.fields.get("charStyles").cloned().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default(); + let mut expanded = d.fields.get("moreOptions").and_then(Value::as_bool).unwrap_or(true); let mut rows = toc_rows(d); let mut sel = d.n("selected").map_or(0, |n| n.max(0.0) as usize); let mut other = d.s("other"); let mut title_style = d.s("titleStyle"); + // The dialog body's width, less the two labels and the style selector (200 pt) beside the title. + let title_room = ui.available_width() - 300.0; egui::Grid::new("toc_title").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { crate::rtl::label(ui, tr("Title:")); - text_field(ui, d, "title", 200.0); - crate::rtl::label(ui, tr("Style:")); - toc_style_combo(ui, "title", &styles, "TOC Title", &mut title_style); + let mut title = d.s("title"); + // 200 pt, reserved: a desired width alone is squeezed by the grid to a few characters. + // Narrower when the dialog is, so the style selector beside it stays in view. + let w = 200.0_f32.min(title_room.max(80.0)); + let field = ui.add_sized([w, ui.spacing().interact_size.y], egui::TextEdit::singleline(&mut title).align(egui::Align2::LEFT_TOP)); + if field.accessible_name(tr("Title:")).changed() { + d.fields.insert("title".into(), json!(title)); + } + let name = crate::rtl::label(ui, tr("Style:")).id; + toc_style_combo(ui, "title", &styles, "TOC Title", &mut title_style, name); ui.end_row(); }); ui.add_space(8.0); - crate::rtl::label(ui, egui::RichText::new(tr("Styles in Table of Contents")).font(semibold(12.0))); + ui.horizontal(|ui| { + crate::rtl::label(ui, egui::RichText::new(tr("Styles in Table of Contents")).font(semibold(12.0))); + let toggle = ui.button(tr(if expanded { "Fewer Options" } else { "More Options" })); + // Enter on the focused toggle toggles it (egui activates a focused button with Enter): + // the footer drawn after it mustn't take that Enter for OK too. + if toggle.has_focus() { + ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); + } + if toggle.clicked() { + expanded = !expanded; + } + }); let others: Vec<&String> = styles.iter().filter(|n| !rows.iter().any(|r| &r.style == *n)).collect(); ui.horizontal_top(|ui| { ui.vertical(|ui| { @@ -6945,50 +8497,77 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { crate::rtl::label(ui, egui::RichText::new(format!("{} {}", tr("Style:"), r.style)).font(semibold(12.0))); let none = designcraft_doc::NO_CHAR_STYLE; egui::Grid::new("toc_entry").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, tr("Entry Style:")); + let name = crate::rtl::label(ui, tr("Entry Style:")).id; let auto = format!("TOC Level {}", r.level.clamp(1, 9)); - toc_style_combo(ui, "entry", &styles, &auto, &mut r.entry_style); + toc_style_combo(ui, "entry", &styles, &auto, &mut r.entry_style, name); ui.end_row(); - crate::rtl::label(ui, tr("Page Number:")); + if !expanded { + return; + } + let page_number = crate::rtl::label(ui, tr("Page Number:")).id; let choices = [("after", "After Entry"), ("before", "Before Entry"), ("none", "No Page Number")]; let cur = choices.iter().find(|(v, _)| *v == r.page_number).map_or("After Entry", |(_, l)| l); - craft_ui::ComboBox::from_id_salt("toc_page_number").selected_text(tr(cur)).width(200.0).show_ui(ui, |ui| { - for (v, l) in choices { - if ui.selectable_label(r.page_number == v, tr(l)).clicked() { - r.page_number = v.into(); + craft_ui::ComboBox::from_id_salt("toc_page_number") + .selected_text(tr(cur)) + .width(200.0) + .show_ui(ui, |ui| { + for (v, l) in choices { + if ui.selectable_label(r.page_number == v, tr(l)).clicked() { + r.page_number = v.into(); + } } - } - }); - crate::rtl::label(ui, tr("Style:")); - toc_style_combo(ui, "page_number", &char_styles, none, &mut r.page_number_style); + }) + .response + .labelled_by(page_number); + let name = crate::rtl::label(ui, tr("Style:")).id; + toc_style_combo(ui, "page_number", &char_styles, none, &mut r.page_number_style, name); ui.end_row(); - crate::rtl::label(ui, tr("Between Entry and Number:")); - ui.add(egui::TextEdit::singleline(&mut r.separator).desired_width(80.0).align(egui::Align2::LEFT_TOP)); - crate::rtl::label(ui, tr("Style:")); - toc_style_combo(ui, "separator", &char_styles, none, &mut r.separator_style); + let between = crate::rtl::label(ui, tr("Between Entry and Number:")).id; + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(&mut r.separator).desired_width(80.0).align(egui::Align2::LEFT_TOP)).labelled_by(between); + ui.menu_button("▸", |ui| { + for (label, code) in [("Tab Character", "^t"), ("Right Indent Tab", "^y"), ("Em Space", "^m"), ("En Space", "^>")] { + if ui.button(tr(label)).clicked() { + toc_separator_append(&mut r.separator, code); + ui.close(); + } + } + }) + .response + .on_hover_text(tr("Insert Special Character")); + }); + let name = crate::rtl::label(ui, tr("Style:")).id; + toc_style_combo(ui, "separator", &char_styles, none, &mut r.separator_style, name); ui.end_row(); crate::rtl::label(ui, ""); ui.checkbox(&mut r.sort, tr("Sort Entries in Alphabetical Order")); ui.end_row(); - crate::rtl::label(ui, tr("Level:")); - craft_ui::ComboBox::from_id_salt("toc_level").selected_text(r.level.to_string()).width(60.0).show_ui(ui, |ui| { - for l in 1..=9u8 { - if ui.selectable_label(r.level == l, l.to_string()).clicked() { - r.level = l; + let level = crate::rtl::label(ui, tr("Level:")).id; + craft_ui::ComboBox::from_id_salt("toc_level") + .selected_text(r.level.to_string()) + .width(60.0) + .show_ui(ui, |ui| { + for l in 1..=9u8 { + if ui.selectable_label(r.level == l, l.to_string()).clicked() { + r.level = l; + } } - } - }); + }) + .response + .labelled_by(level); ui.end_row(); }); } ui.add_space(8.0); crate::rtl::label(ui, egui::RichText::new(tr("Options")).font(semibold(12.0))); + check(ui, d, "createBookmarks", &tr("Create PDF Bookmarks")); let has_toc = d.b("hasToc"); ui.add_enabled_ui(has_toc, |ui| check(ui, d, "replace", &tr("Replace Existing Table of Contents"))); if let Some(st) = d.fields.get("status").and_then(Value::as_str) { ui.add_space(6.0); crate::rtl::label(ui, egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); } + d.fields.insert("moreOptions".into(), json!(expanded)); d.fields.insert("included".into(), json!(rows)); d.fields.insert("selected".into(), json!(sel)); d.fields.insert("other".into(), json!(other)); @@ -7108,6 +8687,206 @@ fn step_and_repeat_preview(app: &mut DesignApp, d: &mut Dialog) { d.fields.insert(PREVIEWED.into(), params); } +/// The corners of Object › Corner Options, in `object.cornerOptions` path order (top-left, +/// top-right, bottom-right, bottom-left), with the tip InDesign gives each. +const CORNERS: [(&str, &str); 4] = [("0", "Top Left:"), ("1", "Top Right:"), ("2", "Bottom Right:"), ("3", "Bottom Left:")]; + +/// The Corner Options dialog's fields from the first selected object's corners, once: each +/// corner's size (in the ruler units) and shape, linked when all four are the same. +fn seed_corner_options(app: &DesignApp, d: &mut Dialog) { + use designcraft_geom::corners::Corner; + let Some(st) = app.session.active() else { return }; + d.unit = st.doc.settings.horizontal_units; + if d.fields.contains_key("_seeded") { + return; + } + let corners: [Corner; 4] = st.selection.items.iter().find_map(|id| st.doc.item(*id)).map(|it| it.corners.corners).unwrap_or_default(); + for ((k, _), c) in CORNERS.iter().zip(corners) { + d.fields.entry(format!("size{k}")).or_insert(json!(format_measure(c.size, d.unit))); + d.fields.entry(format!("shape{k}")).or_insert(serde_json::to_value(c.shape).unwrap_or(json!("none"))); + } + let first = corners[0]; + d.fields.entry("link").or_insert(json!(corners.iter().all(|c| *c == first))); + d.fields.insert("_seed".into(), json!(corner_fields(d))); + d.fields.insert("_seeded".into(), json!(true)); +} + +/// Each corner's size and shape fields, in `CORNERS` order. +fn corner_fields(d: &Dialog) -> Vec<(String, String)> { + CORNERS.iter().map(|(k, _)| (d.s(&format!("size{k}")), d.s(&format!("shape{k}")))).collect() +} + +/// The corners edited since the dialog opened: changed from the first object's, or set by a +/// linked edit (all four, even one that was already the value). +fn edited_corners(d: &Dialog) -> [bool; 4] { + let touched: [bool; 4] = d.fields.get("_touched").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + let seed: Vec<(String, String)> = d.fields.get("_seed").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + let now = corner_fields(d); + std::array::from_fn(|i| touched[i] || seed.get(i) != now.get(i)) +} + +/// Linked corners: a corner changed since the dialog last looked (size or shape, typed or set +/// over the control channel) is all four. +fn link_corners(d: &mut Dialog) { + let now = corner_fields(d); + let seen: Vec<(String, String)> = d.fields.get("_corners").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_else(|| now.clone()); + let mut now = now; + let mut touched: [bool; 4] = d.fields.get("_touched").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + for (t, (a, b)) in touched.iter_mut().zip(now.iter().zip(&seen)) { + *t |= a != b; + } + if d.b("link") + && let Some(i) = now.iter().zip(&seen).position(|(a, b)| a != b) + && let Some(changed) = now.get(i).cloned() + { + for (k, _) in &CORNERS { + d.fields.insert(format!("size{k}"), json!(changed.0)); + d.fields.insert(format!("shape{k}"), json!(changed.1)); + } + now = vec![changed; CORNERS.len()]; + touched = [true; 4]; + } + d.fields.insert("_corners".into(), json!(now)); + d.fields.insert("_touched".into(), json!(touched)); +} + +/// `object.cornerOptions`' params from the dialog: the corners edited, the others null (each +/// selected object keeps its own). A size that does not parse, or a shape the dialog doesn't +/// offer, is an error to show. +fn corner_options_params(d: &Dialog) -> Result { + use designcraft_geom::corners::CornerShape; + let edited = edited_corners(d); + let mut corners = Vec::with_capacity(CORNERS.len()); + for ((k, _), edited) in CORNERS.iter().zip(edited) { + let key = format!("size{k}"); + let size = + d.m(&key).filter(|v| v.is_finite() && *v >= 0.0).ok_or_else(|| format!("Enter a valid corner size, e.g. 12 or 3 mm: {}", d.s(&key)))?; + let shape: CornerShape = match d.fields.get(&format!("shape{k}")) { + None => CornerShape::default(), + Some(v) => serde_json::from_value(v.clone()).map_err(|_| format!("Choose a corner shape: {v} isn't one"))?, + }; + corners.push(if edited { json!({"shape": shape, "size": size}) } else { Value::Null }); + } + Ok(json!({"corners": corners})) +} + +/// A corner's icon: the corner of a small square, with a dot at the corner it sets. +fn paint_corner_icon(ui: &mut egui::Ui, corner: usize, tip: &str) { + let t = crate::theme::Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::hover()); + resp.on_hover_text(tip); + let b = r.shrink(3.0); + ui.painter().rect_stroke(b, 0.0, egui::Stroke::new(1.0, t.icon), egui::StrokeKind::Inside); + let at = match corner { + 0 => b.left_top(), + 1 => b.right_top(), + 2 => b.right_bottom(), + _ => b.left_bottom(), + }; + ui.painter().rect_filled(egui::Rect::from_center_size(at, egui::vec2(4.0, 4.0)), 0.0, t.accent); +} + +/// Object › Corner Options, as InDesign's: under "Corner Size and Shape:", each corner's size and +/// shape (top corners above, bottom ones below, as on the frame), the link button that makes all +/// four the same, and Preview. +fn corner_options_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + use designcraft_geom::corners::CornerShape; + seed_corner_options(app, d); + link_corners(d); + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); + crate::rtl::label(ui, tr("Corner Size and Shape:")); + ui.add_space(8.0); + let corner = |ui: &mut egui::Ui, d: &mut Dialog, i: usize| { + let Some((k, tip)) = CORNERS.get(i) else { return }; + ui.horizontal(|ui| { + paint_corner_icon(ui, i, &tr(tip)); + measure_field(ui, d, &format!("size{k}"), 70.0); + let key = format!("shape{k}"); + let shape: CornerShape = d.fields.get(&key).and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + let resp = crate::panels::properties::corner_shape_button(ui, shape, 46.0); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, format!("{} {}", tr(tip), tr(shape.label())))); + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(150.0); + for s in CornerShape::ALL { + if ui.selectable_label(s == shape, crate::rtl::widget(ui, tr(s.label()))).clicked() { + d.fields.insert(key.clone(), json!(s)); + ui.close(); + } + } + }); + }); + }; + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + corner(ui, d, 0); + ui.add_space(6.0); + corner(ui, d, 3); + }); + ui.add_space(8.0); + let linked = d.b("link"); + let tip = tr("Make all settings the same"); + let resp = ui + .vertical(|ui| { + ui.add_space(12.0); + crate::icons::button(ui, if linked { "link" } else { "link-broken" }, 20.0, linked, &tip) + }) + .inner; + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &tip)); + if resp.clicked() { + d.fields.insert("link".into(), json!(!linked)); + } + ui.add_space(8.0); + ui.vertical(|ui| { + corner(ui, d, 1); + ui.add_space(6.0); + corner(ui, d, 2); + }); + }); + link_corners(d); + ui.add_space(10.0); + check(ui, d, "preview", &tr("Preview")); + corner_options_preview(app, d); + if let Some(st) = d.fields.get("status").and_then(Value::as_str) { + ui.add_space(6.0); + crate::rtl::label(ui, egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); + } +} + +/// Preview on: the corners show on the frame (redone when a field changes); off: they go back. +fn corner_options_preview(app: &mut DesignApp, d: &mut Dialog) { + let shown = d.fields.get(PREVIEWED).cloned(); + if !d.b("preview") { + if shown.is_some() { + app.session.cancel_interaction(); + d.fields.remove(PREVIEWED); + d.fields.remove("status"); + } + return; + } + let params = match corner_options_params(d) { + Ok(p) => p, + Err(e) => { + d.fields.insert("status".into(), json!(e)); + return; + } + }; + if shown.as_ref() == Some(¶ms) { + return; + } + if shown.is_none() + && let Err(e) = app.session.begin_interaction("Corner Options") + { + d.fields.insert("status".into(), json!(e.to_string())); + return; + } + match app.session.preview("object.cornerOptions", params.clone()) { + Ok(()) => d.fields.remove("status"), + Err(e) => d.fields.insert("status".into(), json!(e.to_string())), + }; + d.fields.insert(PREVIEWED.into(), params); +} + /// New Cross-Reference: link to a paragraph (paragraph styles on the left, their paragraphs on /// the right) or a text anchor, with a cross-reference format. fn insert_xref(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { @@ -7208,107 +8987,300 @@ fn nums(d: &Dialog, k: &str) -> Option<[f64; N]> { Some(out) } -/// What changed the Color Picker's colour this frame. -#[derive(Clone, Copy, PartialEq)] -enum ColorEdit { - None, - /// The saturation/brightness field, the hue slider or the hex field: keeps the colour's model. - Picked, - /// Typed R/G/B values: the colour becomes RGB. - Rgb, - /// Typed C/M/Y/K values: the colour becomes CMYK. - Cmyk, +const EYEDROPPER_DRAG: &str = "color_picker_eyedropper_dragging"; + +/// The Color Picker's eyedropper at `at`, as InDesign's: dragged onto the page, it takes the colour +/// shown under the pointer when it is let go. The colour taken, if any. +fn eyedropper(app: &mut DesignApp, ui: &mut egui::Ui, at: egui::Pos2, tip: String, t: &crate::theme::Tokens) -> Option<[u8; 3]> { + let er = egui::Rect::from_center_size(at, egui::vec2(21.0, 21.0)); + let resp = ui.interact(er, egui::Id::unique("color_picker_eyedropper"), egui::Sense::click_and_drag()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &tip)); + if resp.hovered() || resp.dragged() { + ui.painter().rect_filled(er, 3.0, t.hover); + } + crate::icons::paint(ui.painter(), egui::Rect::from_center_size(er.center(), egui::vec2(15.0, 15.0)), "tool-eyedropper", t.icon); + // Esc during the drag cancels just the drag (the picker's own Esc/Enter handling stands down + // while `EYEDROPPER_DRAG` is set), and the release takes nothing. + let cancel_id = egui::Id::unique("color_picker_eyedropper_cancelled"); + if resp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { + ui.data_mut(|d| d.insert_temp(cancel_id, true)); + } + ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); + } + ui.data_mut(|d| d.insert_temp(egui::Id::unique(EYEDROPPER_DRAG), resp.dragged())); + let cancelled = resp.drag_stopped() && ui.data_mut(|d| d.remove_temp::(cancel_id)).unwrap_or(false); + // Let go over the picker itself it takes nothing, though a page may lie under it. (Its modal + // backdrop shares the picker's layer and covers the screen: the picker is where it draws.) + let mut taken = None; + if resp.drag_stopped() + && !cancelled + && let Some(pos) = ui.input(|i| i.pointer.latest_pos()) + && !ui.clip_rect().contains(pos) + { + taken = crate::canvas::color_at(app, ui.ctx(), pos); + } + resp.on_hover_text(tip); + taken } -/// Color Picker: saturation/brightness field and hue slider, with R/G/B, C/M/Y/K and hex fields. -/// -/// `rgb` (0-255) and `cmyk` (percent) hold the values as typed, so a field being edited is never -/// rebuilt from the colour; the hex field keeps its text in `hexEdit` while it has focus and shows -/// the colour again when focus leaves. `model` is what OK applies: picking keeps a CMYK colour CMYK -/// (converted through the CMYK working space); typing R/G/B or C/M/Y/K values chooses that model. -fn color_picker(ui: &mut egui::Ui, d: &mut Dialog) { - use designcraft_color::Color; - let to8 = |v: f64| v.clamp(0.0, 255.0).round() as u8; - let display_rgb = |c: Color| c.to_rgb().map(|v| (f64::from(v) * 255.0).clamp(0.0, 255.0).round()); - let cmyk_of = |rgb: [f64; 3]| { - let c = Color::rgb((rgb[0] / 255.0) as f32, (rgb[1] / 255.0) as f32, (rgb[2] / 255.0) as f32); - c.to_cmyk_managed(designcraft_color::cms::active_settings().intent).map(|v| f64::from(v) * 100.0) +/// Closes the picker for a click on OK or Cancel, else for Enter (OK) or Esc (Cancel) unless an +/// eyedropper drag is on. +fn picker_close(ui: &mut egui::Ui, d: &mut Dialog, ok: bool, cancel: bool) { + let dragging = ui.data(|d| d.get_temp::(egui::Id::unique(EYEDROPPER_DRAG))).unwrap_or(false); + let (enter, esc) = if dragging { + (false, false) + } else { + ( + ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)), + ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)), + ) }; - let hex_rgb = |s: &str| match Color::from_hex(s) { - Some(Color::Rgb { r, g, b }) => Some([r, g, b].map(|v| (f64::from(v) * 255.0).round())), - _ => None, + if ok || cancel { + d.fields.insert("_close".into(), json!(if cancel { "cancel" } else { "ok" })); + } else if enter || esc { + d.fields.insert("_close".into(), json!(if enter { "ok" } else { "cancel" })); + } +} + +/// The Color Picker, laid out as InDesign's: the colour space view (a field of two components, +/// a slider for the third: the one whose radio button is on, R at first), the new colour over the +/// one it replaces, OK / Cancel / Add CMYK Swatch at the top right, H/S/B beside L/a/b, R/G/B +/// beside C/M/Y/K, and the hex code. Its buttons replace the dialog's own. +fn color_picker(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + use picker_layout as at; + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); + let old = d.s("hex"); + d.fields.entry("old").or_insert_with(|| json!(old)); + // The component the picker was last left on, unless the opener chose one. + d.fields.entry("component").or_insert_with(|| json!(app.ui.color_picker_component)); + let comp = PickComp::from_key(&d.s("component")); + let mut p = picked_of(d); + let before = p; + d.fields.entry("rgb").or_insert_with(|| json!(p.rgb.map(|x| x * 255.0))); + if !d.fields.contains_key("cmyk") { + d.fields.insert( + "cmyk".into(), + json!( + designcraft_color::Color::Rgb { r: p.rgb[0], g: p.rgb[1], b: p.rgb[2] } + .to_cmyk_managed(designcraft_color::cms::active_settings().intent) + .map(|x| x * 100.0) + ), + ); + } + let mut exact_cmyk = None; + let t = crate::theme::Tokens::get(ui.ctx()); + let (area, _) = ui.allocate_exact_size(at::SIZE, egui::Sense::hover()); + let o = area.min.to_vec2(); + let put = |r: egui::Rect| r.translate(o); + let font = egui::FontId::proportional(11.5); + let text = |ui: &egui::Ui, pos: egui::Pos2, align: egui::Align2, s: &str| { + ui.painter().text(pos, align, s, egui::FontId::proportional(11.5), t.text_strong); }; - let mut rgb = nums::<3>(d, "rgb").unwrap_or_else(|| hex_rgb(&d.s("hex")).unwrap_or([0.0; 3])); - let mut cmyk = nums::<4>(d, "cmyk").unwrap_or_else(|| cmyk_of(rgb)); - let mut model = if d.s("model") == "cmyk" || (!d.fields.contains_key("model") && d.fields.contains_key("cmyk")) { "cmyk" } else { "rgb" }; - let mut edit = ColorEdit::None; - let hex_id = egui::Id::unique("color_picker_hex"); - ui.horizontal_top(|ui| { - let mut c = egui::Color32::from_rgb(to8(rgb[0]), to8(rgb[1]), to8(rgb[2])); - let shown = c; - if egui::color_picker::color_picker_color32(ui, &mut c, egui::color_picker::Alpha::Opaque) && c != shown { - rgb = [c.r(), c.g(), c.b()].map(f64::from); - edit = ColorEdit::Picked; - } - ui.vertical(|ui| { - egui::Grid::new("cp_values").num_columns(4).spacing([6.0, 4.0]).show(ui, |ui| { - let mut rgb_rows = rgb.iter_mut().zip(["R", "G", "B"]); - for (v, l) in cmyk.iter_mut().zip(["C", "M", "Y", "K"]) { - if let Some((rv, rl)) = rgb_rows.next() { - ui.label(rl); - // The channel letter is a label beside a bare spinner: name the spinner. - let field = ui.add(egui::DragValue::new(rv).range(0.0..=255.0).max_decimals(1)).accessible_name(rl.to_owned()); - if field.changed() { - edit = ColorEdit::Rgb; - } - } else { - ui.label(""); - ui.label(""); - } - ui.label(l); - let field = ui.add(egui::DragValue::new(v).range(0.0..=100.0).max_decimals(1).suffix("%")).accessible_name(l.to_owned()); - if field.changed() { - edit = ColorEdit::Cmyk; - } - ui.end_row(); - } - }); - ui.add_space(6.0); - ui.horizontal(|ui| { - ui.label("#"); - let editing = ui.memory(|m| m.has_focus(hex_id)); - let mut h = if editing { d.s("hexEdit") } else { format!("{:02x}{:02x}{:02x}", to8(rgb[0]), to8(rgb[1]), to8(rgb[2])) }; - let hex_field = ui.add(egui::TextEdit::singleline(&mut h).id(hex_id).desired_width(70.0)).accessible_name("Hex".to_owned()); - if hex_field.changed() - && let Some(v) = hex_rgb(&h) - { - rgb = v; - edit = ColorEdit::Picked; - } - d.fields.insert("hexEdit".into(), json!(h)); - }); - }); + // The colour space view (a label, so screen readers hear which). + ui.scope_builder( + egui::UiBuilder::new() + .max_rect(egui::Rect::from_min_size(area.min, egui::vec2(260.0, 24.0))) + .layout(egui::Layout::left_to_right(egui::Align::Center)), + |ui| { + ui.label( + egui::RichText::new(tr(if comp.lab() { "Lab Color Space View" } else { "RGB Color Space View" })).size(11.5).color(t.text_strong), + ); + }, + ); + let fr = put(at::field()); + let fresp = ui.interact(fr, ui.scope_id().with("cp_field"), egui::Sense::click_and_drag()); + let (tv, _, _) = p.coords(comp); + let cells = if matches!(comp, PickComp::R | PickComp::G | PickComp::Bl | PickComp::H) { 2 } else { 24 }; + gradient_mesh(ui.painter(), fr, cells, cells, |x, y| p.at(comp, tv, x, y).rgb); + fresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, true, "Color field")); + if let Some(pos) = fresp.interact_pointer_pos().filter(|_| fresp.is_pointer_button_down_on() || fresp.clicked()) { + let u = ((pos.x - fr.left()) / fr.width()).clamp(0.0, 1.0); + let v = ((fr.bottom() - pos.y) / fr.height()).clamp(0.0, 1.0); + p = p.at(comp, tv, u, v); + } + // The marker: a cross with an open centre, white over black. Fixed colours: it sits on the + // colour field, whatever the theme. + let (_, u, v) = p.coords(comp); + let m = egui::pos2(fr.left() + u * fr.width(), fr.bottom() - v * fr.height()); + let painter = ui.painter().with_clip_rect(fr); + for (dx, dy) in [(1.0, 0.0), (-1.0, 0.0), (0.0, 1.0), (0.0, -1.0)] { + let (a, b) = (m + egui::vec2(dx * 3.0, dy * 3.0), m + egui::vec2(dx * 10.0, dy * 10.0)); + let side = egui::vec2(dy, dx) * 1.2; + painter.line_segment([a + side, b + side], egui::Stroke::new(1.0, egui::Color32::BLACK)); + painter.line_segment([a - side, b - side], egui::Stroke::new(1.0, egui::Color32::BLACK)); + painter.line_segment([a, b], egui::Stroke::new(1.5, egui::Color32::WHITE)); + } + // The slider, with a marker on each side (white with a dark edge, readable on any colour). + let sr = put(at::slider()); + let sresp = ui.interact(sr.expand2(egui::vec2(6.0, 0.0)), ui.scope_id().with("cp_slider"), egui::Sense::click_and_drag()); + gradient_mesh(ui.painter(), sr, 1, 48, |_, y| p.slider_at(comp, y)); + sresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Slider, true, "Color slider")); + if let Some(pos) = sresp.interact_pointer_pos().filter(|_| sresp.is_pointer_button_down_on() || sresp.clicked()) { + let tn = ((sr.bottom() - pos.y) / sr.height()).clamp(0.0, 1.0); + let (_, u, v) = p.coords(comp); + p = p.at(comp, tn, u, v); + } + let (tn, _, _) = p.coords(comp); + let y = sr.bottom() - tn * sr.height(); + for (x, dir) in [(sr.left(), 1.0), (sr.right(), -1.0)] { + let pts = vec![ + egui::pos2(x - 5.0 * dir, y - 4.5), + egui::pos2(x, y - 4.5), + egui::pos2(x + 3.5 * dir, y), + egui::pos2(x, y + 4.5), + egui::pos2(x - 5.0 * dir, y + 4.5), + ]; + ui.painter().add(egui::Shape::convex_polygon(pts, egui::Color32::WHITE, egui::Stroke::new(1.0, egui::Color32::from_gray(40)))); + } + // The new colour over the one it replaces. + let pr = put(at::swatch()); + let old = designcraft_color::Color::from_hex(&d.s("old")).map_or([0.0; 3], |c| c.to_rgb_uncalibrated()); + ui.painter().rect_filled(egui::Rect::from_min_max(pr.min, egui::pos2(pr.max.x, pr.center().y)), 0.0, color32(p.rgb)); + ui.painter().rect_filled(egui::Rect::from_min_max(egui::pos2(pr.min.x, pr.center().y), pr.max), 0.0, color32(old)); + ui.painter().rect_stroke(pr, 0.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); + // OK, Cancel, Add CMYK Swatch: rounded, OK filled. + let pill = |ui: &mut egui::Ui, i: usize, label: &str, primary: bool| -> bool { + let r = put(at::button(i)); + let resp = ui.interact(r, ui.scope_id().with(("cp_button", i)), egui::Sense::click()); + let (fill, fg, stroke) = if primary { + (t.accent_strong, egui::Color32::WHITE, egui::Stroke::NONE) + } else { + (if resp.hovered() { t.hover } else { egui::Color32::TRANSPARENT }, t.text_strong, egui::Stroke::new(1.0, t.text_dim)) + }; + ui.painter().rect(r, r.height() / 2.0, fill, stroke, egui::StrokeKind::Inside); + ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, label, egui::FontId::proportional(12.0), fg); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label)); + resp.clicked() + }; + // The picker draws its own buttons, so the generic footer's Enter/Esc handling is skipped. + // The keys are consumed here so nothing else reacts to them as well, and a click on a pill + // wins over a key pressed in the same frame, so the dialog closes once. + // The footer's padded OK key, which every catalog has. + let ok = pill(ui, 0, tr(" OK ").trim(), true); + let cancel = pill(ui, 1, &tr("Cancel"), false); + picker_close(ui, d, ok, cancel); + // The C/M/Y/K the fields show: the percentages typed or given, else the colour converted. + let cmyk = nums::<4>(d, "cmyk").map(|v| v.map(|x| x as f32)).unwrap_or_else(|| { + designcraft_color::Color::Rgb { r: p.rgb[0], g: p.rgb[1], b: p.rgb[2] } + .to_cmyk_managed(designcraft_color::cms::active_settings().intent) + .map(|v| v * 100.0) }); - match edit { - ColorEdit::None => {} - ColorEdit::Picked => cmyk = cmyk_of(rgb), - ColorEdit::Rgb => { - model = "rgb"; - cmyk = cmyk_of(rgb); - } - ColorEdit::Cmyk => { - model = "cmyk"; - let [c, m, y, k] = cmyk.map(|v| (v / 100.0) as f32); - rgb = display_rgb(Color::cmyk(c, m, y, k)); + if pill(ui, 2, &tr("Add CMYK Swatch"), false) { + let [c, m, y, k] = cmyk.map(|v| v.round()); + match app.run("swatch.create", json!({"color": {"c": c, "m": m, "y": y, "k": k}})) { + Ok(r) => app.status(format!("{} {}", tr("Added swatch"), r["name"].as_str().unwrap_or(""))), + Err(e) => app.status(e), } } - if edit != ColorEdit::None { + // The values: a radio button, a label, the field, a unit. + let mut comp_now = comp; + let mut edited: Option = None; + // (column x of the radio, row, component, label, key, value, range, unit) + let rows: [(f32, usize, Option, &str, &str, f32, (f32, f32), &str); 13] = [ + (at::LEFT, 0, Some(PickComp::H), "H:", "h", p.hsb[0], (0.0, 360.0), "°"), + (at::LEFT, 1, Some(PickComp::S), "S:", "s", p.hsb[1] * 100.0, (0.0, 100.0), "%"), + (at::LEFT, 2, Some(PickComp::Br), "B:", "b", p.hsb[2] * 100.0, (0.0, 100.0), "%"), + (at::RIGHT, 0, Some(PickComp::L), "L:", "l", p.lab[0], (0.0, 100.0), ""), + (at::RIGHT, 1, Some(PickComp::A), "a:", "la", p.lab[1], (-128.0, 127.0), ""), + (at::RIGHT, 2, Some(PickComp::Lb), "b:", "lb", p.lab[2], (-128.0, 127.0), ""), + (at::LEFT, 3, Some(PickComp::R), "R:", "r", p.rgb[0] * 255.0, (0.0, 255.0), ""), + (at::LEFT, 4, Some(PickComp::G), "G:", "g", p.rgb[1] * 255.0, (0.0, 255.0), ""), + (at::LEFT, 5, Some(PickComp::Bl), "B:", "bl", p.rgb[2] * 255.0, (0.0, 255.0), ""), + (at::RIGHT, 3, None, "C:", "c", cmyk[0], (0.0, 100.0), "%"), + (at::RIGHT, 4, None, "M:", "m", cmyk[1], (0.0, 100.0), "%"), + (at::RIGHT, 5, None, "Y:", "y", cmyk[2], (0.0, 100.0), "%"), + (at::RIGHT, 6, None, "K:", "k", cmyk[3], (0.0, 100.0), "%"), + ]; + for (x, row, c, label, key, value, range, unit) in rows { + let cy = area.min.y + at::ROWS[row]; + let x = area.min.x + x; + match c { + Some(c) => { + let rr = egui::Rect::from_center_size(egui::pos2(x, cy), egui::vec2(14.0, 14.0)); + let resp = ui.interact(rr, ui.scope_id().with(("cp_radio", key)), egui::Sense::click()); + ui.painter().circle(rr.center(), 6.0, t.input, egui::Stroke::new(1.0, t.text_dim)); + if comp_now == c { + ui.painter().circle_filled(rr.center(), 2.6, t.text_strong); + } + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, true, comp_now == c, label)); + if resp.clicked() { + comp_now = c; + } + text(ui, egui::pos2(x + 11.0, cy), egui::Align2::LEFT_CENTER, label); + } + // C/M/Y/K have no radio button: their labels end at the field. + None => text(ui, egui::pos2(x + 28.0, cy), egui::Align2::RIGHT_CENTER, label), + } + let fr = egui::Rect::from_min_size(egui::pos2(x + 35.0, cy - 10.5), egui::vec2(44.0, 21.0)); + if let Some(v) = picker_field(ui, d, fr, key, label.trim_end_matches(':'), value, range) { + if matches!(key, "r" | "g" | "bl") { + d.fields.insert("model".into(), json!("rgb")); + } + if matches!(key, "c" | "m" | "y" | "k") { + d.fields.insert("model".into(), json!("cmyk")); + } + edited = Some(match key { + "h" => Picked::from_hsb([v, p.hsb[1], p.hsb[2]]), + "s" => Picked::from_hsb([p.hsb[0], v / 100.0, p.hsb[2]]), + "b" => Picked::from_hsb([p.hsb[0], p.hsb[1], v / 100.0]), + "l" => Picked::from_lab([v, p.lab[1], p.lab[2]], p.hsb[0]), + "la" => Picked::from_lab([p.lab[0], v, p.lab[2]], p.hsb[0]), + "lb" => Picked::from_lab([p.lab[0], p.lab[1], v], p.hsb[0]), + "r" => Picked::from_rgb([v / 255.0, p.rgb[1], p.rgb[2]], p.hsb[0]), + "g" => Picked::from_rgb([p.rgb[0], v / 255.0, p.rgb[2]], p.hsb[0]), + "bl" => Picked::from_rgb([p.rgb[0], p.rgb[1], v / 255.0], p.hsb[0]), + _ => { + let mut c = cmyk; + c[["c", "m", "y", "k"].iter().position(|k| *k == key).unwrap_or(0)] = v; + exact_cmyk = Some(c); + let [c, m, y, k] = c.map(|x| x / 100.0); + Picked::from_rgb(designcraft_color::Color::Cmyk { c, m, y, k }.to_rgb(), p.hsb[0]) + } + }); + } + if !unit.is_empty() { + text(ui, egui::pos2(fr.right() + 8.0, cy), egui::Align2::LEFT_CENTER, unit); + } + } + if let Some(e) = edited { + p = e; + } + if comp_now != comp { + d.fields.insert("component".into(), json!(comp_now.key())); + app.ui.color_picker_component = comp_now.key().to_string(); + } + // The hex code, and the eyedropper beside it. + let cy = area.min.y + at::ROWS[7]; + text(ui, egui::pos2(area.min.x + 321.0, cy), egui::Align2::LEFT_CENTER, "#:"); + let hr = egui::Rect::from_min_size(egui::pos2(area.min.x + 341.0, cy - 10.5), egui::vec2(64.0, 21.0)); + let id = egui::Id::unique("color_picker_hex"); + let editing = ui.memory(|m| m.has_focus(id)); + let mut h = if editing { d.s("hexEdit") } else { p.hex().trim_start_matches('#').to_string() }; + if ui + .put(hr, egui::TextEdit::singleline(&mut h).id(id).font(font).align(egui::Align2::LEFT_CENTER).margin(egui::Margin::symmetric(4, 2))) + .accessible_name("Hex".to_owned()) + .changed() + && (h.len() == 6 || (h.len() == 7 && h.starts_with('#'))) + && let Some(c) = designcraft_color::Color::from_hex(&h) + { + p = Picked::from_rgb(c.to_rgb_uncalibrated(), p.hsb[0]); + } + d.fields.insert("hexEdit".into(), json!(h)); + if let Some(c) = eyedropper(app, ui, egui::pos2(hr.right() + 16.0, cy), tr("Eyedropper: drag it onto the page"), &t) { + p = Picked::from_rgb(c.map(|v| f32::from(v) / 255.0), p.hsb[0]); + } + if p != before { + store_picked(d, &p); + } + // Typed C/M/Y/K are kept as typed, even when the colour shown stays the same (at 100% K, a + // device conversion is black whatever C, M and Y are). + if let Some(c) = exact_cmyk { + d.fields.insert("cmyk".into(), json!(c)); d.fields.insert("edited".into(), json!(true)); } - d.fields.insert("rgb".into(), json!(rgb)); - d.fields.insert("cmyk".into(), json!(cmyk)); - d.fields.insert("model".into(), json!(model)); - d.fields.insert("hex".into(), json!(format!("#{:02x}{:02x}{:02x}", to8(rgb[0]), to8(rgb[1]), to8(rgb[2])))); } /// Find/Replace Font: the document's fonts (missing ones first, flagged) and a replacement. @@ -7474,3 +9446,252 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { #[cfg(test)] mod tests; + +/// The Color Picker's components: the one picked runs along the slider, the other two of its +/// space across the field (InDesign's, as Photoshop's: R puts B across and G up). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum PickComp { + H, + S, + Br, + L, + A, + Lb, + R, + G, + Bl, +} + +impl PickComp { + const ALL: [PickComp; 9] = + [PickComp::H, PickComp::S, PickComp::Br, PickComp::L, PickComp::A, PickComp::Lb, PickComp::R, PickComp::G, PickComp::Bl]; + + fn key(self) -> &'static str { + match self { + PickComp::H => "h", + PickComp::S => "s", + PickComp::Br => "b", + PickComp::L => "l", + PickComp::A => "la", + PickComp::Lb => "lb", + PickComp::R => "r", + PickComp::G => "g", + PickComp::Bl => "bl", + } + } + + fn from_key(k: &str) -> PickComp { + PickComp::ALL.into_iter().find(|c| c.key() == k).unwrap_or(PickComp::R) + } + + fn lab(self) -> bool { + matches!(self, PickComp::L | PickComp::A | PickComp::Lb) + } +} + +/// The colour the picker shows, in each of its spaces (HSB keeps its hue through greys). +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Picked { + pub rgb: [f32; 3], + pub hsb: [f32; 3], + pub lab: [f32; 3], +} + +impl Picked { + pub fn from_rgb(rgb: [f32; 3], hue: f32) -> Picked { + let rgb = rgb.map(|v| v.clamp(0.0, 1.0)); + let c = designcraft_color::Color::Rgb { r: rgb[0], g: rgb[1], b: rgb[2] }; + let mut hsb = c.to_hsb(); + if hsb[1] <= 1e-4 || hsb[2] <= 1e-4 { + hsb[0] = hue; + } + let l = designcraft_color::cms::lab::srgb_to_lab(rgb); + Picked { rgb, hsb, lab: [l.l, l.a, l.b] } + } + + fn from_hsb(hsb: [f32; 3]) -> Picked { + let hsb = [hsb[0].rem_euclid(360.0), hsb[1].clamp(0.0, 1.0), hsb[2].clamp(0.0, 1.0)]; + let mut p = Picked::from_rgb(designcraft_color::Color::from_hsb(hsb[0], hsb[1], hsb[2]).to_rgb_uncalibrated(), hsb[0]); + p.hsb = hsb; + p + } + + fn from_lab(lab: [f32; 3], hue: f32) -> Picked { + let lab = [lab[0].clamp(0.0, 100.0), lab[1].clamp(-128.0, 127.0), lab[2].clamp(-128.0, 127.0)]; + let mut p = Picked::from_rgb(designcraft_color::cms::lab::lab_to_srgb(designcraft_color::cms::Lab::new(lab[0], lab[1], lab[2])), hue); + p.lab = lab; + p + } + + /// (slider, across, up), each 0..1, for component `c`. + pub fn coords(&self, c: PickComp) -> (f32, f32, f32) { + let [r, g, b] = self.rgb; + let [h, s, v] = self.hsb; + let [l, la, lb] = self.lab; + let (a, bb) = ((la + 128.0) / 255.0, (lb + 128.0) / 255.0); + match c { + PickComp::R => (r, b, g), + PickComp::G => (g, b, r), + PickComp::Bl => (b, r, g), + PickComp::H => (h / 360.0, s, v), + PickComp::S => (s, h / 360.0, v), + PickComp::Br => (v, h / 360.0, s), + PickComp::L => (l / 100.0, a, bb), + PickComp::A => (a, bb, l / 100.0), + PickComp::Lb => (bb, a, l / 100.0), + } + } + + /// The colour at (slider `t`, across `u`, up `v`) for component `c`. + pub fn at(&self, c: PickComp, t: f32, u: f32, v: f32) -> Picked { + let hue = self.hsb[0]; + let ab = |x: f32| x * 255.0 - 128.0; + match c { + PickComp::R => Picked::from_rgb([t, v, u], hue), + PickComp::G => Picked::from_rgb([v, t, u], hue), + PickComp::Bl => Picked::from_rgb([u, v, t], hue), + PickComp::H => Picked::from_hsb([t * 360.0, u, v]), + PickComp::S => Picked::from_hsb([u * 360.0, t, v]), + PickComp::Br => Picked::from_hsb([u * 360.0, v, t]), + PickComp::L => Picked::from_lab([t * 100.0, ab(u), ab(v)], hue), + PickComp::A => Picked::from_lab([v * 100.0, ab(t), ab(u)], hue), + PickComp::Lb => Picked::from_lab([v * 100.0, ab(u), ab(t)], hue), + } + } + + /// The slider's colour at `t`: the hue slider shows pure hues, the others this colour with + /// the component at `t`. + fn slider_at(&self, c: PickComp, t: f32) -> [f32; 3] { + if c == PickComp::H { + return designcraft_color::Color::from_hsb(t * 360.0, 1.0, 1.0).to_rgb_uncalibrated(); + } + let (_, u, v) = self.coords(c); + self.at(c, t, u, v).rgb + } + + pub fn hex(&self) -> String { + let q = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8; + format!("#{:02x}{:02x}{:02x}", q(self.rgb[0]), q(self.rgb[1]), q(self.rgb[2])) + } +} + +fn color32(rgb: [f32; 3]) -> egui::Color32 { + let q = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8; + egui::Color32::from_rgb(q(rgb[0]), q(rgb[1]), q(rgb[2])) +} + +/// A `cols` × `rows` grid of quads over `r`, coloured at each vertex by `f(x, y)` (0..1, y up). +fn gradient_mesh(painter: &egui::Painter, r: egui::Rect, cols: usize, rows: usize, f: impl Fn(f32, f32) -> [f32; 3]) { + let mut mesh = egui::Mesh::default(); + for j in 0..=rows { + for i in 0..=cols { + let (x, y) = (i as f32 / cols as f32, j as f32 / rows as f32); + mesh.colored_vertex(egui::pos2(r.left() + x * r.width(), r.bottom() - y * r.height()), color32(f(x, y))); + } + } + let w = (cols + 1) as u32; + for j in 0..rows as u32 { + for i in 0..cols as u32 { + let a = j * w + i; + mesh.add_triangle(a, a + 1, a + w); + mesh.add_triangle(a + 1, a + w + 1, a + w); + } + } + painter.add(egui::Shape::mesh(mesh)); +} + +/// The colour in the dialog's fields: `hex`, with the HSB hue and the Lab values kept while +/// they round-trip (`_hsb`, `_lab`). +fn picked_of(d: &Dialog) -> Picked { + let mut rgb = designcraft_color::Color::from_hex(&d.s("hex")).map_or([0.0; 3], |c| c.to_rgb_uncalibrated()); + if let Some(raw) = nums::<3>(d, "rgb") { + let raw = raw.map(|x| (x / 255.0) as f32); + if Picked::from_rgb(raw, 0.0).hex() == Picked::from_rgb(rgb, 0.0).hex() { + rgb = raw; + } + } + let hue = nums::<3>(d, "_hsb").map_or(0.0, |h| h[0] as f32); + let mut p = Picked::from_rgb(rgb, hue); + // The exact values the picker last set, while they still give this colour. + if let Some(h) = nums::<3>(d, "_hsb").map(|h| h.map(|v| v as f32)) + && Picked::from_hsb(h).hex() == p.hex() + { + p.hsb = h; + } + if let Some(l) = nums::<3>(d, "_lab").map(|l| l.map(|v| v as f32)) + && Picked::from_lab(l, p.hsb[0]).hex() == p.hex() + { + p.lab = l; + } + p +} + +fn store_picked(d: &mut Dialog, p: &Picked) { + d.fields.insert("edited".into(), json!(true)); + d.fields.insert("rgb".into(), json!(p.rgb.map(|x| x * 255.0))); + d.fields.insert( + "cmyk".into(), + json!( + designcraft_color::Color::Rgb { r: p.rgb[0], g: p.rgb[1], b: p.rgb[2] } + .to_cmyk_managed(designcraft_color::cms::active_settings().intent) + .map(|x| x * 100.0) + ), + ); + d.fields.insert("hex".into(), json!(p.hex())); + d.fields.insert("_hsb".into(), json!(p.hsb)); + d.fields.insert("_lab".into(), json!(p.lab)); +} + +/// The Color Picker's place in the dialog, as InDesign's (points from the body's top-left). +mod picker_layout { + use egui::{Rect, pos2, vec2}; + // InDesign's proportions at this app's density (its 30 px fields are 21 pt here). + pub const SIZE: egui::Vec2 = vec2(566.0, 334.0); + pub fn field() -> Rect { + Rect::from_min_size(pos2(2.0, 31.0), vec2(265.0, 287.0)) + } + pub fn slider() -> Rect { + Rect::from_min_size(pos2(285.0, 32.0), vec2(19.0, 282.0)) + } + pub fn swatch() -> Rect { + Rect::from_min_size(pos2(319.0, 0.0), vec2(71.0, 90.0)) + } + /// OK, Cancel, Add … Swatch. + pub fn button(i: usize) -> Rect { + Rect::from_min_size(pos2(433.0, [0.0, 39.0, 78.0][i.min(2)]), vec2(132.0, 25.0)) + } + /// Row centres: H/L, S/a, B/b, (gap), R/C, G/M, B/Y, K, #. + pub const ROWS: [f32; 8] = [129.0, 157.0, 185.0, 224.0, 252.0, 280.0, 308.0, 320.0]; + /// The radio buttons' columns. + pub const LEFT: f32 = 326.0; + pub const RIGHT: f32 = 457.0; +} + +/// A value field, named `name` (its caption): the text typed stays while it has the focus; a +/// number that parses applies. +fn picker_field(ui: &mut egui::Ui, d: &mut Dialog, r: egui::Rect, key: &str, name: &str, value: f32, range: (f32, f32)) -> Option { + let id = egui::Id::unique(("color_picker_field", key)); + let editing = ui.memory(|m| m.has_focus(id)); + let shown = format!("{}", value.round() as i64); + let edit_key = format!("_edit_{key}"); + let mut text = if editing { d.fields.get(&edit_key).and_then(Value::as_str).map(str::to_string).unwrap_or(shown) } else { shown }; + let resp = ui + .put( + r, + egui::TextEdit::singleline(&mut text) + .id(id) + .font(egui::FontId::proportional(11.5)) + .align(egui::Align2::LEFT_CENTER) + .margin(egui::Margin::symmetric(4, 2)), + ) + .accessible_name(name.to_owned()); + let mut out = None; + if resp.changed() + && let Ok(v) = text.trim().parse::() + && v.is_finite() + { + out = Some(v.clamp(range.0, range.1)); + } + d.fields.insert(edit_key, json!(text)); + out +} diff --git a/crates/ui-egui/src/dialogs/options.rs b/crates/ui-egui/src/dialogs/options.rs index 191436fa4..0adcf2d8a 100644 --- a/crates/ui-egui/src/dialogs/options.rs +++ b/crates/ui-egui/src/dialogs/options.rs @@ -352,7 +352,8 @@ mod tests { } fn double_click(h: &mut Harness<'static, DesignApp>, label: &str) { - let at = h.get_by_label(label).rect().center(); + // A swatch row's accessible name is its name, then what its marks show. + let at = h.get_by(|n| n.label().is_some_and(|l| crate::panels::swatches::names_swatch(&l, label))).rect().center(); h.hover_at(at); // Two clicks without the pointer leaving (`drop_at` would end the double click). for pressed in [true, false, true, false] { diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 84cbf3bea..272165b08 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -563,17 +563,6 @@ fn picker(fields: Value) -> (DesignApp, egui::Context, egui::Rect, u64) { (app, ctx, screen, id) } -/// The rightmost painted `text`: the picker's own field, right of egui's colour editor (which -/// has R/G/B fields of its own). -fn rightmost(labels: &[(String, egui::Rect, egui::Rect)], text: &str) -> egui::Pos2 { - labels - .iter() - .filter(|(t, _, _)| t.trim() == text) - .map(|(_, r, _)| r.center()) - .max_by(|a, b| a.x.total_cmp(&b.x)) - .unwrap_or_else(|| panic!("{text} is painted")) -} - /// Types `text` one character per frame, with an idle frame after each. fn type_slowly(app: &mut DesignApp, ctx: &egui::Context, screen: egui::Rect, text: &str) { for ch in text.chars() { @@ -587,6 +576,28 @@ fn fill(app: &DesignApp, id: u64) -> designcraft_color::Color { d.resolve_color(&d.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch, 1.0).unwrap() } +fn key_press(key: egui::Key) -> egui::Event { + egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE } +} + +#[test] +fn the_color_picker_enter_confirms_and_escape_cancels_even_in_a_field() { + for (key, applied) in [(egui::Key::Enter, true), (egui::Key::Escape, false)] { + let (mut app, ctx, screen, id) = picker(json!({"target": "fill", "hex": "#ffffff"})); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "ffffff").center()); + dialog_frame(&mut app, &ctx, screen, vec![select_all_key()]); + type_slowly(&mut app, &ctx, screen, "#f59019"); + dialog_frame(&mut app, &ctx, screen, vec![key_press(key)]); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert!(app.ui.dialog.is_none(), "{key:?} closes the picker"); + let d = &app.session.active().unwrap().doc; + let swatch = d.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch.clone(); + let hex = d.resolve_color(&swatch, 1.0).map(|c| c.to_hex()); + assert_eq!(hex.as_deref() == Some("#f59019"), applied, "{key:?}: {hex:?}"); + } +} + #[test] fn the_hex_field_keeps_typed_text_and_commits_a_valid_code() { let (mut app, ctx, screen, _) = picker(json!({"target": "fill", "hex": "#ffffff"})); @@ -603,7 +614,7 @@ fn the_hex_field_keeps_typed_text_and_commits_a_valid_code() { dialog_frame(&mut app, &ctx, screen, vec![select_all_key()]); type_slowly(&mut app, &ctx, screen, "zz"); let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "#").center()); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "#:").center()); let labels = dialog_frame(&mut app, &ctx, screen, vec![]); visible_label(&labels, screen, "f59019"); assert_eq!(app.ui.dialog.as_ref().unwrap().s("hex"), "#f59019"); @@ -613,11 +624,13 @@ fn the_hex_field_keeps_typed_text_and_commits_a_valid_code() { fn typed_channel_values_are_kept_while_typing() { let (mut app, ctx, screen, id) = picker(json!({"target": "fill", "hex": "#010203", "model": "rgb", "rgb": [1, 2, 3]})); let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - click_dialog(&mut app, &ctx, screen, rightmost(&labels, "1")); + let r = visible_label(&labels, screen, "R:"); + click_dialog(&mut app, &ctx, screen, egui::pos2(r.min.x + 46.0, r.center().y)); dialog_frame(&mut app, &ctx, screen, vec![select_all_key()]); type_slowly(&mut app, &ctx, screen, "12.5"); let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - assert!(rightmost(&labels, "12.5").x > rightmost(&labels, "2").x - 1.0, "the typed text stays in the R field"); + visible_label(&labels, screen, "12.5"); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["rgb"][1], json!(2.0)); assert_eq!(app.ui.dialog.as_ref().unwrap().s("hex"), "#0d0203"); confirm(&mut app).unwrap(); let designcraft_color::Color::Rgb { r, .. } = fill(&app, id) else { panic!("not RGB") }; @@ -639,7 +652,8 @@ fn a_cmyk_colour_stays_cmyk_unless_rgb_values_are_typed() { // A typed C/M/Y/K percent is a percent: M 1 is 1 %. let (mut app, ctx, screen, id) = picker(cmyk.clone()); let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - click_dialog(&mut app, &ctx, screen, rightmost(&labels, "50")); + let r = visible_label(&labels, screen, "M:"); + click_dialog(&mut app, &ctx, screen, egui::pos2(r.max.x + 29.0, r.center().y)); dialog_frame(&mut app, &ctx, screen, vec![select_all_key()]); type_slowly(&mut app, &ctx, screen, "1"); confirm(&mut app).unwrap(); @@ -649,7 +663,9 @@ fn a_cmyk_colour_stays_cmyk_unless_rgb_values_are_typed() { let (mut app, ctx, screen, id) = picker(cmyk); let r = app.ui.dialog.as_ref().unwrap().fields["rgb"][0].as_f64().unwrap(); let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - click_dialog(&mut app, &ctx, screen, rightmost(&labels, &r.to_string())); + let _ = r; + let r = visible_label(&labels, screen, "R:"); + click_dialog(&mut app, &ctx, screen, egui::pos2(r.min.x + 46.0, r.center().y)); dialog_frame(&mut app, &ctx, screen, vec![select_all_key()]); type_slowly(&mut app, &ctx, screen, "10"); confirm(&mut app).unwrap(); @@ -1647,10 +1663,19 @@ fn dialog_frame(app: &mut DesignApp, ctx: &egui::Context, screen: egui::Rect, ev labels } +/// What Find/Change's Search list shows for a scope. +fn scope_label(scope: &str) -> &'static str { + match scope { + "story" => "Story", + "selection" => "Selection", + _ => "Document", + } +} + fn visible_label(labels: &[(String, egui::Rect, egui::Rect)], screen: egui::Rect, expected: &str) -> egui::Rect { let (_, rect, clip) = labels.iter().find(|(text, _, _)| text.trim() == expected).unwrap_or_else(|| panic!("{expected} is painted")); assert!(screen.contains_rect(*rect), "{expected} must stay on screen: {rect:?}"); - assert!(clip.contains_rect(*rect), "{expected} must not be clipped: {rect:?}, {clip:?}"); + assert!(clip.expand(0.5).contains_rect(*rect), "{expected} must not be clipped: {rect:?}, {clip:?}"); *rect } @@ -1730,8 +1755,8 @@ fn scope_dialog_with_tool(text: &str, end: usize, tool: Option<&str>) -> (Design for _ in 0..3 { dialog_frame(&mut app, &ctx, screen, vec![]); } - scope_click(&mut app, &ctx, screen, "document"); - scope_click(&mut app, &ctx, screen, "selection"); + scope_click(&mut app, &ctx, screen, "Document"); + scope_click(&mut app, &ctx, screen, "Selection"); assert_eq!(app.ui.dialog.as_ref().unwrap().s("scope"), "selection"); (app, ctx, screen, sid) } @@ -1806,8 +1831,8 @@ fn find_scope_dialog_explicit_story_ignored_scope_preserves_grep_cursor() { let cur = app.ui.dialog.as_ref().unwrap().s("scope"); let next = if cur == "selection" { "document" } else { "selection" }; if physical { - scope_click(&mut app, &ctx, screen, &cur); - scope_click(&mut app, &ctx, screen, next); + scope_click(&mut app, &ctx, screen, scope_label(&cur)); + scope_click(&mut app, &ctx, screen, scope_label(next)); } else { scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "scope", "value": next})); } @@ -1824,8 +1849,8 @@ fn find_scope_dialog_explicit_story_ignored_scope_preserves_grep_cursor() { let cur = app.ui.dialog.as_ref().unwrap().s("scope"); let next = if cur == "selection" { "document" } else { "selection" }; if physical { - scope_click(&mut app, &ctx, screen, &cur); - scope_click(&mut app, &ctx, screen, next); + scope_click(&mut app, &ctx, screen, scope_label(&cur)); + scope_click(&mut app, &ctx, screen, scope_label(next)); } else { scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "scope", "value": next})); } @@ -2401,7 +2426,7 @@ fn find_scope_dialog_domain_and_object_changes_release_original_range() { #[test] fn dialog_title_and_actions_fit_scaled_viewports() { for language in ["en", "ar"] { - for size in [egui::vec2(1178.0, 814.0), egui::vec2(589.0, 407.0), egui::vec2(392.0, 271.0), egui::vec2(450.0, 280.0)] { + for size in [egui::vec2(1178.0, 814.0), egui::vec2(589.0, 407.0), egui::vec2(392.0, 271.0), egui::vec2(360.0, 280.0)] { for (command, title, ok, cancel) in [("app.preferences", "Preferences", "OK", "Cancel"), ("app.newDocumentDialog", "New Document", "Create", "Close")] { @@ -2528,7 +2553,7 @@ fn preferences_horizontal_scroll_reaches_clipped_options() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); app.run("app.preferences", json!({})).unwrap(); let ctx = dialog_context("en"); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(450.0, 280.0)); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(360.0, 280.0)); for _ in 0..3 { dialog_frame(&mut app, &ctx, screen, vec![]); } @@ -2587,7 +2612,7 @@ fn new_document_vertical_scroll_reaches_every_field_and_preset() { #[test] fn arabic_new_document_fields_remain_reachable_and_editable() { - for size in [egui::vec2(392.0, 271.0), egui::vec2(450.0, 280.0), egui::vec2(1178.0, 814.0)] { + for size in [egui::vec2(392.0, 271.0), egui::vec2(360.0, 280.0), egui::vec2(1178.0, 814.0)] { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); app.ui.language = "ar".into(); app.run("app.newDocumentDialog", json!({})).unwrap(); @@ -2932,6 +2957,47 @@ fn epub_export_options_store_split_document() { assert_eq!(app.session.active().unwrap().doc.settings.epub_split, EpubSplit::ExportTags, "Cancel stores nothing"); } +/// EPUB Export Options › Cover Image: a PNG or JPEG file named there (typed, or picked with +/// Choose…) is the cover; left empty, the first page is, as before (#1934). +#[test] +fn epub_export_options_take_a_cover_image_file() { + use std::io::Read; + use std::sync::{Arc, Mutex}; + let mut app = test_app(); + let dir = tempfile::tempdir().unwrap(); + let png = dir.path().join("cover.png"); + let bytes = b"\x89PNG\r\n\x1a\ncover".to_vec(); + std::fs::write(&png, &bytes).unwrap(); + let written: Arc>> = Arc::new(Mutex::new(Vec::new())); + let sink = written.clone(); + app.services.write = Some(Box::new(move |_: &str, b: &[u8]| { + *sink.lock().unwrap() = b.to_vec(); + Ok(()) + })); + app.answer_dialogs(crate::app::file_dialogs::testing::answer_with(Some(dir.path().join("book.epub").to_string_lossy().into_owned()))); + app.run("app.exportEpub", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("coverImage"), "", "no file by default: the first page is the cover"); + // Choose… puts the picked file into the field; OK passes it to the export. + let path = png.to_string_lossy().into_owned(); + set_epub_cover(&mut app, path.clone()).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("coverImage"), path); + confirm(&mut app).unwrap(); + app.settle_dialogs(); + let out = written.lock().unwrap().clone(); + assert!(!out.is_empty(), "the EPUB was written"); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(out)).unwrap(); + let mut cover = Vec::new(); + z.by_name("OEBPS/images/cover.png").unwrap().read_to_end(&mut cover).unwrap(); + assert_eq!(cover, bytes, "the file is the cover image"); + let mut opf = String::new(); + z.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut opf).unwrap(); + assert!(opf.contains("properties=\"cover-image\""), "{opf}"); + // The field is per export: the next dialog starts empty again. + app.run("app.exportEpub", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("coverImage"), ""); + cancel(&mut app); +} + #[test] fn pdf_export_defaults() { let d = Dialog::new("pdfExport", json!({})); @@ -3253,7 +3319,9 @@ fn new_character_style_opens_its_options_and_creates_the_style() { ctl(&mut app, "ui.dialog.set", json!({"field": "c.size", "value": 9})); assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); let st = char_style(&app, "Character Style 1").unwrap(); - assert_eq!((st.based_on, st.chars), (None, CharAttrs { size: Some(9.0), ..Default::default() })); + // The selected text's own Italic (its Emphasis style) comes along, as InDesign's New + // Character Style takes it. + assert_eq!((st.based_on, st.chars), (None, CharAttrs { size: Some(9.0), font_style: Some("Italic".into()), ..Default::default() })); crate::menus::activate(&mut app, "style.character.create", &Value::Null); assert_eq!(app.ui.dialog.as_ref().unwrap().s("rename"), "Character Style 2"); } @@ -3269,7 +3337,8 @@ fn new_paragraph_style_opens_its_options_and_creates_the_style() { assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); let st = app.session.active().unwrap().doc.styles.para("Paragraph Style 1").cloned().unwrap(); assert_eq!(st.based_on.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); - assert_eq!(st.chars, CharAttrs { size: Some(20.0), ..Default::default() }); + // The selected text's own Italic comes along, as InDesign's New Paragraph Style takes it. + assert_eq!(st.chars, CharAttrs { size: Some(20.0), font_style: Some("Italic".into()), ..Default::default() }); } #[test] @@ -3369,7 +3438,7 @@ fn dragging_the_title_moves_the_dialog_and_dragging_the_body_does_not() { assert!((centred.center() - egui::pos2(600.0, 400.0)).length() < 1.0, "{centred:?}"); assert!(centred.width() < 1000.0 && centred.height() < 700.0, "the area is the dialog, not the backdrop: {centred:?}"); - let title = h.get_by_label("Create Table").rect().center(); + let title = h.get_by_label("Insert Table").rect().center(); drag(&mut h, title, title + vec2(100.0, 60.0)); let moved = rect(&h); assert!((moved.min - centred.min - vec2(100.0, 60.0)).length() < 2.0, "{centred:?} -> {moved:?}"); @@ -3379,7 +3448,7 @@ fn dragging_the_title_moves_the_dialog_and_dragging_the_body_does_not() { assert_eq!(rect(&h), moved, "a drag in the body leaves the dialog in place"); // The title row stays inside the top edge. - let title = h.get_by_label("Create Table").rect().center(); + let title = h.get_by_label("Insert Table").rect().center(); drag(&mut h, title, title - vec2(0.0, 1000.0)); assert!(rect(&h).top().abs() < 1.0, "{:?}", rect(&h)); @@ -3391,7 +3460,7 @@ fn dragging_the_title_moves_the_dialog_and_dragging_the_body_does_not() { assert!((rect(&h).center() - centred.center()).length() < 1.0, "{:?}", rect(&h)); // Past the left edge, about 100 px of the title row stays inside. - let title = h.get_by_label("Create Table").rect().center(); + let title = h.get_by_label("Insert Table").rect().center(); drag(&mut h, title, title - vec2(900.0, 0.0)); assert!((rect(&h).right() - 100.0).abs() < 2.0, "{:?}", rect(&h)); } @@ -3468,19 +3537,259 @@ fn guide_counts(app: &DesignApp) -> Vec { (0..doc.page_count()).map(|abs| doc.page(abs).unwrap().guides.len()).collect() } +/// The eyedropper takes the colour the canvas shows: with Proof Colors the proofed colour, and +/// with the ink-limit preview the warning colour over the limit. #[test] -fn create_guides_dialog_targets_the_page_in_view() { +fn the_eyedropper_samples_what_proof_colors_and_the_ink_limit_show() { + use egui_kittest::Harness; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("swatch.create", json!({"name": "Vivid Green", "color": [0, 255, 0]})).unwrap(); + let id = app.run("frame.create", json!({"rect": [36, 36, 560, 700], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + app.run("object.fill", json!({"ids": [id], "swatch": "Vivid Green"})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.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(6); + let target = { + let app = h.state(); + let doc = &app.session.doc().unwrap().doc; + let layout = designcraft_tools::CanvasLayout::new(doc, false); + let v = *app.view().unwrap(); + crate::canvas::Xf::new(app.canvas_rect.unwrap(), &v).to_screen(layout.slots[0].xf * designcraft_geom::Point::new(300.0, 600.0)) + }; + let ctx = h.ctx.clone(); + let sample = |h: &mut Harness<'_, DesignApp>| crate::canvas::color_at(h.state_mut(), &ctx, target).expect("on the page"); + // What a view makes of the plain colour, as the canvas texture gets it. + let shown = |c: [u8; 3], view: &dyn Fn(&mut designcraft_render::Rendered)| { + let mut img = designcraft_render::Rendered { width: 1, height: 1, pixels: vec![c[0], c[1], c[2], 255] }; + view(&mut img); + [img.pixels[0], img.pixels[1], img.pixels[2]] + }; + let plain = sample(&mut h); + h.state_mut().ui.proof_colors = true; + let setup = h.state().ui.proof_setup.clone(); + let proofed = sample(&mut h); + assert_eq!(proofed, shown(plain, &|img| designcraft_render::proof_view(img, &setup))); + assert_ne!(proofed, plain, "the proof changes this green"); +} + +/// The course's chapter 23: the Color Picker's eyedropper, dragged from the picker onto the +/// page and let go there, takes the colour the page shows. +#[test] +fn the_color_pickers_eyedropper_takes_a_colour_off_the_page() { + use egui_kittest::{Harness, kittest::Queryable}; + 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": [36, 36, 560, 700], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + app.run("swatch.create", json!({"name": "Eyedrop Red", "color": {"c": 0, "m": 100, "y": 100, "k": 0}})).unwrap(); + app.run("object.fill", json!({"ids": [id], "swatch": "Eyedrop Red"})).unwrap(); + app.ui.dialog = Some(Dialog::new("colorPicker", json!({"target": "fill", "hex": "#000000"}))); + let mut h = Harness::builder().with_size(egui::vec2(1400.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(6); + // Where the frame shows on screen. + let target = { + let app = h.state(); + let doc = &app.session.doc().unwrap().doc; + let layout = designcraft_tools::CanvasLayout::new(doc, false); + let v = *app.view().unwrap(); + crate::canvas::Xf::new(app.canvas_rect.unwrap(), &v).to_screen(layout.slots[0].xf * designcraft_geom::Point::new(300.0, 600.0)) + }; + let from = h.get_by_label("Eyedropper: drag it onto the page").rect().center(); + h.hover_at(from); + h.drag_at(from); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(from + (target - from) * (k as f32 / 4.0)); + h.run_steps(1); + } + h.drop_at(target); + h.run_steps(3); + let d = h.state().ui.dialog.as_ref().expect("the picker stays open"); + let [r, g, b, _] = h.state().session.doc().unwrap().doc.resolve_color("Eyedrop Red", 1.0).unwrap().to_rgba8(1.0); + let got: Vec = d.fields["rgb"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); + for (a, e) in got.iter().zip([r, g, b]) { + assert!((a - f64::from(e)).abs() <= 3.0, "{got:?} vs {:?}", [r, g, b]); + } + assert!(d.b("edited")); + // Let go off the page (here, on the dialog's own field area): no colour, nothing changes. + let hex = d.s("hex"); + h.hover_at(from); + h.drag_at(from); + h.run_steps(1); + h.hover_at(from + egui::vec2(-40.0, 0.0)); + h.run_steps(1); + h.drop_at(egui::pos2(2.0, 2.0)); + h.run_steps(3); + assert_eq!(h.state().ui.dialog.as_ref().unwrap().s("hex"), hex); + // Let go over the picker's own colour field, with the page under it: nothing either. + let field = h.get_by_label("Color field").rect().center(); + let ctx = h.ctx.clone(); + assert!(crate::canvas::color_at(h.state_mut(), &ctx, field).is_some(), "the page lies under the field"); + h.hover_at(from); + h.drag_at(from); + h.run_steps(1); + h.hover_at(field); + h.run_steps(1); + h.drop_at(field); + h.run_steps(3); + assert_eq!(h.state().ui.dialog.as_ref().unwrap().s("hex"), hex, "the picker isn't sampled"); +} + +/// The Color Picker laid out as InDesign's: a field and a slider for the component whose radio +/// is on, the new colour over the old one, and the values in every space. +#[test] + +fn the_color_picker_keeps_a_typed_cmyk_value_the_colour_shown_does_not_change_with() { + use egui_kittest::kittest::Queryable; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.ui.dialog = Some(Dialog::new("colorPicker", json!({"target": "fill", "cmyk": [0.0, 0.0, 0.0, 100.0], "text": false}))); + let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); + h.run_steps(3); + // A C typed at 100% K is kept, whether or not the colour shown changes with it. + let rgb = h.state().app.ui.dialog.as_ref().unwrap().fields.get("rgb").cloned(); + h.get_by_label("C").focus(); + h.run_steps(1); + h.event(egui::Event::Text("50".into())); + h.run_steps(2); + let d = h.state().app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.fields["cmyk"], json!([50.0, 0.0, 0.0, 100.0]), "rgb {:?} (was {rgb:?})", d.fields.get("rgb")); + assert!(d.b("edited")); +} + +#[test] +fn the_color_picker_picks_on_its_field_and_slider_and_adds_a_cmyk_swatch() { + use egui_kittest::kittest::Queryable; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [72, 72, 200, 200], "shape": "rectangle"})).unwrap(); + app.run("object.color", json!({"color":designcraft_color::Color::from_hex("#7c1315").unwrap(),"target":"fill"})).unwrap(); + app.ui.dialog = Some(Dialog::new("colorPicker", json!({"target": "fill", "hex": "#7c1315", "text": false}))); + let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); + h.run_steps(3); + // R at first: R up the slider, B across and G up the field. + let _ = h.get_by_label("RGB Color Space View"); + let field = h.get_by_label("Color field").rect(); + crate::test_window::click_at(&mut h, egui::pos2(field.right() - 1.0, field.top() + 1.0)); + h.run_steps(2); + let p = picked_of(h.state().app.ui.dialog.as_ref().unwrap()); + assert!((p.rgb[0] - 124.0 / 255.0).abs() < 0.01 && p.rgb[1] > 0.97 && p.rgb[2] > 0.97, "{p:?}: R kept, G and B full"); + // The slider sets R alone. + let slider = h.get_by_label("Color slider").rect(); + crate::test_window::click_at(&mut h, egui::pos2(slider.center().x, slider.bottom() - 1.0)); + h.run_steps(2); + let p = picked_of(h.state().app.ui.dialog.as_ref().unwrap()); + assert!(p.rgb[0] < 0.02 && p.rgb[1] > 0.97, "{p:?}"); + // Hue on the slider: the field is saturation across, brightness up. + store_picked(h.state_mut().app.ui.dialog.as_mut().unwrap(), &Picked::from_rgb([124.0 / 255.0, 19.0 / 255.0, 21.0 / 255.0], 0.0)); + h.state_mut().app.ui.dialog.as_mut().unwrap().fields.insert("component".into(), json!("h")); + h.run_steps(2); + crate::test_window::click_at(&mut h, egui::pos2(field.left() + 1.0, field.top() + 1.0)); + h.run_steps(2); + let p = picked_of(h.state().app.ui.dialog.as_ref().unwrap()); + assert!(p.hsb[1] < 0.02 && p.hsb[2] > 0.98 && (p.hsb[0] - 359.0).abs() < 2.0, "{p:?}: no saturation, full brightness, the hue kept"); + // Lab shows its own view. + h.state_mut().app.ui.dialog.as_mut().unwrap().fields.insert("component".into(), json!("l")); + h.run_steps(2); + let _ = h.get_by_label("Lab Color Space View"); + // Add CMYK Swatch adds the colour as a CMYK swatch; OK applies it. + store_picked(h.state_mut().app.ui.dialog.as_mut().unwrap(), &Picked::from_rgb([124.0 / 255.0, 19.0 / 255.0, 21.0 / 255.0], 0.0)); + h.run_steps(2); + let before = h.state().app.session.active().unwrap().doc.swatches.len(); + h.get_by_label("Add CMYK Swatch").click(); + h.run_steps(3); + let doc = &h.state().app.session.active().unwrap().doc; + assert_eq!(doc.swatches.len(), before + 1); + assert!(matches!( + doc.swatches.last().unwrap().value, + designcraft_color::swatch::SwatchValue::Color { color: designcraft_color::Color::Cmyk { .. }, .. } + )); + // The percentages the C/M/Y/K fields show, exactly (not the colour converted back). + h.state_mut().app.ui.dialog.as_mut().unwrap().fields.insert("cmyk".into(), json!([0.0, 50.0, 100.0, 0.0])); + h.run_steps(2); + h.get_by_label("Add CMYK Swatch").click(); + h.run_steps(3); + let doc = &h.state().app.session.active().unwrap().doc; + let designcraft_color::swatch::SwatchValue::Color { color: designcraft_color::Color::Cmyk { c, m, y, k }, .. } = + doc.swatches.last().unwrap().value + else { + panic!("a CMYK swatch") + }; + assert_eq!([c, m, y, k].map(|v| (v * 100.0).round()), [0.0, 50.0, 100.0, 0.0]); + // Its own OK, at the top right, applies it. + h.get_by_label("OK").click(); + h.run_steps(3); + assert!(h.state().app.ui.dialog.is_none(), "OK closes it"); + let st = h.state().app.session.active().unwrap(); + let id = st.selection.items[0]; + let fill = st.doc.item(id).unwrap().fill.swatch.clone(); + assert_eq!(st.doc.resolve_color(&fill, 1.0).unwrap().to_hex(), "#7c1315"); +} + +/// The Color Picker reopens on the component it was left on, and the choice survives the +/// preferences file; an opener that names a component still gets it. +#[test] +fn the_color_picker_remembers_its_component() { + use egui_kittest::kittest::Queryable; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [72, 72, 200, 200], "shape": "rectangle"})).unwrap(); + assert_eq!(app.ui.color_picker_component, "r", "R at first, as before"); + app.ui.dialog = Some(Dialog::new("colorPicker", json!({"target": "fill", "hex": "#7c1315", "text": false}))); + let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); + h.run_steps(3); + assert_eq!(h.state().app.ui.dialog.as_ref().unwrap().s("component"), "r"); + h.get_by_role_and_label(egui::accesskit::Role::RadioButton, "H:").click(); + h.run_steps(2); + assert_eq!(h.state().app.ui.dialog.as_ref().unwrap().s("component"), "h"); + assert_eq!(h.state().app.ui.color_picker_component, "h", "the choice is kept outside the dialog"); + h.get_by_label("Cancel").click(); + h.run_steps(2); + assert!(h.state().app.ui.dialog.is_none()); + // Reopened: H, not R. + h.state_mut().app.ui.dialog = Some(Dialog::new("colorPicker", json!({"target": "fill", "hex": "#7c1315", "text": false}))); + h.run_steps(3); + assert_eq!(h.state().app.ui.dialog.as_ref().unwrap().s("component"), "h"); + h.get_by_label("Cancel").click(); + h.run_steps(2); + // An opener's own choice wins. + h.state_mut().app.ui.dialog = Some(Dialog::new("colorPicker", json!({"target": "fill", "hex": "#7c1315", "text": false, "component": "l"}))); + h.run_steps(3); + assert_eq!(h.state().app.ui.dialog.as_ref().unwrap().s("component"), "l"); + // Saved with the preferences, and read back; a file without it is R. + let saved = serde_json::to_vec(&h.state().app.ui).unwrap(); + assert_eq!(crate::UiState::from_preferences_json(&saved).unwrap().color_picker_component, "h"); + assert_eq!(crate::UiState::from_preferences_json(b"{}").unwrap().color_picker_component, "r"); +} + +#[test] +fn create_guides_dialog_covers_the_spread_in_view() { let mut app = facing_app(6); + // As in InDesign, both pages of the facing spread in view get the guides. + crate::canvas::go_to_page(&mut app, 1); + crate::menus::activate(&mut app, "layout.createGuides", &Value::Null); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("target"), "spread"); + ctl(&mut app, "ui.dialog.set", json!({"field": "columns", "value": "2"})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + assert_eq!(guide_counts(&app), vec![0, 2, 2, 0, 0, 0]); + // "This page" narrows them to the page in view. crate::canvas::go_to_page(&mut app, 4); crate::menus::activate(&mut app, "layout.createGuides", &Value::Null); ctl(&mut app, "ui.dialog.set", json!({"field": "columns", "value": "3"})); - assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); - assert_eq!(guide_counts(&app), vec![0, 0, 0, 0, 4, 0]); - // "Both pages of the spread" covers the facing pair. - crate::canvas::go_to_page(&mut app, 1); - crate::menus::activate(&mut app, "layout.createGuides", &Value::Null); - ctl(&mut app, "ui.dialog.set", json!({"field": "columns", "value": "2"})); - ctl(&mut app, "ui.dialog.set", json!({"field": "target", "value": "spread"})); + ctl(&mut app, "ui.dialog.set", json!({"field": "target", "value": "page"})); assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); assert_eq!(guide_counts(&app), vec![0, 2, 2, 0, 4, 0]); } @@ -3521,3 +3830,1211 @@ fn character_style_options_set_the_digits() { assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); assert_eq!(char_style(&app, "Emphasis").unwrap().chars.digits, Some(designcraft_doc::Digits::Farsi)); } + +#[test] +fn import_warning_show_selects_the_frame_it_is_about() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap(); + app.run("selection.set", json!({"ids": []})).unwrap(); + let lines = json!([ + {"text": "a table of 2 rows was read as 1", "item": null}, + {"text": "story 30892: text orientation of its frames is not known or differs; written as horizontal", "item": f["id"]}, + ]); + app.ui.dialog = Some(Dialog::new("alert", json!({"title": "Import Warnings", "file": "book.indd", "message": "", "lines": lines}))); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 800.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::unique("import_warning_show_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(3); + // Only the warning that names a frame offers Show. + assert_eq!(h.query_all_by_label("Show").count(), 1); + h.get_by_label("Show").click(); + h.run_steps(3); + let app = h.state(); + assert!(app.ui.dialog.is_none(), "Show closes the warnings"); + assert_eq!(app.session.doc().unwrap().selection.items, vec![designcraft_doc::ItemId(f["id"].as_u64().unwrap())]); +} + +/// Replace the text of the field beside the `nth` label `label` (on its row, to its right) with +/// `new`, and leave the field. +fn retype_beside(h: &mut egui_kittest::Harness<'static, crate::test_window::Window>, label: &str, nth: usize, new: &str) { + use egui_kittest::kittest::Queryable; + let l = h.get_all_by_label(label).nth(nth).unwrap_or_else(|| panic!("no label {label} #{nth}")).rect(); + let field = h + .get_all_by(|n| n.role() == egui::accesskit::Role::TextInput) + .map(|n| n.rect()) + .filter(|r| r.center().x > l.max.x && (r.center().y - l.center().y).abs() < l.height().max(8.0)) + .min_by(|a, b| a.min.x.total_cmp(&b.min.x)) + .unwrap_or_else(|| panic!("no field beside {label} #{nth}")) + .center(); + crate::test_window::click_at(h, field); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text(new.into())); + h.run_steps(1); + // Tab leaves the field (Enter would be the dialog's OK). + h.key_press(egui::Key::Tab); + h.run_steps(3); +} + +/// The course's chapter 40: Paragraph Border's four weights are linked as InDesign's, so a +/// side typed in sets all four; with the link off ("Make all settings the same") a side is set +/// alone, as the lesson gives the border only a thick left side. The corners link the same way. +#[test] +fn paragraph_border_sides_and_corners_link_as_indesigns() { + use egui_kittest::kittest::Queryable; + let (app, _) = ruled_paragraph(); + let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); + h.state_mut().app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "border", "p.borderOn": true}))); + h.run_steps(4); + let field = |h: &egui_kittest::Harness<'static, crate::test_window::Window>, key: &str| { + h.state().app.ui.dialog.as_ref().and_then(|d| d.fields.get(&format!("p.{key}")).cloned()).unwrap_or_default() + }; + // The four weights start the same, so linked: Top sets them all. + retype_beside(&mut h, "Top:", 0, "3"); + assert_eq!(field(&h, "borderWeights"), json!([3.0, 3.0, 3.0, 3.0])); + // Unlinked, Left alone takes 10 pt. + // The link of the weights (0), corners (1) or offsets (2), where it is now: the dialog + // scrolls as fields take focus. + let link = |h: &egui_kittest::Harness<'static, crate::test_window::Window>, n: usize| { + let links: Vec = h.get_all_by_label("Make all settings the same").map(|n| n.rect()).collect(); + assert_eq!(links.len(), 3, "weights, corners and offsets each have a link"); + links[n].center() + }; + let at = link(&h, 0); + crate::test_window::click_at(&mut h, at); + retype_beside(&mut h, "Left:", 0, "10"); + assert_eq!(field(&h, "borderWeights"), json!([3.0, 10.0, 3.0, 3.0])); + // Linked again, the four take Top's weight; unlinked once more for the thick left side. + let at = link(&h, 0); + crate::test_window::click_at(&mut h, at); + assert_eq!(field(&h, "borderWeights"), json!([3.0, 3.0, 3.0, 3.0])); + let at = link(&h, 0); + crate::test_window::click_at(&mut h, at); + retype_beside(&mut h, "Left:", 0, "10"); + assert_eq!(field(&h, "borderWeights"), json!([3.0, 10.0, 3.0, 3.0])); + // The corners start the same too: Top Left's size goes to all four. + retype_beside(&mut h, "Top Left Corner", 0, "0p4"); + let corners = field(&h, "borderCorners"); + assert!((0..4).all(|i| corners[i]["size"] == json!(4.0)), "{corners}"); + // Unlinked, Top Right alone takes 0p8; linked again, all four take Top Left's 4 pt. + let at = link(&h, 1); + crate::test_window::click_at(&mut h, at); + retype_beside(&mut h, "Top Right Corner", 0, "0p8"); + assert_eq!(field(&h, "borderCorners")[1]["size"], json!(8.0)); + assert_eq!(field(&h, "borderCorners")[0]["size"], json!(4.0)); + let at = link(&h, 1); + crate::test_window::click_at(&mut h, at); + let corners = field(&h, "borderCorners"); + assert!((0..4).all(|i| corners[i] == corners[0] && corners[i]["size"] == json!(4.0)), "{corners}"); + confirm(&mut h.state_mut().app).unwrap(); + let style = h.state().app.session.doc().unwrap().doc.styles.para("Ruled Child").cloned().unwrap(); + assert_eq!(style.para.border_weights, Some([3.0, 10.0, 3.0, 3.0])); +} + +/// The course's chapter 41: Paragraph Shading as InDesign's. The page is headed with its name; +/// the tint has Overprint beside it; the corners are a two-by-two grid whose shape menus show +/// the shapes. The lesson rounds the top two corners at 10 pt, unlinked, and moves the +/// offsets. +#[test] +fn paragraph_shading_page_is_laid_out_as_indesigns() { + use designcraft_geom::corners::CornerShape; + use egui_kittest::kittest::Queryable; + let (app, _) = ruled_paragraph(); + let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); + h.state_mut().app.ui.dialog = + Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "shading", "p.shadingOn": true}))); + h.run_steps(4); + let field = |h: &egui_kittest::Harness<'static, crate::test_window::Window>, key: &str| { + h.state().app.ui.dialog.as_ref().and_then(|d| d.fields.get(&format!("p.{key}")).cloned()).unwrap_or_default() + }; + assert!(h.get_all_by_label("Paragraph Shading").count() >= 2, "the section list and the page's heading"); + h.get_by_label("Overprint").click(); + h.run_steps(2); + assert_eq!(field(&h, "shadingOverprint"), json!(true)); + // The corners' link (0) and the offsets' (1). + let link = |h: &egui_kittest::Harness<'static, crate::test_window::Window>, n: usize| { + let links: Vec = h.get_all_by_label("Make all settings the same").map(|n| n.rect()).collect(); + assert_eq!(links.len(), 2, "corners and offsets each have a link"); + links[n].center() + }; + let at = link(&h, 0); + crate::test_window::click_at(&mut h, at); + for (corner, nth) in [("Top Left Corner", 0), ("Top Right Corner", 1)] { + retype_beside(&mut h, corner, 0, "10 pt"); + h.get_all_by_label("Corner Shape").nth(nth).unwrap().click(); + h.run_steps(2); + h.get_by_label("Rounded").click(); + h.run_steps(2); + } + let corners: [designcraft_geom::corners::Corner; 4] = serde_json::from_value(field(&h, "shadingCorners")).unwrap(); + assert_eq!( + corners.map(|c| (c.shape, c.size)), + [(CornerShape::Rounded, 10.0), (CornerShape::Rounded, 10.0), (CornerShape::None, 0.0), (CornerShape::None, 0.0)] + ); + // The offsets, unlinked: Top alone. + let at = link(&h, 1); + crate::test_window::click_at(&mut h, at); + retype_beside(&mut h, "Top:", 0, "3 pt"); + assert_eq!(field(&h, "shadingOffsets"), json!([3.0, 0.0, 0.0, 0.0])); + confirm(&mut h.state_mut().app).unwrap(); + let style = h.state().app.session.doc().unwrap().doc.styles.para("Ruled Child").cloned().unwrap(); + assert_eq!((style.para.shading_on, style.para.shading_overprint), (Some(true), Some(true))); + assert_eq!(style.para.shading_corners.map(|c| c[1].shape), Some(CornerShape::Rounded)); +} + +/// Paragraph Rules › Color offers Text Color above the swatches, so a rule set to a swatch can +/// go back to it; Gap Color lists only swatches. +#[test] +fn the_rule_colour_menu_offers_text_color() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let doc = app.session.doc().unwrap().doc.clone(); + let menu = |ctx: &egui::Context, text: Option<&str>, click: Option| { + let mut picked = None; + let mut labels = Vec::new(); + let mut events = Vec::new(); + if let Some(pos) = click { + events.push(egui::Event::PointerMoved(pos)); + events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + } + let input = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 600.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + picked = swatch_menu_with(ui, &doc, "m", "[Black]", 130.0, text); + }); + out.textures_delta.clear(); + for s in out.shapes { + if let egui::Shape::Text(t) = s.shape { + labels.push((t.galley.job.text.clone(), t.visual_bounding_rect().center())); + } + } + (picked, labels) + }; + for text in [Some("Text Color"), None] { + let ctx = egui::Context::default(); + let (_, labels) = menu(&ctx, text, None); + let combo = labels.iter().find(|(t, _)| t == "[Black]").unwrap().1; + menu(&ctx, text, Some(combo)); + let (_, labels) = menu(&ctx, text, None); + let item = labels.iter().find(|(t, _)| t == "Text Color").map(|l| l.1); + assert_eq!(item.is_some(), text.is_some(), "{labels:?}"); + if let Some(pos) = item { + let (picked, _) = menu(&ctx, text, Some(pos)); + assert_eq!(picked.as_deref(), Some(designcraft_doc::TEXT_COLOR)); + } + } +} + +/// The course's chapter 20: a paragraph style's rule below becomes wavy, 3 pt, with a gap +/// colour, from Paragraph Rules › Type and Gap Color. +#[test] +fn paragraph_style_rules_take_a_type_and_a_gap_colour() { + let (mut app, _) = ruled_paragraph(); + let ctx = egui::Context::default(); + app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "rules", "rule": "ruleBelow"}))); + draw(&mut app, &ctx); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("p.ruleBelow.type".into(), json!({"kind": "wavy"})); + d.fields.insert("p.ruleBelow.weight".into(), json!(3.0)); + d.fields.insert("p.ruleBelow.gapColor".into(), json!("[Paper]")); + } + draw(&mut app, &ctx); + confirm(&mut app).unwrap(); + let r = app.session.doc().unwrap().doc.styles.resolve_para_style("Ruled Child").0.rule_below; + assert_eq!((r.kind, r.weight, r.gap_color.as_str()), (designcraft_doc::StrokeType::Wavy, 3.0, "[Paper]")); +} + +/// A rule's Gap Tint shows 100% at its default (the rule's JSON leaves a full gap tint out). +#[test] +fn a_rules_default_gap_tint_shows_100() { + use egui_kittest::kittest::Queryable; + let (mut app, _) = ruled_paragraph(); + app.run( + "style.paragraph.edit", + json!({"name": "Ruled Child", "para": {"ruleBelow": {"type": {"kind": "dashed", "pattern": [4.0, 2.0]}, "gapColor": "[Paper]"}}}), + ) + .unwrap(); + let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); + h.state_mut().app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "rules", "rule": "ruleBelow"}))); + h.run_steps(4); + let label = h.get_by_label("Gap Tint:").rect(); + let field = h + .get_all_by(|n| n.role() == egui::accesskit::Role::TextInput) + .filter(|n| n.rect().center().x > label.max.x && (n.rect().center().y - label.center().y).abs() < 8.0) + .min_by(|a, b| a.rect().min.x.total_cmp(&b.rect().min.x)) + .and_then(|n| n.value()); + assert_eq!(field.as_deref(), Some("100%")); +} + +/// The course's chapter 20: the "Recipe Categories" title formatted on the page (size, All +/// Caps, tracking, space after), then New Paragraph Style from it: the dialog starts from the +/// text, and with Apply Style to Selection the paragraph takes the new style. Next Style and +/// Reset To Base are set from General. +#[test] +fn a_new_paragraph_style_starts_from_the_selected_text() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Egg Recipes\rBoil them."})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 11})).unwrap(); + app.run("type.char", json!({"attrs": {"size": 20, "capitalization": "allCaps", "tracking": 50}})).unwrap(); + app.run("type.para", json!({"attrs": {"spaceAfter": 6}})).unwrap(); + open_new_style(&mut app, true); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!( + (d.fields["c.size"].clone(), d.fields["c.capitalization"].clone(), d.fields["p.spaceAfter"].clone()), + (json!(20.0), json!("allCaps"), json!(6.0)) + ); + assert!(d.b("apply") && !d.fields.contains_key("c.fontFamily"), "only what differs from [No Paragraph Style]"); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("rename".into(), json!("Recipe Categories")); + d.fields.insert("nextStyle".into(), json!(designcraft_doc::BASIC_PARAGRAPH)); + } + confirm(&mut app).unwrap(); + let a = app.run("type.selectionAttrs", json!({})).unwrap(); + assert_eq!(a["paragraphStyle"], "Recipe Categories", "applied to the selection"); + assert_eq!(a["paraOverrides"].as_u64().unwrap_or(0) + a["charOverrides"].as_u64().unwrap_or(0), 0); + let style = |app: &DesignApp| app.session.doc().unwrap().doc.styles.para("Recipe Categories").cloned().unwrap(); + assert_eq!(style(&app).next_style.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); + assert_eq!(style(&app).chars.size, Some(20.0)); + // Reset To Base: the style keeps nothing of its own, apart from the edits made then. + app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Recipe Categories", "basedOn": designcraft_doc::BASIC_PARAGRAPH}))); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("resetToBase".into(), json!(true)); + d.fields.insert("c.tracking".into(), json!(10.0)); + } + confirm(&mut app).unwrap(); + let s = style(&app); + assert_eq!((s.chars.size, s.chars.tracking, s.para.space_after), (None, Some(10.0), None), "{s:?}"); + assert_eq!(s.based_on.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); +} + +/// The course's chapter 22: "eggs" made bold and red on the page, then New Character Style +/// from it. The dialog is titled as InDesign's, starts from what the text has of its own +/// over its paragraph style ("[None] + Bold + color"), and Apply Style to Selection gives the +/// text the style, with no override left. +#[test] +fn a_new_character_style_starts_from_the_selected_text() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Drop eggs into boiling water."})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 29})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Recipe", "chars": {"size": 14}})).unwrap(); + app.run("style.paragraph.apply", json!({"name": "Recipe"})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 5, "focus": 9})).unwrap(); + app.run("type.char", json!({"attrs": {"fontStyle": "Bold", "fill": "C=15 M=100 Y=100 K=0"}})).unwrap(); + open_new_style(&mut app, false); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!((d.fields["c.fontStyle"].clone(), d.fields["c.fill"].clone()), (json!("Bold"), json!("C=15 M=100 Y=100 K=0"))); + assert!(d.b("apply")); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::unique("new_character_style_test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(4); + for l in ["New Character Style", "Apply Style to Selection"] { + assert!(h.query_by_label(l).is_some(), "{l}"); + } + let app = h.state_mut(); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("rename".into(), json!("eggs")); + } + confirm(app).unwrap(); + let style = app.session.doc().unwrap().doc.styles.char_style("eggs").cloned().unwrap(); + assert_eq!((style.chars.font_style.as_deref(), style.chars.fill.as_deref()), (Some("Bold"), Some("C=15 M=100 Y=100 K=0"))); + assert_eq!(style.chars.size, None, "the size is the paragraph style's, not the text's own"); + let a = app.run("type.selectionAttrs", json!({})).unwrap(); + assert_eq!((a["characterStyle"].clone(), a["charOverrides"].clone()), (json!("eggs"), json!(0)), "applied to the selection"); +} + +/// A Based On changed in the dialog (without Reset To Base) is what the fields are checked +/// against: the style's own settings over the new parent. +#[test] +fn a_changed_based_on_is_what_the_fields_are_checked_against() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Loose", "para": {"wordSpaceMin": 1.2, "wordSpaceDesired": 1.3}})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Child", "basedOn": "Loose"})).unwrap(); + let mut d = Dialog::new("paragraphStyleOptions", json!({"name": "Child"})); + d.fields.insert("p.wordSpaceDesired".into(), json!(1.0)); + assert!(check_paragraph_style_justification(&app, &d).is_err(), "below Loose's minimum"); + d.fields.insert("basedOn".into(), json!(designcraft_doc::BASIC_PARAGRAPH)); + assert_eq!(check_paragraph_style_justification(&app, &d), Ok(()), "[Basic Paragraph]'s minimum is lower"); +} + +/// With Reset To Base, the justification fields are checked against the base style (the +/// style's own minimum no longer applies), and Preview shows the reset on the page. +#[test] +fn reset_to_base_checks_and_previews_against_the_base() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Loose words"})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 5})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Loose", "basedOn": designcraft_doc::BASIC_PARAGRAPH, "para": {"wordSpaceMin": 1.2, "wordSpaceDesired": 1.3}, "chars": {"size": 30}})) + .unwrap(); + app.run("style.paragraph.apply", json!({"name": "Loose"})).unwrap(); + let mut d = Dialog::new("paragraphStyleOptions", json!({"name": "Loose"})); + d.fields.insert("p.wordSpaceDesired".into(), json!(1.1)); + assert!(check_paragraph_style_justification(&app, &d).is_err(), "below the style's own minimum"); + d.fields.insert("resetToBase".into(), json!(true)); + assert_eq!(check_paragraph_style_justification(&app, &d), Ok(()), "the base's minimum is 100%"); + d.fields.insert("preview".into(), json!(true)); + style_options_preview(&mut app, &mut d); + assert_eq!(d.fields[PREVIEWED]["resetToBase"], true); + let size = app.run("type.selectionAttrs", json!({})).unwrap()["chars"]["size"].clone(); + assert_eq!(size, json!(12.0), "the page shows the style reset to its base"); + app.session.cancel_interaction(); +} + +/// The course's chapter 22: Find/Change › Change Format. "eggs", case sensitive, nothing in +/// Change to; Change Format Settings › Style Options sets Character Style "eggs", and the box +/// shows it (with Change to's ⓘ). Escape leaves the settings, not Find/Change. Change All +/// gives every lowercase "eggs" the style and keeps the words; the trash clears the format. +#[test] +fn find_change_gives_the_found_text_a_change_format() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let text = "Poached Eggs\nBreak eggs; cook until eggs are set."; + let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": text})).unwrap(); + let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); + app.run("style.character.create", json!({"name": "eggs", "chars": {"fontStyle": "Bold"}})).unwrap(); + app.run("app.findChange", json!({})).unwrap(); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("find".into(), json!("eggs")); + d.fields.insert("caseSensitive".into(), json!(true)); + } + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::unique("change_format_test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(4); + const TIP: &str = "With a Change Format and nothing in Change to, the found text stays and is only formatted"; + assert!(h.query_by_label("Change Format:").is_some()); + assert!(h.query_by_label(TIP).is_none(), "no ⓘ without a Change Format"); + h.get_by_label("Specify attributes to change").click(); + h.run_steps(3); + // Style Options is the section's name in the list and its heading. + for l in ["Change Format Settings", "Style Options", "Basic Character Formats", "Character Color"] { + assert!(h.query_all_by_label(l).next().is_some(), "{l}"); + } + // Style Options › Character Style: eggs. + h.get_all_by_value("[Any Style]").next().unwrap().click(); + h.run_steps(3); + h.get_by_label("eggs").click(); + h.run_steps(3); + h.get_all_by_label(" OK ").last().unwrap().click(); + h.run_steps(3); + let field = |h: &Harness<'_, DesignApp>, k: &str| h.state().ui.dialog.as_ref().unwrap().fields.get(k).cloned(); + assert_eq!((field(&h, CHANGE_FORMAT), field(&h, FORMAT_EDIT)), (Some(json!({"charStyle": "eggs"})), None)); + assert!(h.query_by_label("Character Style: eggs").is_some(), "the box shows it"); + assert!(h.query_by_label(TIP).is_some(), "Change to's ⓘ"); + // Escape closes Change Format Settings only, and keeps the Change Format. + h.get_by_label("Specify attributes to change").click(); + h.run_steps(3); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert_eq!(h.state().ui.dialog.as_ref().map(|d| d.id.as_str()), Some("findChange")); + assert_eq!((field(&h, CHANGE_FORMAT), field(&h, FORMAT_EDIT)), (Some(json!({"charStyle": "eggs"})), None)); + // Change All: the words stay, every lowercase "eggs" has the style. + h.get_by_label("Change All").click(); + h.run_steps(3); + assert_eq!(field(&h, "status"), Some(json!("2 replacement(s) made"))); + let story = h.state().session.doc().unwrap().doc.story(sid).cloned().unwrap(); + assert_eq!(story.text, text); + assert_eq!( + [8, 19, 36].map(|at| story.format_after(at).style.clone()), + [designcraft_doc::NO_CHAR_STYLE.to_string(), "eggs".into(), "eggs".into()] + ); + // The trash clears it (the second: the first is the Find Format's). + h.get_all_by_label("Clear specified attributes").nth(1).unwrap().click(); + h.run_steps(3); + assert_eq!(field(&h, CHANGE_FORMAT), None); + assert!(h.query_by_label(TIP).is_none()); +} + +/// What Change Format Settings sets goes to find.change: a paragraph style and character +/// attributes as well as a character style; the box sums them up. +#[test] +fn a_change_format_goes_with_change_all() { + let mut d = Dialog::new("findChange", json!({"find": "cook"})); + d.fields.insert(CHANGE_FORMAT.into(), json!({"charStyle": "eggs", "paraStyle": "Method", "chars": {"underline": true}})); + let p = find_change_params(&d); + assert_eq!((p["charStyle"].clone(), p["paraStyle"].clone(), p["attrs"].clone()), (json!("eggs"), json!("Method"), json!({"underline": true}))); + assert_eq!(format_summary("en", d.fields.get(CHANGE_FORMAT)), "Character Style: eggs + Paragraph Style: Method + Underline: on"); + assert!(find_change_params(&Dialog::new("findChange", json!({"find": "cook"}))).get("attrs").is_none()); + // A Find Format goes with every search. + d.fields.insert(FIND_FORMAT.into(), json!({"paraStyle": "Method"})); + assert_eq!(find_change_params(&d)["findFormat"], json!({"paraStyle": "Method"})); +} + +/// Find/Change › Find Format, as InDesign's: Find Format Settings sets the character style the +/// found text must have, the box shows it with Find what's ⓘ, and Count counts only "eggs" in +/// it; with nothing in Find what, Find Next finds the text in the style; the first trash clears +/// it. +#[test] +fn find_format_finds_only_text_with_it() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Break eggs; cook eggs."})).unwrap(); + app.run("style.character.create", json!({"name": "eggs", "chars": {"fontStyle": "Bold"}})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 6, "focus": 10})).unwrap(); + app.run("style.character.apply", json!({"name": "eggs"})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 0})).unwrap(); + app.run("app.findChange", json!({})).unwrap(); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("find".into(), json!("eggs")); + } + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::unique("find_format_test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(4); + const TIP: &str = "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it"; + assert!(h.query_by_label("Find Format:").is_some() && h.query_by_label(TIP).is_none()); + h.get_by_label("Specify attributes to find").click(); + h.run_steps(3); + assert!(h.query_by_label("Find Format Settings").is_some()); + h.get_all_by_value("[Any Style]").next().unwrap().click(); + h.run_steps(3); + h.get_by_label("eggs").click(); + h.run_steps(3); + h.get_all_by_label(" OK ").last().unwrap().click(); + h.run_steps(3); + let field = |h: &Harness<'_, DesignApp>, k: &str| h.state().ui.dialog.as_ref().unwrap().fields.get(k).cloned(); + assert_eq!((field(&h, FIND_FORMAT), field(&h, CHANGE_FORMAT)), (Some(json!({"charStyle": "eggs"})), None)); + assert!(h.query_by_label("Character Style: eggs").is_some() && h.query_by_label(TIP).is_some()); + h.get_by_label("Count").click(); + h.run_steps(3); + assert_eq!(field(&h, "status"), Some(json!("1 match(es)")), "only the \"eggs\" in the style"); + // Nothing in Find what: the text in the style. + h.state_mut().ui.dialog.as_mut().unwrap().fields.insert("find".into(), json!("")); + h.run_steps(2); + h.get_by_label("Find Next").click(); + h.run_steps(3); + let sel = h.state().session.doc().unwrap().selection.text.unwrap(); + assert_eq!(sel.range(), 6..10); + // The first trash is the Find Format's. + h.get_all_by_label("Clear specified attributes").next().unwrap().click(); + h.run_steps(3); + assert_eq!(field(&h, FIND_FORMAT), None); +} + +/// The course's chapter 26: Preferences › Interface as InDesign's. A Color Theme swatch shows +/// its theme at once and Cancel goes back; OK keeps the theme and the Tools panel's columns. +/// UI Size is in a UI Scaling section of its own. +#[test] +fn preferences_interface_sets_the_color_theme_and_tools_panel() { + use crate::theme::Brightness; + use egui::accesskit::Role; + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let opened = app.ui.brightness; + let open = |app: &mut DesignApp| { + app.run("app.preferences", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!("interface")); + }; + open(&mut app); + let mut h = Harness::builder().with_size(egui::vec2(1100.0, 760.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::unique("prefs_interface_test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(3); + let swatch = |h: &Harness<'_, DesignApp>, name: &str| h.get_by(|n| n.role() == Role::RadioButton && n.label().as_deref() == Some(name)).click(); + for l in ["Appearance", "Color Theme:", "Panels", "Tools Panel:"] { + assert!(h.query_by_label(l).is_some(), "{l}"); + } + // A click shows the theme at once; Cancel goes back. + swatch(&h, "Light"); + h.run_steps(3); + assert_eq!(h.state().ui.brightness, Brightness::Light); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert!(h.state().ui.dialog.is_none()); + assert_eq!(h.state().ui.brightness, opened, "Cancel goes back to the theme Preferences opened with"); + // OK keeps the theme and the Tools panel's columns. + open(h.state_mut()); + h.run_steps(3); + swatch(&h, "Dark"); + h.run_steps(2); + h.get_by(|n| n.role() == Role::ComboBox && n.value().as_deref() == Some("Single Column")).click(); + h.run_steps(2); + h.get_by_label("Double Column").click(); + h.run_steps(2); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert!(h.state().ui.dialog.is_none()); + assert_eq!(h.state().ui.brightness, Brightness::Dark); + assert!(h.state().ui.tools_double_column); + // The same command the Window menu toggles, with an explicit state for Preferences. + assert_eq!(h.state_mut().run("window.toolsDoubleColumn", json!({"on": false})).unwrap(), json!(false)); + assert_eq!(h.state_mut().run("window.toolsDoubleColumn", json!({"on": false})).unwrap(), json!(false), "set, not toggled"); + assert_eq!(h.state_mut().run("window.toolsDoubleColumn", json!({})).unwrap(), json!(true), "toggled without `on`"); + // UI Size has a section of its own. + open(h.state_mut()); + h.state_mut().ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!("uiScaling")); + h.run_steps(3); + assert!(h.query_by_label("UI Size:").is_some()); +} + +/// With no document open, Preferences has Units & Increments, as InDesign's: OK keeps them +/// for new documents, and New Document opens in them (Lollo's inches, set once). +#[test] +fn preferences_with_no_document_set_the_units_of_new_documents() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + crate::menus::activate(&mut app, "app.preferences", &Value::Null); + let mut d = app.ui.dialog.take().expect("Preferences"); + assert!(d.b("defaults"), "for new documents"); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1178.0, 814.0)); + app.ui.dialog = Some(d.clone()); + let mut labels = vec![]; + for _ in 0..3 { + labels = dialog_frame(&mut app, &ctx, screen, vec![]); + } + assert!(labels.iter().any(|(t, _, _)| t == "Units & Increments"), "listed with no document: {labels:?}"); + d.fields.insert("horizontalUnits".into(), json!("inches")); + d.fields.insert("verticalUnits".into(), json!("inches")); + d.fields.insert("keyboardIncrement".into(), json!("0.5 pt")); + app.ui.dialog = Some(d); + confirm(&mut app).unwrap(); + let du = app.session.prefs.default_units; + assert_eq!((du.horizontal_units, du.vertical_units, du.keyboard_increment), (Unit::Inches, Unit::Inches, 0.5)); + assert!(app.session.active().is_none(), "no document made"); + // A Cursor Key of 0 or text that isn't a measure is refused, keeping the dialog open and + // the saved values, the other preferences' too; a Kerning/Tracking of 0.5 is kept as it is. + let scale = app.session.prefs.scale_strokes; + for bad in [json!({"keyboardIncrement": "0 pt"}), json!({"keyboardIncrement": "wide"}), json!({"kerningIncrement": "lots"})] { + let mut d = Dialog::new("preferences", json!({"defaults": true, "horizontalUnits": "inches", "scaleStrokes": !scale})); + for (k, v) in bad.as_object().unwrap() { + d.fields.insert(k.clone(), v.clone()); + } + app.ui.dialog = Some(d); + assert!(confirm(&mut app).is_err(), "{bad}"); + assert!(app.ui.dialog.as_ref().is_some_and(|d| d.id == "preferences"), "still open: {bad}"); + assert_eq!(app.session.prefs.default_units.keyboard_increment, 0.5, "{bad}"); + assert_eq!(app.session.prefs.scale_strokes, scale, "no other preference saved: {bad}"); + } + app.ui.dialog = Some(Dialog::new("preferences", json!({"defaults": true, "horizontalUnits": "inches", "kerningIncrement": "0.5"}))); + confirm(&mut app).unwrap(); + assert_eq!(app.session.prefs.default_units.kerning_increment, 0.5, "not rounded up to 1"); + // New Document opens in inches, and the document it makes has inch rulers. + crate::menus::activate(&mut app, "app.newDocumentDialog", &Value::Null); + let nd = app.ui.dialog.clone().expect("New Document"); + assert_eq!(nd.fields["units"], json!("inches")); + assert_eq!(nd.s("width"), designcraft_geom::format_measure(612.0, Unit::Inches)); + confirm(&mut app).unwrap(); + let st = &app.session.doc().unwrap().doc.settings; + assert_eq!((st.horizontal_units, st.vertical_units, st.keyboard_increment), (Unit::Inches, Unit::Inches, 0.5)); + // With the document open, Preferences edits that document's units, not the defaults. + crate::menus::activate(&mut app, "app.preferences", &Value::Null); + assert!(!app.ui.dialog.as_ref().unwrap().b("defaults")); +} + +/// Preferences › File Handling shows the number of recent items as a whole number, as +/// InDesign's does ("20"; it read "20.0"). +#[test] +fn file_handling_shows_the_recent_items_count_as_a_whole_number() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("app.preferences", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!("files")); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 700.0)); + dialog_frame(&mut app, &ctx, screen, vec![]); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + visible_label(&labels, screen, "Number of Recent Items to Display:"); + let count = app.ui.dialog.as_ref().unwrap().n("recentFilesMax").unwrap(); + assert_eq!(count, 20.0, "InDesign's default"); + visible_label(&labels, screen, &format!("{count}")); + assert!(!labels.iter().any(|(t, _, _)| t.trim() == format!("{count:.1}")), "no decimal shown"); +} + +/// Collapsing advanced TOC controls changes visibility without discarding their values. +#[test] +fn toc_more_options_preserves_hidden_settings() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.newSample", json!({})).unwrap(); + open_toc(&mut app); + app.ui.dialog.as_mut().unwrap().fields.insert( + "included".into(), + json!([{ + "style": "Headline", "level": 3, "separator": " ^t", "pageNumber": "before", + "pageNumberStyle": "Accent", "separatorStyle": "Emphasis", "sort": true + }]), + ); + let before = toc_params(&app, app.ui.dialog.as_ref().unwrap()); + let revision = app.session.doc().unwrap().revision; + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0)); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + visible_label(&labels, screen, "Between Entry and Number:"); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "Fewer Options").center()); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + assert!(!labels.iter().any(|(text, _, _)| text == "Between Entry and Number:")); + visible_label(&labels, screen, "Entry Style:"); + assert_eq!(toc_params(&app, app.ui.dialog.as_ref().unwrap()), before); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "More Options").center()); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + visible_label(&labels, screen, "Between Entry and Number:"); + assert_eq!(toc_params(&app, app.ui.dialog.as_ref().unwrap()), before); + assert_eq!(app.session.doc().unwrap().revision, revision); +} + +/// Enter on the focused Fewer Options / More Options button toggles the options; it doesn't +/// also make the table of contents. +#[test] +fn enter_on_the_toc_options_toggle_only_toggles() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.newSample", json!({})).unwrap(); + open_toc(&mut app); + let revision = app.session.doc().unwrap().revision; + let mut h = Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::unique("toc_toggle_test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(3); + h.get_by_label("Fewer Options").focus(); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(3); + let d = h.state().ui.dialog.as_ref().expect("the dialog stays open"); + assert_eq!((d.id.as_str(), d.b("moreOptions")), (TOC, false), "the options are fewer"); + assert_eq!(h.state().session.doc().unwrap().revision, revision, "no table of contents made"); + assert!(h.query_by_label("More Options").is_some()); +} + +/// The title stays readable beside the style selector after the grid settles. +#[test] +fn toc_title_field_shows_the_whole_default_title() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.newSample", json!({})).unwrap(); + open_toc(&mut app); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0)); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + let title_row = |labels: &[(String, egui::Rect, egui::Rect)]| { + let y = visible_label(labels, screen, "Title:").center().y; + labels.iter().filter(|(_, rect, _)| (rect.center().y - y).abs() < 2.0).cloned().collect::>() + }; + visible_label(&title_row(&labels), screen, "Contents"); + app.ui.dialog.as_mut().unwrap().fields.insert("title".into(), json!("Table of Contents")); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + visible_label(&title_row(&labels), screen, "Table of Contents"); + // A small window: the title narrows and the style selector beside it stays in view. + let small = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(450.0, 280.0)); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, small, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, small, vec![]); + let y = visible_label(&labels, small, "Title:").center().y; + let row: Vec<_> = labels.iter().filter(|(_, rect, _)| (rect.center().y - y).abs() < 2.0).cloned().collect(); + let style = visible_label(&row, small, "TOC Title"); + // The selector is 200 pt wide from just before its text: all of it is in the dialog body. + let (_, _, body) = row.iter().find(|(text, _, _)| text == "Style:").unwrap(); + assert!(style.min.x - 4.0 + 200.0 <= body.max.x, "the style selector fits the dialog: {style:?} in {body:?}"); +} + +/// The bookmarks checkbox persists through generation and reopening the dialog. +#[test] +fn toc_bookmarks_checkbox_sets_and_reopens_the_persisted_option() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.newSample", json!({})).unwrap(); + open_toc(&mut app); + assert!(app.ui.dialog.as_ref().unwrap().b("createBookmarks")); + app.ui.dialog.as_mut().unwrap().fields.insert("included".into(), json!([{"style": "Headline", "level": 1}])); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + visible_label(&labels, screen, "Create PDF Bookmarks"); + confirm(&mut app).unwrap(); + assert!(app.session.doc().unwrap().doc.toc.as_ref().unwrap().create_bookmarks); + assert!(!app.session.doc().unwrap().doc.bookmarks.is_empty()); + open_toc(&mut app); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "Create PDF Bookmarks").center()); + confirm(&mut app).unwrap(); + assert!(app.session.doc().unwrap().doc.bookmarks.is_empty()); + open_toc(&mut app); + assert!(!app.ui.dialog.as_ref().unwrap().b("createBookmarks")); +} + +#[test] +fn corner_options_dialog_sets_each_corner_with_preview_and_one_undo_step() { + use designcraft_geom::corners::CornerShape; + 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": [72, 72, 360, 360]})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let corners = |app: &DesignApp| app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().corners.corners; + let undo = |app: &DesignApp| app.session.doc().unwrap().history.undo.len(); + let set = |app: &mut DesignApp, k: &str, v: Value| { + app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); + }; + let u0 = undo(&app); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)); + // Object › Corner Options… opens InDesign's dialog, not a form of the command's params. + crate::menus::activate(&mut app, "object.cornerOptions", &Value::Null); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some(CORNER_OPTIONS)); + dialog_frame(&mut app, &ctx, screen, vec![]); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + for expected in ["Corner Options", "Corner Size and Shape:", "Preview", "OK", "Cancel"] { + visible_label(&labels, screen, expected); + } + assert!(!labels.iter().any(|(t, _, _)| t.trim() == "Ids"), "no raw command fields"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.s("size1"), d.s("shape1"), d.b("link")), ("0p0".into(), "none".into(), true), "seeded from the frame, linked"); + // Linked: a corner changed is all four. + set(&mut app, "size0", json!("1p0")); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("size2"), "1p0"); + // Unlinked, only the top-right corner rounds, as in the tutorial. + set(&mut app, "link", json!(false)); + for k in 0..4 { + set(&mut app, &format!("size{k}"), json!("0")); + } + set(&mut app, "size1", json!("3 cm")); + set(&mut app, "shape1", json!("rounded")); + set(&mut app, "preview", json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!(corners(&app)[1].shape, CornerShape::Rounded, "Preview shows it"); + assert_eq!(undo(&app), u0, "without an undo step"); + set(&mut app, "preview", json!(false)); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!(corners(&app)[1].shape, CornerShape::None, "Preview off puts it back"); + set(&mut app, "preview", json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + confirm(&mut app).unwrap(); + let c = corners(&app); + assert_eq!(c.map(|c| c.shape), [CornerShape::None, CornerShape::Rounded, CornerShape::None, CornerShape::None]); + assert!((c[1].size - 3.0 / 2.54 * 72.0).abs() < 1e-6, "{}", c[1].size); + assert_eq!(undo(&app), u0 + 1, "one undo step"); + // Reopened, it shows the frame's corners, no longer linked. + crate::menus::activate(&mut app, "object.cornerOptions", &Value::Null); + dialog_frame(&mut app, &ctx, screen, vec![]); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.s("shape1"), d.s("shape0"), d.b("link")), ("rounded".into(), "none".into(), false)); + // A size that does not parse keeps the dialog open with the error; so does a shape the + // dialog doesn't offer (rather than another shape taking its place). + set(&mut app, "size0", json!("abc")); + assert!(confirm(&mut app).is_err()); + assert!(app.ui.dialog.is_some()); + set(&mut app, "size0", json!("0")); + set(&mut app, "shape0", json!("spiky")); + assert!(confirm(&mut app).is_err()); + assert!(app.ui.dialog.is_some()); + cancel(&mut app); + assert_eq!(corners(&app), c); +} + +/// Several objects: the dialog shows the first one's corners, and OK sets only the corners +/// edited; the others keep each object's own. A linked edit sets all four. +#[test] +fn corner_options_keep_each_objects_untouched_corners() { + use designcraft_geom::corners::CornerShape; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let a = app.run("frame.create", json!({"rect": [72, 72, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + let b = app.run("frame.create", json!({"rect": [272, 72, 400, 200]})).unwrap()["id"].as_u64().unwrap(); + app.run("object.cornerOptions", json!({"ids": [b], "shape": "bevel", "size": 12})).unwrap(); + app.run("selection.set", json!({"ids": [a, b]})).unwrap(); + let corners = |app: &DesignApp, id: u64| app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().corners.corners; + let set = |app: &mut DesignApp, k: &str, v: Value| { + app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); + }; + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)); + let b0 = corners(&app, b); + // Unlinked: only the top-right corner rounds, on both. + crate::menus::activate(&mut app, "object.cornerOptions", &Value::Null); + dialog_frame(&mut app, &ctx, screen, vec![]); + set(&mut app, "link", json!(false)); + set(&mut app, "size1", json!("1p0")); + set(&mut app, "shape1", json!("rounded")); + dialog_frame(&mut app, &ctx, screen, vec![]); + confirm(&mut app).unwrap(); + let (ca, cb) = (corners(&app, a), corners(&app, b)); + assert_eq!(ca.map(|c| c.shape), [CornerShape::None, CornerShape::Rounded, CornerShape::None, CornerShape::None]); + assert_eq!(cb.map(|c| c.shape), [CornerShape::Bevel, CornerShape::Rounded, CornerShape::Bevel, CornerShape::Bevel], "b keeps its others"); + assert_eq!((cb[0], cb[2], cb[3]), (b0[0], b0[2], b0[3])); + assert_eq!(ca[1], cb[1]); + // Linked: a corner set to the first object's own value still sets all four, on both. + crate::menus::activate(&mut app, "object.cornerOptions", &Value::Null); + dialog_frame(&mut app, &ctx, screen, vec![]); + set(&mut app, "link", json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + set(&mut app, "size0", json!("0p6")); + dialog_frame(&mut app, &ctx, screen, vec![]); + confirm(&mut app).unwrap(); + let (ca, cb) = (corners(&app, a), corners(&app, b)); + assert_eq!(ca, cb, "all four, on both"); + assert!(ca.iter().all(|c| c.shape == CornerShape::None && (c.size - 6.0).abs() < 1e-6), "{ca:?}"); +} + +#[test] +fn on_a_page_without_a_section_the_window_is_new_section() { + // Chris Nielsen's "Numbering Page Sections" (4:43): page 3 selected, the Pages panel menu's + // Numbering & Section Options opens New Section with Start Section on and 1 offered. + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 6, "facingPages": true})).unwrap(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 800.0))); + crate::canvas::go_to_page(&mut app, 2); + crate::menus::activate(&mut app, "layout.section", &Value::Null); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!( + (d.n("page"), d.b("newSection"), d.b("startSection"), d.b("automatic"), d.s("startNumber")), + (Some(3.0), true, true, true, "1".into()) + ); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1178.0, 814.0)); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + visible_label(&labels, screen, "New Section"); + // Typing a number in the field chooses Start Page Numbering at; OK: page 3 is page 5 (odd, + // so a facing page stays on the right). + let field = visible_label(&labels, screen, "1"); + click_dialog(&mut app, &ctx, screen, field.center()); + dialog_frame( + &mut app, + &ctx, + screen, + vec![ + egui::Event::Key { key: egui::Key::End, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }, + egui::Event::Key { key: egui::Key::Backspace, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }, + egui::Event::Text("5".into()), + ], + ); + assert!(!app.ui.dialog.as_ref().unwrap().b("automatic"), "typing chose Start Page Numbering at"); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "OK").center()); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.active().unwrap().doc.page_name(2), "5"); + // Opened again on that page, it is Numbering & Section Options, showing the section. + crate::menus::activate(&mut app, "layout.section", &Value::Null); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.b("newSection"), d.b("startSection"), d.b("automatic"), d.s("startNumber")), (false, true, false, "5".into())); + let labels = (0..4).map(|_| dialog_frame(&mut app, &ctx, screen, vec![])).last().unwrap(); + visible_label(&labels, screen, "Numbering & Section Options"); + assert!(!labels.iter().any(|(t, _, _)| t == "New Section")); +} + +/// Find/Change's Change and Change/Find, as InDesign's: Find Next selects a match, Change/Find +/// changes it and selects the next, Change changes that one and keeps it selected. With no +/// text selected they're off. +#[test] +fn change_and_change_find_change_the_found_text() { + use egui_kittest::Harness; + use egui_kittest::kittest::{NodeT, Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "red red | red"})).unwrap(); + let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); + app.run("text.select", json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + app.run("app.findChange", json!({})).unwrap(); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("find".into(), json!("red")); + d.fields.insert("change".into(), json!("blue")); + } + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::unique("change_find_test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(4); + assert!(h.query_by_value("Document").is_some(), "the Search choices are InDesign's"); + assert!(h.get_by_label("Change").accesskit_node().is_disabled(), "nothing found yet"); + let text = |h: &Harness<'_, DesignApp>| h.state().session.doc().unwrap().doc.story(sid).unwrap().text.clone(); + let selected = |h: &Harness<'_, DesignApp>| h.state().session.doc().unwrap().selection.text.unwrap().range(); + h.get_by_label("Find Next").click(); + h.run_steps(3); + assert_eq!(selected(&h), 0..3); + h.get_by_label("Change/Find").click(); + h.run_steps(3); + assert_eq!((text(&h), selected(&h)), ("blue red | red".to_string(), 5..8)); + h.get_by_label("Change").click(); + h.run_steps(3); + assert_eq!((text(&h), selected(&h)), ("blue blue | red".to_string(), 5..9)); + assert_eq!(h.state().ui.dialog.as_ref().unwrap().fields["status"], json!("1 replacement(s) made")); +} + +/// Insert Table: the dimensions as typed, and the Table Style chosen is applied to the new +/// table; [Basic Table] (the default) leaves it unstyled. +#[test] +fn insert_table_applies_its_dimensions_and_table_style() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("style.table.create", json!({"name": "Grid"})).unwrap(); + for (style, expected) in [("Grid", "Grid"), (designcraft_doc::BASIC_TABLE, "")] { + app.run("frame.create", json!({"rect": [72, 72, 372, 372], "content": "text"})).unwrap(); + assert_eq!(app.run("app.insertTableDialog", json!({})).unwrap(), Value::Null); + for (field, value) in [("bodyRows", json!(5)), ("columns", json!(3)), ("headerRows", json!(1)), ("tableStyle", json!(style))] { + ctl(&mut app, "ui.dialog.set", json!({"field": field, "value": value})); + } + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + let t = app.run("table.get", json!({})).unwrap(); + let len = |k: &str| t[k].as_array().map_or(0, Vec::len); + assert_eq!((len("rows"), len("columns"), t["headerRows"].as_u64()), (6, 3, Some(1)), "{t}"); + assert_eq!(t["style"].as_str().unwrap_or(""), expected, "{t}"); + // One Insert Table, one undo. + app.run("edit.undo", json!({})).unwrap(); + assert!(app.run("table.get", json!({})).is_err(), "the table is gone"); + } + // A null style is no style: the table is made, unstyled. + app.run("frame.create", json!({"rect": [72, 72, 372, 372], "content": "text"})).unwrap(); + app.run("app.insertTableDialog", json!({})).unwrap(); + ctl(&mut app, "ui.dialog.set", json!({"field": "tableStyle", "value": null})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + assert_eq!(app.run("table.get", json!({})).unwrap()["style"].as_str().unwrap_or(""), ""); + app.run("edit.undo", json!({})).unwrap(); + // A style that no longer exists changes nothing. + let tables = |app: &DesignApp| app.session.active().unwrap().doc.stories.values().map(|s| s.tables.len()).sum::(); + app.run("frame.create", json!({"rect": [72, 72, 372, 372], "content": "text"})).unwrap(); + app.run("app.insertTableDialog", json!({})).unwrap(); + ctl(&mut app, "ui.dialog.set", json!({"field": "tableStyle", "value": "Gone"})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false); + assert_eq!(tables(&app), 0); + let d = app.ui.dialog.as_ref().expect("the dialog stays open to be corrected"); + assert_eq!((d.id.as_str(), d.s("tableStyle")), ("insertTable", "Gone".to_string())); + // So does a style that isn't a name: the command refuses it, rather than making the table + // unstyled. + ctl(&mut app, "ui.dialog.set", json!({"field": "tableStyle", "value": 42})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false); + assert_eq!(tables(&app), 0); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("insertTable"), "the dialog stays open"); +} + +/// Convert Text to Table: available with text selected (not with a caret alone); its +/// separators and Number of Columns shape the table. Convert Table to Text takes it back with +/// the separators chosen there. +#[test] +fn convert_dialogs_use_their_separators() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let text = "Name:\nAddress:\nCity:\nState:"; + let sid = app.run("frame.create", json!({"rect": [72, 72, 372, 372], "content": "text", "text": text})).unwrap()["story"].clone(); + app.run("text.select", json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + assert!(!crate::menus::menu_enabled(&app, "app.convertTextToTableDialog"), "a caret alone"); + assert!(!crate::menus::menu_enabled(&app, "app.convertTableToTextDialog"), "no table"); + app.run("text.select", json!({"story": sid, "anchor": 0, "focus": text.len()})).unwrap(); + assert!(crate::menus::menu_enabled(&app, "app.convertTextToTableDialog")); + app.run("app.convertTextToTableDialog", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("convertTextToTable")); + // "comma" and a typed "," are the same separator, so Number of Columns applies; a count of + // 0 is refused, not taken as 1, and the dialog stays open. + ctl(&mut app, "ui.dialog.set", json!({"field": "columnSeparator", "value": "comma"})); + ctl(&mut app, "ui.dialog.set", json!({"field": "rowSeparator", "value": ","})); + assert!(same_separators(app.ui.dialog.as_ref().unwrap())); + ctl(&mut app, "ui.dialog.set", json!({"field": "columns", "value": 0})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("convertTextToTable")); + // An emptied separator: the command refuses it and the dialog stays open, as typed. + ctl(&mut app, "ui.dialog.set", json!({"field": "rowSeparator", "value": ""})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false); + assert_eq!(app.ui.dialog.as_ref().map(|d| (d.id.as_str(), d.s("rowSeparator"))), Some(("convertTextToTable", String::new()))); + for (field, value) in [("columnSeparator", json!("paragraph")), ("rowSeparator", json!("paragraph")), ("columns", json!(2))] { + ctl(&mut app, "ui.dialog.set", json!({"field": field, "value": value})); + } + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + let t = app.run("table.get", json!({})).unwrap(); + let len = |k: &str| t[k].as_array().map_or(0, Vec::len); + assert_eq!((len("rows"), len("columns")), (2, 2), "{t}"); + assert!(crate::menus::menu_enabled(&app, "app.convertTableToTextDialog")); + app.run("app.convertTableToTextDialog", json!({})).unwrap(); + ctl(&mut app, "ui.dialog.set", json!({"field": "columnSeparator", "value": ", "})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + let story = app.session.active().unwrap().doc.story(designcraft_doc::StoryId(sid.as_u64().unwrap())).unwrap().text.clone(); + // (The empty paragraph after a table that ends its story stays after the text.) + assert_eq!(story.trim_end(), "Name:, Address:\nCity:, State:"); +} + +/// The Rectangle dialog a frame-tool click opens shows the width in the horizontal ruler unit and +/// the height in the vertical one, not always picas, and reads bare numbers the same way (#1964). +#[test] +fn frame_size_dialog_follows_the_ruler_units() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("document.preferences", json!({"horizontalUnits": "inches", "verticalUnits": "millimeters"})).unwrap(); + // As the frame tool asks: a click at (36, 36), 72 pt by 72 pt. + app.session.ui_requests.push(designcraft_engine::UiRequest::Dialog { + id: "frameSize".into(), + params: json!({"spread": 0, "shape": "rectangle", "content": "graphic", "x": 36.0, "y": 36.0, "width": 72.0, "height": 72.0}), + }); + app.after_engine(); + let d = app.ui.dialog.as_ref().expect("the Rectangle dialog"); + assert_eq!((d.id.as_str(), d.unit), ("frameSize", Unit::Inches)); + assert_eq!(d.s("width"), format_measure(72.0, Unit::Inches)); + assert_eq!(d.s("height"), format_measure(72.0, Unit::Millimeters)); + assert!(!d.s("width").contains('p') && !d.s("height").contains('p'), "no picas: {} x {}", d.s("width"), d.s("height")); + // Bare numbers typed in each field are in that field's unit: 2 in by 50 mm. + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("width".into(), json!("2")); + d.fields.insert("height".into(), json!("50")); + let before = app.session.doc().unwrap().doc.clone(); + confirm(&mut app).unwrap(); + let after = &app.session.doc().unwrap().doc; + let added: Vec<_> = after.spreads[0].items.iter().filter(|i| !before.spreads[0].items.iter().any(|b| b.id == i.id)).collect(); + assert_eq!(added.len(), 1, "one frame"); + let b = added[0].bounds(); + assert!((b.width() - 144.0).abs() < 0.01 && (b.height() - 50.0 / 25.4 * 72.0).abs() < 0.01, "{b:?}"); + // The properties panel's link opens it in the same units. + let d = frame_size_dialog(&app, json!({"width": 36.0, "height": 36.0})); + assert_eq!((d.s("width"), d.s("height")), (format_measure(36.0, Unit::Inches), format_measure(36.0, Unit::Millimeters))); + // Every ruler unit: the shown size reads back as the size, so OK leaves a 72 pt frame 72 pt. + let units = serde_json::to_value(Unit::ALL).unwrap(); + for (unit, name) in Unit::ALL.iter().zip(units.as_array().unwrap()) { + app.run("document.preferences", json!({"horizontalUnits": name, "verticalUnits": name})).unwrap(); + let d = frame_size_dialog(&app, json!({"width": 72.0, "height": 145.0})); + assert_eq!(d.unit, *unit); + for (k, pt) in [("width", 72.0), ("height", 145.0)] { + let back = d.m_in(k, *unit).unwrap_or(f64::NAN); + assert!((back - pt).abs() < 0.05, "{name}: {k} shows {:?}, reads back {back}", d.s(k)); + } + } +} + +/// #2055 review: the Rectangle dialog reads the height in the vertical unit it showed when it +/// opened. Rulers switched while it is open (the control channel) leave a typed 50 at 50 mm. +#[test] +fn frame_size_dialog_keeps_its_units_while_open() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("document.preferences", json!({"horizontalUnits": "millimeters", "verticalUnits": "millimeters"})).unwrap(); + app.session.ui_requests.push(designcraft_engine::UiRequest::Dialog { + id: "frameSize".into(), + params: json!({"spread": 0, "shape": "rectangle", "content": "graphic", "x": 36.0, "y": 36.0, "width": 72.0, "height": 72.0}), + }); + app.after_engine(); + let d = app.ui.dialog.as_mut().expect("the Rectangle dialog"); + d.fields.insert("width".into(), json!("50")); + d.fields.insert("height".into(), json!("50")); + app.run("document.preferences", json!({"horizontalUnits": "inches", "verticalUnits": "inches"})).unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + confirm(&mut app).unwrap(); + let after = &app.session.doc().unwrap().doc; + let added: Vec<_> = after.spreads[0].items.iter().filter(|i| !before.spreads[0].items.iter().any(|b| b.id == i.id)).collect(); + assert_eq!(added.len(), 1, "one frame"); + let b = added[0].bounds(); + let mm50 = 50.0 / 25.4 * 72.0; + assert!((b.width() - mm50).abs() < 0.01 && (b.height() - mm50).abs() < 0.01, "50 mm by 50 mm (about 141.73 pt): {b:?}"); + // A dialog made without the vertical unit (`Dialog::new`) reads the height in its own unit. + let mut d = Dialog::new("frameSize", json!({"width": "2", "height": "3"})); + d.unit = Unit::Inches; + assert_eq!(d.vertical_unit, None); + app.ui.dialog = Some(d); + let before = app.session.doc().unwrap().doc.clone(); + confirm(&mut app).unwrap(); + let after = &app.session.doc().unwrap().doc; + let added: Vec<_> = after.spreads[0].items.iter().filter(|i| !before.spreads[0].items.iter().any(|b| b.id == i.id)).collect(); + assert_eq!(added.len(), 1, "one frame"); + let b = added[0].bounds(); + assert!((b.width() - 144.0).abs() < 0.01 && (b.height() - 216.0).abs() < 0.01, "{b:?}"); +} + +#[test] +fn parent_resize_exact_dialog_keeps_its_target_and_rejects_invalid_input() { + use designcraft_tools::{PointerEvent, PointerKind}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width":400,"height":600,"pages":3,"facingPages":true})).unwrap(); + app.run("layout.parents.new", json!({"prefix":"B"})).unwrap(); + app.run("layout.parents.edit", json!({"on":true})).unwrap(); + app.session.set_tool("page"); + let before = app.session.doc().unwrap().doc.clone(); + let sr = designcraft_doc::SpreadRef::Parent(1); + let p = app.session.layout().spread_page_rect(&before, sr, 1).unwrap().center(); + app.session.pointer(&PointerEvent::new(PointerKind::DoubleClick, p.x, p.y), designcraft_engine::ViewInfo::at_zoom(1.0)).unwrap(); + let request = app.session.ui_requests.pop().unwrap(); + let designcraft_engine::UiRequest::Dialog { id, params } = request else { panic!("expected exact-size dialog") }; + app.ui.dialog = Some(Dialog::new(&id, params)); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("width".into(), json!("530")); + d.fields.insert("height".into(), json!("710")); + confirm(&mut app).unwrap(); + let doc = &app.session.doc().unwrap().doc; + assert_eq!((doc.parents[1].pages[1].width, doc.parents[1].pages[1].height), (530.0, 710.0)); + assert_eq!(doc.parents[0], before.parents[0]); + assert_eq!(doc.spreads, before.spreads); + let saved = doc.clone(); + app.ui.dialog = Some(Dialog::new("cmd:layout.parentPageSize", json!({"parent":saved.parents[1].id.0,"page":1,"width":"NaN"}))); + assert!(confirm(&mut app).is_err()); + assert!(app.ui.dialog.is_some()); + assert!(std::sync::Arc::ptr_eq(&saved, &app.session.doc().unwrap().doc)); +} diff --git a/crates/ui-egui/src/effects_dialog.rs b/crates/ui-egui/src/effects_dialog.rs index 0fc2b4efc..0d27513d2 100644 --- a/crates/ui-egui/src/effects_dialog.rs +++ b/crates/ui-egui/src/effects_dialog.rs @@ -210,18 +210,109 @@ fn percent(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str]) { } /// The effect's own angle at `path`, or with Use Global Light (`own` false) the document's -/// global light angle: editing it then changes that one, for every effect that uses it. +/// global light angle: editing it then changes that one, for every effect that uses it. The dial +/// and the field both edit it. fn angle(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str], own: bool, global: f64) -> egui::Response { let mut v = if own { num(d, path) } else { global }; - let r = ui.add(egui::DragValue::new(&mut v).range(-180.0..=180.0).suffix("°").max_decimals(1)); - if r.changed() { + let put = |d: &mut Dialog, v: f64| { if own { set(d, path, json!(v)); } else { d.fields.insert(GLOBAL_LIGHT.into(), json!(v)); } + }; + ui.horizontal(|ui| { + if let Some(a) = angle_dial(ui, v, true) { + v = a; + put(d, v); + } + if ui.add(egui::DragValue::new(&mut v).range(-180.0..=180.0).suffix("°").max_decimals(1)).changed() { + put(d, v); + } + }) + .response +} + +/// InDesign's angle dial: a circle with a radius pointing where the light comes from (0° to the +/// right, counter-clockwise). A click or a drag on it points it at the pointer. Focused, the +/// arrow keys turn it a degree (Shift: 10°) — Right and Up counter-clockwise — and assistive +/// technology can step it or set its value, as with a slider. +fn angle_dial(ui: &mut egui::Ui, deg: f64, enabled: bool) -> Option { + use egui::accesskit::{Action, ActionData}; + let sense = if enabled { egui::Sense::click_and_drag() } else { egui::Sense::hover() }; + let (r, resp) = ui.allocate_exact_size(egui::vec2(26.0, 26.0), sense); + resp.widget_info(|| egui::WidgetInfo::slider(enabled, deg, "Angle")); + let col = if enabled { ui.visuals().text_color() } else { ui.visuals().weak_text_color() }; + let (c, rad) = (r.center(), 11.0); + ui.painter().circle_stroke(c, rad, egui::Stroke::new(1.2, col)); + let a = deg.to_radians() as f32; + ui.painter().line_segment([c, c + egui::vec2(a.cos(), -a.sin()) * rad], egui::Stroke::new(1.4, col)); + if resp.has_focus() { + ui.painter().circle_stroke(c, rad + 2.0, ui.visuals().selection.stroke); } - r + if !enabled { + return None; + } + ui.ctx().accesskit_node_builder(resp.id, |node| { + node.set_min_numeric_value(-180.0); + node.set_max_numeric_value(180.0); + node.set_numeric_value_step(1.0); + node.add_action(Action::SetValue); + node.add_action(Action::Increment); + node.add_action(Action::Decrement); + }); + let mut next = None; + let mut steps = 0i64; + if resp.has_focus() { + // The arrows turn the dial instead of moving the focus. + ui.memory_mut(|m| { + m.set_focus_lock_filter(resp.id, egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() }) + }); + ui.input(|i| { + for e in &i.events { + if let egui::Event::Key { key, pressed: true, modifiers, .. } = e { + let dir = match key { + egui::Key::ArrowRight | egui::Key::ArrowUp => 1, + egui::Key::ArrowLeft | egui::Key::ArrowDown => -1, + _ => 0, + }; + steps += dir * if modifiers.shift { 10 } else { 1 }; + } + } + }); + } + ui.input(|i| { + steps += i.num_accesskit_action_requests(resp.id, Action::Increment) as i64; + steps -= i.num_accesskit_action_requests(resp.id, Action::Decrement) as i64; + for request in i.accesskit_action_requests(resp.id, Action::SetValue) { + if let Some(ActionData::NumericValue(v)) = request.data { + next = Some(v); + } + } + }); + if steps != 0 { + next = Some(deg + steps as f64); + } + if (resp.dragged() || resp.clicked()) + && let Some(a) = resp.interact_pointer_pos().and_then(|p| dial_angle(c, p)) + { + next = Some(a); + } + next.filter(|v| v.is_finite()).map(wrap_angle) +} + +/// `deg` turned into the dial's range, −180 (exclusive) to 180°. +fn wrap_angle(deg: f64) -> f64 { + let a = (deg + 180.0).rem_euclid(360.0) - 180.0; + if a <= -180.0 { 180.0 } else { a } +} + +/// The angle a dial at `c` points at for the pointer at `p`, in whole degrees (−180 to 180, +/// counter-clockwise from the right); none too close to the centre to tell. +fn dial_angle(c: egui::Pos2, p: egui::Pos2) -> Option { + let d = p - c; + // Straight left is 180° (not −180°, which a negative zero would give). + (d.length() > 2.0).then(|| f64::from((-d.y).atan2(d.x)).to_degrees().round()).map(|a| if a <= -180.0 { 180.0 } else { a }) } fn flag(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str], label: &str) { @@ -318,6 +409,7 @@ fn page_ui(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, page: &str) { let key = page; heading(ui, &tr("Blending")); grid("b").show(ui, |ui| { + mode_row(ui, d, key, &tr); row(ui, "Color:"); swatch(app, ui, d, &[key, "color"]); row(ui, "Opacity:"); @@ -359,23 +451,7 @@ fn page_ui(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, page: &str) { let key = page; heading(ui, &tr("Blending")); grid("b").show(ui, |ui| { - // Inner Glow: its blend mode (Screen, as InDesign starts it). - if key == "innerGlow" { - row(ui, "Mode:"); - let cur = d.fields.get("fx").and_then(|f| f["innerGlow"]["mode"].as_str()).unwrap_or("Screen").to_string(); - let label = |m: &designcraft_color::BlendMode| { - serde_json::to_value(m).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default() - }; - let shown = designcraft_color::BlendMode::ALL.iter().find(|m| label(m) == cur).map_or("Screen", |m| m.label()); - craft_ui::ComboBox::from_id_salt("fx_glow_mode").selected_text(tr(shown)).width(150.0).show_ui(ui, |ui| { - for m in designcraft_color::BlendMode::ALL { - if ui.selectable_label(label(&m) == cur, tr(m.label())).clicked() { - set(d, &["innerGlow", "mode"], json!(label(&m))); - } - } - }); - ui.end_row(); - } + mode_row(ui, d, key, &tr); row(ui, "Color:"); swatch(app, ui, d, &[key, "color"]); row(ui, "Opacity:"); @@ -565,6 +641,28 @@ fn summary(lang: &str, d: &Dialog) -> String { format!("{}: {} {:.0}%; {}", crate::i18n::tr(lang, "OBJECT").to_uppercase(), crate::i18n::tr(lang, blend), opacity * 100.0, effects) } +/// The Mode row of a shadow's or glow's Blending: its blend mode, as InDesign's (shadows start +/// Multiply, glows Screen). +fn mode_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, tr: &dyn Fn(&str) -> String) { + crate::rtl::label(ui, tr("Mode:")); + let fallback = if key.ends_with("Glow") { "Screen" } else { "Multiply" }; + let cur = d.fields.get("fx").and_then(|f| f[key]["mode"].as_str()).unwrap_or(fallback).to_string(); + let name = |m: &designcraft_color::BlendMode| serde_json::to_value(m).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); + let shown = designcraft_color::BlendMode::ALL.iter().find(|m| name(m) == cur).map_or(fallback, |m| m.label()); + let mut picked = None; + craft_ui::ComboBox::from_id_salt(("fx_mode", key.to_string())).selected_text(tr(shown)).width(150.0).show_ui(ui, |ui| { + for m in designcraft_color::BlendMode::ALL { + if ui.selectable_label(name(&m) == cur, tr(m.label())).clicked() { + picked = Some(name(&m)); + } + } + }); + if let Some(m) = picked { + set(d, &[key, "mode"], json!(m)); + } + ui.end_row(); +} + /// The dialog's body. pub fn show(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let lang = app.ui.language.clone(); @@ -767,6 +865,76 @@ mod tests { assert!(!fx(&app).inner_shadow.on); } + /// The angle dial points where the pointer is, as InDesign's: right is 0°, up 90°, left 180°, + /// down −90°, upper left 135°. + #[test] + fn the_angle_dial_points_at_the_pointer() { + let c = egui::pos2(100.0, 100.0); + assert_eq!(dial_angle(c, egui::pos2(120.0, 100.0)), Some(0.0)); + assert_eq!(dial_angle(c, egui::pos2(100.0, 80.0)), Some(90.0)); + assert_eq!(dial_angle(c, egui::pos2(80.0, 100.0)), Some(180.0)); + assert_eq!(dial_angle(c, egui::pos2(100.0, 120.0)), Some(-90.0)); + assert_eq!(dial_angle(c, egui::pos2(90.0, 90.0)), Some(135.0)); + assert_eq!(dial_angle(c, egui::pos2(101.0, 100.0)), None, "too close to the centre"); + } + + /// Without a pointer: Tab focuses the dial, the arrows turn it (Shift: 10°), wrapping at 180°. + #[test] + fn the_angle_dial_turns_with_the_keyboard() { + let ctx = egui::Context::default(); + let key = |key, shift| egui::Event::Key { + key, + physical_key: None, + pressed: true, + repeat: false, + modifiers: if shift { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE }, + }; + let mut deg = 175.0; + let frame = |events: Vec, deg: &mut f64| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + if let Some(a) = angle_dial(ui, *deg, true) { + *deg = a; + } + }); + out.textures_delta.clear(); + }; + frame(vec![], &mut deg); + frame(vec![key(egui::Key::Tab, false)], &mut deg); + frame(vec![key(egui::Key::ArrowRight, false)], &mut deg); + assert_eq!(deg, 176.0); + frame(vec![key(egui::Key::ArrowUp, true)], &mut deg); + assert_eq!(deg, -174.0, "past 180° it comes round to −174°"); + frame(vec![key(egui::Key::ArrowLeft, true)], &mut deg); + frame(vec![key(egui::Key::ArrowDown, false)], &mut deg); + assert_eq!(deg, 175.0); + assert_eq!(wrap_angle(-180.0), 180.0); + assert_eq!(wrap_angle(540.0), 180.0); + } + + /// Drop Shadow and Outer Glow have InDesign's Mode under Blending (Multiply and Screen to + /// start); a mode picked there is what OK applies. + #[test] + fn shadows_and_glows_have_a_blend_mode() { + let (mut app, id) = app_with_frame(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, "en"); + open(&mut app, "dropShadow"); + let texts = frame(&mut app, &ctx); + assert!(texts.iter().any(|x| x.trim() == "Mode:") && texts.iter().any(|x| x.trim() == "Multiply"), "{texts:?}"); + crate::dialogs::cancel(&mut app); + open(&mut app, "outerGlow"); + let texts = frame(&mut app, &ctx); + assert!(texts.iter().any(|x| x.trim() == "Screen"), "{texts:?}"); + if let Some(d) = app.ui.dialog.as_mut() { + set(d, &["outerGlow", "mode"], json!("Normal")); + } + frame(&mut app, &ctx); + crate::dialogs::confirm(&mut app).unwrap(); + let fx = app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().effects.clone(); + assert!(fx.outer_glow.on); + assert_eq!(fx.outer_glow.mode, designcraft_color::BlendMode::Normal); + } + #[test] fn offsets_follow_distance_and_angle_as_in_indesign() { let (mut app, _) = app_with_frame(); diff --git a/crates/ui-egui/src/guide_selection_tests.rs b/crates/ui-egui/src/guide_selection_tests.rs index 626a7b826..8a1cde599 100644 --- a/crates/ui-egui/src/guide_selection_tests.rs +++ b/crates/ui-egui/src/guide_selection_tests.rs @@ -328,3 +328,31 @@ fn a_guide_dragged_out_of_a_ruler_snaps() { drag(&mut h, ruler, dest); assert_eq!(positions(&h), [200.0, 300.0, 400.0, 400.0]); } + +/// A guide pulled out of a ruler keeps the resize cursor across it over the canvas until it is +/// dropped; the tool's arrow came back as soon as the pointer left the ruler. +#[test] +fn a_guide_dragged_out_of_a_ruler_keeps_its_cursor_until_dropped() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let mut h = harness(app); + let rect = h.state().canvas_rect.unwrap(); + for (from, icon) in [ + (pos2(rect.center().x, rect.min.y - crate::canvas::RULER / 2.0), egui::CursorIcon::ResizeVertical), + (pos2(rect.min.x - crate::canvas::RULER / 2.0, rect.center().y), egui::CursorIcon::ResizeHorizontal), + ] { + let to = rect.center(); + h.hover_at(from); + h.step(); + press(&mut h, from, true, Modifiers::NONE); + for k in 1..=4 { + h.hover_at(from + (to - from) * (k as f32 / 4.0)); + h.step(); + assert_eq!(h.output().platform_output.cursor_icon, icon, "step {k} of the drag from {from:?}"); + } + press(&mut h, to, false, Modifiers::NONE); + h.hover_at(to); + h.run_steps(2); + assert_ne!(h.output().platform_output.cursor_icon, icon, "dropped: the tool's cursor again"); + } +} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 3c2565052..73e6e83db 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -12,12 +12,14 @@ mod ar; mod cs; mod de; mod fr; +mod hu; mod it; mod ja; mod ka; mod pl; mod pt_br; mod ru; +mod sv; mod uk; /// Supported interface languages: (code, name in that language). @@ -36,6 +38,8 @@ pub const LANGUAGES: &[(&str, &str)] = &[ ("cs", "Čeština"), ("pl", "Polski"), ("ka", "ქართული"), + ("hu", "Magyar"), + ("sv", "Svenska"), ]; /// The interface languages in the order the Preferences picker lists them: English first (the @@ -77,6 +81,8 @@ pub fn tr<'a>(lang: &str, s: &'a str) -> &'a str { if text.trim().is_empty() { s } else { text } } +/// Looks up a label in lazily cached language tables, using the shared table where supported. +/// Unknown languages and missing keys keep the source text; `tr` also handles blank entries. fn lookup<'a>(lang: &str, s: &'a str) -> &'a str { static GERMAN: OnceLock> = OnceLock::new(); static JAPANESE: OnceLock> = OnceLock::new(); @@ -90,6 +96,11 @@ fn lookup<'a>(lang: &str, s: &'a str) -> &'a str { static CZECH: OnceLock> = OnceLock::new(); static FRENCH: OnceLock> = OnceLock::new(); static GEORGIAN: OnceLock> = OnceLock::new(); + static HUNGARIAN: OnceLock> = OnceLock::new(); + if lang == "hu" { + return HUNGARIAN.get_or_init(|| hu::TABLE.iter().copied().collect()).get(s).copied().unwrap_or(s); + } + static SWEDISH: OnceLock> = OnceLock::new(); if lang == "ar" { return ARABIC.get_or_init(|| ar::TABLE.iter().copied().collect()).get(s).copied().unwrap_or(s); } @@ -123,6 +134,9 @@ fn lookup<'a>(lang: &str, s: &'a str) -> &'a str { if lang == "ka" { return GEORGIAN.get_or_init(|| ka::TABLE.iter().copied().collect()).get(s).copied().unwrap_or(s); } + if lang == "sv" { + return SWEDISH.get_or_init(|| sv::TABLE.iter().copied().collect()).get(s).copied().unwrap_or(s); + } if lang == "uk" { return UKRAINIAN.get_or_init(|| uk::TABLE.iter().copied().collect()).get(s).copied().unwrap_or(s); } @@ -145,12 +159,14 @@ const CONTEXT: &[(&str, &str, [&str; 5])] = &[ /// Context-sensitive UI captions; unsupported contexts keep the shared translation. pub fn tr_context<'a>(lang: &str, s: &'a str, context: &str) -> &'a str { let contexts = match lang { + "hu" => hu::CONTEXT, "uk" => uk::CONTEXT, "ru" => ru::CONTEXT, "cs" => cs::CONTEXT, "pl" => pl::CONTEXT, "ja" => ja::CONTEXT, "ar" => ar::CONTEXT, + "sv" => sv::CONTEXT, _ => &[], }; let written = |text: &&str| !text.trim().is_empty(); @@ -176,6 +192,12 @@ pub(crate) fn russian_catalog() -> Vec<&'static str> { ru::TABLE.iter().map(|(_, text)| *text).chain(ru::CONTEXT.iter().map(|(_, _, text)| *text)).collect() } +/// Whether the Swedish catalog has an entry for `s` (many Swedish labels read like the English ones). +#[cfg(test)] +pub(crate) fn swedish_has(s: &str) -> bool { + sv::TABLE.iter().any(|(key, _)| *key == s) +} + /// Ukrainian, Russian, Polish and Georgian put the category before the value in count captions /// ("сторінок: 5", "страниц: 5", "Strony: 5", "გვერდი: 5"): every number stays grammatical in the /// languages with several plural forms, and Georgian nouns stay singular after numerals. Other @@ -184,12 +206,29 @@ pub fn category_first(lang: &str) -> bool { matches!(lang, "uk" | "ru" | "pl" | "ka") } +/// Swedish singular for a count of one, where it differs from the catalog's plural ("1 sida", +/// "5 sidor"); "fel", "objekt" and "lager" read the same either way. +fn swedish_singular(noun: &str) -> Option<&'static str> { + match noun { + "pages" => Some("sida"), + "stories" => Some("artikel"), + "warnings" => Some("varning"), + _ => None, + } +} + /// Caption for a counted category, e.g. `count_label("ru", "pages", 5)` is "страниц: 5". Czech -/// picks the plural form for the count (1, 2–4, other). +/// picks the plural form for the count (1, 2–4, other); Swedish the singular for one. pub fn count_label(lang: &str, noun: &str, count: usize) -> String { if category_first(lang) { return format!("{}: {count}", tr(lang, noun)); } + if lang == "sv" + && count == 1 + && let Some(one) = swedish_singular(noun) + { + return format!("{count} {one}"); + } if lang == "cs" && let Some((_, [one, few, other])) = cs::PLURALS.iter().find(|(key, _)| *key == noun) { @@ -231,6 +270,40 @@ pub fn workspace_name<'a>(lang: &str, name: &'a str) -> &'a str { #[cfg(test)] mod tests; +#[cfg(test)] +mod swedish_tests { + use super::*; + + #[test] + fn swedish_catalog_preserves_placeholders_and_ellipses() { + let catalog: HashMap<_, _> = sv::TABLE.iter().copied().collect(); + assert_eq!(catalog.len(), sv::TABLE.len(), "duplicate Swedish 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 sv::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}"); + assert_eq!(tr("sv", key), *translation, "sv: {key}"); + } + let contexts: std::collections::HashSet<_> = sv::CONTEXT.iter().map(|(key, scope, _)| (*key, *scope)).collect(); + assert_eq!(contexts.len(), sv::CONTEXT.len(), "duplicate Swedish context keys"); + for (key, context, expected) in sv::CONTEXT { + assert_eq!(placeholders(key), placeholders(expected), "placeholders: {key} ({context})"); + assert_eq!(tr_context("sv", key, context), *expected, "sv {context}: {key}"); + } + assert_eq!(tr("sv", "File"), "Arkiv"); + assert_eq!(tr("sv", "Save As…"), "Spara som…"); + assert_eq!(tr("sv", "Unknown label"), "Unknown label"); + assert!(!is_rtl("sv")); + assert_eq!(count_label("sv", "pages", 5), "5 sidor"); + assert_eq!(count_label("sv", "pages", 1), "1 sida"); + assert_eq!(count_label("sv", "warnings", 1), "1 varning"); + assert_eq!(count_label("sv", "errors", 1), "1 fel"); + } +} + /// `name` lowercased with the Latin accents that occur in language names folded away, so a /// byte-wise sort reads alphabetically. Other scripts are left as they are. fn name_sort_key(name: &str) -> String { diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 202de0df4..651b748f9 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1,5 +1,7 @@ //! Original Arabic interface translations, stored in logical Unicode order. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Fewer Options", "خيارات أقل"), + ("Tab Character", "محرف جدولة"), ("File", "ملف"), ("Edit", "تحرير"), ("Layout", "تخطيط"), @@ -31,6 +33,100 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Keep In Frame", "الإبقاء داخل الإطار"), + ("New Paragraph Style", "نمط فقرة جديد"), + ("New Character Style", "نمط حرف جديد"), + ("Next Style:", "النمط التالي:"), + ("[Same style]", "[النمط نفسه]"), + ("Reset To Base", "إعادة التعيين إلى الأساس"), + ("Apply Style to Selection", "تطبيق النمط على التحديد"), + ("Change/Find", "تغيير/بحث"), + ("Find Format:", "البحث عن تنسيق:"), + ("Find Format Settings", "إعدادات البحث عن تنسيق"), + ("Specify attributes to find", "تحديد السمات المراد البحث عنها"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "مع تنسيق للبحث، يُعثر فقط على النص الذي يحمله؛ وإذا كان حقل «البحث عن» فارغًا، فعلى كل نص يحمله", + ), + ("Change Format:", "تغيير التنسيق:"), + ("Change Format Settings", "إعدادات تغيير التنسيق"), + ("Style Options", "خيارات النمط"), + ("[Any Style]", "[أي نمط]"), + ("Specify attributes to change", "تحديد السمات المراد تغييرها"), + ("Clear specified attributes", "مسح السمات المحددة"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "مع تنسيق للتغيير وحقل «تغيير إلى» فارغ، يبقى النص الذي تم العثور عليه ويتغير تنسيقه فقط", + ), + ("Double Column", "عمود مزدوج"), + ("Tools Panel:", "لوحة الأدوات:"), + ("Color Theme:", "سمة الألوان:"), + ("At End of Document", "في نهاية المستند"), + ("At Start of Document", "في بداية المستند"), + ("Before Page", "قبل الصفحة"), + ("After Page", "بعد الصفحة"), + ("Parent:", "الصفحة الأصلية:"), + ("Insert:", "إدراج:"), + ("Stroke Type", "نوع الحد"), + ("Corner Shape", "شكل الزاوية"), + ("Corner Size and Shape:", "حجم الزوايا وشكلها:"), + ("Join", "وصل"), + ("Convert Point", "تحويل النقطة"), + ("New Section", "قسم جديد"), + ("Clear Frame Fitting Options", "مسح خيارات ملاءمة الإطار"), + ("Cannot be edited", "غير قابل للتحرير"), + ("Maximum:", "الحد الأقصى:"), + ("Paragraph Spacing Limit:", "حد تباعد الفقرات:"), + ("Overprint", "طباعة فوقية"), + ("Top Left Corner", "الزاوية العلوية اليسرى"), + ("Top Right Corner", "الزاوية العلوية اليمنى"), + ("Bottom Left Corner", "الزاوية السفلية اليسرى"), + ("Bottom Right Corner", "الزاوية السفلية اليمنى"), + ("Anchor Point(s)", "نقطة (نقاط) الارتكاز"), + ("Convert Points", "تحويل النقاط"), + ("Delete Point(s)", "حذف النقطة (النقاط)"), + ("Convert Text to Table…", "تحويل النص إلى جدول…"), + ("Convert Table to Text…", "تحويل الجدول إلى نص…"), + ("Column Separator", "فاصل الأعمدة"), + ("Row Separator", "فاصل الصفوف"), + ("Number of Columns", "عدد الأعمدة"), + ("Comma", "فاصلة"), + ("RGB Color Space View", "عرض فضاء ألوان RGB"), + ("Lab Color Space View", "عرض فضاء ألوان Lab"), + ("Add CMYK Swatch", "إضافة عينة لون CMYK"), + ("Added swatch", "تمت إضافة عينة اللون"), + ("Eyedropper: drag it onto the page", "القطارة: اسحبها إلى الصفحة"), + ("PDF Preset:", "إعداد PDF المسبق:"), + ("Packaged {count} files into {dir}", "تم حزم {count} ملفات في {dir}"), + ("Continue", "متابعة"), + ("Printing Instructions", "تعليمات الطباعة"), + ("Show Problems Only", "إظهار المشكلات فقط"), + ("Include PDF (Print)", "تضمين PDF (للطباعة)"), + ("Include IDML", "تضمين IDML"), + ("Create Printing Instructions", "إنشاء تعليمات الطباعة"), + ("Entire Publication", "المنشور بالكامل"), + ("Scope:", "النطاق:"), + ("Source", "المصدر"), + ("Installed", "مثبت"), + ("Bundled", "مضمن مع التطبيق"), + ("Substituted", "مستبدل"), + ("Modified", "معدل"), + ("Missing", "مفقود"), + ("Instructions:", "التعليمات:"), + ("Email:", "البريد الإلكتروني:"), + ("Fax:", "الفاكس:"), + ("Phone:", "الهاتف:"), + ("Address:", "العنوان:"), + ("Company:", "الشركة:"), + ("Contact:", "جهة الاتصال:"), + ("Process Black", "أسود معالجة"), + ("Process Yellow", "أصفر معالجة"), + ("Process Magenta", "أرجواني معالجة"), + ("Process Cyan", "سماوي معالجة"), + ("Colors and Inks", "الألوان والأحبار"), + ("Links and Images", "الروابط والصور"), + ("Fonts", "الخطوط"), + ("Summary", "الملخص"), ("Restart Numbering", "إعادة الترقيم"), ("Continue Numbering", "متابعة الترقيم"), ("Keep Options…", "خيارات الإبقاء…"), @@ -391,6 +487,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object Layer Options", "خيارات طبقات الكائن"), ("Colour Picker", "منتقي الألوان"), ("Color Picker", "أداة اختيار الألوان"), + ("Color spectrum", "طيف الألوان"), ("Color Settings", "إعدادات الألوان"), ("User Dictionary", "قاموس المستخدم"), ("Menu Customization", "تخصيص القوائم"), @@ -924,6 +1021,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Box", "مربع"), ("Comb", "مشط"), ("Cover", "تغطية"), + ("Cover Image:", "صورة الغلاف:"), + ("Choose…", "اختيار…"), + ("A PNG or JPEG file; left empty, the first page is the cover.", "ملف PNG أو JPEG؛ إن تُرك فارغًا تكون الصفحة الأولى هي الغلاف."), ("Dissolve", "تلاشي متناثر"), ("Fade", "تلاشي"), ("Push", "دفع"), @@ -1975,6 +2075,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object/Fitting", "كائن/ملاءمة"), ("Object/Pathfinder", "كائن/باحث المسارات"), ("Object/Paths", "كائن/مسارات"), + ("Object/Convert Point", "كائن/تحويل النقطة"), ("Object/Select", "كائن/تحديد"), ("Object/Transform", "كائن/تحويل"), ("Object/Transform Again", "كائن/تحويل مجدداً"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 76e2df932..b6459bc36 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -3,6 +3,8 @@ //! product's localization was used). Phrases shared with the MIT OR Apache-2.0 Czech catalogs of //! the ArtCraft siblings (VectorCraft, PhotoCraft) are kept identical; see ASSETS.md for sources. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Fewer Options", "Méně možností"), + ("Tab Character", "Znak tabulátoru"), ("File", "Soubor"), ("Edit", "Úpravy"), ("Layout", "Rozvržení"), @@ -32,6 +34,103 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Keep In Frame", "Zachovat v rámečku"), + ("Gap Tint:", "Odstín mezery:"), + ("New Paragraph Style", "Nový styl odstavce"), + ("New Character Style", "Nový styl znaku"), + ("Next Style:", "Další styl:"), + ("[Same style]", "[Stejný styl]"), + ("Reset To Base", "Obnovit podle základu"), + ("Apply Style to Selection", "Použít styl na výběr"), + ("Change/Find", "Změnit/Najít"), + ("Find Format:", "Hledat formát:"), + ("Find Format Settings", "Nastavení hledání formátu"), + ("Specify attributes to find", "Určit atributy k hledání"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "S formátem hledání se najde jen text, který ho má; s prázdným polem „Najít“ veškerý text, který ho má", + ), + ("Change Format:", "Změnit formát:"), + ("Change Format Settings", "Nastavení změny formátu"), + ("Style Options", "Volby stylu"), + ("[Any Style]", "[Libovolný styl]"), + ("Specify attributes to change", "Určit atributy ke změně"), + ("Clear specified attributes", "Vymazat určené atributy"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "Se změnou formátu a prázdným polem „Změnit na“ nalezený text zůstane a změní se jen jeho formát", + ), + ("Double Column", "Dvojitý sloupec"), + ("Single Column", "Jednoduchý sloupec"), + ("Tools Panel:", "Panel nástrojů:"), + ("Color Theme:", "Barevný motiv:"), + ("At End of Document", "Na konec dokumentu"), + ("At Start of Document", "Na začátek dokumentu"), + ("Before Page", "Před stránku"), + ("After Page", "Za stránku"), + ("Parent:", "Vzor:"), + ("Insert:", "Vložit:"), + ("Stroke Type", "Typ tahu"), + ("Corner Shape", "Tvar rohu"), + ("Corner Size and Shape:", "Velikost a tvar rohů:"), + ("Join", "Spojit"), + ("Convert Point", "Převést bod"), + ("Plain", "Obyčejný"), + ("Symmetrical", "Symetrický"), + ("New Section", "Nový oddíl"), + ("Clear Frame Fitting Options", "Vymazat volby přizpůsobení rámečku"), + ("Cannot be edited", "Nelze upravit"), + ("Maximum:", "Maximum:"), + ("Paragraph Spacing Limit:", "Limit mezer mezi odstavci:"), + ("Overprint", "Přetisk"), + ("Top Left Corner", "Levý horní roh"), + ("Top Right Corner", "Pravý horní roh"), + ("Bottom Left Corner", "Levý dolní roh"), + ("Bottom Right Corner", "Pravý dolní roh"), + ("Anchor Point(s)", "Kotevní body"), + ("Convert Points", "Převést body"), + ("Delete Point(s)", "Odstranit body"), + ("Convert Text to Table…", "Převést text na tabulku…"), + ("Convert Table to Text…", "Převést tabulku na text…"), + ("Column Separator", "Oddělovač sloupců"), + ("Row Separator", "Oddělovač řádků"), + ("Number of Columns", "Počet sloupců"), + ("Comma", "Čárka"), + ("RGB Color Space View", "Zobrazení barevného prostoru RGB"), + ("Lab Color Space View", "Zobrazení barevného prostoru Lab"), + ("Add CMYK Swatch", "Přidat vzorek CMYK"), + ("Added swatch", "Vzorek přidán"), + ("Eyedropper: drag it onto the page", "Kapátko: přetáhněte ho na stránku"), + ("PDF Preset:", "Předvolba PDF:"), + ("Continue", "Pokračovat"), + ("Printing Instructions", "Pokyny k tisku"), + ("Show Problems Only", "Zobrazit jen problémy"), + ("Include PDF (Print)", "Zahrnout PDF (tisk)"), + ("Include IDML", "Zahrnout IDML"), + ("Create Printing Instructions", "Vytvořit pokyny k tisku"), + ("Entire Publication", "Celá publikace"), + ("Scope:", "Rozsah:"), + ("Spot", "Přímá"), + ("Process", "Procesní"), + ("Source", "Zdroj"), + ("Installed", "Nainstalováno"), + ("Bundled", "Přibaleno"), + ("Substituted", "Nahrazeno"), + ("Modified", "Změněno"), + ("Missing", "Chybí"), + ("Instructions:", "Pokyny:"), + ("Fax:", "Fax:"), + ("Address:", "Adresa:"), + ("Company:", "Společnost:"), + ("Contact:", "Kontakt:"), + ("Process Black", "Procesní černá"), + ("Process Yellow", "Procesní žlutá"), + ("Process Magenta", "Procesní purpurová"), + ("Process Cyan", "Procesní azurová"), + ("Colors and Inks", "Barvy a inkousty"), + ("Links and Images", "Odkazy a obrazy"), + ("Fonts", "Písma"), + ("Summary", "Souhrn"), ("Restart Numbering", "Začít číslování znovu"), ("Continue Numbering", "Pokračovat v číslování"), ("Keep Options…", "Volby zachování…"), @@ -194,6 +293,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Choose Image…", "Vybrat obrázek…"), ("Class:", "Třída:"), ("Color Picker", "Výběr barvy"), + ("Color spectrum", "Barevné spektrum"), ("Color Settings", "Nastavení barev"), ("Color:", "Barva:"), ("Columns", "Sloupce"), @@ -1656,6 +1756,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object/Fitting", "Objekt/Přizpůsobení"), ("Object/Pathfinder", "Objekt/Operace s cestami"), ("Object/Paths", "Objekt/Cesty"), + ("Object/Convert Point", "Objekt/Převést bod"), ("Object/Select", "Objekt/Vybrat"), ("Object/Transform", "Objekt/Transformace"), ("Object/Transform Again", "Objekt/Transformovat znovu"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 24dc45c4c..f27e40c52 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -3,6 +3,8 @@ //! Brazilian Portuguese, Italian and Ukrainian tables has a row here; the German column of the shared //! table is carried over and stays the fallback for keys added there after this table. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Fewer Options", "Weniger Optionen"), + ("Tab Character", "Tabulatorzeichen"), ("File", "Datei"), ("Edit", "Bearbeiten"), ("Layout", "Layout"), @@ -31,6 +33,30 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Ausschneiden"), ("Copy", "Kopieren"), ("Paste", "Einfügen"), + ("Stroke Type", "Konturtyp"), + ("Corner Shape", "Eckenform"), + ("Corner Size and Shape:", "Eckengröße und -form:"), + ("Join", "Verbinden"), + ("Convert Point", "Punkt konvertieren"), + ("Plain", "Einfach"), + ("Symmetrical", "Symmetrisch"), + ("New Section", "Neuer Abschnitt"), + ("Clear Frame Fitting Options", "Rahmeneinpassungsoptionen löschen"), + ("Cannot be edited", "Nicht bearbeitbar"), + ("Overprint", "Überdrucken"), + ("Top Left Corner", "Ecke oben links"), + ("Top Right Corner", "Ecke oben rechts"), + ("Bottom Left Corner", "Ecke unten links"), + ("Bottom Right Corner", "Ecke unten rechts"), + ("Anchor Point(s)", "Ankerpunkt(e)"), + ("Convert Points", "Punkte konvertieren"), + ("Delete Point(s)", "Punkt(e) löschen"), + ("Convert Text to Table…", "Text in Tabelle umwandeln …"), + ("Convert Table to Text…", "Tabelle in Text umwandeln …"), + ("Column Separator", "Spaltentrennzeichen"), + ("Row Separator", "Zeilentrennzeichen"), + ("Number of Columns", "Spaltenanzahl"), + ("Comma", "Komma"), ("Restart Numbering", "Nummerierung neu beginnen"), ("Continue Numbering", "Nummerierung fortführen"), ("Keep Options…", "Umbruchoptionen …"), @@ -223,6 +249,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Choose Image…", "Bild auswählen …"), ("Class:", "Klasse:"), ("Color Picker", "Farbwähler"), + ("Color spectrum", "Farbspektrum"), ("Color Settings", "Farbeinstellungen"), ("Color:", "Farbe:"), ("Columns", "Spalten"), @@ -1420,6 +1447,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Box", "Rechteck"), ("Comb", "Kamm"), ("Cover", "Überdecken"), + ("Cover Image:", "Titelbild:"), + ("Choose…", "Auswählen …"), + ( + "A PNG or JPEG file; left empty, the first page is the cover.", + "Eine PNG- oder JPEG-Datei; bleibt das Feld leer, ist die erste Seite das Titelbild.", + ), ("Dissolve", "Auflösen"), ("Fade", "Überblenden"), ("Push", "Schieben"), @@ -1668,6 +1701,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object/Fitting", "Objekt/Anpassen"), ("Object/Pathfinder", "Objekt/Pathfinder"), ("Object/Paths", "Objekt/Pfade"), + ("Object/Convert Point", "Objekt/Punkt konvertieren"), ("Object/Select", "Objekt/Auswählen"), ("Object/Transform", "Objekt/Transformieren"), ("Object/Transform Again", "Objekt/Erneut transformieren"), diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index b7123a7ae..18937c075 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -60,7 +60,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("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"), @@ -105,7 +104,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("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"), ( @@ -168,10 +166,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("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"), @@ -245,6 +241,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object/Fitting", "Objet/Ajustement"), ("Object/Pathfinder", "Objet/Pathfinder"), ("Object/Paths", "Objet/Tracés"), + ("Object/Convert Point", "Objet/Convertir le point"), ("Object/Select", "Objet/Sélectionner"), ("Object/Transform", "Objet/Transformation"), ("Object/Transform Again", "Objet/Répéter la transformation"), @@ -254,7 +251,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("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"), @@ -264,7 +260,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("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"), @@ -321,7 +316,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("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"), @@ -346,7 +340,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("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"), diff --git a/crates/ui-egui/src/i18n/hu.rs b/crates/ui-egui/src/i18n/hu.rs new file mode 100644 index 000000000..890959761 --- /dev/null +++ b/crates/ui-egui/src/i18n/hu.rs @@ -0,0 +1,1963 @@ +//! Hungarian interface translation, prepared with AI assistance and initially reviewed +//! by a native Hungarian contributor with 30+ years of DTP experience. +//! Further terminology and in-context UI review is welcome. +//! Uncatalogued keys fall back to English. +pub(super) const TABLE: &[(&str, &str)] = &[ + ("Redefine Style", "Stílus újradefiniálása"), + ("Alt-click clears overrides", "Alt-kattintás: felülírások törlése"), + ("Apply \"{name}\", Clear Overrides", "„{name}” alkalmazása, felülírások törlése"), + (" Close ", " Bezárás "), + (" New file ", " Új fájl "), + (" OK ", " OK "), + (" Open ", " Megnyitás "), + (" Open sample magazine ", " Mintaújság megnyitása "), + (" (auto)", " (automatikus)"), + ("(Text Color)", "(Szövegszín)"), + ("(Top Level)", "(Legfelső szint)"), + ("(import)", "(importálás)"), + ("180°", "180°"), + ("90° CCW", "90° balra"), + ("90° CW", "90° jobbra"), + ("A, B, C, D...", "A, B, C, D..."), + ("About DesignCraft", "A DesignCraft névjegye"), + ("Absolute Colorimetric", "Abszolút kolorimetrikus"), + ("Absolute Numbering (instead of Section Numbering)", "Abszolút oldalszámozás (szakasz szerinti helyett)"), + ("Accept", "Elfogadás"), + ("Accept All", "Összes elfogadása"), + ("Accept All Changes", "Összes módosítás elfogadása"), + ("Accept Change", "Módosítás elfogadása"), + ("Activate Document", "Dokumentum aktiválása"), + ("Activate Panel", "Panel aktiválása"), + ("Actual Size", "Tényleges méret"), + ("Add", "Hozzáadás"), + ("Add Anchor", "Csomópont hozzáadása"), + ("Add Anchor Point", "Csomópont hozzáadása"), + ("Add Document", "Dokumentum hozzáadása"), + ("Add Document…", "Dokumentum hozzáadása…"), + ("Add Hyphenation Exception", "Elválasztási kivétel hozzáadása"), + ("Add Item", "Elem hozzáadása"), + ("Add Page to Spread", "Oldal hozzáadása az oldalpárhoz"), + ("Add Selection", "Kijelölt elemek hozzáadása"), + ("Add Selection to Article", "Kijelölt elemek hozzáadása a cikkhez"), + ("Add Unnamed Colors", "Névtelen színek hozzáadása"), + ( + "Add and remove pages as the primary text frame's story grows and shrinks", + "Oldalak hozzáadása és eltávolítása az elsődleges szövegkeret szövegfolyamának hosszához igazodva", + ), + ("Add the selection", "Kijelölt elemek hozzáadása"), + ("Add to Dictionary", "Hozzáadás a szótárhoz"), + ("Add to Flex Layout", "Hozzáadás a rugalmas elrendezéshez"), + ("Add to Previous Spread", "Hozzáadás az előző oldalpárhoz"), + ("Add to Swatches", "Hozzáadás a színtárhoz"), + ("Added", "Hozzáadva"), + ("Added Text (highlighted)", "Beszúrt szöveg (kiemelve)"), + ("Adjust Layout", "Elrendezés igazítása"), + ("Adjust Layout (objects follow the new page size)", "Elrendezés igazítása (az objektumok követik az új oldalméretet)"), + ("Advanced", "Haladó"), + ("Advanced Character Formats", "Speciális karakterformázás"), + ("Advanced Type", "Speciális szövegbeállítások"), + ("After", "Utána"), + ("After First:", "Az első után:"), + ("Align", "Igazítás"), + ("Align Center", "Középre igazítás"), + ("Align From:", "Igazítás alapja:"), + ("Align Left", "Balra igazítás"), + ("Align Left Edge", "Bal szél igazítása"), + ("Align Right", "Jobbra igazítás"), + ("Align Stroke:", "Körvonal igazítása:"), + ("Align away from spine", "Igazítás a gerinctől kifelé"), + ("Align bottom edges", "Alsó szélek igazítása"), + ("Align center", "Középre igazítás"), + ("Align horizontal centers", "Vízszintes középpontok igazítása"), + ("Align left", "Balra igazítás"), + ("Align left edges", "Bal szélek igazítása"), + ("Align right", "Jobbra igazítás"), + ("Align right edges", "Jobb szélek igazítása"), + ("Align to Key Object", "Igazítás a kulcsobjektumhoz"), + ("Align to Margins", "Igazítás a margókhoz"), + ("Align to Object Centers", "Igazítás az objektumok középpontjához"), + ("Align to Object Edges", "Igazítás az objektumok széléhez"), + ("Align to Page", "Igazítás az oldalhoz"), + ("Align to Selection", "Igazítás a kijelöléshez"), + ("Align to Spread", "Igazítás az oldalpárhoz"), + ("Align to baseline grid", "Igazítás az alapvonalrácshoz"), + ("Align top edges", "Felső szélek igazítása"), + ("Align towards spine", "Igazítás a gerinc felé"), + ("Align vertical centers", "Függőleges középpontok igazítása"), + ("Alignment", "Igazítás"), + ("Alignment:", "Igazítás:"), + ("All Caps", "Nagybetűk"), + ("All Documents", "Összes dokumentum"), + ("All Spots to Process", "Összes direktszín átalakítása nyomdai alapszínekké"), + ("All records", "Összes rekord"), + ("Allow Selected Spread to Shuffle", "Kijelölt oldalpár oldalainak átrendezése engedélyezett"), + ("Allow Spread to Shuffle", "Oldalpár oldalainak átrendezése engedélyezett"), + ("Alpha Channel", "Alfa-csatorna"), + ("Alternating Row Fills", "Váltakozó sorkitöltések"), + ("Anchored Object", "Rögzített objektum"), + ("Anchored Object Options", "Rögzített objektum beállításai"), + ("Anchored Object Options…", "Rögzített objektum beállításai…"), + ("Anchored Objects", "Rögzített objektumok"), + ("Angle", "Szög"), + ("Angle:", "Szög:"), + ("Appearance", "Megjelenés"), + ("Appearance of Black", "Fekete megjelenítése"), + ("Applies to fill", "A kitöltésre vonatkozik"), + ("Applies to text", "A szövegre vonatkozik"), + ("Apply", "Alkalmazás"), + ("Apply Attributes (Eyedropper", "Tulajdonságok alkalmazása (pipetta"), + ("Apply Attributes (Eyedropper)", "Tulajdonságok alkalmazása (pipetta)"), + ("Apply Cell Style", "Cellastílus alkalmazása"), + ("Apply Character Style", "Karakterstílus alkalmazása"), + ("Apply Color", "Szín alkalmazása"), + ("Apply Color (,)", "Szín alkalmazása (,)"), + ("Apply Condition", "Feltétel alkalmazása"), + ("Apply Gradient", "Színátmenet alkalmazása"), + ("Apply Gradient (.)", "Színátmenet alkalmazása (.)"), + ("Apply None", "Szín eltávolítása"), + ("Apply None (/)", "Szín eltávolítása (/)"), + ("Apply Normal", "Normál karakterhelyzet"), + ("Apply Object Style", "Objektumstílus alkalmazása"), + ("Apply Paragraph Style", "Bekezdésstílus alkalmazása"), + ("Apply Parent", "Mesteroldal alkalmazása"), + ("Apply Parent to Pages", "Mesteroldal alkalmazása az oldalakra"), + ("Apply Parent to Pages…", "Mesteroldal alkalmazása az oldalakra…"), + ("Apply Style:", "Alkalmazandó stílus:"), + ("Apply Subscript", "Alsó index alkalmazása"), + ("Apply Superscript", "Felső index alkalmazása"), + ("Apply Table Style", "Táblázatstílus alkalmazása"), + ("Apply to All Spreads", "Alkalmazás az összes oldalpárra"), + ("Apply to Stroke", "Alkalmazás a körvonalra"), + ("Arabic", "Arab"), + ("Arrange", "Sorrend"), + ("Arrangement", "Elrendezés"), + ("Art", "Grafika"), + ("ArtCraft App Page", "ArtCraft alkalmazásoldal"), + ("ArtCraft App Page…", "ArtCraft alkalmazásoldal…"), + ("ArtCraft Website", "ArtCraft weboldala"), + ("ArtCraft Website…", "ArtCraft weboldala…"), + ("Article Options", "Cikk beállításai"), + ("Articles", "Cikkek"), + ("Ascent", "Felső betűszármagasság"), + ("At Least", "Legalább"), + ("Attributes", "Tulajdonságok"), + ("Auto", "Automatikus"), + ("Auto Size", "Automatikus méretezés"), + ("Auto-Fit", "Automatikus illesztés"), + ("Autocorrect", "Automatikus javítás"), + ("Bar", "Vonás"), + ("Barbed", "Szakállas nyíl"), + ("Based On:", "Alapstílus:"), + ("Baseline", "Alapvonal"), + ("Baseline Grid", "Alapvonalrács"), + ("Baseline Shift:", "Alapvonal-eltolás:"), + ("Basic Character Formats", "Alapvető karakterformázás"), + ("Basic Feather", "Alapvető lágyítás"), + ("Before", "Előtte"), + ("Before Last:", "Az utolsó előtt:"), + ("Between syllables (not only at spaces)", "Szótagok között (nem csak szóközöknél)"), + ("Bevel", "Levágott sarok"), + ("Bevel and Emboss", "Domborítás"), + ("Beveled Rectangle", "Levágott sarkú téglalap"), + ("Bind to frame", "Hozzárendelés a kerethez"), + ("Binding", "Kötésirány"), + ("Binding:", "Kötésirány:"), + ("Black", "Fekete"), + ("Bleed", "Kifutó"), + ("Bleed Marks", "Kifutójelek"), + ("Bleed and Slug", "Kifutó és infóterület"), + ("Bleed:", "Kifutó:"), + ("Blinds", "Redőny"), + ("Body Rows", "Törzssorok"), + ("Bold", "Félkövér"), + ("Book", "Könyv"), + ("Bookmarks", "Könyvjelzők"), + ("Border", "Szegély"), + ("Both", "Mindkettő"), + ("Bottom", "Alul"), + ("Bottom Inset", "Alsó belső margó"), + ("Bottom:", "Lent:"), + ("Box", "Doboz"), + ("Break Link to Style", "Stíluskapcsolat megszüntetése"), + ("Break Thread", "Szövegkeret-lánc megszakítása"), + ("Bring Forward", "Egy szinttel előrébb"), + ("Bring to Front", "Előtérbe hozás"), + ("Bullet Character", "Felsorolásjel"), + ("Bulleted & Numbered Lists", "Felsorolások és számozott listák"), + ("Bulleted List", "Felsorolás"), + ("Bulleted and Numbered Lists", "Felsorolások és számozott listák"), + ("Bullets and Numbering", "Felsorolás és számozás"), + ("Business Card", "Névjegykártya"), + ("Butt", "Tompa vonalvég"), + ("Buttons", "Gombok"), + ("Buttons act in interactive PDF export.", "A gombok interaktív PDF-exportban működnek."), + ("Buttons and Forms", "Gombok és űrlapok"), + ("CMYK", "CMYK"), + ("CMYK:", "CMYK:"), + ("Can't Open the File", "A fájl nem nyitható meg"), + ("Can't Place the File", "A fájl nem helyezhető el"), + ("Cancel", "Mégse"), + ("Cap Height", "Nagybetűmagasság"), + ("Cap:", "Vonalvég:"), + ("Captions", "Képaláírások"), + ("Case sensitive", "Kis- és nagybetű megkülönböztetése"), + ("Case:", "Betűváltozat:"), + ("Cell Options", "Cellabeállítások"), + ("Cell Style", "Cellastílus"), + ("Cell Style Options…", "Cellastílus beállításai…"), + ("Cell Text", "Cellaszöveg"), + ("Cell Type", "Cellatípus"), + ("Center", "Középre"), + ("Center Content", "Tartalom középre igazítása"), + ("Center in frame", "Középre igazítás a keretben"), + ("Change", "Csere"), + ("Change All", "Összes cseréje"), + ("Change All (Object", "Összes cseréje (objektum"), + ("Change All (Object)", "Összes cseréje (objektum)"), + ("Change Case", "Kis- és nagybetűk váltása"), + ("Change Panel Layout", "Panelelrendezés módosítása"), + ("Change To", "Csere erre"), + ("Change to:", "Csere erre:"), + ("Changes", "Módosítások"), + ("Character", "Karakter"), + ("Character Color", "Karakter színe"), + ("Character Formatting", "Karakterformázás"), + ("Character Settings", "Karakterbeállítások"), + ("Character Style Options", "Karakterstílus beállításai"), + ("Character Style Options…", "Karakterstílus beállításai…"), + ("Character Style:", "Karakterstílus:"), + ("Character Styles", "Karakterstílusok"), + ("Character Styles panel", "Karakterstílusok panel"), + ("Characters", "Karakterek"), + ("Characters:", "Karakterek:"), + ("Check Box", "Jelölőnégyzet"), + ("Check Spelling", "Helyesírás-ellenőrzés"), + ("Check Spelling…", "Helyesírás-ellenőrzés…"), + ("Checked by default", "Alapértelmezés szerint bejelölve"), + ("Chinese", "Kínai"), + ("Choices", "Választási lehetőségek"), + ("Choose Image…", "Kép kiválasztása…"), + ("Choose a link.", "Válasszon egy csatolást."), + ("Circle", "Kör"), + ("Circle - Solid", "Kitöltött kör"), + ("Class:", "Osztály:"), + ("Clear", "Törlés"), + ("Clear Conveyor", "Tartalomgyűjtő kiürítése"), + ("Clear Rotation", "Elforgatás törlése"), + ("Clear Transformations", "Átalakítások törlése"), + ("Clear overrides", "Helyi felülírások törlése"), + ("Click to pin the object relative to the page edge.", "Kattintson az objektum oldalszélhez rögzítéséhez."), + ("Clipping Path", "Vágógörbe"), + ("Clipping Path…", "Vágógörbe…"), + ("Close", "Bezárás"), + ("Close Book", "Könyv bezárása"), + ("Close Library", "Könyvtár bezárása"), + ("Close Panel", "Panel bezárása"), + ("Close Path", "Görbe bezárása"), + ("Closed Path", "Zárt görbe"), + ("Col ←", "Oszlop ←"), + ("Col →", "Oszlop →"), + ("Collect", "Gyűjtés"), + ( + "Collect objects with the Content Collector (B); place them with the Content Placer.", + "Gyűjtse össze az objektumokat a Tartalomgyűjtővel (B), majd helyezze el őket a Tartalomelhelyezővel.", + ), + ("Color", "Szín"), + ("Color Blindness – Deuteranopia", "Színtévesztés – deuteranópia"), + ("Color Blindness – Protanopia", "Színtévesztés – protanópia"), + ("Color Group Options", "Színcsoport beállításai"), + ("Color Picker", "Színválasztó"), + ("Color Settings", "Színbeállítások"), + ("Color Settings…", "Színbeállítások…"), + ("Color:", "Szín:"), + ("Colour", "Szín"), + ("Colour Picker", "Színválasztó"), + ("Column", "Hasáb"), + ("Column Break", "Hasábtörés"), + ("Column Rules", "Hasábelválasztó vonalak"), + ("Column Width", "Oszlopszélesség"), + ("Column spacing", "Oszlopköz"), + ("Columns", "Hasábok"), + ("Columns first", "Először az oszlopok"), + ("Columns:", "Hasábok:"), + ("Comb", "Fésű"), + ("Combo Box", "Kombinált lista"), + ("Commercial Printing", "Nyomdai előállítás"), + ("Community", "Közösség"), + ("Community & Project Links", "Közösségi és projekthivatkozások"), + ("Composer", "Tördelő"), + ("Composer:", "Tördelő:"), + ("Composition", "Tördelés"), + ("Compress images", "Képek tömörítése"), + ("Compression", "Tömörítés"), + ("Condition Options…", "Feltétel beállításai…"), + ("Conditional Text", "Feltételes szöveg"), + ("Conditions", "Feltételek"), + ("Constrain proportions", "Arányok megtartása"), + ("Constrain proportions for width and height", "Szélesség és magasság arányának megtartása"), + ("Container", "Tároló"), + ("Content", "Tartalom"), + ("Content Fitting", "Tartalom illesztése"), + ("Content:", "Tartalom:"), + ("Contextual Alternates", "Környezetfüggő változatok"), + ("Contextual Task Bar", "Környezetfüggő feladatsáv"), + ("Control", "Vezérlő"), + ("Controlled by Parent", "A mesteroldal határozza meg"), + ("Convert Cell Type", "Cellatípus átalakítása"), + ("Convert Cell to Graphic Cell", "Cella átalakítása grafikus cellává"), + ("Convert Cell to Graphic Cell…", "Cella átalakítása grafikus cellává…"), + ("Convert Cell to Text Cell", "Cella átalakítása szöveges cellává"), + ("Convert Color", "Szín átalakítása"), + ("Convert Direction Point", "Irányítópont átalakítása"), + ("Convert Note to Text", "Jegyzet átalakítása szöveggé"), + ("Convert Point", "Pont átalakítása"), + ("Convert Point:", "Pont átalakítása:"), + ("Convert Selection to Multi-State Object", "Kijelölés átalakítása többállapotú objektummá"), + ("Convert Shape", "Alakzat átalakítása"), + ("Convert Shape:", "Alakzat átalakítása:"), + ("Convert Table to Text", "Táblázat átalakítása szöveggé"), + ("Convert Text to Table", "Szöveg átalakítása táblázattá"), + ("Convert to Button", "Átalakítás gombbá"), + ("Convert to Flex Layout", "Átalakítás rugalmas elrendezéssé"), + ("Convert to Form Field", "Átalakítás űrlapmezővé"), + ("Convert to Liquid Guide", "Átalakítás alkalmazkodó segédvonallá"), + ("Convert to Object", "Átalakítás objektummá"), + ("Convert to Text", "Átalakítás szöveggé"), + ("Conveyor", "Tartalomgyűjtő tálca"), + ("Copies:", "Példányszám:"), + ("Copy", "Másolás"), + ("Copy Link(s", "Csatolások másolása"), + ("Copy Link(s) To…", "Csatolások másolása ide…"), + ("Copyright Symbol", "Szerzői jog jele"), + ("Corner", "Sarok"), + ("Corner Options", "Sarokbeállítások"), + ("Corner Options…", "Sarokbeállítások…"), + ("Count", "Darabszám"), + ("Count:", "Darabszám:"), + ("Cover", "Borítás"), + ("Create", "Létrehozás"), + ("Create Alternate Layout", "Alternatív elrendezés létrehozása"), + ("Create Alternate Layout…", "Alternatív elrendezés létrehozása…"), + ("Create Flex Layout", "Rugalmas elrendezés létrehozása"), + ("Create Frame", "Keret létrehozása"), + ("Create Guides", "Segédvonalak létrehozása"), + ("Create Guides…", "Segédvonalak létrehozása…"), + ("Create Line", "Vonal létrehozása"), + ("Create Links When Placing Text Files", "Csatolások létrehozása szövegfájlok elhelyezésekor"), + ("Create Merged Document…", "Egyesített dokumentum létrehozása…"), + ("Create New Layer", "Új réteg létrehozása"), + ("Create New Style from Selection", "Új stílus létrehozása a kijelölésből"), + ("Create Outlines", "Görbévé alakítás"), + ("Create Path", "Görbe létrehozása"), + ("Create Table", "Táblázat létrehozása"), + ("Create Table…", "Táblázat létrehozása…"), + ("Create as a grid", "Létrehozás rácsként"), + ("Create new page", "Új oldal létrehozása"), + ("Create new style", "Új stílus létrehozása"), + ("Crop", "Vágás"), + ("Crop Amount", "Vágás mértéke"), + ("Crop Marks", "Vágójelek"), + ("Crop to:", "Vágás határa:"), + ("Cross-Reference Format", "Kereszthivatkozás formátuma"), + ("Cross-Reference Formats", "Kereszthivatkozás-formátumok"), + ("Cross-References", "Kereszthivatkozások"), + ("Current Page Number", "Aktuális oldalszám"), + ("Cursor Key:", "Kurzorgomb lépésköze:"), + ("Curved", "Ívelt"), + ("Custom", "Egyéni"), + ("Custom Page Size…", "Egyéni oldalméret…"), + ("Custom Tracking/Kerning", "Egyéni betűköz/alávágás"), + ("Cut", "Kivágás"), + ("Cyan", "Cián"), + ("Czech", "Cseh"), + ("Danish", "Dán"), + ("Dark", "Sötét"), + ("Dashed", "Szaggatott"), + ("Data Fields", "Adatmezők"), + ("Data Merge", "Adategyesítés"), + ("Data Merge Fields", "Adategyesítési mezők"), + ("Data Merge Options", "Adategyesítés beállításai"), + ("Decrease Point Size", "Betűméret csökkentése"), + ("Default", "Alapértelmezett"), + ("Default Figure Style", "Alapértelmezett számjegystílus"), + ("Default Fill and Stroke", "Alapértelmezett kitöltés és körvonal"), + ("Default Fill and Stroke (D)", "Alapértelmezett kitöltés és körvonal (D)"), + ("Default View:", "Alapértelmezett nézet:"), + ("Define Cross-Reference Format", "Kereszthivatkozás-formátum meghatározása"), + ("Define Cross-Reference Format…", "Kereszthivatkozás-formátum meghatározása…"), + ("Define Lists", "Listák meghatározása"), + ("Define Lists…", "Listák meghatározása…"), + ("Define Text Variable", "Szövegváltozó meghatározása"), + ("Define Text Variable…", "Szövegváltozó meghatározása…"), + ("Delete", "Törlés"), + ("Delete All Guides on Spread", "Oldalpár összes segédvonalának törlése"), + ("Delete Anchor Point", "Csomópont törlése"), + ("Delete Article", "Cikk törlése"), + ("Delete Bookmark", "Könyvjelző törlése"), + ("Delete Character Style", "Karakterstílus törlése"), + ("Delete Character Style…", "Karakterstílus törlése…"), + ("Delete Column", "Oszlop törlése"), + ("Delete Condition", "Feltétel törlése"), + ("Delete DTD", "DTD törlése"), + ("Delete Endnote", "Végjegyzet törlése"), + ("Delete Footnote", "Lábjegyzet törlése"), + ("Delete Guide", "Segédvonal törlése"), + ("Delete Hyperlink", "Hiperhivatkozás törlése"), + ("Delete Item", "Elem törlése"), + ("Delete Layer", "Réteg törlése"), + ("Delete List", "Lista törlése"), + ("Delete Note", "Jegyzet törlése"), + ("Delete Page", "Oldal törlése"), + ("Delete Pages", "Oldalak törlése"), + ("Delete Paragraph Style", "Bekezdésstílus törlése"), + ("Delete Paragraph Style…", "Bekezdésstílus törlése…"), + ("Delete Row", "Sor törlése"), + ("Delete Spread", "Oldalpár törlése"), + ("Delete Stroke Style", "Körvonalstílus törlése"), + ("Delete Swatch", "Színminta törlése"), + ("Delete Table", "Táblázat törlése"), + ("Delete Tag", "Címke törlése"), + ("Delete Text", "Szöveg törlése"), + ("Delete Text Variable", "Szövegváltozó törlése"), + ("Delete Type from Path", "Görbére írt szöveg törlése"), + ("Delete Unused Layers", "Nem használt rétegek törlése"), + ("Delete Workspace", "Munkaterület törlése"), + ("Delete Workspace…", "Munkaterület törlése…"), + ("Delete selected pages", "Kijelölt oldalak törlése"), + ("Delete the character style \"{name}\"?", "Törli a(z) „{name}” karakterstílust?"), + ("Delete the paragraph style \"{name}\"?", "Törli a(z) „{name}” bekezdésstílust?"), + ("Deleted", "Törölve"), + ("Deselect All", "Kijelölés megszüntetése"), + ("DesignCraft", "DesignCraft"), + ("DesignCraft Website", "DesignCraft weboldala"), + ("DesignCraft Website…", "DesignCraft weboldala…"), + ("DesignCraft on GitHub", "DesignCraft a GitHubon"), + ("DesignCraft on GitHub…", "DesignCraft a GitHubon…"), + ("Desired", "Kívánt"), + ("Desktop Printing", "Asztali nyomtatás"), + ("Detach All Objects from Parent", "Összes objektum leválasztása a mesteroldalról"), + ("Detect Edges", "Szélek felismerése"), + ("Dialog", "Párbeszédablak"), + ("Dictionary", "Szótár"), + ("Digital Publishing", "Digitális kiadványkészítés"), + ("Digits", "Számjegyek"), + ("Dimensions Include Stroke Weight", "A méretek tartalmazzák a körvonal vastagságát"), + ("Direction", "Irány"), + ("Directional Feather", "Irányított lágyítás"), + ("Discretionary Hyphen", "Feltételes elválasztójel"), + ("Discretionary Ligatures", "Tetszőleges ligatúrák"), + ("Display All Blacks Accurately", "Minden fekete pontos megjelenítése"), + ("Display All Blacks as Rich Black", "Minden fekete megjelenítése dús feketeként"), + ("Display Performance", "Megjelenítési teljesítmény"), + ("Dissolve", "Áttűnés"), + ("Distance", "Távolság"), + ("Distribute", "Elosztás"), + ("Distribute Columns Evenly", "Oszlopok egyenletes elosztása"), + ("Distribute Rows Evenly", "Sorok egyenletes elosztása"), + ("Distribute ↔", "Elosztás ↔"), + ("Distribute ↕", "Elosztás ↕"), + ("Dock Panel", "Panel dokkolása"), + ("Dock panel", "Panel dokkolása"), + ("Document", "Dokumentum"), + ("Document CMYK", "Dokumentum CMYK-színtere"), + ("Document Endnote Options", "Dokumentum végjegyzet-beállításai"), + ("Document Endnote Options…", "Dokumentum végjegyzet-beállításai…"), + ("Document Footnote Options", "Dokumentum lábjegyzet-beállításai"), + ("Document Footnote Options...", "Dokumentum lábjegyzet-beállításai..."), + ("Document Footnote Options…", "Dokumentum lábjegyzet-beállításai…"), + ("Document Grid", "Dokumentumrács"), + ("Document Preferences", "Dokumentumbeállítások"), + ("Document Presets", "Dokumentumkészletek"), + ("Document RGB", "Dokumentum RGB-színtere"), + ("Document Recovery Data", "Dokumentum helyreállítási adatai"), + ("Document Setup", "Dokumentum beállításai"), + ("Document Setup…", "Dokumentum beállításai…"), + ("Document…", "Dokumentum…"), + ("Don't Save", "Ne mentse"), + ("Done", "Kész"), + ("Dotted", "Pontozott"), + ("Double Left Quotation Marks", "Bal oldali dupla idézőjel"), + ("Double Right Quotation Marks", "Jobb oldali dupla idézőjel"), + ("Drag Rows or Columns", "Sorok vagy oszlopok húzása"), + ("Draw Missing Glyphs from Fallback Fonts", "Hiányzó karakterjelek megjelenítése helyettesítő betűtípusból"), + ("Drop Caps", "Iniciálék"), + ("Drop Caps and Nested Styles", "Iniciálék és beágyazott stílusok"), + ("Drop Chars", "Iniciálé karaktereinek száma"), + ("Drop Lines", "Iniciálé sorainak száma"), + ("Drop Shadow", "Vetett árnyék"), + ("Duplicate", "Másolat készítése"), + ("Duplicate Spread", "Oldalpár másolása"), + ("Duplicate Spread at End of Document", "Oldalpár másolása a dokumentum végére"), + ("Dutch: 2005 Reform", "Holland: 2005-ös reform"), + ("Dynamic Spelling", "Folyamatos helyesírás-ellenőrzés"), + ("EPUB and HTML", "EPUB és HTML"), + ("Edit", "Szerkesztés"), + ("Edit \"{name}\"…", "„{name}” szerkesztése…"), + ("Edit Endnote", "Végjegyzet szerkesztése"), + ("Edit Note", "Jegyzet szerkesztése"), + ("Edit Page", "Oldal szerkesztése"), + ("Edit Parents", "Mesteroldalak szerkesztése"), + ("Edit Story", "Szövegfolyam szerkesztése"), + ("Edit in Story Editor", "Szerkesztés a szövegszerkesztőben"), + ("Edit page size", "Oldalméret szerkesztése"), + ("Edit/Interface Language", "Szerkesztés/Felület nyelve"), + ("Edit/Spelling", "Szerkesztés/Helyesírás"), + ("Edit/Transparency Blend Space", "Szerkesztés/Átlátszóság keverési színtere"), + ("Edit/Transparency Flattener Presets", "Szerkesztés/Átlátszóság-összeolvasztási készletek"), + ("Editorial", "Szerkesztőség"), + ("Effects", "Hatások"), + ("Effects: Drop Shadow", "Hatások: vetett árnyék"), + ("Ellipse", "Ellipszis"), + ("Ellipsis", "Három pont"), + ("Em Box Height", "Négyzet magassága"), + ("Em Dash", "Hosszú gondolatjel"), + ("Em Space", "Négyzetnyi szóköz"), + ("Email", "E-mail"), + ("Embed", "Beágyazás"), + ("Embed Link", "Csatolás beágyazása"), + ("Embedded", "Beágyazva"), + ("En Dash", "Félkvirtmínusz"), + ("En Space", "Félnégyzetnyi szóköz"), + ("Enable Autocorrect", "Automatikus javítás engedélyezése"), + ("Enable Dynamic Spelling (underline misspelled words)", "Folyamatos helyesírás-ellenőrzés (hibás szavak aláhúzása)"), + ("Endnotes", "Végjegyzetek"), + ("English: Canadian", "Angol: kanadai"), + ("English: UK", "Angol: brit"), + ("English: USA", "Angol: amerikai"), + ("Entire Pasteboard", "Teljes munkaterület"), + ("Erase", "Radírozás"), + ("Essentials", "Alapok"), + ("Essentials Classic", "Klasszikus alapok"), + ("Even Page Break", "Páros oldaltörés"), + ("Exactly", "Pontosan"), + ("Exclude Overlap", "Átfedés kizárása"), + ("Export", "Exportálás"), + ("Export Book to PDF…", "Könyv exportálása PDF-be…"), + ("Export EPUB", "EPUB exportálása"), + ("Export EPUB (Fixed Layout", "Rögzített elrendezésű EPUB exportálása"), + ("Export EPUB (Fixed Layout)", "Rögzített elrendezésű EPUB exportálása"), + ("Export EPUB (Fixed Layout)…", "Rögzített elrendezésű EPUB exportálása…"), + ("Export EPUB (Reflowable", "Újratördelhető EPUB exportálása"), + ("Export EPUB (Reflowable)", "Újratördelhető EPUB exportálása"), + ("Export EPUB (Reflowable)…", "Újratördelhető EPUB exportálása…"), + ("Export EPUB…", "EPUB exportálása…"), + ("Export HTML", "HTML exportálása"), + ("Export HTML…", "HTML exportálása…"), + ("Export IDML", "IDML exportálása"), + ("Export IDML…", "IDML exportálása…"), + ("Export PDF", "PDF exportálása"), + ("Export PDF (Interactive)", "Interaktív PDF exportálása"), + ("Export PDF (Interactive)…", "Interaktív PDF exportálása…"), + ("Export PDF…", "PDF exportálása…"), + ("Export Page as PNG", "Oldal exportálása PNG-ként"), + ("Export Page as PNG…", "Oldal exportálása PNG-ként…"), + ("Export PowerPoint…", "PowerPoint exportálása…"), + ("Export Selection as Snippet", "Kijelölés exportálása részletként"), + ("Export Tagging", "Exportcímkézés"), + ("Export Text", "Szöveg exportálása"), + ("Export Text…", "Szöveg exportálása…"), + ("Export XML", "XML exportálása"), + ("Export XML…", "XML exportálása…"), + ("Extend Text Selection", "Szövegkijelölés kiterjesztése"), + ("Extras", "Kiegészítők"), + ("Facing Pages", "Szemközti oldalak"), + ("Fade", "Elhalványulás"), + ("Farsi", "Perzsa"), + ("Fast", "Gyors"), + ("Fast Display", "Gyors megjelenítés"), + ("Favorites", "Kedvencek"), + ("Figure Space", "Számjegynyi szóköz"), + ("Figure Style:", "Számjegystílus:"), + ("File", "Fájl"), + ("File Handling", "Fájlkezelés"), + ("File/Export", "Fájl/Exportálás"), + ("Fill", "Kitöltés"), + ("Fill (X)", "Kitöltés (X)"), + ("Fill Frame Proportionally", "Keret arányos kitöltése"), + ("Fill proportionally", "Arányos kitöltés"), + ("Fill with Placeholder Text", "Kitöltés helykitöltő szöveggel"), + ("Find", "Keresés"), + ("Find All", "Összes keresése"), + ("Find Next", "Következő keresése"), + ("Find Object", "Objektum keresése"), + ("Find What", "Keresett szöveg"), + ("Find by Script Label", "Keresés parancsfájlcímke alapján"), + ("Find what:", "Keresett szöveg:"), + ("Find/Change", "Keresés és csere"), + ("Find/Change…", "Keresés és csere…"), + ("Find/Replace Font", "Betűtípus keresése és cseréje"), + ("Find/Replace Font…", "Betűtípus keresése és cseréje…"), + ("Finnish", "Finn"), + ("First", "Első"), + ("First Baseline", "Első alapvonal"), + ("First Line Indent:", "Első sor behúzása:"), + ("First Object Above", "Legfelső objektum"), + ("First Page", "Első oldal"), + ("First Spread", "Első oldalpár"), + ("Fit Content Proportionally", "Tartalom arányos illesztése"), + ("Fit Content to Frame", "Tartalom igazítása a kerethez"), + ("Fit Frame to Content", "Keret igazítása a tartalomhoz"), + ("Fit Guides to:", "Segédvonalak igazítása ehhez:"), + ("Fit Page in Window", "Oldal igazítása az ablakhoz"), + ("Fit Selection in Window", "Kijelölés igazítása az ablakhoz"), + ("Fit Spread in Window", "Oldalpár igazítása az ablakhoz"), + ("Fit Text", "Szöveg illesztése"), + ("Fit content to frame", "Tartalom igazítása a kerethez"), + ("Fit frame height to text", "Keretmagasság igazítása a szöveghez"), + ("Fit proportionally", "Arányos illesztés"), + ("Fitting", "Illesztés"), + ("Fitting:", "Illesztés:"), + ("Fixed", "Rögzített"), + ("Flattener Preview", "Lapítási előnézet"), + ("Flattener preset", "Lapítási készlet"), + ("Flex End", "Rugalmas elrendezés vége"), + ("Flex Layout", "Rugalmas elrendezés"), + ("Flex Layout Options", "Rugalmas elrendezés beállításai"), + ("Flex Start", "Rugalmas elrendezés eleje"), + ("Flip", "Tükrözés"), + ("Flip Horizontal", "Vízszintes tükrözés"), + ("Flip Vertical", "Függőleges tükrözés"), + ("Float Panel", "Panel lebegtetése"), + ("Float panel", "Panel lebegtetése"), + ("Flush Space", "Kitöltő szóköz"), + ("Font", "Betűtípus"), + ("Font Family:", "Betűcsalád:"), + ("Font Size:", "Betűméret:"), + ("Font Style:", "Betűváltozat:"), + ("Fonts in Document", "Betűtípusok a dokumentumban"), + ("Fonts in Document: {count} Missing: {missing}", "Betűtípusok a dokumentumban: {count} Hiányzik: {missing}"), + ("Footer Rows", "Láblécsorok"), + ("Footnote Formatting", "Lábjegyzet formázása"), + ("Footnote Options", "Lábjegyzet beállításai"), + ("Footnote Reference", "Lábjegyzethivatkozás"), + ("Footnote Reference Number in Text", "Lábjegyzethivatkozás száma a szövegben"), + ("Footnote Text", "Lábjegyzet szövege"), + ("Footnotes", "Lábjegyzetek"), + ("Forced Line Break", "Kényszerített sortörés"), + ("Form Field", "Űrlapmező"), + ("Form Fields", "Űrlapmezők"), + ("Format:", "Formátum:"), + ("Formatting Affects Text", "A formázás a szövegre vonatkozik"), + ("Formatting affects container (J)", "A formázás a tárolóra vonatkozik (J)"), + ("Formatting affects text (J)", "A formázás a szövegre vonatkozik (J)"), + ("Fractions", "Törtek"), + ("Frame Break", "Kerettörés"), + ("Frame Edges", "Keretek szélei"), + ("Frame Fitting", "Keretillesztés"), + ("Frame Fitting Options", "Keretillesztés beállításai"), + ("Frame Fitting Options…", "Keretillesztés beállításai…"), + ("French", "Francia"), + ("French: Canadian", "Francia: kanadai"), + ("GREP", "GREP"), + ("GREP Style", "GREP-stílus"), + ("GREP Styles", "GREP-stílusok"), + ("Gap Color:", "Hézag színe:"), + ("General", "Általános"), + ("Generate Index", "Tárgymutató létrehozása"), + ("Generate Index…", "Tárgymutató létrehozása…"), + ("Generate Live Caption", "Élő képaláírás létrehozása"), + ("Generate QR Code", "QR-kód létrehozása"), + ("Generate QR Code…", "QR-kód létrehozása…"), + ("Generate Static Caption", "Statikus képaláírás létrehozása"), + ("Georgian", "Grúz"), + ("German: 2006 Reform", "Német: 2006-os reform"), + ("German: Swiss 2006 Reform", "Német: svájci, 2006-os reform"), + ("Get Story", "Szövegfolyam lekérése"), + ("Get Table", "Táblázat lekérése"), + ( + "Get help, share your layouts and shape what we build next.", + "Kérjen segítséget, mutassa meg kiadványait, és alakítsa velünk a következő fejlesztéseket.", + ), + ("Global Light", "Globális megvilágítás"), + ("Global Light…", "Globális megvilágítás…"), + ("Glyph Scaling:", "Karakterjel méretezése:"), + ("Glyphs", "Karakterjelek"), + ("Go To", "Ugrás"), + ("Go To First Page", "Ugrás az első oldalra"), + ("Go To Last Page", "Ugrás az utolsó oldalra"), + ("Go To Link", "Ugrás a csatoláshoz"), + ("Go To Next Page", "Ugrás a következő oldalra"), + ("Go To Page", "Ugrás oldalra"), + ("Go To Previous Page", "Ugrás az előző oldalra"), + ("Go To Source", "Ugrás a forráshoz"), + ("Go To URL", "Ugrás az URL-re"), + ("Go to Footnote Reference", "Ugrás a lábjegyzethivatkozáshoz"), + ("Go to Page", "Ugrás oldalra"), + ("Go to Page…", "Ugrás oldalra…"), + ("Gradient", "Színátmenet"), + ("Gradient Feather", "Színátmenetes lágyítás"), + ("Graphic", "Grafika"), + ("Graphic…", "Grafika…"), + ("Gray", "Szürke"), + ("Greek", "Görög"), + ("Gridify", "Rácsba rendezés"), + ("Grids", "Rácsok"), + ("Grids & Guides", "Rácsok és segédvonalak"), + ("Grids in Back", "Rácsok a háttérben"), + ("Group", "Csoportosítás"), + ("Group Transparency", "Csoport átlátszósága"), + ("Group with", "Csoportosítás ezzel"), + ("Group:", "Csoport:"), + ("Guide-Based", "Segédvonal-alapú"), + ("Guide-based", "Segédvonal-alapú"), + ("Guides", "Segédvonalak"), + ("Guides & Pasteboard", "Segédvonalak és munkaterület"), + ("Gutter", "Hasábköz"), + ("Gutter:", "Hasábköz:"), + ("H&J Violations", "Elválasztási és sorkizárási eltérések"), + ("H:", "M.:"), + ("Hair Space", "Hajszálköz"), + ("Header Rows", "Fejlécsorok"), + ("Hebrew", "Héber"), + ("Height", "Magasság"), + ("Height Only", "Csak magasság"), + ("Height and Width", "Magasság és szélesség"), + ("Height and width", "Magasság és szélesség"), + ("Height only", "Csak magasság"), + ("Height:", "Magasság:"), + ("Help", "Súgó"), + ("Hidden Characters", "Rejtett karakterek"), + ("Hide", "Elrejtés"), + ("Hide Bar", "Sáv elrejtése"), + ("Hide Guides", "Segédvonalak elrejtése"), + ("Hide Menu Item", "Menüpont elrejtése"), + ("Hide Others", "Többi elrejtése"), + ("Hide Rulers", "Vonalzók elrejtése"), + ("Hide/Lock Others", "Többi elrejtése/zárolása"), + ("High Contrast", "Erős kontraszt"), + ("High Quality", "Kiváló minőség"), + ("High Quality Display", "Kiváló minőségű megjelenítés"), + ("High Resolution", "Nagy felbontás"), + ("Highlight", "Kiemelés"), + ("Hindi", "Hindi"), + ("History", "Előzmények"), + ("Home", "Kezdőlap"), + ("Horizontal", "Vízszintes"), + ("Horizontal Gridline Every:", "Vízszintes rácsvonalak távolsága:"), + ("Horizontal Margins:", "Vízszintes margók:"), + ("Horizontal Offset", "Vízszintes eltolás"), + ("Horizontal Scale:", "Vízszintes méretezés:"), + ("Horizontal Spacing", "Vízszintes térköz"), + ("Horizontal:", "Vízszintes:"), + ("Hungarian", "Magyar"), + ("Hyperlink Options…", "Hiperhivatkozás beállításai…"), + ("Hyperlink from the selected text or frames", "Hiperhivatkozás a kijelölt szövegből vagy keretekből"), + ("Hyperlinks", "Hiperhivatkozások"), + ("Hyperlinks & Cross-References", "Hiperhivatkozások és kereszthivatkozások"), + ("Hyperlinks Cross-References", "Hiperhivatkozások és kereszthivatkozások"), + ("Hyphen Limit:", "Egymást követő elválasztások korlátja:"), + ("Hyphenate", "Elválasztás engedélyezése"), + ("Hyphenation", "Elválasztás"), + ("Hyphenation Exceptions", "Elválasztási kivételek"), + ("Hyphens and Dashes", "Kötőjelek és gondolatjelek"), + ("I, II, III, IV...", "I, II, III, IV..."), + ("IT", "IT"), + ("Image", "Kép"), + ("Import File", "Fájl importálása"), + ("Import Options", "Importálási beállítások"), + ("Import Options: Styles", "Importálási beállítások: stílusok"), + ("Import Warnings", "Importálási figyelmeztetések"), + ("Import XML", "XML importálása"), + ("Import XML…", "XML importálása…"), + ("In justified Arabic text", "Sorkizárt arab szövegben"), + ("Include Bleed", "Kifutóval együtt"), + ("Include Stroke Weight", "Körvonalvastagsággal együtt"), + ("Include document bleed", "Dokumentum kifutójának felvétele"), + ("Include when exporting", "Felvétel exportáláskor"), + ("Increase Point Size", "Betűméret növelése"), + ("Increment Every:", "Lépésköz:"), + ("Indent", "Behúzás"), + ("Indent to Here", "Behúzás eddig"), + ("Indents and Spacing", "Behúzások és térközök"), + ("Index", "Tárgymutató"), + ("Index Page References", "Tárgymutató-oldalhivatkozások"), + ("Info", "Információ"), + ("Ink Limit 300%", "300%-os festékterhelési korlát"), + ("Ink Manager", "Festékkezelő"), + ("Inner Glow", "Belső ragyogás"), + ("Inner Shadow", "Belső árnyék"), + ("Insert", "Beszúrás"), + ("Insert Anchored Object", "Rögzített objektum beszúrása"), + ("Insert Anchored Object…", "Rögzített objektum beszúrása…"), + ("Insert Break Character", "Töréskarakter beszúrása"), + ("Insert Column", "Oszlop beszúrása"), + ("Insert Column Left", "Oszlop beszúrása balra"), + ("Insert Column Right", "Oszlop beszúrása jobbra"), + ("Insert Column Rule", "Hasábelválasztó vonal beszúrása"), + ("Insert Cross-Reference", "Kereszthivatkozás beszúrása"), + ("Insert Cross-Reference…", "Kereszthivatkozás beszúrása…"), + ("Insert Endnote", "Végjegyzet beszúrása"), + ("Insert Field", "Mező beszúrása"), + ("Insert Footnote", "Lábjegyzet beszúrása"), + ("Insert Kashidas:", "Kasídák beszúrása:"), + ("Insert Math Expression", "Matematikai kifejezés beszúrása"), + ("Insert Math Expression…", "Matematikai kifejezés beszúrása…"), + ("Insert Page", "Oldal beszúrása"), + ("Insert Page After", "Oldal beszúrása utána"), + ("Insert Pages", "Oldalak beszúrása"), + ("Insert Pages…", "Oldalak beszúrása…"), + ("Insert Row", "Sor beszúrása"), + ("Insert Row Above", "Sor beszúrása fölé"), + ("Insert Row Below", "Sor beszúrása alá"), + ("Insert Special Character", "Speciális karakter beszúrása"), + ("Insert Table…", "Táblázat beszúrása…"), + ("Insert Variable", "Változó beszúrása"), + ("Insert White Space", "Szóközkarakter beszúrása"), + ("Insert field", "Mező beszúrása"), + ("Inset spacing", "Belső margók"), + ("Inside", "Belül"), + ("Inside:", "Belül:"), + ("Inspect Document", "Dokumentum vizsgálata"), + ("Intent:", "Leképezési mód:"), + ("Interactive", "Interaktív"), + ("Interactive for PDF", "Interaktív PDF"), + ("Interface", "Felület"), + ("Interface Color Theme", "Felület színtémája"), + ("Interface Language", "Felület nyelve"), + ("Internet Standard RGB (sRGB)", "Internetes szabványos RGB (sRGB)"), + ("Intersect", "Metszet"), + ("Invalid value", "Érvénytelen érték"), + ("Inverse Rounded Rectangle", "Befelé lekerekített téglalap"), + ("Invert", "Megfordítás"), + ("Inward", "Befelé"), + ("Isolate Blending", "Keverés elkülönítése"), + ("Italian", "Olasz"), + ("Italic", "Dőlt"), + ("Japanese", "Japán"), + ("Join", "Összekapcsolás"), + ("Join Path", "Görbék összekapcsolása"), + ("Join our Discord", "Csatlakozás a Discordhoz"), + ("Join the ArtCraft Discord", "Csatlakozás az ArtCraft Discordhoz"), + ("Join the ArtCraft Discord…", "Csatlakozás az ArtCraft Discordhoz…"), + ("Join the ArtCraft community", "Csatlakozás az ArtCraft közösségéhez"), + ("Join the community", "Csatlakozás a közösséghez"), + ("Join:", "Vonalcsatlakozás:"), + ("Jump Object", "Objektum átugrása"), + ("Jump to Next Column", "Ugrás a következő hasábba"), + ("Justification", "Sorkizárás"), + ("Justify", "Sorkizárt"), + ("Justify Left", "Sorkizárt, utolsó sor balra"), + ("Justify all lines", "Minden sor sorkizárt"), + ("Justify with last line aligned center", "Sorkizárt, utolsó sor középre"), + ("Justify with last line aligned left", "Sorkizárt, utolsó sor balra"), + ("Justify with last line aligned right", "Sorkizárt, utolsó sor jobbra"), + ("Keep Violations", "Együtt tartási eltérések"), + ("Kenten", "Kenten"), + ("Kerning", "Alávágás"), + ("Kerning:", "Alávágás:"), + ("Keyboard Increments", "Billentyűzeti lépésközök"), + ("Keyboard Shortcuts", "Billentyűparancsok"), + ("Keyboard Shortcuts…", "Billentyűparancsok…"), + ("Kind:", "Fajta:"), + ("Knockout Group", "Kiütési csoport"), + ("Korean", "Koreai"), + ("Korean Line Breaks:", "Koreai sortörések:"), + ("Lab", "Lab"), + ("Landscape", "Fekvő"), + ("Language", "Nyelv"), + ("Language:", "Nyelv:"), + ("Last", "Utolsó"), + ("Last Object Below", "Legalsó objektum"), + ("Last Page", "Utolsó oldal"), + ("Last Spread", "Utolsó oldalpár"), + ("Layer Options…", "Réteg beállításai…"), + ("Layers", "Rétegek"), + ("Layout", "Elrendezés"), + ("Layout/Index", "Elrendezés/Tárgymutató"), + ("Layout/Pages", "Elrendezés/Oldalak"), + ("Leading", "Sortávolság"), + ("Leading:", "Sortávolság:"), + ("Left", "Balra"), + ("Left Indent:", "Bal oldali behúzás:"), + ("Left to Right", "Balról jobbra"), + ("Left:", "Balra:"), + ("Legacy Macintosh RGB", "Régi Macintosh RGB"), + ("Letter Spacing:", "Betűköz:"), + ("Library", "Könyvtár"), + ("Library Contents", "Könyvtár tartalma"), + ("Library Items", "Könyvtári elemek"), + ("Ligatures", "Ligatúrák"), + ("Light", "Világos"), + ("Limit", "Korlát"), + ("Line", "Vonal"), + ("Linear", "Lineáris"), + ("Lines:", "Sorok:"), + ("Link To:", "Hivatkozás célja:"), + ("Link images", "Képek csatolása"), + ("Linked Stories", "Csatolt szövegfolyamok"), + ("Links", "Csatolások"), + ("Liquid Layout", "Alkalmazkodó elrendezés"), + ("Liquid Page Rule", "Alkalmazkodó oldalszabály"), + ("Liquid Page Rule:", "Alkalmazkodó oldalszabály:"), + ("Liquid guides", "Alkalmazkodó segédvonalak"), + ("List Box", "Listamező"), + ("List Composite Fonts", "Összetett betűtípusok listázása"), + ("List Documents", "Dokumentumok listázása"), + ("List Styles", "Stílusok listázása"), + ("List Tools", "Eszközök listázása"), + ("Load DTD", "DTD betöltése"), + ("Load DTD…", "DTD betöltése…"), + ("Load Place Cursor", "Elhelyezőkurzor feltöltése"), + ("Load Profile…", "Profil betöltése…"), + ("Load Swatches", "Színtár betöltése"), + ("Load Swatches…", "Színtár betöltése…"), + ("Load…", "Betöltés…"), + ("Location:", "Hely:"), + ("Lock", "Zárolás"), + ("Lock Guides", "Segédvonalak zárolása"), + ("Lock Others", "Többi zárolása"), + ("Loop", "Ismétlés"), + ("Low Resolution", "Kis felbontás"), + ("Magenta", "Bíbor"), + ("Make Compound", "Összetett alakzat készítése"), + ("Make Compound Path", "Összetett görbe készítése"), + ("Make all settings the same", "Összes beállítás azonossá tétele"), + ("Map Styles to Tags", "Stílusok hozzárendelése címkékhez"), + ("Margins", "Margók"), + ("Margins and Columns", "Margók és hasábok"), + ("Margins and Columns…", "Margók és hasábok…"), + ("Margins:", "Margók:"), + ("Markers", "Jelölők"), + ("Marks and Bleeds", "Nyomdai jelek és kifutó"), + ("Math Expression SVG", "Matematikai kifejezés SVG-je"), + ("Maximum", "Maximum"), + ("Media", "Média"), + ("Media Options", "Médiabeállítások"), + ("Medium Dark", "Középsötét"), + ("Medium Light", "Középvilágos"), + ("Medium Resolution", "Közepes felbontás"), + ("Menu Customization", "Menük testreszabása"), + ("Menus", "Menük"), + ("Menus…", "Menük…"), + ("Merge Cells", "Cellák egyesítése"), + ("Merge Layers", "Rétegek egyesítése"), + ("Merge into Active Layer", "Egyesítés az aktív réteggel"), + ("Merge options", "Egyesítési beállítások"), + ("Metrics", "Metrikus"), + ("Min:", "Min.:"), + ("Minimum", "Minimum"), + ("Minimum Space Before First Footnote:", "Legkisebb térköz az első lábjegyzet előtt:"), + ("Minus Back", "Hátsó alakzat kivonása"), + ("Missing Glyphs", "Hiányzó karakterjelek"), + ("Misspelled word → Correction (one per line):", "Hibás szó → Javítás (soronként egy):"), + ("Miter", "Hegyes vonalcsatlakozás"), + ("Miter Limit:", "Saroknyúlás korlátja:"), + ("Mobile", "Mobil"), + ("Monitor RGB", "Monitor RGB-színtere"), + ("More ArtCraft apps", "További ArtCraft-alkalmazások"), + ("More Options", "További beállítások"), + ("Move", "Áthelyezés"), + ("Move Anchors", "Csomópontok áthelyezése"), + ("Move Caret", "Szövegkurzor áthelyezése"), + ("Move Column", "Oszlop áthelyezése"), + ("Move Gap", "Hézag áthelyezése"), + ("Move Guide", "Segédvonal áthelyezése"), + ("Move Layer", "Réteg áthelyezése"), + ("Move Pages", "Oldalak áthelyezése"), + ("Move Pages…", "Oldalak áthelyezése…"), + ("Move Panel", "Panel áthelyezése"), + ("Move Row", "Sor áthelyezése"), + ("Move Selection Here", "Kijelölés áthelyezése ide"), + ("Move to Color Group", "Áthelyezés színcsoportba"), + ("Move to Group", "Áthelyezés csoportba"), + ("Move to Layer", "Áthelyezés rétegre"), + ("Multiline", "Többsoros"), + ("Multiple Objects", "Több objektum"), + ("Multiple records", "Több rekord"), + ("Name", "Név"), + ("Name:", "Név:"), + ("Native", "Natív"), + ("Nested Line Styles", "Beágyazott sorstílusok"), + ("Nested Styles", "Beágyazott stílusok"), + ("Never (continuous)", "Soha (folyamatos)"), + ("New", "Új"), + ("New Article", "Új cikk"), + ("New Article from Selection", "Új cikk a kijelölésből"), + ("New Book", "Új könyv"), + ("New Bookmark", "Új könyvjelző"), + ("New Bookmark for This Page", "Új könyvjelző ehhez az oldalhoz"), + ("New Book…", "Új könyv…"), + ("New Cell Style…", "Új cellastílus…"), + ("New Character Style…", "Új karakterstílus…"), + ("New Color Group", "Új színcsoport"), + ("New Color Group with Swatch", "Új színcsoport színmintával"), + ("New Color Swatch…", "Új színminta…"), + ("New Condition", "Új feltétel"), + ("New Condition…", "Új feltétel…"), + ("New Cross-Reference", "Új kereszthivatkozás"), + ("New Document", "Új dokumentum"), + ("New Document…", "Új dokumentum…"), + ("New GREP Style", "Új GREP-stílus"), + ("New Guide", "Új segédvonal"), + ("New Hyperlink", "Új hiperhivatkozás"), + ("New Hyperlink Destination", "Új hivatkozási cél"), + ("New Hyperlink Destination…", "Új hivatkozási cél…"), + ("New Hyperlink…", "Új hiperhivatkozás…"), + ("New Layer", "Új réteg"), + ("New Layer…", "Új réteg…"), + ("New Library", "Új könyvtár"), + ("New Library…", "Új könyvtár…"), + ("New Line Style", "Új sorstílus"), + ("New Nested Style", "Új beágyazott stílus"), + ("New Note", "Új jegyzet"), + ("New Note at Cursor", "Új jegyzet a kurzornál"), + ("New Object Style…", "Új objektumstílus…"), + ("New Page Reference", "Új oldalhivatkozás"), + ("New Page Reference…", "Új oldalhivatkozás…"), + ("New Paragraph Style…", "Új bekezdésstílus…"), + ("New Parent", "Új mesteroldal"), + ("New Parent…", "Új mesteroldal…"), + ("New Stroke Style", "Új körvonalstílus"), + ("New Stroke Style…", "Új körvonalstílus…"), + ("New Swatch", "Új színminta"), + ("New Table Style…", "Új táblázatstílus…"), + ("New Tag", "Új címke"), + ("New Window", "Új ablak"), + ("New Workspace", "Új munkaterület"), + ("New Workspace…", "Új munkaterület…"), + ("New condition name", "Új feltétel neve"), + ("New group…", "Új csoport…"), + ("New tag", "Új címke"), + ("Next", "Következő"), + ("Next Cell", "Következő cella"), + ("Next Document", "Következő dokumentum"), + ("Next Object Above", "Következő felső objektum"), + ("Next Object Below", "Következő alsó objektum"), + ("Next Object in Group", "Következő objektum a csoportban"), + ("Next Page", "Következő oldal"), + ("Next Page Number", "Következő oldalszám"), + ("Next Spread", "Következő oldalpár"), + ("No Break", "Nem törhető"), + ("No Selection", "Nincs kijelölés"), + ("No Text Wrap", "Nincs körbefuttatás"), + ("No articles: exports follow page order.", "Nincs cikk: az exportálás az oldalak sorrendjét követi."), + ("No book open.", "Nincs megnyitott könyv."), + ("No bookmarks. They become the PDF outline.", "Nincsenek könyvjelzők. Ezekből készül a PDF navigációs vázlata."), + ("No changes.", "Nincs módosítás."), + ( + "No conditions. Create one, then select text and click it to apply.", + "Nincs feltétel. Hozzon létre egyet, majd jelöljön ki szöveget, és kattintással alkalmazza rá.", + ), + ("No conflicts", "Nincs ütközés"), + ("No data source", "Nincs adatforrás"), + ("No document open", "Nincs megnyitott dokumentum"), + ("No document.", "Nincs dokumentum."), + ("No errors", "Nincs hiba"), + ( + "No hyperlinks. Select text or a frame, type a URL and click New.", + "Nincs hiperhivatkozás. Jelöljön ki szöveget vagy keretet, írjon be egy URL-t, és kattintson az Új gombra.", + ), + ("No library open.", "Nincs megnyitott könyvtár."), + ("No notes in this document.", "Nincs jegyzet ebben a dokumentumban."), + ("No placed graphics.", "Nincs elhelyezett grafika."), + ("No scripts. Add one below.", "Nincs parancsfájl. Alább hozzáadhat egyet."), + ("No spot inks", "Nincs direktszín"), + ("No text anchors in this document.", "Nincs szöveghorgony ebben a dokumentumban."), + ("No text selected", "Nincs kijelölt szöveg"), + ("Nonbreaking Hyphen", "Nem törhető kötőjel"), + ("Nonbreaking Space", "Nem törhető szóköz"), + ("None", "Nincs"), + ("None (keep transparency)", "Nincs (átlátszóság megőrzése)"), + ("Nonprinting", "Nem nyomtatandó"), + ("Normal", "Normál"), + ("Normal (W)", "Normál (W)"), + ("Norwegian: Bokmål", "Norvég: bokmål"), + ("Not a button", "Nem gomb"), + ("Not a form field", "Nem űrlapmező"), + ("Note text", "Jegyzet szövege"), + ("Notes", "Jegyzetek"), + ("Number of Pages", "Oldalak száma"), + ("Number of Pages:", "Oldalak száma:"), + ("Number of Sides:", "Sokszög oldalszáma:"), + ("Number of columns", "Oszlopok száma"), + ("Number:", "Szám:"), + ("Numbered List", "Számozott lista"), + ("Numbering", "Számozás"), + ("Numbering & Section Options", "Számozás és szakaszbeállítások"), + ("Numbering & Section Options…", "Számozás és szakaszbeállítások…"), + ("Numbering and Formatting", "Számozás és formázás"), + ("OK", "OK"), + ("OK changes all: text and paragraph/character styles.", "Az OK mindenhol cserél: a szövegben, valamint a bekezdés- és karakterstílusokban is."), + ("OVERSET", "TÚLCSORDULT SZÖVEG"), + ("Object", "Objektum"), + ("Object & Layout", "Objektum és elrendezés"), + ("Object Constraints:", "Objektum méret- és helyzetkötései:"), + ("Object Export Options", "Objektum exportálási beállításai"), + ("Object Export Options…", "Objektum exportálási beállításai…"), + ("Object Layer Options", "Objektum rétegbeállításai"), + ("Object Layer Options…", "Objektum rétegbeállításai…"), + ("Object Layers", "Objektumrétegek"), + ("Object Liquid Settings", "Objektum alkalmazkodási beállításai"), + ("Object States", "Objektumállapotok"), + ("Object Styles", "Objektumstílusok"), + ("Object is pinned relative to the page edge. Click to release.", "Az objektum az oldalszélhez van rögzítve. Kattintson a feloldáshoz."), + ("Object-Based", "Objektumalapú"), + ("Object-based", "Objektumalapú"), + ("Object/Anchored Object", "Objektum/Rögzített objektum"), + ("Object/Arrange", "Objektum/Sorrend"), + ("Object/Captions", "Objektum/Képaláírások"), + ("Object/Clipping Path", "Objektum/Vágógörbe"), + ("Object/Content", "Objektum/Tartalom"), + ("Object/Convert Shape", "Objektum/Alakzat átalakítása"), + ("Object/Effects", "Objektum/Hatások"), + ("Object/Fitting", "Objektum/Illesztés"), + ("Object/Pathfinder", "Objektum/Görbeműveletek"), + ("Object/Paths", "Objektum/Görbék"), + ("Object/Select", "Objektum/Kijelölés"), + ("Object/Transform", "Objektum/Átalakítás"), + ("Object/Transform Again", "Objektum/Átalakítás ismétlése"), + ("Object:", "Objektum:"), + ("Odd Page Break", "Páratlan oldaltörés"), + ("Off", "Ki"), + ("Offset", "Eltolás"), + ("Offset:", "Eltolás:"), + ("Offsets", "Eltolások"), + ("On", "Be"), + ("On Release", "Felengedéskor"), + ("On Screen:", "Képernyőn:"), + ("One record", "Egy rekord"), + ("Opacity", "Fedettség"), + ("Open", "Megnyitás"), + ("Open Book", "Könyv megnyitása"), + ("Open Bytes", "Megnyitás bájtokból"), + ("Open IDML", "IDML megnyitása"), + ("Open Library", "Könyvtár megnyitása"), + ("Open Path", "Görbe megnyitása"), + ("Open source. No telemetry.", "Nyílt forráskód. Nincs telemetria."), + ("OpenType", "OpenType"), + ("OpenType All Small Caps", "OpenType kiskapitális minden betűre"), + ("OpenType Denominator", "OpenType nevező"), + ("OpenType Features", "OpenType-funkciók"), + ("OpenType Numerator", "OpenType számláló"), + ("OpenType Subscript", "OpenType alsó index"), + ("OpenType Superscript", "OpenType felső index"), + ("Open…", "Megnyitás…"), + ("Optical", "Optikai"), + ("Optical Margin Alignment", "Optikai margóigazítás"), + ("Options", "Beállítások"), + ("Options for Black on RGB and Grayscale Devices", "Fekete beállításai RGB- és szürkeárnyalatos eszközökön"), + ("Ordinal", "Sorszámjel"), + ("Orientation", "Tájolás"), + ("Orientation:", "Tájolás:"), + ("Orthogonal Line", "Merőleges vonal"), + ("Other", "Egyéb"), + ("Outer Glow", "Külső ragyogás"), + ("Output", "Kimenet"), + ("Output All Blacks Accurately", "Minden fekete pontos kimenete"), + ("Output All Blacks as Rich Black", "Minden fekete kimenete dús feketeként"), + ("Outside", "Kívül"), + ("Outside:", "Kívül:"), + ("Overprint Fill", "Kitöltés felülnyomása"), + ("Overprint Gap", "Hézag felülnyomása"), + ("Overprint Preview", "Felülnyomási előnézet"), + ("Overprint Stroke", "Körvonal felülnyomása"), + ("Overprint [Black] Swatch at 100%", "A [Fekete] színminta felülnyomása 100%-nál"), + ("Override All Parent Page Items", "Mesteroldal összes elemének helyi felülírása"), + ("PDF/A-2b", "PDF/A-2b"), + ("PDF/X-4", "PDF/X-4"), + ("Package", "Csomagolás"), + ("Package…", "Csomagolás…"), + ("Page", "Oldal"), + ("Page (1–{count}):", "Oldal (1–{count}):"), + ("Page Break", "Oldaltörés"), + ("Page Information", "Oldalinformációk"), + ("Page Numbering", "Oldalszámozás"), + ("Page Options", "Oldalbeállítások"), + ("Page Range", "Oldaltartomány"), + ("Page Range:", "Oldaltartomány:"), + ("Page Size", "Oldalméret"), + ("Page Transition", "Oldaláttűnés"), + ("Page Transitions", "Oldaláttűnések"), + ( + "Page layout for print and screen — fast, open and scriptable.", + "Kiadványszerkesztés nyomtatáshoz és képernyőre – gyors, nyílt és parancsfájlokkal vezérelhető.", + ), + ("Page · PPI", "Oldal · PPI"), + ("Page:", "Oldal:"), + ("Pages", "Oldalak"), + ("Pages:", "Oldalak:"), + ("Panels", "Panelek"), + ("Paragraph", "Bekezdés"), + ("Paragraph Composer", "Bekezdéstördelő"), + ("Paragraph Formatting", "Bekezdésformázás"), + ("Paragraph Return", "Bekezdésjel"), + ("Paragraph Rules", "Bekezdésvonalak"), + ("Paragraph Rules…", "Bekezdésvonalak…"), + ("Paragraph Style Options", "Bekezdésstílus beállításai"), + ("Paragraph Style Options…", "Bekezdésstílus beállításai…"), + ("Paragraph Style:", "Bekezdésstílus:"), + ("Paragraph Styles", "Bekezdésstílusok"), + ("Paragraph Styles panel", "Bekezdésstílusok panel"), + ("Paragraph Symbol", "Bekezdés szimbóluma"), + ("Paragraph alignment", "Bekezdés igazítása"), + ("Parent Pages", "Mesteroldalak"), + ("Part of the ArtCraft suite of open creative tools.", "Az ArtCraft nyílt kreatív eszközcsalád része."), + ("Pass csv, rows, or bytes from the control channel.", "Adjon át csv, rows vagy bytes adatot a vezérlőcsatornán."), + ("Paste", "Beillesztés"), + ("Paste Into", "Beillesztés belülre"), + ("Paste in Place", "Beillesztés az eredeti helyre"), + ("Paste without Formatting", "Beillesztés formázás nélkül"), + ("Pasteboard Options", "Munkaterület beállításai"), + ("Path", "Görbe"), + ("Pathfinder", "Görbeműveletek"), + ("Pathfinder:", "Görbeműveletek:"), + ("Paths", "Görbék"), + ("Paths:", "Görbék:"), + ("Perceptual", "Érzékelési"), + ("Picas", "Pica"), + ("Pick a file in the folder holding the moved graphics", "Válasszon egy fájlt az áthelyezett grafikákat tartalmazó mappában"), + ("Pin Bar Position", "Sáv helyzetének rögzítése"), + ("Pin bottom", "Rögzítés alul"), + ("Pin left", "Rögzítés balra"), + ("Pin right", "Rögzítés jobbra"), + ("Pin top", "Rögzítés felül"), + ("Pin:", "Rögzítés:"), + ("Place", "Elhelyezés"), + ("Place Caret", "Szövegkurzor elhelyezése"), + ("Place Item", "Elem elhelyezése"), + ("Place Loaded Graphic", "Betöltött grafika elhelyezése"), + ("Place PDF", "PDF elhelyezése"), + ("Place Snippet", "Részlet elhelyezése"), + ("Place and Link", "Elhelyezés és csatolás"), + ("Place the insertion point in a table, or create one.", "Helyezze a szövegkurzort egy táblázatba, vagy hozzon létre táblázatot."), + ("Place with Import Options", "Elhelyezés importálási beállításokkal"), + ("Place with Import Options…", "Elhelyezés importálási beállításokkal…"), + ("Place…", "Elhelyezés…"), + ("Plain", "Egyszerű"), + ("Plain Text", "Egyszerű szöveg"), + ("Play on Page Load", "Lejátszás az oldal betöltésekor"), + ("Plays in EPUB export; print and PDF show the poster.", "EPUB-exportban lejátszható; nyomtatásban és PDF-ben a poszterkép látható."), + ("Polish", "Lengyel"), + ("Polygon", "Sokszög"), + ("Polygon Settings", "Sokszög beállításai"), + ("Polygon Settings…", "Sokszög beállításai…"), + ("Portrait", "Álló"), + ("Portuguese", "Portugál"), + ("Portuguese: Brazilian", "Portugál: brazíliai"), + ("Position", "Pozíció"), + ("Position:", "Pozíció:"), + ("Poster", "Poszter"), + ("Preferences", "Beállítások"), + ("Preferences…", "Beállítások…"), + ("Prefix:", "Előtag:"), + ("Preflight", "Nyomdai előellenőrzés"), + ("Preflight Document", "Dokumentum nyomdai előellenőrzése"), + ("Presentation", "Bemutató"), + ("Preset", "Készlet"), + ("Preset:", "Készlet:"), + ("Preview", "Előnézet"), + ("Preview (W)", "Előnézet (W)"), + ("Preview Record", "Rekord előnézete"), + ("Previous", "Előző"), + ("Previous Cell", "Előző cella"), + ("Previous Document", "Előző dokumentum"), + ("Previous Object in Group", "Előző objektum a csoportban"), + ("Previous Page", "Előző oldal"), + ("Previous Page Number", "Előző oldalszám"), + ("Previous Spread", "Előző oldalpár"), + ("Primary Text Frame", "Elsődleges szövegkeret"), + ("Print", "Nyomtatás"), + ("Print Booklet", "Füzet nyomtatása"), + ("Print Booklet…", "Füzet nyomtatása…"), + ("Printer's Marks", "Nyomdai jelek"), + ("Printer:", "Nyomtató:"), + ("Printers", "Nyomtatók"), + ("Printing / Exporting:", "Nyomtatás / exportálás:"), + ("Printing and Proofing", "Nyomtatás és próbanyomat"), + ("Print…", "Nyomtatás…"), + ("Projecting", "Túlnyúló"), + ("Proof Colors", "Próbaszínek"), + ("Proof Setup", "Próbanyomat beállításai"), + ("Properties", "Tulajdonságok"), + ("Properties Panel", "Tulajdonságok panel"), + ("Proportional Lining", "Arányos verzálszámok"), + ("Proportional Oldstyle", "Arányos ugráló számok"), + ("Punctuation Space", "Írásjelköz"), + ("Push", "Eltolás"), + ("Quarter Space", "Negyedkvirt szóköz"), + ("Quick Actions", "Gyorsműveletek"), + ("Quick Apply", "Gyors alkalmazás"), + ("Quick Apply…", "Gyors alkalmazás…"), + ("Quit DesignCraft", "Kilépés a DesignCraftból"), + ("Quotation Marks", "Idézőjelek"), + ("RGB", "RGB"), + ("RGB:", "RGB:"), + ("Radial", "Sugárirányú"), + ("Radio Button", "Választógomb"), + ("Range", "Tartomány"), + ("Re-center", "Újra középre"), + ("Recently Used", "Legutóbb használt"), + ("Record", "Rekord"), + ("Records per page", "Rekordok oldalanként"), + ("Recover Documents", "Dokumentumok helyreállítása"), + ("Recovery Data", "Helyreállítási adatok"), + ("Rectangle", "Téglalap"), + ("Redo", "Újra"), + ("Registered Trademark Symbol", "Bejegyzett védjegy jele"), + ("Reject", "Elutasítás"), + ("Reject All", "Összes elutasítása"), + ("Reject All Changes", "Összes módosítás elutasítása"), + ("Reject Change", "Módosítás elutasítása"), + ("Relative Colorimetric", "Relatív kolorimetrikus"), + ("Relative To:", "Viszonyítási alap:"), + ("Release", "Feloldás"), + ("Release Compound Path", "Összetett görbe feloldása"), + ("Release State to Objects", "Állapot feloldása objektumokra"), + ("Release to Objects", "Feloldás objektumokra"), + ("Relink", "Újracsatolás"), + ("Relink to Folder", "Újracsatolás mappához"), + ("Relink to Folder…", "Újracsatolás mappához…"), + ("Relink…", "Újracsatolás…"), + ("Remove", "Eltávolítás"), + ("Remove All Local Overrides", "Összes helyi felülírás törlése"), + ("Remove Conditions from Selection", "Feltételek eltávolítása a kijelölésből"), + ("Remove Data Source", "Adatforrás eltávolítása"), + ("Remove Document", "Dokumentum eltávolítása"), + ("Remove Field", "Mező eltávolítása"), + ("Remove Flex Layout", "Rugalmas elrendezés eltávolítása"), + ("Remove Frame from Thread", "Keret kivétele a láncból"), + ("Remove Hyphenation Exception", "Elválasztási kivétel eltávolítása"), + ("Remove Styles and Formatting from Text and Tables", "Stílusok és formázás eltávolítása a szövegből és a táblázatokból"), + ("Remove from Article", "Eltávolítás a cikkből"), + ("Rename", "Átnevezés"), + ("Rename Bookmark", "Könyvjelző átnevezése"), + ("Render", "Renderelés"), + ("Reorder in Flex Layout", "Átrendezés a rugalmas elrendezésben"), + ("Repeat", "Ismétlés"), + ("Repeat Header Rows", "Fejlécsorok ismétlése"), + ("Replace With", "Csere erre"), + ("Replace with:", "Csere erre:"), + ("Report an Issue", "Hiba bejelentése"), + ("Report an Issue…", "Hiba bejelentése…"), + ("Required", "Kötelező"), + ("Reset", "Visszaállítás"), + ("Reset All to Defaults", "Összes alapérték visszaállítása"), + ("Reset Bar Position", "Sáv pozíciójának visszaállítása"), + ("Reset Shortcuts", "Gyorsbillentyűk visszaállítása"), + ("Reset Workspace", "Munkaterület visszaállítása"), + ("Resize", "Átméretezés"), + ("Resize Page", "Oldal átméretezése"), + ("Resize height", "Magasság módosítása"), + ("Resize panel window", "Panelablak átméretezése"), + ("Resize panels", "Panelek átméretezése"), + ("Resize width", "Szélesség módosítása"), + ("Resize with Page:", "Átméretezés az oldallal:"), + ("Restart Numbering Every:", "Számozás újrakezdése:"), + ("Reverse", "Megfordítás"), + ("Reverse Column", "Fordított oszlopsorrend"), + ("Reverse Path", "Görbeirány megfordítása"), + ("Reverse Row", "Fordított sorsorrend"), + ("Reverse the gradient", "Színátmenet megfordítása"), + ("Revert", "Visszaállítás a mentett változatra"), + ("Right", "Jobbra"), + ("Right Indent Tab", "Jobbra behúzó tabulátor"), + ("Right Indent:", "Jobb behúzás:"), + ("Right to Left", "Jobbról balra"), + ("Right-click: stroke, groups", "Jobb kattintás: körvonal, csoportok"), + ("Right:", "Jobbra:"), + ("Rotate", "Forgatás"), + ("Rotate 90° Clockwise", "Forgatás 90°-kal jobbra"), + ("Rotate 90° Counterclockwise", "Forgatás 90°-kal balra"), + ("Rotate Spread", "Oldalpár elforgatása"), + ("Rotation", "Elforgatás"), + ("Rotation Angle", "Elforgatás szöge"), + ("Round", "Kerek"), + ("Rounded Rectangle", "Lekerekített téglalap"), + ("Row", "Sor"), + ("Row Height", "Sormagasság"), + ("Row spacing", "Sorköz"), + ("Row ↑", "Sor ↑"), + ("Row ↓", "Sor ↓"), + ("Rows", "Sorok"), + ("Rows first", "Először a sorok"), + ("Rows:", "Sorok:"), + ("Ruby", "Ruby"), + ("Ruby:", "Ruby:"), + ("Ruby…", "Ruby…"), + ("Rule Above", "Bekezdés feletti vonal"), + ("Rule Below", "Vonal alatta"), + ("Rule On", "Vonal bekapcsolása"), + ("Ruler Units", "Vonalzó mértékegységei"), + ("Rulers", "Vonalzók"), + ("Rulers & Grids", "Vonalzók és rácsok"), + ("Run", "Futtatás"), + ("Run Script", "Szkript futtatása"), + ("Russian", "Orosz"), + ("Sample Color (Eyedropper)", "Színmintavétel (pipetta)"), + ("Sample Document", "Mintadokumentum"), + ("Satin", "Szatén"), + ("Saturation", "Telítettség"), + ("Save", "Mentés"), + ("Save As", "Mentés másként"), + ("Save As…", "Mentés másként…"), + ("Save Recovery Data", "Helyreállítási adatok mentése"), + ("Save Swatches for Exchange", "Színminták mentése cseréhez"), + ("Save Swatches for Exchange…", "Színtár mentése cseréhez…"), + ("Save a Copy", "Másolat mentése"), + ("Save a Copy…", "Másolat mentése…"), + ("Save recovery data every:", "Helyreállítási adatok mentési időköze:"), + ("Saves which bars show and where the panels are.", "Menti a látható sávokat és a panelek helyét."), + ("Scale", "Méretezés"), + ("Scale X Percentage", "Vízszintes méretezés százalékban"), + ("Scale Y Percentage", "Függőleges méretezés százalékban"), + ("Scale for Descenders", "Méretezés a lenyúló szárakhoz"), + ("Screen Mode", "Képernyőmód"), + ("Screen and Email", "Képernyő és e-mail"), + ("Script Label", "Szkriptcímke"), + ("Scripts", "Szkriptek"), + ("Search", "Keresés"), + ("Search commands", "Parancsok keresése"), + ("Search fonts", "Betűtípusok keresése"), + ("Search styles and commands…", "Stílusok és parancsok keresése…"), + ("Search:", "Keresés:"), + ("Search: character or U+code", "Keresés: karakter vagy U+kód"), + ("Section", "Szakasz"), + ("Section Marker", "Szakaszjelölő"), + ("Section Symbol", "Paragrafusjel"), + ("Select", "Kijelölés"), + ("Select All", "Az összes kijelölése"), + ("Select All on Layer", "Összes kijelölése a rétegen"), + ("Select Cells", "Cellák kijelölése"), + ("Select Column", "Oszlop kijelölése"), + ("Select Data Source…", "Adatforrás kiválasztása…"), + ("Select Frame", "Keret kijelölése"), + ("Select Page", "Oldal kijelölése"), + ("Select Paragraph", "Bekezdés kijelölése"), + ("Select Row", "Sor kijelölése"), + ("Select Sentence", "Mondat kijelölése"), + ("Select Story", "Szövegfolyam kijelölése"), + ("Select Table", "Táblázat kijelölése"), + ("Select Text", "Szöveg kijelölése"), + ("Select Text Unit", "Szövegegység kijelölése"), + ("Select Tool", "Eszköz kiválasztása"), + ("Select Word", "Szó kijelölése"), + ( + "Select a placed video or sound (File › Place a .mp4, .mov, .mp3, .wav …).", + "Jelöljön ki egy elhelyezett videót vagy hangot (Fájl › Elhelyezés: .mp4, .mov, .mp3, .wav …).", + ), + ("Select an object or text to color its fill or stroke.", "Jelöljön ki egy objektumot vagy szöveget a kitöltés vagy a körvonal színezéséhez."), + ("Select an object to color its fill or stroke.", "Jelöljön ki egy objektumot a kitöltés vagy a körvonal színezéséhez."), + ("Select an object to give its fill a gradient.", "Jelöljön ki egy objektumot a színátmenetes kitöltéshez."), + ("Select an object to make it a button.", "Jelöljön ki egy objektumot a gombbá alakításhoz."), + ("Select an object.", "Jelöljön ki egy objektumot."), + ("Select text or a text frame.", "Jelöljön ki szöveget vagy szövegkeretet."), + ("Select text to apply a condition", "Jelöljön ki szöveget a feltétel alkalmazásához"), + ( + "Select two or more objects (or a group) to make a multi-state object.", + "Jelöljön ki legalább két objektumot (vagy egy csoportot) a többállapotú objektum létrehozásához.", + ), + ("Selection", "Kijelölés"), + ("Selection Attributes", "Kijelölés tulajdonságai"), + ("Send Backward", "Eggyel hátrébb"), + ("Send to Back", "Leghátra"), + ("Sentence case", "Mondatkezdő nagybetű"), + ("Separated by commas", "Vesszővel elválasztva"), + ("Separations Preview", "Színkivonatok előnézete"), + ("Separator:", "Elválasztó:"), + ("Serialize", "Szerializálás"), + ("Set", "Beállítás"), + ("Set Active Layer", "Aktív réteg beállítása"), + ("Set Anchor", "Csomópont beállítása"), + ("Set Composite Font", "Összetett betűtípus beállítása"), + ("Set Cross-Reference Format", "Kereszthivatkozás formátumának beállítása"), + ("Set Footnote Text", "Lábjegyzetszöveg beállítása"), + ("Set OpenType Features", "OpenType-funkciók beállítása"), + ("Set Shortcut", "Gyorsbillentyű beállítása"), + ("Set Story Text", "Szövegfolyam szövegének beállítása"), + ("Set Visibility / Lock", "Láthatóság / zárolás beállítása"), + ("Set over the selected text; empty removes it.", "A kijelölt szöveg fölé kerül; üresen hagyva eltávolítható."), + ("Shading", "Háttérszínezés"), + ("Share", "Megosztás"), + ("Shear", "Döntés"), + ("Shear X Angle", "Vízszintes döntés szöge"), + ("Show", "Megjelenítés"), + ("Show / hide text with this condition", "Az ezzel a feltétellel jelölt szöveg megjelenítése / elrejtése"), + ("Show All Layers", "Összes réteg megjelenítése"), + ("Show All Menu Items", "Összes menüpont megjelenítése"), + ("Show All on Spread", "Összes megjelenítése az oldalpáron"), + ("Show Baseline Grid", "Alapvonalrács megjelenítése"), + ("Show Controls", "Vezérlők megjelenítése"), + ("Show Document Grid", "Dokumentumrács megjelenítése"), + ("Show Favorites Only", "Csak a kedvencek megjelenítése"), + ("Show Flex Controls", "Rugalmas elrendezés vezérlőinek megjelenítése"), + ("Show Font Names in English", "Betűtípusnevek megjelenítése angolul"), + ("Show Guides", "Segédvonalak megjelenítése"), + ("Show Hidden Characters", "Rejtett karakterek megjelenítése"), + ("Show Note Anchors in Layout View", "Jegyzethorgonyok megjelenítése elrendezési nézetben"), + ("Show Panel", "Panel megjelenítése"), + ("Show Parent Page Overlay", "Mesteroldal fedvényének megjelenítése"), + ("Show Prefix/Suffix in:", "Előtag/utótag megjelenítése itt:"), + ("Show Rulers", "Vonalzók megjelenítése"), + ("Show State", "Állapot megjelenítése"), + ("Show Tag Markers", "Címkejelölők megjelenítése"), + ("Show in the text", "Megjelenítés a szövegben"), + ("Show the Typography panel", "Tipográfia panel megjelenítése"), + ("Show/Hide Baseline Grid", "Alapvonalrács megjelenítése/elrejtése"), + ("Show/Hide Depth Ruler", "Mélységmérő megjelenítése/elrejtése"), + ("Show/Hide Frame Edges", "Keretélek megjelenítése/elrejtése"), + ("Show/Hide Guides", "Segédvonalak megjelenítése/elrejtése"), + ("Show/Hide Hidden Characters", "Rejtett karakterek megjelenítése/elrejtése"), + ("Show/Hide Panels", "Panelek megjelenítése/elrejtése"), + ("Show/Hide Panels Except Tools", "Panelek megjelenítése/elrejtése az eszközök kivételével"), + ("Show/Hide Rulers", "Vonalzók megjelenítése/elrejtése"), + ("Show/Hide Tagged Frames", "Címkézett keretek megjelenítése/elrejtése"), + ("Show/Hide Text Threads", "Szövegláncok megjelenítése/elrejtése"), + ("Signature", "Aláírás"), + ("Signature Field", "Aláírásmező"), + ("Simple", "Egyszerű"), + ("Simple - Wide", "Egyszerű – széles"), + ("Simulate Paper Color", "Papírszín szimulálása"), + ("Single Left Quotation Mark", "Bal oldali szimpla idézőjel"), + ("Single Right Quotation Mark", "Jobb oldali szimpla idézőjel"), + ("Single record", "Egy rekord"), + ("Single-line", "Egysoros"), + ("Single-line Composer", "Egysoros tördelő"), + ("Sixth Space", "Hatodkvirt szóköz"), + ("Size", "Méret"), + ("Size:", "Méret:"), + ("Skew:", "Dőlés:"), + ("Slashed Zero", "Áthúzott nulla"), + ("Slug", "Infóterület"), + ("Slug:", "Infóterület:"), + ("Small Caps", "Kiskapitális"), + ("Smart Dimensions", "Intelligens méretek"), + ("Smart Guide Options", "Intelligens segédvonalak beállításai"), + ("Smart Guides", "Intelligens segédvonalak"), + ("Smart Spacing", "Intelligens térközök"), + ("Smart Text Reflow", "Intelligens szövegáttördelés"), + ("Smooth", "Simítás"), + ("Snap to Document Grid", "Igazítás a dokumentumrácshoz"), + ("Snap to Guides", "Igazítás a segédvonalakhoz"), + ("Snap to Zone", "Zónához igazítás"), + ("Solid", "Folytonos"), + ("Sort", "Rendezés"), + ("Space After", "Térköz utána"), + ("Space After:", "Térköz utána:"), + ("Space Around", "Térköz az elemek körül"), + ("Space Before", "Térköz előtte"), + ("Space Before:", "Térköz előtte:"), + ("Space Between", "Térköz az elemek között"), + ("Space Between Footnotes:", "Lábjegyzetek közötti térköz:"), + ("Space Evenly", "Egyenletes térköz"), + ("Spacing", "Térköz"), + ("Spacing Options", "Térköz beállításai"), + ("Span Footnotes Across Columns", "Lábjegyzetek több hasábon át"), + ("Spanish", "Spanyol"), + ("Speed", "Sebesség"), + ("Spelling", "Helyesírás"), + ("Split", "Felosztás"), + ("Split Cell Horizontally", "Cella vízszintes felosztása"), + ("Split Cell Vertically", "Cella függőleges felosztása"), + ("Split Path", "Görbe felosztása"), + ("Split Window", "Ablak felosztása"), + ("Spread", "Oldalpár"), + ("Spreads", "Oldalpárok"), + ("Spreads of up to 10 pages", "Legfeljebb 10 oldalas oldalcsoportok"), + ("Square", "Négyzet"), + ("Square - Solid", "Négyzet – tömör"), + ("Standard", "Szabványos"), + ("Standard:", "Szabvány:"), + ("Star Inset:", "Csillag behúzása:"), + ("Start Page #", "Kezdőoldalszám"), + ("Start Page #:", "Kezdő oldalszám:"), + ("Start a new document", "Új dokumentum létrehozása"), + ("Start at:", "Kezdőérték:"), + ("Start/End:", "Kezdet/vég:"), + ("Start:", "Kezdet:"), + ("State Options", "Állapot beállításai"), + ("Status", "Állapot"), + ("Step and Repeat", "Ismételt másolás"), + ("Step and Repeat…", "Ismételt másolás…"), + ("Stop Color:", "Színpont színe:"), + ("Stop Preview", "Előnézet leállítása"), + ("Story", "Szövegfolyam"), + ("Story Column Direction", "Szövegfolyam hasábiránya"), + ("Story Direction", "Szövegfolyam iránya"), + ("Story Editor", "Szövegfolyam-szerkesztő"), + ("Story Editor Display", "Szövegfolyam-szerkesztő megjelenítése"), + ("Straight Double Quotation Marks", "Egyenes dupla idézőjel"), + ("Straight Hash", "Egyenes rovátkázás"), + ("Straight Single Quotation Mark (Apostrophe)", "Egyenes szimpla idézőjel (aposztróf)"), + ("Strikethrough", "Áthúzás"), + ("Strikethrough On", "Áthúzás bekapcsolása"), + ("Strikethrough Options", "Áthúzás beállításai"), + ("Stripes", "Csíkok"), + ("Stroke", "Körvonal"), + ("Stroke (X)", "Körvonal (X)"), + ("Stroke Behind Fill", "Körvonal a kitöltés mögött"), + ("Stroke Styles", "Körvonalstílusok"), + ("Structure", "Struktúra"), + ("Style", "Stílus"), + ("Style Mapping", "Stílusok megfeleltetése"), + ("Style Name Conflicts", "Stílusnevek ütközései"), + ("Style Name:", "Stílus neve:"), + ("Style Settings:", "Stílus beállításai:"), + ("Style Source", "Stílusforrás"), + ("Style source", "Stílusforrás"), + ("Style:", "Stílus:"), + ("Styles", "Stílusok"), + ("Styles panel", "Stílusok panel"), + ("Stylistic Set", "Stílusváltozat-készlet"), + ("Stylistic Sets", "Stílusváltozat-készletek"), + ("Subdivisions:", "Felosztások:"), + ("Subscript", "Alsó index"), + ("Subscript:", "Alsó index:"), + ("Substituted Fonts", "Helyettesített betűtípusok"), + ("Subtract", "Kivonás"), + ("Suffix:", "Utótag:"), + ("Superscript", "Felső index"), + ("Superscript:", "Felső index:"), + ("Swap Fill and Stroke", "Kitöltés és körvonal felcserélése"), + ("Swap Fill and Stroke (Shift+X)", "Kitöltés és körvonal felcserélése (Shift+X)"), + ("Swap Text Fill and Stroke", "Szöveg kitöltésének és körvonalának felcserélése"), + ("Swash", "Díszes betűváltozat"), + ("Swatches", "Színtár"), + ("Swedish", "Svéd"), + ("Symbols", "Szimbólumok"), + ("Symmetrical", "Szimmetrikus"), + ("Synchronize", "Szinkronizálás"), + ("Synchronize Book", "Könyv szinkronizálása"), + ("System default", "Rendszer alapértelmezése"), + ("Tab", "Tabulátor"), + ("Table", "Táblázat"), + ("Table Dimensions", "Táblázat méretei"), + ("Table Options", "Táblázat beállításai"), + ("Table Panel", "Táblázat panel"), + ("Table Style", "Táblázatstílus"), + ("Table Style Options…", "Táblázatstílus beállításai…"), + ("Table of Contents", "Tartalomjegyzék"), + ("Table of Contents Entries", "Tartalomjegyzék bejegyzései"), + ("Table of Contents…", "Tartalomjegyzék…"), + ("Table/Convert Cell Type", "Táblázat/Cellatípus átalakítása"), + ("Table/Delete", "Táblázat/Törlés"), + ("Table/Insert", "Táblázat/Beszúrás"), + ("Table/Select", "Táblázat/Kijelölés"), + ("Tabs", "Tabulátorok"), + ("Tabular Lining", "Táblázatos verzálszámok"), + ("Tabular Oldstyle", "Táblázatos ugráló számok"), + ("Tag Frame", "Keret címkézése"), + ("Tag Text", "Szöveg címkézése"), + ("Tag the selection", "Kijelölés címkézése"), + ("Tag:", "Címke:"), + ("Tagged PDF", "Címkézett PDF"), + ("Tags", "Címkék"), + ("Tate-Chu-Yoko", "Tate-Chu-Yoko"), + ("Text", "Szöveg"), + ("Text Anchors", "Szöveghorgonyok"), + ("Text Display Options", "Szöveg megjelenítési beállításai"), + ("Text Field", "Szövegmező"), + ("Text Frame", "Szövegkeret"), + ("Text Frame Options", "Szövegkeret beállításai"), + ("Text Frame Options…", "Szövegkeret beállításai…"), + ("Text Message", "Szöveges üzenet"), + ("Text Style", "Szövegstílus"), + ("Text Variables", "Szövegváltozók"), + ("Text Wrap", "Szöveg körbefuttatása"), + ("The fill isn't a gradient.", "A kitöltés nem színátmenetes."), + ( + "The library is empty. Select objects and Add Selection.", + "A könyvtár üres. Jelöljön ki objektumokat, majd válassza a Kijelölt elemek hozzáadása parancsot.", + ), + ( + "The object changes height as the page changes height. Click to lock the object height.", + "Az objektum magassága az oldal magasságával együtt változik. Kattintson az objektum magasságának rögzítéséhez.", + ), + ( + "The object changes width as the page changes width. Click to lock the object width.", + "Az objektum szélessége az oldal szélességével együtt változik. Kattintson az objektum szélességének rögzítéséhez.", + ), + ( + "The object's height is locked. Click to let the object change height as the page changes height.", + "Az objektum magassága rögzített. Kattintson, hogy a magassága az oldal magasságával együtt változhasson.", + ), + ( + "The object's width is locked. Click to let the object change width as the page changes width.", + "Az objektum szélessége rögzített. Kattintson, hogy a szélessége az oldal szélességével együtt változhasson.", + ), + ("The selected fonts have no OpenType features.", "A kijelölt betűtípusoknak nincsenek OpenType-funkcióik."), + ("The stroke isn't a gradient.", "A körvonal nem színátmenetes."), + ("The structure is valid.", "A struktúra érvényes."), + ("Thick - Thick", "Vastag – vastag"), + ("Thick - Thin", "Vastag – vékony"), + ("Thick - Thin - Thick", "Vastag – vékony – vastag"), + ("Thin - Thick", "Vékony – vastag"), + ("Thin - Thick - Thin", "Vékony – vastag – vékony"), + ("Thin - Thin", "Vékony – vékony"), + ("Thin Space", "Keskeny szóköz"), + ("Third Space", "Harmadkvirt szóköz"), + ("This PDF has no layers.", "Ez a PDF nem tartalmaz rétegeket."), + ( + "This story was edited in the layout: you'll be asked before those edits are replaced", + "Ezt a szövegfolyamot az elrendezésben módosította: a módosítások felülírása előtt megerősítést kérünk", + ), + ("This style is in use. Text using it will be reassigned to:", "Ez a stílus használatban van. Az ezt használó szöveg új stílusa:"), + ("Thread Text Frames", "Szövegkeretek összeláncolása"), + ("Tint %", "Árnyalat %"), + ("Tint:", "Százalékos árnyalat:"), + ("Title Case", "Szavak kezdőbetűje nagy"), + ("Titling Alternates", "Címbetűváltozatok"), + ("To Text:", "Erre a szövegre:"), + ("Toggle Fill and Stroke", "Váltás a kitöltés és a körvonal között"), + ("Toggle Normal/Preview", "Váltás normál nézet és előnézet között"), + ("Toggle Selection", "Kijelölés átváltása"), + ("Toggle double column", "Váltás két oszlopra"), + ("Tools: Double Column", "Eszközök: két oszlop"), + ("Top", "Fent"), + ("Top Inset", "Felső belső margó"), + ("Top Margin", "Felső margó"), + ("Top of Page", "Oldal teteje"), + ("Top:", "Fent:"), + ("Track Changes", "Változások követése"), + ("Track Changes in All Stories", "Változások követése minden szövegfolyamban"), + ("Tracking", "Betűköz"), + ("Tracking:", "Betűköz:"), + ("Trademark Symbol", "Védjegy jele"), + ("Transform", "Átalakítás"), + ("Transform Again", "Átalakítás ismétlése"), + ("Transform Again Individually", "Átalakítás ismétlése egyenként"), + ("Transform Panel", "Átalakítás panel"), + ("Transform Sequence Again", "Átalakítássorozat ismétlése"), + ("Transform Sequence Again Individually", "Átalakítássorozat ismétlése egyenként"), + ("Transform Values", "Átalakítási értékek"), + ("Transformations are Totals", "Összesített átalakítások"), + ("Transitions play in interactive PDF (full-screen mode).", "Az áttűnések interaktív PDF-ben, teljes képernyős módban játszhatók le."), + ("Transparency", "Átlátszóság"), + ("Transparency Blend Space", "Átlátszóság keverési színtere"), + ("Transparency Flattener Presets", "Átlátszóság-összeolvasztási készletek"), + ("Triangle", "Háromszög"), + ("Triangle - Wide", "Háromszög – széles"), + ("Trim", "Körülvágás"), + ("Turkish", "Török"), + ("Type", "Szöveg"), + ("Type & Tables", "Szöveg és táblázatok"), + ("Type Options", "Szövegbeállítások"), + ( + "Type a word with ~ at each allowed break (ex~am~ple); without ~ the word is never hyphenated.", + "Írjon be egy szót, és jelölje ~ karakterrel az engedélyezett elválasztásokat (el~vá~lasz~tás); ~ nélkül a szó nem választható el.", + ), + ("Type on a Path", "Szöveg görbén"), + ("Type on a Path Options", "Görbére írt szöveg beállításai"), + ("Type on a Path Options…", "Görbére írt szöveg beállításai…"), + ("Type/Bulleted and Numbered Lists", "Szöveg/Felsorolások és számozott listák"), + ("Type/Change Case", "Szöveg/Kis- és nagybetűk váltása"), + ("Type/Hyperlinks & Cross-References", "Szöveg/Hiperhivatkozások és kereszthivatkozások"), + ("Type/Insert Break Character", "Szöveg/Töréskarakter beszúrása"), + ("Type/Insert Special Character/Hyphens and Dashes", "Szöveg/Speciális karakter beszúrása/Kötőjelek és gondolatjelek"), + ("Type/Insert Special Character/Markers", "Szöveg/Speciális karakter beszúrása/Jelölők"), + ("Type/Insert Special Character/Other", "Szöveg/Speciális karakter beszúrása/Egyéb"), + ("Type/Insert Special Character/Quotation Marks", "Szöveg/Speciális karakter beszúrása/Idézőjelek"), + ("Type/Insert Special Character/Symbols", "Szöveg/Speciális karakter beszúrása/Szimbólumok"), + ("Type/Insert White Space", "Szöveg/Szóközkarakter beszúrása"), + ("Type/Proof Setup", "Szöveg/Próbanyomat beállításai"), + ("Type/Rotate Spread", "Szöveg/Oldalpár elforgatása"), + ("Type/Separations Preview", "Szöveg/Színkivonatok előnézete"), + ("Type/Story Direction", "Szöveg/Szövegfolyam iránya"), + ("Type/Text Variables", "Szöveg/Szövegváltozók"), + ("Type/Track Changes", "Szöveg/Változások követése"), + ("Type/Type on a Path", "Szöveg/Szöveg görbén"), + ("Type:", "Típus:"), + ("Typical", "Szokásos"), + ("Typical Display", "Szokásos megjelenítés"), + ("Typography", "Tipográfia"), + ("UI Scaling", "Felület méretezése"), + ("UI Size:", "Felület mérete:"), + ("UPPERCASE", "NAGYBETŰK"), + ("URL:", "URL:"), + ("US Business Card", "Amerikai névjegykártya"), + ("Ukrainian", "Ukrán"), + ("Unassigned", "Nincs hozzárendelve"), + ("Uncover", "Felfedés"), + ("Underline", "Aláhúzás"), + ("Underline On", "Aláhúzás bekapcsolása"), + ("Underline Options", "Aláhúzás beállításai"), + ("Undo", "Visszavonás"), + ("Ungroup", "Csoportbontás"), + ("Ungroup Color Group", "Színcsoport felbontása"), + ("Units", "Mértékegységek"), + ("Units & Increments", "Mértékegységek és lépésközök"), + ("Unlink", "Csatolás megszüntetése"), + ("Unlock", "Zárolás feloldása"), + ("Unlock All Layers", "Összes réteg zárolásának feloldása"), + ("Unlock All on Spread", "Összes zárolás feloldása az oldalpáron"), + ("Unmerge", "Egyesítés felbontása"), + ("Unmerge Cells", "Cellák egyesítésének megszüntetése"), + ("Unsaved Changes", "Nem mentett módosítások"), + ("Untag Selection", "Címke eltávolítása a kijelölésről"), + ("Update", "Frissítés"), + ("Update Data Source", "Adatforrás frissítése"), + ("Update Index", "Tárgymutató frissítése"), + ("Update Link", "Csatolás frissítése"), + ("Update Numbering", "Számozás frissítése"), + ("Update Table of Contents", "Tartalomjegyzék frissítése"), + ("Use Black Point Compensation", "Feketepont-kompenzáció használata"), + ("Use Global Light", "Globális fény használata"), + ("Use Typographer's Quotes", "Tipográfiai idézőjelek használata"), + ("User Dictionary", "Felhasználói szótár"), + ("User Dictionary (this document)", "Felhasználói szótár (ez a dokumentum)"), + ("User Dictionary Words", "Felhasználói szótár szavai"), + ("User Dictionary…", "Felhasználói szótár…"), + ("Utilities", "Segédeszközök"), + ("VA", "VA"), + ("Validate", "Érvényesítés"), + ("Validate from Root Element", "Érvényesítés a gyökérelemtől"), + ("Variable Font", "Változtatható betűtípus"), + ("Version", "Verzió"), + ("Vertical", "Függőleges"), + ("Vertical Gridline Every:", "Függőleges rácsvonalak távolsága:"), + ("Vertical Margins:", "Függőleges margók:"), + ("Vertical Scale:", "Függőleges méretezés:"), + ("Vertical Spacing", "Függőleges térköz"), + ("Vertical justification", "Függőleges igazítás"), + ("Vertical:", "Függőleges:"), + ("View", "Nézet"), + ("View Options", "Nézetbeállítások"), + ("View Threshold:", "Megjelenítési küszöb:"), + ("View/Display Performance", "Nézet/Megjelenítési teljesítmény"), + ("View/Extras", "Nézet/Kiegészítők"), + ("View/Grids & Guides", "Nézet/Rácsok és segédvonalak"), + ("View/Proof Setup", "Nézet/Próbanyomat beállításai"), + ("View/Rotate Spread", "Nézet/Oldalpár elforgatása"), + ("View/Screen Mode", "Nézet/Képernyőmód"), + ("View/Separations Preview", "Nézet/Színkivonatok előnézete"), + ("View/Structure", "Nézet/Struktúra"), + ("W:", "Sz.:"), + ("Warichu", "Warichu"), + ("Wavy", "Hullámos"), + ("Web", "Web"), + ("Web Hyperlink", "Webes hiperhivatkozás"), + ("Weight", "Vonalvastagság"), + ("Weight:", "Vonalvastagság:"), + ("Welcome to DesignCraft", "Üdvözöl a DesignCraft"), + ("When Scaling", "Méretezéskor"), + ("Whole word", "Teljes szó"), + ("Width", "Szélesség"), + ("Width Only", "Csak szélesség"), + ("Width only", "Csak szélesség"), + ("Width:", "Szélesség:"), + ("Window", "Ablak"), + ("Window/Arrange", "Ablak/Elrendezés"), + ("Window/Interactive", "Ablak/Interaktív"), + ("Window/Interface Color Theme", "Ablak/Felület színtémája"), + ("Window/Output", "Ablak/Kimenet"), + ("Window/Panels", "Ablak/Panelek"), + ("Window/Type & Tables", "Ablak/Szöveg és táblázatok"), + ("Window/Utilities", "Ablak/Segédeszközök"), + ("Wipe", "Törléses áttűnés"), + ("Word Spacing:", "Szóköz:"), + ("Words with at Least:", "Szavak legalább ennyi betűvel:"), + ("Words, one per line:", "Szavak, soronként egy:"), + ("Working CMYK", "Munka CMYK"), + ("Workspace", "Munkaterület"), + ("Wrap Around Bounding Box", "Körbefuttatás a határolókeret körül"), + ("Wrap Around Object Shape", "Körbefuttatás az objektum alakja körül"), + ("Wrap Content", "Tartalom tördelése"), + ("X:", "X:"), + ("Y:", "Y:"), + ("Yellow", "Sárga"), + ("Zoom In", "Nagyítás"), + ("Zoom Out", "Kicsinyítés"), + ("Zoom To", "Nagyítás erre"), + ("[Automatic]", "[Automatikus]"), + ("[Basic Paragraph]", "[Alapbekezdés]"), + ("[Basic] (working) ▾", "[Alap] (munka) ▾"), + ("[Black] Overprint", "[Fekete] felülnyomás"), + ("[No Group]", "[Nincs csoport]"), + ("[No Paragraph Style]", "[Nincs bekezdésstílus]"), + ("[None]", "[Nincs]"), + ("[None] — choose a colour on the ramp.", "[Nincs] — válasszon színt a színátmeneten."), + ("a, b, c, d...", "a, b, c, d..."), + ("bottom", "lent"), + ("center", "középen"), + ("characters", "karakter"), + ("chars", "karakter"), + ("digits", "számjegy"), + ("discord.gg/artcraft — help, feedback and show-and-tell", "discord.gg/artcraft — segítség, visszajelzés és bemutatók"), + ("document", "dokumentum"), + ("dropcap", "iniciálé"), + ("emSpace", "Kvirt szóköz"), + ("enSpace", "Félkvirt szóköz"), + ("error", "hiba"), + ("errors", "hiba"), + ("for", "ehhez"), + ("forcedLineBreak", "Kényszerített sortörés"), + ("graphic", "grafika"), + ("i, ii, iii, iv...", "i, ii, iii, iv..."), + ("items", "elem"), + ("justify", "sorkizárt"), + ("letters", "betű"), + ("line", "sor"), + ("lines", "sor"), + ("lowercase", "kisbetűk"), + ("objects", "objektum"), + ("pages", "oldal"), + ("selection", "kijelölés"), + ("sentences", "mondat"), + ("stories", "szövegfolyam"), + ("story", "szövegfolyam"), + ("tab", "tabulátor"), + ("text", "szöveg"), + ("through", "beleértve"), + ("top", "fent"), + ("unassigned", "nincs hozzárendelve"), + ("unused", "nem használt"), + ("up to", "kizárva"), + ("warnings", "figyelmeztetés"), + ("words", "szó"), + ("x Height", "x-magasság"), + ("{count} characters don't fit", "{count} karakter nem fér el"), + ("{count} glyphs — click to insert at the text cursor", "{count} karakterjel — kattintással beszúrható a szövegkurzornál"), + ("{pages} Pages in {spreads} Spreads", "{pages} oldal, {spreads} oldalpáron"), + ( + "“{}” has been edited in this layout. Updating replaces those edits with the text from the file.", + "A(z) „{}” szövegét módosította ebben az elrendezésben. A frissítés a fájl szövegével felülírja ezeket a módosításokat.", + ), + ("“{}” has changes that are not saved. Save them before closing?", "A(z) „{}” nem mentett módosításokat tartalmaz. Menti őket bezárás előtt?"), + ("⇧", "⇧"), + ("⌃", "⌃"), + ("⌘", "⌘"), + ("⌥", "⌥"), + ("⌥-click clears overrides", "⌥-kattintás törli a felülírásokat"), + ("▲ Modified", "▲ Módosítva"), + ("⚠ Missing", "⚠ Hiányzik"), + ("⚠ out of date", "⚠ elavult"), + ("Selection Tool", "Kijelölő eszköz"), + ("Direct Selection Tool", "Közvetlen kijelölő eszköz"), + ("Page Tool", "Oldaleszköz"), + ("Gap Tool", "Térközeszköz"), + ("Content Collector Tool", "Tartalomgyűjtő eszköz"), + ("Content Placer Tool", "Tartalomelhelyező eszköz"), + ("Type Tool", "Szövegeszköz"), + ("Type on a Path Tool", "Görbére író szövegeszköz"), + ("Vertical Type Tool", "Függőleges szövegeszköz"), + ("Vertical Type on a Path Tool", "Görbére író függőleges szövegeszköz"), + ("Line Tool", "Vonaleszköz"), + ("Pen Tool", "Toll eszköz"), + ("Add Anchor Point Tool", "Csomópont-hozzáadó eszköz"), + ("Delete Anchor Point Tool", "Csomóponttörlő eszköz"), + ("Convert Direction Point Tool", "Csomópont-átalakító eszköz"), + ("Pencil Tool", "Ceruza eszköz"), + ("Smooth Tool", "Simító eszköz"), + ("Erase Tool", "Radír eszköz"), + ("Rectangle Frame Tool", "Téglalapkeret eszköz"), + ("Ellipse Frame Tool", "Ellipsziskeret eszköz"), + ("Polygon Frame Tool", "Sokszögkeret eszköz"), + ("Rectangle Tool", "Téglalap eszköz"), + ("Ellipse Tool", "Ellipszis eszköz"), + ("Polygon Tool", "Sokszög eszköz"), + ("Scissors Tool", "Olló eszköz"), + ("Free Transform Tool", "Szabad átalakítás eszköz"), + ("Rotate Tool", "Forgató eszköz"), + ("Scale Tool", "Méretező eszköz"), + ("Shear Tool", "Döntő eszköz"), + ("Gradient Swatch Tool", "Színátmenet eszköz"), + ("Gradient Feather Tool", "Színátmenetes lágyítás eszköz"), + ("Note Tool", "Jegyzeteszköz"), + ("Color Theme Tool", "Színtéma eszköz"), + ("Eyedropper Tool", "Pipetta eszköz"), + ("Measure Tool", "Mérőeszköz"), + ("Hand Tool", "Kéz eszköz"), + ("Zoom Tool", "Nagyító eszköz"), +]; + +pub(super) const CONTEXT: &[(&str, &str, &str)] = + &[("Columns", "table", "Oszlopok"), ("Columns:", "grid", "Oszlopok:"), ("Group", "selection", "Csoport"), ("Number:", "count", "Darabszám:")]; diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 885568696..70d08085e 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1,6 +1,11 @@ //! Original Italian interface translations, written from the English source //! using ordinary Italian desktop-publishing vocabulary. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Fewer Options", "Meno opzioni"), + ("Tab Character", "Carattere di tabulazione"), + ("Right Indent Tab", "Tabulazione rientro destro"), + ("Em Space", "Spazio em"), + ("En Space", "Spazio en"), ("File", "File"), ("Edit", "Modifica"), ("Layout", "Layout"), @@ -29,6 +34,103 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Keep In Frame", "Mantieni nella cornice"), + ("New Paragraph Style", "Nuovo stile di paragrafo"), + ("New Character Style", "Nuovo stile di carattere"), + ("Next Style:", "Stile successivo:"), + ("[Same style]", "[Stesso stile]"), + ("Reset To Base", "Ripristina base"), + ("Apply Style to Selection", "Applica stile alla selezione"), + ("Change/Find", "Sostituisci/Trova"), + ("Find Format:", "Trova formato:"), + ("Find Format Settings", "Impostazioni di ricerca formato"), + ("Specify attributes to find", "Specifica gli attributi da trovare"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "Con un formato di ricerca si trova solo il testo che lo ha; con «Trova» vuoto, tutto il testo che lo ha", + ), + ("Change Format:", "Modifica formato:"), + ("Change Format Settings", "Impostazioni di modifica formato"), + ("Style Options", "Opzioni stile"), + ("[Any Style]", "[Qualsiasi stile]"), + ("Specify attributes to change", "Specifica gli attributi da modificare"), + ("Clear specified attributes", "Cancella gli attributi specificati"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "Con un formato di modifica e «Sostituisci con» vuoto, il testo trovato resta e cambia solo la formattazione", + ), + ("Double Column", "Colonna doppia"), + ("Tools Panel:", "Pannello Strumenti:"), + ("Color Theme:", "Tema colore:"), + ("At End of Document", "Alla fine del documento"), + ("At Start of Document", "All'inizio del documento"), + ("Before Page", "Prima della pagina"), + ("After Page", "Dopo la pagina"), + ("Parent:", "Pagina mastro:"), + ("Insert:", "Inserisci:"), + ("Insert Pages", "Inserisci pagine"), + ("Stroke Type", "Tipo traccia"), + ("Corner Shape", "Forma angolo"), + ("Corner Size and Shape:", "Dimensione e forma angoli:"), + ("Join", "Unisci"), + ("Convert Point", "Converti punto"), + ("New Section", "Nuova sezione"), + ("Clear Frame Fitting Options", "Cancella opzioni di adattamento cornice"), + ("Cannot be edited", "Non modificabile"), + ("Maximum:", "Massimo:"), + ("Paragraph Spacing Limit:", "Limite spaziatura paragrafo:"), + ("Overprint", "Sovrastampa"), + ("Top Left Corner", "Angolo superiore sinistro"), + ("Top Right Corner", "Angolo superiore destro"), + ("Bottom Left Corner", "Angolo inferiore sinistro"), + ("Bottom Right Corner", "Angolo inferiore destro"), + ("Anchor Point(s)", "Punti di ancoraggio"), + ("Convert Points", "Converti punti"), + ("Delete Point(s)", "Elimina punti"), + ("Tab", "Tabulazione"), + ("Convert Text to Table…", "Converti testo in tabella…"), + ("Convert Table to Text…", "Converti tabella in testo…"), + ("Column Separator", "Separatore colonna"), + ("Row Separator", "Separatore riga"), + ("Number of Columns", "Numero di colonne"), + ("Comma", "Virgola"), + ("RGB Color Space View", "Vista spazio colore RGB"), + ("Lab Color Space View", "Vista spazio colore Lab"), + ("Add CMYK Swatch", "Aggiungi campione CMYK"), + ("Added swatch", "Campione aggiunto"), + ("Eyedropper: drag it onto the page", "Contagocce: trascinalo sulla pagina"), + ("PDF Preset:", "Predefinito PDF:"), + ("Packaged {count} files into {dir}", "{count} file raccolti in {dir}"), + ("Continue", "Continua"), + ("Printing Instructions", "Istruzioni di stampa"), + ("Package", "Pacchetto"), + ("Show Problems Only", "Mostra solo problemi"), + ("Include PDF (Print)", "Includi PDF (stampa)"), + ("Include IDML", "Includi IDML"), + ("Create Printing Instructions", "Crea istruzioni di stampa"), + ("Entire Publication", "Intera pubblicazione"), + ("Scope:", "Ambito:"), + ("Source", "Origine"), + ("Installed", "Installato"), + ("Bundled", "Incluso"), + ("Substituted", "Sostituito"), + ("Modified", "Modificato"), + ("Missing", "Mancante"), + ("Instructions:", "Istruzioni:"), + ("Email:", "E-mail:"), + ("Fax:", "Fax:"), + ("Phone:", "Telefono:"), + ("Address:", "Indirizzo:"), + ("Company:", "Società:"), + ("Contact:", "Contatto:"), + ("Process Black", "Nero quadricromia"), + ("Process Yellow", "Giallo quadricromia"), + ("Process Magenta", "Magenta quadricromia"), + ("Process Cyan", "Cyan quadricromia"), + ("Colors and Inks", "Colori e inchiostri"), + ("Links and Images", "Collegamenti e immagini"), + ("Fonts", "Font"), + ("Summary", "Riepilogo"), ("Restart Numbering", "Riavvia numerazione"), ("Continue Numbering", "Continua numerazione"), ("Keep Options…", "Opzioni di separazione…"), @@ -305,6 +407,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Choose Image…", "Scegli immagine…"), ("Class:", "Classe:"), ("Color Picker", "Selettore colore"), + ("Color spectrum", "Spettro dei colori"), ("Color Settings", "Impostazioni colore"), ("Color:", "Colore:"), ("Columns", "Colonne"), @@ -1524,6 +1627,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Box", "Riquadro"), ("Comb", "Pettine"), ("Cover", "Copri"), + ("Cover Image:", "Immagine di copertina:"), + ("Choose…", "Scegli…"), + ("A PNG or JPEG file; left empty, the first page is the cover.", "Un file PNG o JPEG; se lasciato vuoto, la prima pagina è la copertina."), ("Dissolve", "Dissolvenza"), ("Fade", "Sfuma"), ("Push", "Spingi"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index f06f85725..9732958f7 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -3,6 +3,8 @@ //! Portuguese tables has a row here; the menu titles and panel names that were already Japanese in the //! shared table are carried over unchanged. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Fewer Options", "オプションを減らす"), + ("Tab Character", "タブ文字"), ("File", "ファイル"), ("Edit", "編集"), ("Layout", "レイアウト"), @@ -30,6 +32,28 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("Stroke Type", "線の種類"), + ("Corner Shape", "角のシェイプ"), + ("Corner Size and Shape:", "角のサイズとシェイプ:"), + ("Join", "連結"), + ("Convert Point", "ポイントを切り換え"), + ("New Section", "新規セクション"), + ("Clear Frame Fitting Options", "フレーム調整オプションを消去"), + ("Cannot be edited", "編集不可"), + ("Overprint", "オーバープリント"), + ("Top Left Corner", "左上角"), + ("Top Right Corner", "右上角"), + ("Bottom Left Corner", "左下角"), + ("Bottom Right Corner", "右下角"), + ("Anchor Point(s)", "アンカーポイント"), + ("Convert Points", "ポイントを変換"), + ("Delete Point(s)", "ポイントを削除"), + ("Convert Text to Table…", "テキストを表に変換…"), + ("Convert Table to Text…", "表をテキストに変換…"), + ("Column Separator", "列分解文字"), + ("Row Separator", "行分解文字"), + ("Number of Columns", "列数"), + ("Comma", "カンマ"), ("Restart Numbering", "番号の振り直し"), ("Continue Numbering", "番号の継続"), ("Keep Options…", "段落分離禁止オプション…"), @@ -408,6 +432,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Choose Image…", "画像を選択…"), ("Class:", "クラス:"), ("Color Picker", "カラーピッカー"), + ("Color spectrum", "カラースペクトル"), ("Color Settings", "カラー設定"), ("Color:", "カラー:"), ("Columns", "列数"), @@ -1626,6 +1651,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Box", "ボックス"), ("Comb", "くし状"), ("Cover", "カバー"), + ("Cover Image:", "表紙画像 :"), + ("Choose…", "選択…"), + ("A PNG or JPEG file; left empty, the first page is the cover.", "PNG または JPEG ファイル。空のままにすると最初のページが表紙になります。"), ("Dissolve", "ディゾルブ"), ("Fade", "フェード"), ("Push", "押し出し"), @@ -1872,6 +1900,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object/Fitting", "オブジェクト/オブジェクトサイズの調整"), ("Object/Pathfinder", "オブジェクト/パスファインダー"), ("Object/Paths", "オブジェクト/パス"), + ("Object/Convert Point", "オブジェクト/ポイントを切り換え"), ("Object/Select", "オブジェクト/選択"), ("Object/Transform", "オブジェクト/変形"), ("Object/Transform Again", "オブジェクト/変形を再適用"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 436c157f4..b89840044 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -4,6 +4,8 @@ //! Portuguese, Japanese, Italian and Ukrainian tables has a row here, plus a few labels that only //! the code or the command table use. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Fewer Options", "ნაკლები პარამეტრი"), + ("Tab Character", "ტაბულაციის სიმბოლო"), ("File", "ფაილი"), ("Edit", "რედაქტირება"), ("Layout", "განლაგება"), @@ -32,6 +34,97 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Keep In Frame", "ჩარჩოში შენარჩუნება"), + ("New Paragraph Style", "აბზაცის ახალი სტილი"), + ("New Character Style", "სიმბოლოს ახალი სტილი"), + ("Next Style:", "შემდეგი სტილი:"), + ("[Same style]", "[იგივე სტილი]"), + ("Reset To Base", "საბაზოზე დაბრუნება"), + ("Apply Style to Selection", "სტილის გამოყენება მონიშნულზე"), + ("Change/Find", "ჩანაცვლება/ძიება"), + ("Find Format:", "ფორმატის ძიება:"), + ("Find Format Settings", "ფორმატის ძიების პარამეტრები"), + ("Specify attributes to find", "საძიებო ატრიბუტების მითითება"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "ძიების ფორმატით მხოლოდ ის ტექსტი მოიძებნება, რომელსაც ის აქვს; ცარიელი „ძიება“ ველით — ყველა ასეთი ტექსტი", + ), + ("Change Format:", "ფორმატის შეცვლა:"), + ("Change Format Settings", "ფორმატის შეცვლის პარამეტრები"), + ("Style Options", "სტილის პარამეტრები"), + ("[Any Style]", "[ნებისმიერი სტილი]"), + ("Specify attributes to change", "შესაცვლელი ატრიბუტების მითითება"), + ("Clear specified attributes", "მითითებული ატრიბუტების გასუფთავება"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "ფორმატის შეცვლით და ცარიელი „ჩანაცვლება“ ველით ნაპოვნი ტექსტი რჩება და მხოლოდ ფორმატი იცვლება", + ), + ("Double Column", "ორმაგი სვეტი"), + ("Tools Panel:", "ხელსაწყოების პანელი:"), + ("Color Theme:", "ფერთა თემა:"), + ("At End of Document", "დოკუმენტის ბოლოს"), + ("At Start of Document", "დოკუმენტის დასაწყისში"), + ("Before Page", "გვერდის წინ"), + ("After Page", "გვერდის შემდეგ"), + ("Parent:", "საბაზო გვერდი:"), + ("Insert:", "ჩასმა:"), + ("Stroke Type", "კონტურის ტიპი"), + ("Corner Shape", "კუთხის ფორმა"), + ("Corner Size and Shape:", "კუთხეების ზომა და ფორმა:"), + ("New Section", "ახალი სექცია"), + ("Clear Frame Fitting Options", "ჩარჩოს მორგების პარამეტრების გასუფთავება"), + ("Cannot be edited", "რედაქტირება შეუძლებელია"), + ("Maximum:", "მაქსიმუმი:"), + ("Paragraph Spacing Limit:", "აბზაცებს შორის ინტერვალის ზღვარი:"), + ("Overprint", "ზედაბეჭდვა"), + ("Top Left Corner", "ზედა მარცხენა კუთხე"), + ("Top Right Corner", "ზედა მარჯვენა კუთხე"), + ("Bottom Left Corner", "ქვედა მარცხენა კუთხე"), + ("Bottom Right Corner", "ქვედა მარჯვენა კუთხე"), + ("Anchor Point(s)", "საყრდენი წერტილ(ებ)ი"), + ("Convert Points", "წერტილების გარდაქმნა"), + ("Delete Point(s)", "წერტილ(ებ)ის წაშლა"), + ("Convert Text to Table…", "ტექსტის ცხრილად გარდაქმნა…"), + ("Convert Table to Text…", "ცხრილის ტექსტად გარდაქმნა…"), + ("Column Separator", "სვეტების გამყოფი"), + ("Row Separator", "სტრიქონების გამყოფი"), + ("Number of Columns", "სვეტების რაოდენობა"), + ("Comma", "მძიმე"), + ("RGB Color Space View", "RGB ფერთა სივრცის ხედი"), + ("Lab Color Space View", "Lab ფერთა სივრცის ხედი"), + ("Add CMYK Swatch", "CMYK ფერის ნიმუშის დამატება"), + ("Added swatch", "ფერის ნიმუში დამატებულია"), + ("Eyedropper: drag it onto the page", "პიპეტი: გადაიტანეთ გვერდზე"), + ("PDF Preset:", "PDF-ის წინასწარი პარამეტრი:"), + ("Continue", "გაგრძელება"), + ("Printing Instructions", "ბეჭდვის ინსტრუქციები"), + ("Show Problems Only", "მხოლოდ პრობლემების ჩვენება"), + ("Include PDF (Print)", "PDF-ის ჩართვა (ბეჭდვა)"), + ("Include IDML", "IDML-ის ჩართვა"), + ("Create Printing Instructions", "ბეჭდვის ინსტრუქციების შექმნა"), + ("Entire Publication", "მთელი პუბლიკაცია"), + ("Scope:", "არეალი:"), + ("Source", "წყარო"), + ("Installed", "დაყენებული"), + ("Bundled", "ჩაშენებული"), + ("Substituted", "ჩანაცვლებული"), + ("Modified", "შეცვლილი"), + ("Missing", "აკლია"), + ("Colors and Inks: {process} process, {spot} spot", "ფერები და მელნები: {process} პროცესის მელანი; {spot} სპოტ-საღებავი"), + ("Embedded images: {embedded}", "გამოსახულებები: ჩაშენებულია {embedded}"), + ("Instructions:", "ინსტრუქციები:"), + ("Fax:", "ფაქსი:"), + ("Address:", "მისამართი:"), + ("Company:", "კომპანია:"), + ("Contact:", "საკონტაქტო პირი:"), + ("Process Black", "პროცესის შავი"), + ("Process Yellow", "პროცესის ყვითელი"), + ("Process Magenta", "პროცესის მეწამული"), + ("Process Cyan", "პროცესის ცისფერი"), + ("Colors and Inks", "ფერები და მელნები"), + ("Links and Images", "ბმულები და გამოსახულებები"), + ("Fonts", "შრიფტები"), + ("Summary", "შეჯამება"), ("Restart Numbering", "ნუმერაციის თავიდან დაწყება"), ("Continue Numbering", "ნუმერაციის გაგრძელება"), ("Keep Options…", "შენარჩუნების პარამეტრები…"), @@ -227,6 +320,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Choose Image…", "გამოსახულების არჩევა…"), ("Class:", "კლასი:"), ("Color Picker", "ფერის ამრჩევი"), + ("Color spectrum", "ფერთა სპექტრი"), ("Color Settings", "ფერის პარამეტრები"), ("Color:", "ფერი:"), ("Columns", "სვეტები"), @@ -1451,6 +1545,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Box", "ჩარჩო"), ("Comb", "სავარცხელი"), ("Cover", "დაფარვა"), + ("Cover Image:", "ყდის სურათი:"), + ("Choose…", "არჩევა…"), + ("A PNG or JPEG file; left empty, the first page is the cover.", "PNG ან JPEG ფაილი; ცარიელად დატოვების შემთხვევაში ყდა პირველი გვერდი იქნება."), ("Dissolve", "გაფანტვა"), ("Fade", "მილევა"), ("Push", "გადაწევა"), @@ -1703,6 +1800,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object/Fitting", "ობიექტი/ზომაზე მორგება"), ("Object/Pathfinder", "ობიექტი/ტრაექტორიების ოპერაციები"), ("Object/Paths", "ობიექტი/ტრაექტორიები"), + ("Object/Convert Point", "ობიექტი/წერტილის გარდაქმნა"), ("Object/Select", "ობიექტი/მონიშვნა"), ("Object/Transform", "ობიექტი/გარდაქმნა"), ("Object/Transform Again", "ობიექტი/გარდაქმნის გამეორება"), @@ -2475,4 +2573,392 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Set OpenType Features", "OpenType შესაძლებლობების დაყენება"), ("Swap Text Fill and Stroke", "ტექსტის შევსებისა და კონტურის გაცვლა"), ("Thread Text Frames", "ტექსტური ჩარჩოების დაკავშირება"), + // Command-form parameter names, presets and dialog text. + ("Apply to:", "გამოყენება:"), + ("Auto-Size", "ავტომატური ზომა"), + ("Create Links When Placing Text Files", "ტექსტური ფაილების განთავსებისას ბმულების შექმნა"), + ("Kerning/Tracking:", "კერნინგი/ტრეკინგი:"), + ("Remove Existing Ruler Guides", "სახაზავის არსებული დამხმარე ხაზების წაშლა"), + ("Replace All", "ყველას ჩანაცვლება"), + ( + "Replace All updates this font throughout text and styles. Done keeps remaining fallback fonts.", + "„ყველას ჩანაცვლება“ ამ შრიფტს მთელ ტექსტსა და სტილებში განაახლებს. „მზადაა“ დარჩენილ სათადარიგო შრიფტებს შეინარჩუნებს.", + ), + ("Replace with:", "ჩანაცვლება:"), + ("Stroke Behind Fill", "კონტური შევსების უკან"), + ("Delete the character style \"{name}\"?", "სიმბოლოს სტილი „{name}“ წაიშალოს?"), + ("Delete the paragraph style \"{name}\"?", "აბზაცის სტილი „{name}“ წაიშალოს?"), + ("Currently Rendered With", "ამჟამად გამოსახულია შრიფტით"), + ( + "“{}” has been edited in this layout. Updating replaces those edits with the text from the file.", + "„{}“ ამ განლაგებაში რედაქტირებულია. განახლება ამ ცვლილებებს ფაილის ტექსტით ჩაანაცვლებს.", + ), + ("Proportional Metrics", "პროპორციული მეტრიკა"), + ("Horizontal Kana", "ჰორიზონტალური კანა"), + ("Localized Forms", "ლოკალიზებული ფორმები"), + ("Mark Positioning", "დიაკრიტიკული ნიშნების განლაგება"), + ("Historical Forms", "ისტორიული ფორმები"), + ("Stylistic Alternates", "სტილისტური ალტერნატივები"), + ("Justification Alternates", "გასწორების ალტერნატივები"), + ("Roman Italics", "რომაული დახრილი"), + ("Letter", "Letter"), + ("Legal", "Legal"), + ("Tabloid", "Tabloid"), + ("Letter - Half", "Letter - Half"), + ("Postcard", "ღია ბარათი"), + ("Web 1920 × 1080", "ვები 1920 × 1080"), + ("Web 1366 × 768", "ვები 1366 × 768"), + ("Web 1024 × 768", "ვები 1024 × 768"), + ("Mobile 1080 × 1920", "მობილური 1080 × 1920"), + ("Mobile 390 × 844", "მობილური 390 × 844"), + ("Tablet 1024 × 1366", "პლანშეტი 1024 × 1366"), + ("Action", "მოქმედება"), + ("Adjust layout", "განლაგების მისადაგება"), + ("Advance seconds", "გადასვლის ინტერვალი (წმ)"), + ("Affix in", "პრეფიქსისა და სუფიქსის ადგილი"), + ("Allow", "დაშვება"), + ("Anchor", "ღუზა"), + ("Artifact", "არტეფაქტი"), + ("Asset", "რესურსი"), + ("Assets", "რესურსები"), + ("At end", "ბოლოში"), + ("Attrs", "ატრიბუტები"), + ("Author", "ავტორი"), + ("Auto fit", "ავტომორგება"), + ("Auto size", "ავტომატური ზომა"), + ("Auto size reference", "ავტომატური ზომის საყრდენი წერტილი"), + ("Axis", "ღერძი"), + ("Bands", "ზოლები"), + ("Bookmarks panel", "სანიშნეების პანელი"), + ("Case", "რეგისტრი"), + ("Column gutter", "სვეტებს შორის შუალედი"), + ("Column width", "სვეტის სიგანე"), + ("Columns kind", "სვეტების ტიპი"), + ("Continue across stories", "გაგრძელება ტექსტურ ნაკადებს შორის"), + ("Copies", "ასლები"), + ("Corners", "კუთხეები"), + ("Cover image", "ყდის გამოსახულება"), + ("Definition", "განსაზღვრება"), + ("Dir", "საქაღალდე"), + ("Dry run", "საცდელი გაშვება"), + ("Embed fonts", "შრიფტების ჩაშენება"), + ("End", "დასასრული"), + ("Extension", "გაფართოება"), + ("Facing pages", "მოპირდაპირე გვერდები"), + ("Family", "შრიფტის ოჯახი"), + ("First baseline", "პირველი საბაზისო ხაზი"), + ("First baseline min", "პირველი საბაზისო ხაზის მინიმუმი"), + ("Fit to", "მორგება"), + ("Fixed layout", "ფიქსირებული განლაგება"), + ("Flatten", "გაბრტყელება"), + ("Font size", "შრიფტის ზომა"), + ("Format", "ფორმატი"), + ("From", "საიდან"), + ("Full screen", "სრული ეკრანი"), + ("Grep", "GREP"), + ("Heading", "სათაური"), + ("Heading style", "სათაურის სტილი"), + ("Hidden", "დამალული"), + ("Identifier", "იდენტიფიკატორი"), + ("Idml", "IDML"), + ("Ids", "ID-ები"), + ("Include inside edges", "შიდა კიდეების ჩართვა"), + ("Include prefix", "პრეფიქსის ჩართვა"), + ("Instructions", "ინსტრუქციები"), + ("Intent", "განზრახვა"), + ("Keep within column", "სვეტის ფარგლებში დატოვება"), + ("Kind", "სახეობა"), + ("Label", "იარლიყი"), + ("Latex", "LaTeX"), + ("Layout page", "განლაგების გვერდი"), + ("Line breaks", "სტრიქონის წყვეტები"), + ("Load cursor", "კურსორში ჩატვირთვა"), + ("Marker", "მარკერი"), + ("Marks", "ნიშნები"), + ("Message", "შეტყობინება"), + ("Minimum height", "მინიმალური სიმაღლე"), + ("Minimum width", "მინიმალური სიგანე"), + ("Missing only", "მხოლოდ დაკარგული"), + ("Mode", "რეჟიმი"), + ("Number", "ნომერი"), + ("Object point", "ობიექტის საყრდენი წერტილი"), + ("Org", "ორგანიზაცია"), + ("Page break before", "გვერდის გამყოფი წინ"), + ("Page layout", "გვერდის განლაგება"), + ("Para style", "აბზაცის სტილი"), + ("Paragraphs", "აბზაცები"), + ("Parent", "საბაზო გვერდი"), + ("Pattern", "პატერნი"), + ("Pdf", "PDF"), + ("Pdf crop", "PDF-ის ჩამოჭრა"), + ("Pdf page", "PDF-ის გვერდი"), + ("Phone", "ტელეფონი"), + ("Pos", "პოზიცია"), + ("Prefix", "პრეფიქსი"), + ("Primary text frame", "ძირითადი ტექსტური ჩარჩო"), + ("Printer", "პრინტერი"), + ("Rasterize", "რასტრირება"), + ("Rect", "მართკუთხედი"), + ("Redefine styles", "სტილების ხელახლა განსაზღვრა"), + ("Ref", "საყრდენი წერტილი"), + ("Ref char style", "მიმართვის სიმბოლოს სტილი"), + ("Ref position", "მიმართვის პოზიცია"), + ("Remove existing", "არსებულის წაშლა"), + ("Restart", "თავიდან დაწყება"), + ("Row gutter", "სტრიქონებს შორის შუალედი"), + ("Rule", "წესი"), + ("Run in", "ერთ აბზაცში"), + ("Scope", "მოქმედების არე"), + ("Section headings", "სექციის სათაურები"), + ("Separator", "გამყოფი"), + ("Shape", "ფიგურა"), + ("Space", "ფერთა სივრცე"), + ("Space after", "დაშორება შემდეგ"), + ("Space before", "დაშორება წინ"), + ("Space between", "დაშორება შორის"), + ("Span columns", "სვეტებზე გაშლა"), + ("Start", "დასაწყისი"), + ("Start at", "დაწყება"), + ("Start number", "საწყისი ნომერი"), + ("Start page", "საწყისი გვერდი"), + ("Subject", "თემა"), + ("Subpath", "ქვეტრაექტორია"), + ("Suffix", "სუფიქსი"), + ("Suggestions", "შეთავაზებები"), + ("Tagged", "ტეგირებული"), + ("Target", "სამიზნე"), + ("Threshold", "ზღვარი"), + ("Title", "დასახელება"), + ("To family", "ახალი შრიფტის ოჯახი"), + ("To spread", "სამიზნე გაშლა"), + ("To style", "ახალი სტილი"), + ("Toc title", "სარჩევის სათაური"), + ("Tolerance", "დაშვება"), + ("Topics", "თემები"), + ("Url", "URL"), + ("Use", "გამოყენება"), + ("Use minimum height", "მინიმალური სიმაღლის გამოყენება"), + ("Use minimum width", "მინიმალური სიგანის გამოყენება"), + ("Value", "მნიშვნელობა"), + ("Visible", "ხილული"), + ("Word", "სიტყვა"), + ("X offset", "X წანაცვლება"), + ("X relative", "X-ის ათვლის წერტილი"), + ("Y offset", "Y წანაცვლება"), + (" Save ", " შენახვა "), + (" Update ", " განახლება "), + // Keyboard shortcut sets (craft-keymap), docking and new commands. + ("(cycles)", "(ციკლურად)"), + ("(modified)", "(შეცვლილი)"), + ("+Bin", "+Bin"), + ("+LOC", "+LOC"), + ("A command can have at most {n} shortcuts", "ბრძანების მალსახმობების მაქსიმალური რაოდენობა: {n}"), + ("A set with this name exists", "ამ სახელის ნაკრები უკვე არსებობს"), + ("Add a shortcut to {label}", "{label}: მალსახმობის დამატება"), + ("All Commands", "ყველა ბრძანება"), + ("Also used by {label}", "ასევე იყენებს: {label}"), + ("Alt", "Alt"), + ("Application", "აპლიკაცია"), + ("Approximated", "მიახლოებით წაკითხული"), + ("Assign", "მინიჭება"), + ("Assign to Selected", "მონიშნულზე მინიჭება"), + ("Assign {chord} to {label}", "{label}: მალსახმობის მინიჭება ({chord})"), + ("Auto-Collapse Iconic Panels", "ხატულებად ჩაკეცილი პანელების ავტომატური ჩაკეცვა"), + ("Based on:", "საფუძველი:"), + ("Built-in sets cannot be changed", "ჩაშენებული ნაკრებების შეცვლა შეუძლებელია"), + ("Cancel Import", "იმპორტის გაუქმება"), + ("Cancel the new shortcut", "ახალი მალსახმობის გაუქმება"), + ("Caps Lock", "Caps Lock"), + ("Changed", "შეცვლილი"), + ("Check for Updates…", "განახლებების შემოწმება…"), + ("Choose a command…", "აირჩიეთ ბრძანება…"), + ("Click a key to see its shortcuts", "დაწკაპეთ კლავიშზე მისი მალსახმობების სანახავად"), + ("Close Other Panels in Group", "ჯგუფის სხვა პანელების დახურვა"), + ("Close panel window", "პანელის ფანჯრის დახურვა"), + ("Command", "ბრძანება"), + ("Command for {item}", "ბრძანება ელემენტისთვის „{item}“"), + ("Commands", "ბრძანებები"), + ("Conflicts", "კონფლიქტები"), + ("Context:", "კონტექსტი:"), + ("Copy Report", "ანგარიშის კოპირება"), + ("Ctrl", "Ctrl"), + ("Current shortcuts", "მიმდინარე მალსახმობები"), + ("Current shortcuts:", "მიმდინარე მალსახმობები:"), + ("Currently assigned to:", "ამჟამად მინიჭებულია:"), + ("Default: {chord}", "ნაგულისხმევი: {chord}"), + ("Delete Parent", "საბაზო გვერდის წაშლა"), + ("Delete Surrounding Text", "მიმდებარე ტექსტის წაშლა"), + ("Delete the set “{set}”?", "ნაკრები „{set}“ წაიშალოს?"), + ("Deselect Guides", "დამხმარე ხაზების მონიშვნის მოხსნა"), + ("Detail pane size", "დეტალების არის ზომა"), + ("EPUB Split Document", "EPUB: დოკუმენტის დაყოფა"), + ("Expand", "გაშლა"), + ("Expand Panels", "პანელების გაშლა"), + ("Export…", "ექსპორტი…"), + ("File Info", "ფაილის ინფორმაცია"), + ("File Info…", "ფაილის ინფორმაცია…"), + ("Filter:", "ფილტრი:"), + ("Fixed Keys", "ფიქსირებული კლავიშები"), + ("Float Tab Group", "მცურავი ჩანართების ჯგუფი"), + ("Go to Conflict", "კონფლიქტზე გადასვლა"), + ("Group Tab Group with", "ჩანართების ჯგუფის ერთად დაჯგუფება"), + ("Import", "იმპორტი"), + ("Import InDesign Shortcut Set", "InDesign-ის მალსახმობების ნაკრების იმპორტი"), + ("Import as New Set", "იმპორტი ახალ ნაკრებად"), + ("Import “{set}”", "იმპორტი: „{set}“"), + ("Imported", "იმპორტირებული"), + ("Imported as “{set}”", "იმპორტირებულია როგორც „{set}“"), + ("Import…", "იმპორტი…"), + ("Keep Both", "ორივეს შენარჩუნება"), + ("Key: {key}", "კლავიში: {key}"), + ("Keyboard", "კლავიატურა"), + ("Keyboard Shortcuts (JSON)", "კლავიატურის მალსახმობები (JSON)"), + ("Keyboard: US", "კლავიატურა: US"), + ("Keyboard: US (macOS)", "კლავიატურა: US (macOS)"), + ("Lock Workspace", "სამუშაო სივრცის ჩაკეტვა"), + ("Matched without change", "დაემთხვა ცვლილების გარეშე"), + ("Maximize Frame", "ჩარჩოს გაშლა"), + ("Modifier on", "მოდიფიკატორი ჩართულია"), + ("More", "მეტი"), + ("Move Guides", "დამხმარე ხაზების გადაადგილება"), + ("Move Panel to New Window", "პანელის ახალ ფანჯარაში გადატანა"), + ("Move panel column", "პანელების სვეტის გადაადგილება"), + ("Move panel window", "პანელის ფანჯრის გადაადგილება"), + ("Move tab group", "ჩანართების ჯგუფის გადაადგილება"), + ("New Set…", "ახალი ნაკრები…"), + ("New shortcut:", "ახალი მალსახმობი:"), + ("New shortcut: {chord}", "ახალი მალსახმობი: {chord}"), + ("Not assigned", "არ არის მინიჭებული"), + ("Not changed", "არ შეცვლილა"), + ("Not matched", "შესაბამისობა ვერ მოიძებნა"), + ("Nothing uses this key", "ამ კლავიშს არაფერი იყენებს"), + ("Now used by {label}", "ახლა იყენებს: {label}"), + ("Option", "Option"), + ("PDF/X-1a:2003", "PDF/X-1a:2003"), + ("PRs", "PR-ები"), + ("Panel or context", "პანელი ან კონტექსტი"), + ("Remove {chord} from {label}", "{label}: მალსახმობის წაშლა ({chord})"), + ("Rename…", "სახელის შეცვლა…"), + ("Reset All", "ყველაფრის ჩამოყრა"), + ("Reset All…", "ყველაფრის ჩამოყრა…"), + ("Restore Frame Size", "ჩარჩოს ზომის აღდგენა"), + ("Restore every default shortcut in this set?", "აღდგეს ამ ნაკრების ყველა ნაგულისხმევი მალსახმობი?"), + ("Restore the default for {label}", "{label}: ნაგულისხმევის აღდგენა"), + ("Revert Set", "ნაკრების წინა ვერსიაზე დაბრუნება"), + ("Ruler Zero Point", "სახაზავის ნულოვანი წერტილი"), + ("Save changes to “{set}”?", "შეინახოს ცვლილებები ნაკრებში „{set}“?"), + ("Search by key", "ძიება კლავიშით"), + ("Select Guide", "დამხმარე ხაზის მონიშვნა"), + ("Select a command first", "ჯერ მონიშნეთ ბრძანება"), + ("Select a command to see and change its shortcuts", "მონიშნეთ ბრძანება მისი მალსახმობების სანახავად და შესაცვლელად"), + ("Set name:", "ნაკრების სახელი:"), + ("Set:", "ნაკრები:"), + ("Shift", "Shift"), + ("Shortcut", "მალსახმობი"), + ("Shortcut Sets", "მალსახმობების ნაკრებები"), + ("Show:", "ჩვენება:"), + ("Skipped", "გამოტოვებული"), + ("Story Options", "ტექსტური ნაკადის პარამეტრები"), + ("Suggestions ({n})", "შეთავაზებები ({n})"), + ("Summarize…", "შეჯამება…"), + ("Super", "Super"), + ("Take It", "აღება"), + ("Take It and Go to Conflict", "აღება და კონფლიქტზე გადასვლა"), + ("Taken from other commands", "სხვა ბრძანებებიდან აღებული"), + ("The file is too large", "ფაილი ძალიან დიდია"), + ("The name is empty, too long or has control characters", "სახელი ცარიელია, ძალიან გრძელია ან მმართველ სიმბოლოებს შეიცავს"), + ("The scope {scope} is unknown", "მოქმედების არე {scope} უცნობია"), + ("The set was not found", "ნაკრები ვერ მოიძებნა"), + ("There are too many sets", "ნაკრები ძალიან ბევრია"), + ("This command is unknown", "ეს ბრძანება უცნობია"), + ("This file is from a newer version (format {n})", "ეს ფაილი უფრო ახალი ვერსიისაა (ფორმატი {n})"), + ("This is not a craft-keymap file", "ეს craft-keymap ფაილი არ არის"), + ("This is not a shortcut set file", "ეს მალსახმობების ნაკრების ფაილი არ არის"), + ("Too many shortcuts are assigned", "მინიჭებულია ძალიან ბევრი მალსახმობი"), + ("Tools", "ხელსაწყოები"), + ("Transparency Flattener", "გამჭვირვალობის გაბრტყელება"), + ("Type…", "ტექსტი…"), + ("Unreadable", "წაუკითხავი"), + ("Use Default", "ნაგულისხმევის გამოყენება"), + ("Will change", "შეიცვლება"), + ("Win", "Win"), + ("With Shortcut", "მალსახმობის მქონე"), + ("empty shortcut", "ცარიელი მალსახმობი"), + ("in a context that is not imported", "კონტექსტში, რომელიც არ იმპორტირდება"), + ("key sequences are not supported", "კლავიშთა მიმდევრობები მხარდაჭერილი არ არის"), + ("line {n}", "სტრიქონი {n}"), + ("modifier {key} given twice", "მოდიფიკატორი {key} ორჯერაა მითითებული"), + ("more than one command fits", "ერთზე მეტი ბრძანება შეესაბამება"), + ("no command has this name", "ამ სახელის ბრძანება არ არსებობს"), + ("numeric keypad key read as main key", "ციფრული ბლოკის კლავიში წაკითხულია ძირითად კლავიშად"), + ("only a command of the other kind fits (tool or menu command)", "შეესაბამება მხოლოდ სხვა სახეობის ბრძანება (ხელსაწყო ან მენიუს ბრძანება)"), + ("press keys", "დააჭირეთ კლავიშებს"), + ("reserved by the system", "სისტემისთვისაა დაჯავშნული"), + ("taken by a later entry", "დაიკავა მომდევნო ჩანაწერმა"), + ("taken by {label}", "დაიკავა: {label}"), + ("the scope is unknown", "მოქმედების არე უცნობია"), + ("the scope name is not valid", "მოქმედების არის სახელი არასწორია"), + ("the set's shortcuts could not be added", "ნაკრების მალსახმობების დამატება ვერ მოხერხდა"), + ("the shortcut has no key", "მალსახმობს კლავიში არ აქვს"), + ("the shortcut is too long", "მალსახმობი ძალიან გრძელია"), + ("the shortcuts could not be removed", "მალსახმობების წაშლა ვერ მოხერხდა"), + ("unknown key {key}", "უცნობი კლავიში {key}"), + ("unknown modifier {key}", "უცნობი მოდიფიკატორი {key}"), + ("unsupported modifier {key}", "მხარდაუჭერელი მოდიფიკატორი {key}"), + ("{chord} (Default)", "{chord} (ნაგულისხმევი)"), + ("{chord} (press again for a double press)", "{chord} (ორმაგი დაჭერისთვის კვლავ დააჭირეთ)"), + ("{chord} ({scope})", "{chord} ({scope})"), + ("{chord} is reserved by the system", "{chord} სისტემისთვისაა დაჯავშნული"), + ("{chord} was removed from {label}", "{label}: მალსახმობი {chord} წაიშალა"), + ("{chord}: {label}", "{chord}: {label}"), + ("{key}: listed more than once", "{key}: ერთზე მეტჯერაა მითითებული"), + ("{key}: no command has this name", "{key}: ამ სახელის ბრძანება არ არსებობს"), + ("{key}: part of the entry was dropped", "{key}: ჩანაწერის ნაწილი გამოტოვებულია"), + ("{key}: the scope in “{text}” is unknown", "{key}: მოქმედების არე უცნობია („{text}“)"), + ("{key}: {text} is reserved by the system", "{key}: {text} სისტემისთვისაა დაჯავშნული"), + ("{key}: “{text}” is not a shortcut ({error})", "{key}: „{text}“ მალსახმობი არ არის ({error})"), + ("{label} (Changed)", "{label} (შეცვლილი)"), + ("{label} (cycles)", "{label} (ციკლურად)"), + ("{label} ({scope})", "{label} ({scope})"), + ("{label} ({scope}, both work)", "{label} ({scope}, ორივე მუშაობს)"), + ("{label} keeps its shortcuts (matched by a panel menu name)", "{label} მალსახმობებს ინარჩუნებს (დაემთხვა პანელის მენიუს სახელით)"), + ("{label}: press keys", "{label}: დააჭირეთ კლავიშებს"), + ("{label}: {chord}", "{label}: {chord}"), + ("{label}: {chord} (Now used by {holder})", "{label}: {chord} (ახლა იყენებს: {holder})"), + ("{menu} Menu", "მენიუ „{menu}“"), + ("{n} command changed", "შეცვლილი ბრძანებები: {n}"), + ("{n} command will change", "შესაცვლელი ბრძანებები: {n}"), + ("{n} commands changed", "შეცვლილი ბრძანებები: {n}"), + ("{n} commands will change", "შესაცვლელი ბრძანებები: {n}"), + ("{n} entries cut at a limit", "ლიმიტზე შეკვეცილი ჩანაწერები: {n}"), + ("{n} entries were ignored: too many commands", "უგულებელყოფილი ჩანაწერები (ბრძანება ძალიან ბევრია): {n}"), + ("{n} entry cut at a limit", "ლიმიტზე შეკვეცილი ჩანაწერები: {n}"), + ("{n} entry was ignored: too many commands", "უგულებელყოფილი ჩანაწერები (ბრძანება ძალიან ბევრია): {n}"), + ("{n} item could not be read", "ელემენტები, რომელთა წაკითხვა ვერ მოხერხდა: {n}"), + ("{n} item not matched", "ელემენტები შესაბამისობის გარეშე: {n}"), + ("{n} item without a shortcut", "ელემენტები მალსახმობის გარეშე: {n}"), + ("{n} items could not be read", "ელემენტები, რომელთა წაკითხვა ვერ მოხერხდა: {n}"), + ("{n} items not matched", "ელემენტები შესაბამისობის გარეშე: {n}"), + ("{n} items without a shortcut", "ელემენტები მალსახმობის გარეშე: {n}"), + ("{n} key read approximately", "მიახლოებით წაკითხული კლავიშები: {n}"), + ("{n} keys read approximately", "მიახლოებით წაკითხული კლავიშები: {n}"), + ("{n} saved shortcut could not be used", "შენახული მალსახმობები, რომელთა გამოყენება ვერ მოხერხდა: {n}"), + ("{n} saved shortcuts could not be used", "შენახული მალსახმობები, რომელთა გამოყენება ვერ მოხერხდა: {n}"), + ("{n} shortcut also works in another scope", "სხვა მოქმედების არეშიც მოქმედი მალსახმობები: {n}"), + ("{n} shortcut is assigned more than once", "ერთზე მეტჯერ მინიჭებული მალსახმობები: {n}"), + ("{n} shortcuts also work in another scope", "სხვა მოქმედების არეშიც მოქმედი მალსახმობები: {n}"), + ("{n} shortcuts are assigned more than once", "ერთზე მეტჯერ მინიჭებული მალსახმობები: {n}"), + ("{panel} Panel", "პანელი „{panel}“"), + ("{title} ({n})", "{title} ({n})"), + ("{title}: {set}", "{title}: {set}"), + ("ΔLOC", "ΔLOC"), + ("−Bin", "−Bin"), + ("−LOC", "−LOC"), + ("⚠ {chord} is used by {label}", "⚠ {chord} დაკავებულია: {label}"), + ("Y:", "Y:"), + ("Show the Typography panel", "ტიპოგრაფიის პანელის ჩვენება"), + ( + "This story was edited in the layout: you'll be asked before those edits are replaced", + "ეს ტექსტური ნაკადი განლაგებაში რედაქტირებულია: ამ ცვლილებების ჩანაცვლებამდე დაგეკითხებით", + ), ]; diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index a5eee9e94..fcdb3718e 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -2,6 +2,8 @@ //! are welcome. Terminology follows common Polish DTP usage //! (rozkładówka, łam, strona wzorcowa, obszar informacyjny) and the sibling ArtCraft catalogs. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Fewer Options", "Mniej opcji"), + ("Tab Character", "Znak tabulacji"), ("File", "Plik"), ("Edit", "Edycja"), ("Layout", "Układ"), @@ -30,6 +32,106 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Keep In Frame", "Zachowaj w ramce"), + ("Gap Tint:", "Odcień odstępu:"), + ("New Paragraph Style", "Nowy styl akapitowy"), + ("New Character Style", "Nowy styl znakowy"), + ("Next Style:", "Następny styl:"), + ("[Same style]", "[Ten sam styl]"), + ("Reset To Base", "Przywróć bazowy"), + ("Apply Style to Selection", "Zastosuj styl do zaznaczenia"), + ("Change/Find", "Zmień/Znajdź"), + ("Find Format:", "Znajdź format:"), + ("Find Format Settings", "Ustawienia wyszukiwania formatu"), + ("Specify attributes to find", "Określ atrybuty do znalezienia"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "Przy formacie wyszukiwania znajdowany jest tylko tekst, który go ma; przy pustym polu „Znajdź” — cały taki tekst", + ), + ("Change Format:", "Zmień format:"), + ("Change Format Settings", "Ustawienia zmiany formatu"), + ("Style Options", "Opcje stylu"), + ("[Any Style]", "[Dowolny styl]"), + ("Specify attributes to change", "Określ atrybuty do zmiany"), + ("Clear specified attributes", "Wyczyść określone atrybuty"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "Przy zmianie formatu i pustym polu „Zmień na” znaleziony tekst zostaje, a zmienia się tylko jego format", + ), + ("Double Column", "Podwójna kolumna"), + ("Single Column", "Pojedyncza kolumna"), + ("Tools Panel:", "Panel narzędzi:"), + ("Color Theme:", "Motyw kolorów:"), + ("At End of Document", "Na końcu dokumentu"), + ("At Start of Document", "Na początku dokumentu"), + ("Before Page", "Przed stroną"), + ("After Page", "Po stronie"), + ("Parent:", "Strona wzorcowa:"), + ("Insert:", "Wstaw:"), + ("Stroke Type", "Rodzaj obrysu"), + ("Corner Shape", "Kształt narożnika"), + ("Corner Size and Shape:", "Rozmiar i kształt narożników:"), + ("Join", "Połącz"), + ("Convert Point", "Konwertuj punkt"), + ("Plain", "Zwykły"), + ("Symmetrical", "Symetryczny"), + ("New Section", "Nowa sekcja"), + ("Clear Frame Fitting Options", "Wyczyść opcje dopasowania ramki"), + ("Cannot be edited", "Nie można edytować"), + ("Maximum:", "Maksimum:"), + ("Paragraph Spacing Limit:", "Limit odstępu akapitów:"), + ("Overprint", "Nadruk"), + ("Top Left Corner", "Lewy górny narożnik"), + ("Top Right Corner", "Prawy górny narożnik"), + ("Bottom Left Corner", "Lewy dolny narożnik"), + ("Bottom Right Corner", "Prawy dolny narożnik"), + ("Anchor Point(s)", "Punkty kontrolne"), + ("Convert Points", "Konwertuj punkty"), + ("Delete Point(s)", "Usuń punkty"), + ("Convert Text to Table…", "Zamień tekst na tabelę…"), + ("Convert Table to Text…", "Zamień tabelę na tekst…"), + ("Column Separator", "Separator kolumn"), + ("Row Separator", "Separator wierszy"), + ("Number of Columns", "Liczba kolumn"), + ("Comma", "Przecinek"), + ("RGB Color Space View", "Widok przestrzeni barw RGB"), + ("Lab Color Space View", "Widok przestrzeni barw Lab"), + ("Add CMYK Swatch", "Dodaj próbkę CMYK"), + ("Added swatch", "Dodano próbkę"), + ("Eyedropper: drag it onto the page", "Kroplomierz: przeciągnij go na stronę"), + ("PDF Preset:", "Ustawienie predefiniowane PDF:"), + ("Packaged {count} files into {dir}", "Spakowano {count} plików do {dir}"), + ("Continue", "Kontynuuj"), + ("Printing Instructions", "Instrukcje drukowania"), + ("Show Problems Only", "Pokaż tylko problemy"), + ("Include PDF (Print)", "Dołącz PDF (druk)"), + ("Include IDML", "Dołącz IDML"), + ("Create Printing Instructions", "Utwórz instrukcje drukowania"), + ("Entire Publication", "Cała publikacja"), + ("Scope:", "Zakres:"), + ("Spot", "Dodatkowy"), + ("Process", "Procesowy"), + ("Source", "Źródło"), + ("Installed", "Zainstalowana"), + ("Bundled", "Dołączona"), + ("Substituted", "Zastąpiona"), + ("Modified", "Zmieniony"), + ("Missing", "Brak"), + ("Instructions:", "Instrukcje:"), + ("Email:", "E-mail:"), + ("Fax:", "Faks:"), + ("Phone:", "Telefon:"), + ("Address:", "Adres:"), + ("Company:", "Firma:"), + ("Contact:", "Kontakt:"), + ("Process Black", "Czarny procesowy"), + ("Process Yellow", "Żółty procesowy"), + ("Process Magenta", "Magenta procesowa"), + ("Process Cyan", "Cyjan procesowy"), + ("Colors and Inks", "Kolory i farby"), + ("Links and Images", "Łącza i obrazy"), + ("Fonts", "Czcionki"), + ("Summary", "Podsumowanie"), ("Restart Numbering", "Zacznij numerowanie od nowa"), ("Continue Numbering", "Kontynuuj numerowanie"), ("Keep Options…", "Opcje utrzymywania…"), @@ -194,6 +296,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Choose Image…", "Wybierz obraz…"), ("Class:", "Klasa:"), ("Color Picker", "Próbnik kolorów"), + ("Color spectrum", "Spektrum kolorów"), ("Color Settings", "Ustawienia kolorów"), ("Color:", "Kolor:"), ("Columns", "Łamy"), @@ -1634,6 +1737,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object/Fitting", "Obiekt/Dopasowanie"), ("Object/Pathfinder", "Obiekt/Kształtowanie ścieżek"), ("Object/Paths", "Obiekt/Ścieżki"), + ("Object/Convert Point", "Obiekt/Konwertuj punkt"), ("Object/Select", "Obiekt/Zaznacz"), ("Object/Transform", "Obiekt/Przekształć"), ("Object/Transform Again", "Obiekt/Przekształć ponownie"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 688ae2339..0ef8fa755 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -1,6 +1,11 @@ //! Original Brazilian Portuguese interface translations, written from the English source //! using ordinary Brazilian Portuguese computing vocabulary. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Fewer Options", "Menos opções"), + ("Tab Character", "Caractere de tabulação"), + ("Right Indent Tab", "Tabulação de recuo à direita"), + ("Em Space", "Espaço eme"), + ("En Space", "Espaço ene"), ("File", "Arquivo"), ("Edit", "Editar"), ("Layout", "Layout"), @@ -32,6 +37,103 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Keep In Frame", "Manter no quadro"), + ("New Paragraph Style", "Novo estilo de parágrafo"), + ("New Character Style", "Novo estilo de caractere"), + ("Next Style:", "Próximo estilo:"), + ("[Same style]", "[Mesmo estilo]"), + ("Reset To Base", "Redefinir para base"), + ("Apply Style to Selection", "Aplicar estilo à seleção"), + ("Change/Find", "Alterar/Localizar"), + ("Find Format:", "Localizar formato:"), + ("Find Format Settings", "Configurações de localização de formato"), + ("Specify attributes to find", "Especificar atributos a localizar"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "Com um formato de localização, só o texto que o tem é encontrado; com “Localizar” vazio, todo o texto que o tem", + ), + ("Change Format:", "Alterar formato:"), + ("Change Format Settings", "Configurações de alteração de formato"), + ("Style Options", "Opções de estilo"), + ("[Any Style]", "[Qualquer estilo]"), + ("Specify attributes to change", "Especificar atributos a alterar"), + ("Clear specified attributes", "Limpar atributos especificados"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "Com um formato de alteração e “Alterar para” vazio, o texto encontrado permanece e só a formatação muda", + ), + ("Double Column", "Coluna dupla"), + ("Tools Panel:", "Painel Ferramentas:"), + ("Color Theme:", "Tema de cores:"), + ("At End of Document", "No final do documento"), + ("At Start of Document", "No início do documento"), + ("Before Page", "Antes da página"), + ("After Page", "Após a página"), + ("Parent:", "Página principal:"), + ("Insert:", "Inserir:"), + ("Insert Pages", "Inserir páginas"), + ("Stroke Type", "Tipo de traçado"), + ("Corner Shape", "Forma do canto"), + ("Corner Size and Shape:", "Tamanho e forma dos cantos:"), + ("Join", "Unir"), + ("Convert Point", "Converter ponto"), + ("New Section", "Nova seção"), + ("Clear Frame Fitting Options", "Limpar opções de ajuste do quadro"), + ("Cannot be edited", "Não editável"), + ("Maximum:", "Máximo:"), + ("Paragraph Spacing Limit:", "Limite de espaçamento de parágrafo:"), + ("Overprint", "Superimprimir"), + ("Top Left Corner", "Canto superior esquerdo"), + ("Top Right Corner", "Canto superior direito"), + ("Bottom Left Corner", "Canto inferior esquerdo"), + ("Bottom Right Corner", "Canto inferior direito"), + ("Anchor Point(s)", "Ponto(s) de ancoragem"), + ("Convert Points", "Converter pontos"), + ("Delete Point(s)", "Excluir ponto(s)"), + ("Tab", "Tabulação"), + ("Convert Text to Table…", "Converter texto em tabela…"), + ("Convert Table to Text…", "Converter tabela em texto…"), + ("Column Separator", "Separador de coluna"), + ("Row Separator", "Separador de linha"), + ("Number of Columns", "Número de colunas"), + ("Comma", "Vírgula"), + ("RGB Color Space View", "Exibição do espaço de cores RGB"), + ("Lab Color Space View", "Exibição do espaço de cores Lab"), + ("Add CMYK Swatch", "Adicionar amostra CMYK"), + ("Added swatch", "Amostra adicionada"), + ("Eyedropper: drag it onto the page", "Conta-gotas: arraste-o para a página"), + ("PDF Preset:", "Predefinição de PDF:"), + ("Packaged {count} files into {dir}", "{count} arquivos empacotados em {dir}"), + ("Continue", "Continuar"), + ("Printing Instructions", "Instruções de impressão"), + ("Package", "Empacotar"), + ("Show Problems Only", "Mostrar apenas problemas"), + ("Include PDF (Print)", "Incluir PDF (impressão)"), + ("Include IDML", "Incluir IDML"), + ("Create Printing Instructions", "Criar instruções de impressão"), + ("Entire Publication", "Publicação inteira"), + ("Scope:", "Escopo:"), + ("Source", "Origem"), + ("Installed", "Instalada"), + ("Bundled", "Incluída"), + ("Substituted", "Substituída"), + ("Modified", "Modificado"), + ("Missing", "Ausente"), + ("Instructions:", "Instruções:"), + ("Email:", "E-mail:"), + ("Fax:", "Fax:"), + ("Phone:", "Telefone:"), + ("Address:", "Endereço:"), + ("Company:", "Empresa:"), + ("Contact:", "Contato:"), + ("Process Black", "Preto de escala"), + ("Process Yellow", "Amarelo de escala"), + ("Process Magenta", "Magenta de escala"), + ("Process Cyan", "Ciano de escala"), + ("Colors and Inks", "Cores e tintas"), + ("Links and Images", "Vínculos e imagens"), + ("Fonts", "Fontes"), + ("Summary", "Resumo"), ("Restart Numbering", "Reiniciar numeração"), ("Continue Numbering", "Continuar numeração"), ("Keep Options…", "Opções de manutenção…"), @@ -399,6 +501,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object Layer Options", "Opções de camada do objeto"), ("Colour Picker", "Seletor de cores"), ("Color Picker", "Seletor de cores"), + ("Color spectrum", "Espectro de cores"), ("Color Settings", "Configurações de cores"), ("User Dictionary", "Dicionário do usuário"), ("Menu Customization", "Personalização de menus"), @@ -922,6 +1025,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Box", "Caixa"), ("Comb", "Pente"), ("Cover", "Cobertura"), + ("Cover Image:", "Imagem da capa:"), + ("Choose…", "Escolher…"), + ("A PNG or JPEG file; left empty, the first page is the cover.", "Um arquivo PNG ou JPEG; deixado vazio, a primeira página é a capa."), ("Dissolve", "Dissolver"), ("Fade", "Desvanecer"), ("Push", "Empurrar"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index c37e44bcb..8b933fe60 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -3,6 +3,8 @@ //! The translations are our own; counted nouns are genitive plurals because the caption puts the //! category before the number ("страниц: 5"), see `count_label`. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Fewer Options", "Меньше параметров"), + ("Tab Character", "Символ табуляции"), ("File", "Файл"), ("Edit", "Редактирование"), ("Can't Open the File", "Не удалось открыть файл"), @@ -34,6 +36,106 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Keep In Frame", "Сохранить во фрейме"), + ("Gap Tint:", "Оттенок пробелов:"), + ("New Paragraph Style", "Новый стиль абзаца"), + ("New Character Style", "Новый стиль символов"), + ("Next Style:", "Следующий стиль:"), + ("[Same style]", "[Тот же стиль]"), + ("Reset To Base", "Сбросить до основы"), + ("Apply Style to Selection", "Применить стиль к выделенному"), + ("Change/Find", "Заменить/Найти"), + ("Find Format:", "Найти формат:"), + ("Find Format Settings", "Параметры поиска формата"), + ("Specify attributes to find", "Задать атрибуты для поиска"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "С форматом поиска находится только текст с этим форматом; если поле «Найти» пусто — весь такой текст", + ), + ("Change Format:", "Заменить формат:"), + ("Change Format Settings", "Параметры замены формата"), + ("Style Options", "Параметры стиля"), + ("[Any Style]", "[Любой стиль]"), + ("Specify attributes to change", "Задать атрибуты для замены"), + ("Clear specified attributes", "Очистить заданные атрибуты"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "Если задан формат замены, а поле «Заменить на» пусто, найденный текст остаётся, меняется только его формат", + ), + ("Double Column", "Две колонки"), + ("Single Column", "Одна колонка"), + ("Tools Panel:", "Панель инструментов:"), + ("Color Theme:", "Цветовая тема:"), + ("At End of Document", "В конец документа"), + ("At Start of Document", "В начало документа"), + ("Before Page", "Перед страницей"), + ("After Page", "После страницы"), + ("Parent:", "Шаблон:"), + ("Insert:", "Вставить:"), + ("Stroke Type", "Тип обводки"), + ("Corner Shape", "Форма угла"), + ("Corner Size and Shape:", "Размер и форма углов:"), + ("Join", "Соединить"), + ("Convert Point", "Преобразовать точку"), + ("Plain", "Простая"), + ("Symmetrical", "Симметричная"), + ("New Section", "Новый раздел"), + ("Clear Frame Fitting Options", "Очистить параметры подгонки фрейма"), + ("Cannot be edited", "Нельзя изменить"), + ("Maximum:", "Максимум:"), + ("Paragraph Spacing Limit:", "Предел интервала абзацев:"), + ("Overprint", "Наложение"), + ("Top Left Corner", "Левый верхний угол"), + ("Top Right Corner", "Правый верхний угол"), + ("Bottom Left Corner", "Левый нижний угол"), + ("Bottom Right Corner", "Правый нижний угол"), + ("Anchor Point(s)", "Опорные точки"), + ("Convert Points", "Преобразовать точки"), + ("Delete Point(s)", "Удалить точки"), + ("Convert Text to Table…", "Преобразовать текст в таблицу…"), + ("Convert Table to Text…", "Преобразовать таблицу в текст…"), + ("Column Separator", "Разделитель столбцов"), + ("Row Separator", "Разделитель строк"), + ("Number of Columns", "Число столбцов"), + ("Comma", "Запятая"), + ("RGB Color Space View", "Просмотр цветового пространства RGB"), + ("Lab Color Space View", "Просмотр цветового пространства Lab"), + ("Add CMYK Swatch", "Добавить образец CMYK"), + ("Added swatch", "Образец добавлен"), + ("Eyedropper: drag it onto the page", "Пипетка: перетащите её на страницу"), + ("PDF Preset:", "Стиль PDF:"), + ("Packaged {count} files into {dir}", "Упаковано файлов: {count} в {dir}"), + ("Continue", "Продолжить"), + ("Printing Instructions", "Инструкции для печати"), + ("Show Problems Only", "Показывать только проблемы"), + ("Include PDF (Print)", "Включить PDF (печать)"), + ("Include IDML", "Включить IDML"), + ("Create Printing Instructions", "Создать инструкции для печати"), + ("Entire Publication", "Вся публикация"), + ("Scope:", "Область:"), + ("Spot", "Плашечный"), + ("Process", "Триадный"), + ("Source", "Источник"), + ("Installed", "Установлен"), + ("Bundled", "Встроенный"), + ("Substituted", "Заменен"), + ("Modified", "Изменено"), + ("Missing", "Отсутствует"), + ("Instructions:", "Инструкции:"), + ("Email:", "Эл. почта:"), + ("Fax:", "Факс:"), + ("Phone:", "Телефон:"), + ("Address:", "Адрес:"), + ("Company:", "Компания:"), + ("Contact:", "Контакт:"), + ("Process Black", "Триадный черный"), + ("Process Yellow", "Триадный желтый"), + ("Process Magenta", "Триадный пурпурный"), + ("Process Cyan", "Триадный голубой"), + ("Colors and Inks", "Цвета и краски"), + ("Links and Images", "Связи и изображения"), + ("Fonts", "Шрифты"), + ("Summary", "Сводка"), ("Restart Numbering", "Начать нумерацию заново"), ("Continue Numbering", "Продолжить нумерацию"), ("Keep Options…", "Параметры удержания…"), @@ -194,6 +296,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Choose Image…", "Выбрать изображение…"), ("Class:", "Класс:"), ("Color Picker", "Палитра цветов"), + ("Color spectrum", "Цветовой спектр"), ("Color Settings", "Настройка цветов"), ("Color:", "Цвет:"), ("Columns", "Колонки"), @@ -1638,6 +1741,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object/Fitting", "Объект/Подгонка"), ("Object/Pathfinder", "Объект/Обработка контуров"), ("Object/Paths", "Объект/Контуры"), + ("Object/Convert Point", "Объект/Преобразовать точку"), ("Object/Select", "Объект/Выбрать"), ("Object/Transform", "Объект/Трансформирование"), ("Object/Transform Again", "Объект/Повторить трансформирование"), diff --git a/crates/ui-egui/src/i18n/sv.rs b/crates/ui-egui/src/i18n/sv.rs new file mode 100644 index 000000000..8d7c46e15 --- /dev/null +++ b/crates/ui-egui/src/i18n/sv.rs @@ -0,0 +1,2960 @@ +//! Swedish (sv) interface translations for DesignCraft, written from the English source with ordinary +//! Swedish software vocabulary; no other catalog and no proprietary localisation was used. +//! AI-drafted with Claude Code; spot-checked by a native Swedish speaker, not every string reviewed. +pub(super) const TABLE: &[(&str, &str)] = &[ + ("File", "Arkiv"), + ("Edit", "Redigera"), + ("Layout", "Layout"), + ("Type", "Text"), + ("Object", "Objekt"), + ("Table", "Tabell"), + ("View", "Visa"), + ("Window", "Fönster"), + ("Help", "Hjälp"), + ("New", "Nytt"), + ("New Document…", "Nytt dokument…"), + ("New Document", "Nytt dokument"), + ("Open…", "Öppna…"), + ("Close", "Stäng"), + ("Save", "Spara"), + ("Save As…", "Spara som…"), + ("Save a Copy…", "Spara en kopia…"), + ("Revert", "Återgå"), + ("Place…", "Placera…"), + ("Export PDF…", "Exportera PDF…"), + ("Print…", "Skriv ut…"), + ("Print", "Skriv ut"), + ("Document Setup…", "Dokumentinställningar…"), + ("Document Setup", "Dokumentinställningar"), + ("Package…", "Paketera…"), + ("Undo", "Ångra"), + ("Redo", "Gör om"), + ("Cut", "Klipp ut"), + ("Copy", "Kopiera"), + ("Paste", "Klistra in"), + ("Bulleted & Numbered Lists", "Punktlistor och numrerade listor"), + ("Current Page Number", "Aktuellt sidnummer"), + ("Apply Color (,)", "Använd färg (,)"), + ("Apply Gradient (.)", "Använd gradient (.)"), + ("View Options", "Visningsalternativ"), + ("Frame Edges", "Ramkanter"), + ("Hidden Characters", "Dolda tecken"), + ("Create Flex Layout", "Skapa flexlayout"), + ("Add to Flex Layout", "Lägg till i flexlayout"), + ("Convert to Flex Layout", "Konvertera till flexlayout"), + ("Remove Flex Layout", "Ta bort flexlayout"), + ("Show Flex Controls", "Visa flexkontroller"), + ("Flex Layout Options", "Alternativ för flex-layout"), + ("Reorder in Flex Layout", "Ändra ordning i flexlayout"), + ("Flex Start", "Flex-start"), + ("Flex End", "Flex-slut"), + ("Space Between", "Avstånd mellan"), + ("Space Around", "Avstånd runt"), + ("Space Evenly", "Jämnt avstånd"), + ("Reverse Row", "Omvänd rad"), + ("Reverse Column", "Omvänd kolumn"), + ("Wrap Content", "Ombryt innehåll"), + ("Spacing", "Avstånd"), + ("Horizontal Spacing", "Horisontellt avstånd"), + ("Vertical Spacing", "Vertikalt avstånd"), + ("Constrain proportions", "Behåll proportioner"), + ("Auto", "Auto"), + ("W:", "B:"), + ("H:", "H:"), + ("Number:", "Antal:"), + ("Gutter:", "Spaltmellanrum:"), + ("Fit Guides to:", "Anpassa stödlinjer till:"), + ("Top:", "Överkant:"), + ("Bottom:", "Nederkant:"), + ("Inside:", "Inre:"), + ("Outside:", "Yttre:"), + ("Left:", "Vänster:"), + ("Right:", "Höger:"), + ("Paths:", "Banor:"), + ("Pathfinder:", "Banfinnare:"), + ("Convert Shape:", "Konvertera form:"), + ("Convert Point:", "Konvertera punkt:"), + ("Join Path", "Foga samman bana"), + ("Open Path", "Öppen bana"), + ("Plain", "Normal"), + ("Symmetrical", "Symmetrisk"), + ("The stroke isn't a gradient.", "Konturen är inte en gradient."), + ("Reverse Path", "Vänd banans riktning"), + ("Set Anchor", "Ange ankare"), + ("Clear", "Rensa"), + ("Delete", "Radera"), + ("Duplicate", "Duplicera"), + ("Select All", "Markera allt"), + ("Deselect All", "Avmarkera alla"), + ("Find/Change…", "Sök/ändra…"), + ("Find/Change", "Sök/ändra"), + ("Check Spelling…", "Kontrollera stavning…"), + ("Preferences…", "Inställningar…"), + ("Preferences", "Inställningar"), + ("Keyboard Shortcuts…", "Kortkommandon…"), + ("Keyboard Shortcuts", "Kortkommandon"), + ("Interface Language", "Gränssnittsspråk"), + ("Pages", "Sidor"), + ("Panels", "Paneler"), + ("Page", "Sida"), + ("Margins and Columns…", "Marginaler och spalter…"), + ("Create Guides…", "Skapa stödlinjer…"), + ("Numbering & Section Options…", "Numrerings- och avsnittsalternativ…"), + ("Go to Page…", "Gå till sida…"), + ("Go to Page", "Gå till sida"), + ("Next Page", "Nästa sida"), + ("Previous Page", "Föregående sida"), + ("First Page", "Första sidan"), + ("Last Page", "Sista sidan"), + ("Font", "Typsnitt"), + ("Size", "Storlek"), + ("Character", "Tecken"), + ("Paragraph", "Stycke"), + ("Paragraph Styles", "Styckestilar"), + ("Character Styles", "Teckenstilar"), + ("Paragraph Style Options", "Alternativ för styckestil"), + ("Object Styles", "Objektstilar"), + ("Glyphs", "Glyfer"), + ("Find/Replace Font…", "Sök/ersätt typsnitt…"), + ("Find/Replace Font", "Sök/ersätt typsnitt"), + ("Story Editor", "Artikelredigerare"), + ("Edit in Story Editor", "Redigera i textredigeraren"), + ("Insert Special Character", "Infoga specialtecken"), + ("Insert White Space", "Infoga blanktecken"), + ("Insert Break Character", "Infoga brytningstecken"), + ("Change Case", "Ändra skiftläge"), + ("Text Frame Options…", "Alternativ för textram…"), + ("Text Frame Options", "Alternativ för textram"), + ("Footnote Options", "Fotnotsalternativ"), + ("New Cross-Reference", "Ny korsreferens"), + ("Arrange", "Ordna"), + ("Transform", "Transformera"), + ("Group", "Gruppera"), + ("Ungroup", "Dela upp grupp"), + ("Lock", "Lås"), + ("Unlock All on Spread", "Lås upp alla på uppslaget"), + ("Bring to Front", "Flytta längst fram"), + ("Send to Back", "Flytta längst bak"), + ("Bring Forward", "Flytta framåt"), + ("Send Backward", "Flytta bakåt"), + ("Fitting", "Anpassning"), + ("Fit Frame to Content", "Anpassa ram till innehåll"), + ("Fit Content to Frame", "Anpassa innehåll till ram"), + ("Fill Frame Proportionally", "Fyll ram proportionerligt"), + ("Fit Content Proportionally", "Anpassa innehåll proportionerligt"), + ("Center Content", "Centrera innehåll"), + ("Frame Fitting Options", "Alternativ för ramanpassning"), + ("Generate QR Code", "Generera QR-kod"), + ("Create Table", "Skapa tabell"), + ("Insert Table…", "Infoga tabell…"), + ("Insert", "Infoga"), + ("Rows", "Rader"), + ("Columns", "Kolumner"), + ("Row", "Rad"), + ("Column", "Kolumn"), + ("Merge Cells", "Sammanfoga celler"), + ("Split Cell Horizontally", "Dela cell horisontellt"), + ("Split Cell Vertically", "Dela cell vertikalt"), + ("Table Options", "Tabellalternativ"), + ("Cell Options", "Cellalternativ"), + ("Header Rows", "Rubrikrader"), + ("Footer Rows", "Fotrader"), + ("Zoom In", "Zooma in"), + ("Zoom Out", "Zooma ut"), + ("Fit Page in Window", "Anpassa sidan till fönstret"), + ("Fit Spread in Window", "Anpassa uppslaget till fönstret"), + ("Actual Size", "Verklig storlek"), + ("Screen Mode", "Skärmläge"), + ("Normal", "Normal"), + ("Preview", "Förhandsgranskning"), + ("Rulers", "Linjaler"), + ("Grids & Guides", "Rutnät och stödlinjer"), + ("Show Guides", "Visa stödlinjer"), + ("Hide Guides", "Dölj stödlinjer"), + ("Show Rulers", "Visa linjaler"), + ("Hide Rulers", "Dölj linjaler"), + ("Extras", "Extrafunktioner"), + ("Workspace", "Arbetslayout"), + ("Editorial", "Redaktionell"), + ("Object & Layout", "Objekt och layout"), + ("Styles", "Stilar"), + ("Essentials Classic", "Grundläggande klassisk"), + ("New Workspace", "Ny arbetslayout"), + ("Properties", "Egenskaper"), + ("Layers", "Lager"), + ("Swatches", "Färgrutor"), + ("Colour", "Färg"), + ("Color", "Färg"), + ("Stroke", "Kontur"), + ("Fill", "Fyllning"), + ("Gradient", "Gradient"), + ("Transparency", "Transparens"), + ("Effects", "Effekter"), + ("Links", "Länkar"), + ("Notes", "Anteckningar"), + ("Conditions", "Villkor"), + ("Library", "Bibliotek"), + ("Text Wrap", "Textombrytning"), + ("Align", "Justera"), + ("Pathfinder", "Banfinnare"), + ("Transform Again", "Transformera igen"), + ("Interactive", "Interaktiv"), + ("Buttons and Forms", "Knappar och formulär"), + ("Hyperlinks", "Hyperlänkar"), + ("Bookmarks", "Bokmärken"), + ("Show All Menu Items", "Visa alla menyalternativ"), + ("About DesignCraft", "Om DesignCraft"), + ("Quit DesignCraft", "Avsluta DesignCraft"), + ("Welcome to DesignCraft", "Välkommen till DesignCraft"), + ("Page layout for print and screen — fast, open and scriptable.", "Sidlayout för tryck och skärm – snabb, öppen och skriptbar."), + (" New file ", " Ny fil "), + (" Open ", " Öppna "), + (" Open sample magazine ", " Öppna exempeltidning "), + ("Start a new document", "Skapa ett nytt dokument"), + ("No document open", "Inget dokument är öppet"), + ("Join the ArtCraft community", "Gå med i ArtCraft-gemenskapen"), + ("Get help, share your layouts and shape what we build next.", "Få hjälp, dela dina layouter och påverka vad vi bygger härnäst."), + ("Join our Discord", "Gå med i vår Discord"), + ("Community", "Gemenskap"), + ("Join the community", "Gå med i gemenskapen"), + ("discord.gg/artcraft — help, feedback and show-and-tell", "discord.gg/artcraft – hjälp, återkoppling och visa upp vad du gjort"), + (" OK ", " OK "), + ("Float panel", "Gör panelen flytande"), + ("Dock panel", "Docka panel"), + ("OK", "OK"), + ("Cancel", "Avbryt"), + ("Can't Open the File", "Det går inte att öppna filen"), + ("Can't Place the File", "Det går inte att placera filen"), + ("Apply", "Använd"), + ("Done", "Klart"), + ("Reset", "Återställ"), + ("Add", "Lägg till"), + ("Remove", "Ta bort"), + ("Name", "Namn"), + ("Width", "Bredd"), + ("Height", "Höjd"), + ("Portrait", "Stående"), + ("Landscape", "Liggande"), + ("Orientation", "Orientering"), + ("Units", "Enheter"), + ("Preset", "Förinställning"), + ("Facing Pages", "Motstående sidor"), + ("Primary Text Frame", "Primär textram"), + ("Number of Pages", "Antal sidor"), + ("Start Page #", "Startsida nr"), + ("Page Size", "Sidstorlek"), + ("Margins", "Marginaler"), + ("Bleed", "Utfall"), + ("Slug", "Informationsområde"), + ("Top", "Överkant"), + ("Bottom", "Nederkant"), + ("Left", "Vänster"), + ("Right", "Höger"), + ("Inside", "Inuti"), + ("Outside", "Utanför"), + ("Gutter", "Spaltmellanrum"), + ("Binding", "Bindning"), + ("Left to Right", "Vänster till höger"), + ("Right to Left", "Höger till vänster"), + ("No Selection", "Ingen markering"), + ("Rectangle", "Rektangel"), + ("Ellipse", "Ellips"), + ("Polygon", "Polygon"), + ("Polygon Settings", "Polygoninställningar"), + ("Text", "Text"), + ("Image", "Bild"), + ("Line", "Linje"), + ("Position", "Position"), + ("Appearance", "Utseende"), + ("Opacity", "Opacitet"), + ("Corner", "Hörn"), + ("Rotation", "Rotation"), + ("Rulers & Grids", "Linjaler och rutnät"), + ("Guides", "Stödlinjer"), + ("Flex Layout", "Flex-layout"), + ("Auto Size", "Automatisk storlek"), + ("Tracking", "Spärrning"), + ("Leading", "Radavstånd"), + ("Kerning", "Kerning"), + ("Language", "Språk"), + ("Hyphenation", "Avstavning"), + ("Alignment", "Justering"), + ("Indent", "Indrag"), + ("Space Before", "Avstånd före"), + ("Space After", "Avstånd efter"), + ("New Layer", "Nytt lager"), + ("New Swatch", "Ny färgruta"), + ("Object Layer Options", "Lageralternativ för objekt"), + ("Colour Picker", "Färgväljare"), + ("Color Picker", "Färgväljare"), + ("Color Settings", "Färginställningar"), + ("User Dictionary", "Användarordlista"), + ("Menu Customization", "Anpassning av menyer"), + ("Import Options", "Importalternativ"), + ("Place PDF", "Placera PDF"), + ("Find", "Sök"), + ("Change", "Ändra"), + ("Find What", "Sök efter"), + ("Change To", "Ändra till"), + ("Find Next", "Sök nästa"), + ("Change All", "Ändra alla"), + ("Search", "Sök"), + ("Document", "Dokument"), + ("Selection", "Markering"), + ("All Documents", "Alla dokument"), + ("Direction", "Riktning"), + ("Horizontal", "Horisontell"), + ("Vertical", "Vertikal"), + ("On", "På"), + ("Off", "Av"), + ("None", "Ingen"), + ("[None]", "[Ingen]"), + ("Place with Import Options…", "Placera med importalternativ…"), + ("Print Booklet…", "Skriv ut häfte…"), + ("Export", "Exportera"), + ("Export Page as PNG…", "Exportera sidan som PNG…"), + ("Export IDML…", "Exportera IDML…"), + ("Export EPUB…", "Exportera EPUB…"), + ("Export EPUB (Fixed Layout)…", "Exportera EPUB (fast layout)…"), + ("Export PDF (Interactive)…", "Exportera PDF (interaktiv)…"), + ("Export HTML…", "Exportera HTML…"), + ("Export XML…", "Exportera XML…"), + ("Export Text…", "Exportera text…"), + ("Quick Actions", "Snabbåtgärder"), + ("Import File", "Importera fil"), + ("Insert Page", "Infoga sida"), + ("Delete selected pages", "Radera markerade sidor"), + ("Create new page", "Skapa ny sida"), + ("Edit page size", "Redigera sidstorlek"), + ("Custom Page Size…", "Anpassad sidstorlek…"), + ("Edit Page", "Redigera sida"), + ("Adjust Layout", "Anpassa layout"), + ("Smart Guides", "Smarta stödlinjer"), + ("Lock Guides", "Lås stödlinjer"), + ("Show Document Grid", "Visa dokumentrutnät"), + ("Show Baseline Grid", "Visa baslinjerutnät"), + ("Options", "Alternativ"), + ("More Options", "Fler alternativ"), + ("Dialog", "Dialogruta"), + ("General", "Allmänt"), + ("Basic Character Formats", "Grundläggande teckenformat"), + ("Indents and Spacing", "Indrag och avstånd"), + ("Justification", "Justering"), + ("Drop Caps and Nested Styles", "Anfanger och kapslade stilar"), + ("GREP Style", "GREP-format"), + ("Character Color", "Teckenfärg"), + ("Export Tagging", "Exporttaggning"), + ("Numbering and Formatting", "Numrering och formatering"), + ("Spacing Options", "Avståndsalternativ"), + ("First Baseline", "Första baslinje"), + ("Rule Above", "Linje ovanför"), + ("Numbering", "Numrering"), + ("Footnote Reference Number in Text", "Fotnotsreferensnummer i texten"), + ("Footnote Formatting", "Fotnotsformatering"), + ("Span Footnotes Across Columns", "Låt fotnoter sträcka sig över spalter"), + ("Rule On", "Linje på"), + ("Ascent", "Uppstapelhöjd"), + ("Cap Height", "Versalhöjd"), + ("x Height", "x-höjd"), + ("Em Box Height", "Fyrkantshöjd"), + ("Fixed", "Fast"), + ("Never (continuous)", "Aldrig (kontinuerlig)"), + ("Spread", "Uppslag"), + ("Section", "Avsnitt"), + ("Footnote Reference", "Fotnotsreferens"), + ("Footnote Text", "Fotnotstext"), + ("Both", "Båda"), + ("Apply Superscript", "Använd upphöjd"), + ("Apply Subscript", "Använd nedsänkt"), + ("Apply Normal", "Använd normal"), + ("OpenType Superscript", "OpenType upphöjd"), + ("through", "till"), + ("up to", "upp till"), + ("line", "rad"), + ("lines", "rader"), + ("sentences", "meningar"), + ("words", "ord"), + ("characters", "tecken"), + ("letters", "bokstäver"), + ("digits", "siffror"), + ("tab", "tabb"), + ("forcedLineBreak", "forcedLineBreak"), + ("emSpace", "helfyrkant"), + ("enSpace", "halvfyrkant"), + ("dropcap", "anfang"), + ("Drop Caps", "Anfanger"), + ("Lines:", "Rader:"), + ("Characters:", "Tecken:"), + ("Drop Lines", "Anfangsrader"), + ("Drop Chars", "Anfangtecken"), + ("Align Left Edge", "Justera vänsterkant"), + ("Scale for Descenders", "Skala för nedstaplar"), + ("chars", "tkn"), + ("[Automatic]", "[Automatisk]"), + ("Size:", "Storlek:"), + ("Tracking:", "Spärrning:"), + ("Left Indent:", "Vänsterindrag:"), + ("Right Indent:", "Högerindrag:"), + ("First Line Indent:", "Indrag första raden:"), + ("Space Before:", "Avstånd före:"), + ("Space After:", "Avstånd efter:"), + ("Words with at Least:", "Ord med minst:"), + ("After First:", "Efter första:"), + ("Before Last:", "Före sista:"), + ("Hyphen Limit:", "Gräns för avstavningar:"), + ("Word Spacing:", "Ordavstånd:"), + ("Letter Spacing:", "Teckenavstånd:"), + ("Glyph Scaling:", "Glyfskalning:"), + ("Insert Kashidas:", "Infoga kashidor:"), + ("In justified Arabic text", "I marginaljusterad arabisk text"), + ("Korean Line Breaks:", "Koreanska radbrytningar:"), + ("Between syllables (not only at spaces)", "Mellan stavelser (inte bara vid blanksteg)"), + ("Minimum Space Before First Footnote:", "Minsta avstånd före första fotnoten:"), + ("Space Between Footnotes:", "Avstånd mellan fotnoter:"), + ("Offset:", "Förskjutning:"), + ("Min:", "Min:"), + ("Weight:", "Tjocklek:"), + ("Color:", "Färg:"), + ("Width:", "Bredd:"), + ("Style:", "Stil:"), + ("Start at:", "Börja på:"), + ("Restart Numbering Every:", "Börja om numreringen varje:"), + ("Show Prefix/Suffix in:", "Visa prefix/suffix i:"), + ("Prefix:", "Prefix:"), + ("Suffix:", "Suffix:"), + ("Position:", "Position:"), + ("Character Style:", "Teckenstil:"), + ("Paragraph Style:", "Styckestil:"), + ("Separator:", "Avgränsare:"), + ("EPUB and HTML", "EPUB och HTML"), + ("Tag:", "Tagg:"), + ("Class:", "Klass:"), + ("Nested Styles", "Kapslade stilar"), + ("Nested Line Styles", "Kapslade radstilar"), + ("New Nested Style", "Ny kapslad stil"), + ("New Line Style", "Ny radstil"), + ("GREP Styles", "GREP-format"), + ("Apply Style:", "Använd stil:"), + ("To Text:", "Till text:"), + ("New GREP Style", "Ny GREP-stil"), + ("Font Family:", "Typsnittsfamilj:"), + ("Font Style:", "Teckenstil:"), + ("Leading:", "Radavstånd:"), + ("Alignment:", "Justering:"), + ("Hyphenate", "Avstava"), + ("Minimum", "Minimum"), + ("Desired", "Önskad"), + ("Maximum", "Maximum"), + ("Composer:", "Sättningsmotor:"), + ("Paragraph Composer", "Styckesättare"), + ("Single-line Composer", "Enradssättare"), + ("Style Name:", "Stilnamn:"), + ("Based On:", "Baserat på:"), + ("for", "för"), + ("Hyphenation Exceptions", "Avstavningsundantag"), + ("Binding:", "Bindning:"), + ("Dynamic Spelling", "Dynamisk stavning"), + ("UI Scaling", "Gränssnittsskalning"), + ("Ruby:", "Ruby:"), + ("Set over the selected text; empty removes it.", "Sätts över den markerade texten; tomt tar bort den."), + ("Document Footnote Options…", "Alternativ för dokumentets fotnoter…"), + ("Insert Footnote", "Infoga fotnot"), + ("Go to Footnote Reference", "Gå till fotnotsreferens"), + ("Footnotes", "Fotnoter"), + ("Paragraph Style Options…", "Alternativ för styckestil…"), + ("New Paragraph Style…", "Ny styckestil…"), + ("New Character Style…", "Ny teckenstil…"), + ("[Basic Paragraph]", "[Grundläggande stycke]"), + ("[No Paragraph Style]", "[Ingen styckestil]"), + ("Align left", "Justera vänster"), + ("Align center", "Centrera"), + ("Align right", "Justera höger"), + ("Justify with last line aligned left", "Marginaljustera med sista raden vänsterjusterad"), + ("Justify with last line aligned center", "Marginaljustera med sista raden centrerad"), + ("Justify with last line aligned right", "Marginaljustera med sista raden högerjusterad"), + ("Justify all lines", "Marginaljustera alla rader"), + ("Align towards spine", "Justera mot bokryggen"), + ("Align away from spine", "Justera bort från bokryggen"), + ("Create New Style from Selection", "Skapa ny stil från markering"), + ("No text selected", "Ingen text är markerad"), + ("Essentials", "Grundläggande"), + ("Advanced", "Avancerat"), + ("Book", "Bok"), + ("Digital Publishing", "Digital publicering"), + ("Interactive for PDF", "Interaktiv för PDF"), + ("Printing and Proofing", "Utskrift och korrektur"), + ("Typography", "Typografi"), + ("No errors", "Inga fel"), + ("error", "fel"), + ("errors", "fel"), + ("Display All Blacks as Rich Black", "Visa alla svarta som djupsvart"), + ("Display All Blacks Accurately", "Visa alla svarta korrekt"), + ("Output All Blacks as Rich Black", "Återge all svart som djupsvart"), + ("Output All Blacks Accurately", "Återge all svart korrekt"), + ("Top Margin", "Övre marginal"), + ("Top of Page", "Överst på sidan"), + ("Paragraph Styles panel", "Panelen Styckestilar"), + ("Character Styles panel", "Panel för teckenstilar"), + ("Create new style", "Skapa ny stil"), + ("Clear overrides", "Rensa lokala ändringar"), + ("Accept", "Acceptera"), + ("Reject", "Avvisa"), + ("Add Selection", "Lägg till markering"), + ("Add the selection", "Lägg till markeringen"), + ("Add to Swatches", "Lägg till i färgrutor"), + ("Align Stroke:", "Justera kontur:"), + ("All Caps", "Versaler"), + ("Angle", "Vinkel"), + ("Angle:", "Vinkel:"), + ("Baseline", "Baslinje"), + ("Cap:", "Ände:"), + ("Cell Text", "Celltext"), + ("Cell Type", "Celltyp"), + ("Column Width", "Kolumnbredd"), + ("Column Rules", "Spaltlinjer"), + ("Insert Column Rule", "Infoga spaltlinje"), + ("Tint %", "Färgton %"), + ("Top Inset", "Övre infällning"), + ("Bottom Inset", "Infällning nederkant"), + ("Horizontal Offset", "Horisontell förskjutning"), + ("Composer", "Sättningsmotor"), + ("Convert Selection to Multi-State Object", "Konvertera markering till objekt med flera lägen"), + ("Convert to Text", "Konvertera till text"), + ("Create New Layer", "Skapa nytt lager"), + ("Default", "Standard"), + ("Delete DTD", "Radera DTD"), + ("Delete Layer", "Radera lager"), + ("Delete Page", "Radera sida"), + ("Digits", "Siffror"), + ("Distance", "Avstånd"), + ("Distribute ↔", "Fördela ↔"), + ("Distribute ↕", "Fördela ↕"), + ("Drop Shadow", "Slagskugga"), + ("Effects: Drop Shadow", "Effekter: Slagskugga"), + ("Favorites", "Favoriter"), + ("First Spread", "Första uppslaget"), + ("Frame Fitting", "Ramanpassning"), + ("Gap Color:", "Mellanrumsfärg:"), + ("Go To", "Gå till"), + ("Graphic…", "Grafik…"), + ("Home", "Start"), + ("Hyperlink from the selected text or frames", "Hyperlänk från markerad text eller markerade ramar"), + ("Include when exporting", "Inkludera vid export"), + ("Join:", "Hörn:"), + ("Last Spread", "Sista uppslaget"), + ("Linked Stories", "Länkade artiklar"), + ("Load DTD…", "Läs in DTD…"), + ("Location:", "Plats:"), + ("Miter Limit:", "Spetsgräns:"), + ("Move", "Flytta"), + ("New Article from Selection", "Ny artikel från markering"), + ("New Note at Cursor", "Ny anteckning vid textmarkören"), + ("New condition name", "Namn på nytt villkor"), + ("New group…", "Ny grupp…"), + ("New tag", "Ny tagg"), + ("Next Spread", "Nästa uppslag"), + ("Note text", "Anteckningstext"), + ("Offset", "Förskjutning"), + ("Offsets", "Förskjutningar"), + ("Open", "Öppna"), + ("Paragraph alignment", "Styckejustering"), + ("Pick a file in the folder holding the moved graphics", "Välj en fil i mappen som innehåller den flyttade grafiken"), + ("Place", "Placera"), + ("Previous Spread", "Föregående uppslag"), + ("Release to Objects", "Släpp till objekt"), + ("Reverse the gradient", "Vänd gradienten"), + ("Rotate 90° Counterclockwise", "Rotera 90° moturs"), + ("Rotation Angle", "Rotationsvinkel"), + ("Row Height", "Radhöjd"), + ("Run", "Kör"), + ("Scale X Percentage", "Skala X i procent"), + ("Scale Y Percentage", "Skala Y i procent"), + ("Search commands", "Sök kommandon"), + ("Search fonts", "Sök typsnitt"), + ("Search styles and commands…", "Sök format och kommandon…"), + ("Search: character or U+code", "Sök: tecken eller U+kod"), + ("Separated by commas", "Avgränsade med kommatecken"), + ("Share", "Dela"), + ("Shear X Angle", "Skjuvningsvinkel X"), + ("Show / hide text with this condition", "Visa/dölj text med det här villkoret"), + ("Show Favorites Only", "Visa endast favoriter"), + ("Show in the text", "Visa i texten"), + ("Spreads of up to 10 pages", "Uppslag på upp till 10 sidor"), + ("Stop Color:", "Stoppfärg:"), + ("Strikethrough", "Genomstruken"), + ("Structure", "Struktur"), + ("Style", "Stil"), + ("Style source", "Stilkälla"), + ("Styles panel", "Panelen Stilar"), + ("Superscript", "Upphöjd"), + ("Tag the selection", "Tagga markeringen"), + ("The structure is valid.", "Strukturen är giltig."), + ("Toggle Fill and Stroke", "Växla fyllning och kontur"), + ("Toggle double column", "Växla dubbel kolumn"), + ("Type:", "Typ:"), + ("Underline", "Understrykning"), + ("Untag Selection", "Ta bort tagg från markering"), + ("Update", "Uppdatera"), + ("Validate", "Validera"), + ("Weight", "Tjocklek"), + ("Subtract", "Subtrahera"), + ("Intersect", "Skärning"), + ("Exclude Overlap", "Uteslut överlappning"), + ("Minus Back", "Minus bakom"), + ("Make Compound", "Skapa sammansatt"), + ("Release", "Släpp"), + ("Paths", "Banor"), + ("Align left edges", "Justera vänsterkanter"), + ("Align horizontal centers", "Justera horisontella mittpunkter"), + ("Align right edges", "Justera högerkanter"), + ("Align top edges", "Justera överkanter"), + ("Align vertical centers", "Justera vertikala mittpunkter"), + ("Align bottom edges", "Justera nederkanter"), + ("Align to Selection", "Justera mot markering"), + ("Align to Key Object", "Justera mot nyckelobjekt"), + ("Align to Margins", "Justera mot marginaler"), + ("Align to Page", "Justera mot sida"), + ("Align to Spread", "Justera mot uppslag"), + ("No Text Wrap", "Ingen textombrytning"), + ("Wrap Around Bounding Box", "Ombryt runt avgränsningsramen"), + ("Wrap Around Object Shape", "Ombryt runt objektets form"), + ("Jump Object", "Hoppa över objekt"), + ("Jump to Next Column", "Hoppa till nästa spalt"), + ("Bulleted List", "Punktlista"), + ("Numbered List", "Numrerad lista"), + ("UPPERCASE", "VERSALER"), + ("Small Caps", "Kapitäler"), + ("Butt", "Rak"), + ("Round", "Rund"), + ("Projecting", "Utskjutande"), + ("Miter", "Spets"), + ("Bevel", "Fasning"), + ("Center", "Center"), + ("Start/End:", "Start/slut:"), + ("First", "Första"), + ("Last", "Senaste"), + ("Before", "Före"), + ("After", "Efter"), + ("Shading", "Skuggning"), + ("Border", "Kant"), + ("Overprint Fill", "Övertryck fyllning"), + ("Overprint Stroke", "Övertryck kontur"), + ("Overprint Gap", "Övertryck mellanrum"), + ("Nonprinting", "Skrivs inte ut"), + ("At Least", "Minst"), + ("Exactly", "Exakt"), + ("Justify", "Marginaljustera"), + ("Underline Options", "Alternativ för understrykning"), + ("Strikethrough Options", "Alternativ för genomstrykning"), + ("Character Style Options", "Alternativ för teckenstil"), + ("Advanced Character Formats", "Avancerade teckenformat"), + ("OpenType Features", "OpenType-funktioner"), + ("Style Settings:", "Stilinställningar:"), + ("Kerning:", "Kerning:"), + ("Case:", "Skiftläge:"), + ("OpenType All Small Caps", "OpenType alla kapitäler"), + ("Subscript", "Nedsänkt"), + ("OpenType Subscript", "OpenType nedsänkt"), + ("OpenType Numerator", "OpenType täljare"), + ("OpenType Denominator", "OpenType nämnare"), + ("Ligatures", "Ligaturer"), + ("No Break", "Ingen radbrytning"), + ("Horizontal Scale:", "Horisontell skala:"), + ("Vertical Scale:", "Vertikal skala:"), + ("Baseline Shift:", "Baslinjeförskjutning:"), + ("Skew:", "Skevning:"), + ("Language:", "Språk:"), + ("Tint:", "Färgton:"), + ("Figure Style:", "Formatmall för figur:"), + ("Underline On", "Understrykning på"), + ("Strikethrough On", "Genomstrykning på"), + ("Edit \"{name}\"…", "Redigera \"{name}\"…"), + ("Discretionary Ligatures", "Valfria ligaturer"), + ("Fractions", "Bråk"), + ("Ordinal", "Ordningstal"), + ("Swash", "Swash"), + ("Titling Alternates", "Rubrikalternativ"), + ("Contextual Alternates", "Kontextuella alternativ"), + ("Slashed Zero", "Nolla med snedstreck"), + ("Tabular Lining", "Tabellsiffror, versala"), + ("Proportional Oldstyle", "Proportionella medievalsiffror"), + ("Proportional Lining", "Proportionella versalsiffror"), + ("Tabular Oldstyle", "Medievalsiffror i tabellbredd"), + ("Default Figure Style", "Standardfigurstil"), + ("Not a button", "Inte en knapp"), + ("Go To Next Page", "Gå till nästa sida"), + ("Go To Previous Page", "Gå till föregående sida"), + ("Go To First Page", "Gå till första sidan"), + ("Go To Last Page", "Gå till sista sidan"), + ("Go To Page", "Gå till sida"), + ("Go To URL", "Gå till URL"), + ("Not a form field", "Inte ett formulärfält"), + ("Text Field", "Textfält"), + ("Check Box", "Kryssruta"), + ("Radio Button", "Alternativknapp"), + ("Combo Box", "Kombinationsruta"), + ("List Box", "Listruta"), + ("Signature", "Signatur"), + ("Resize width", "Ändra bredd"), + ("Resize height", "Ändra höjd"), + ("Pin top", "Fäst överkant"), + ("Pin bottom", "Fäst nederkant"), + ("Pin left", "Fäst vänster"), + ("Pin right", "Fäst höger"), + ("Inward", "Inåt"), + ("Metrics", "Mått"), + ("Optical", "Optisk"), + ("Applies to fill", "Gäller för fyllning"), + ("Applies to text", "Gäller för text"), + ("Gray", "Grå"), + ("Linear", "Linjär"), + ("Radial", "Radiell"), + ("Arabic", "Arabiska"), + ("Beveled Rectangle", "Avfasad rektangel"), + ("Chinese", "Kinesiska"), + ("Constrain proportions for width and height", "Behåll proportioner för bredd och höjd"), + ("Custom", "Anpassad"), + ("Czech", "Tjeckiska"), + ("Danish", "Danska"), + ("Dashed", "Streckad"), + ("Dotted", "Prickad"), + ("Dutch: 2005 Reform", "Nederländska: 2005 års reform"), + ("Embedded", "Inbäddad"), + ("English: Canadian", "Engelska: Kanada"), + ("English: UK", "Engelska: Storbritannien"), + ("English: USA", "Engelska: USA"), + ("Farsi", "Farsi"), + ("Finnish", "Finska"), + ("French", "Franska"), + ("French: Canadian", "Franska: kanadensisk"), + ("German: 2006 Reform", "Tyska: reformen 2006"), + ("German: Swiss 2006 Reform", "Tyska: schweizisk, reformen 2006"), + ("Greek", "Grekiska"), + ("Hebrew", "Hebreiska"), + ("Hindi", "Hindi"), + ("Hungarian", "Ungerska"), + ("Inverse Rounded Rectangle", "Omvänd rundad rektangel"), + ("Italian", "Italienska"), + ("Japanese", "Japanska"), + ("Korean", "Koreanska"), + ("Make all settings the same", "Gör alla inställningar lika"), + ("Native", "Ursprunglig"), + ("Norwegian: Bokmål", "Norska: bokmål"), + ("Orthogonal Line", "Ortogonal linje"), + ("Polish", "Polska"), + ("Portuguese", "Portugisiska"), + ("Portuguese: Brazilian", "Portugisiska: brasiliansk"), + ("Rounded Rectangle", "Rundad rektangel"), + ("Russian", "Ryska"), + ("Solid", "Enfärgad"), + ("Spanish", "Spanska"), + ("Swedish", "Svenska"), + ("Thick - Thick", "Tjock - tjock"), + ("Thick - Thin", "Tjock - tunn"), + ("Thin - Thick", "Tunn - tjock"), + ("Thin - Thin", "Tunn - tunn"), + ("Thin - Thick - Thin", "Tunn - tjock - tunn"), + ("Thick - Thin - Thick", "Tjock - tunn - tjock"), + ("Wavy", "Vågig"), + ("Straight Hash", "Rakt nummertecken"), + ("Stripes", "Ränder"), + ("Triangle", "Triangel"), + ("Turkish", "Turkiska"), + ("Ukrainian", "Ukrainska"), + ("Cell Style", "Cellstil"), + ("Table Style", "Tabellstil"), + ("Col ←", "Kol ←"), + ("Col →", "Kol →"), + ("Row ↑", "Rad ↑"), + ("Row ↓", "Rad ↓"), + ("Hide Others", "Göm övriga"), + ("Lock Others", "Lås övriga"), + ("Show All Layers", "Visa alla lager"), + ("Unlock", "Lås upp"), + ("Unlock All Layers", "Lås upp alla lager"), + ("Added", "Tillagd"), + ("Deleted", "Borttaget"), + ("Guide-based", "Stödlinjebaserad"), + ("Page:", "Sida:"), + ("Content:", "Innehåll:"), + ("Object Constraints:", "Objektbegränsningar:"), + ("Resize with Page:", "Ändra storlek med sidan:"), + ("Pin:", "Fäst:"), + ("Object-Based", "Objektbaserad"), + ("Guide-Based", "Stödlinjebaserad"), + ("Resize Page", "Ändra sidstorlek"), + ("Liquid Page Rule:", "Regel för flytande sida:"), + ("Show Parent Page Overlay", "Visa mallsidesöverlägg"), + ("Controlled by Parent", "Styrs av mallsida"), + ("US Business Card", "Amerikanskt visitkort"), + ("Object is pinned relative to the page edge. Click to release.", "Objektet är fäst relativt sidkanten. Klicka för att släppa."), + ("Click to pin the object relative to the page edge.", "Klicka för att fästa objektet relativt sidkanten."), + ( + "The object changes width as the page changes width. Click to lock the object width.", + "Objektets bredd ändras när sidans bredd ändras. Klicka för att låsa objektets bredd.", + ), + ( + "The object's width is locked. Click to let the object change width as the page changes width.", + "Objektets bredd är låst. Klicka för att låta objektets bredd ändras när sidans bredd ändras.", + ), + ( + "The object changes height as the page changes height. Click to lock the object height.", + "Objektets höjd ändras när sidans höjd ändras. Klicka för att låsa objektets höjd.", + ), + ( + "The object's height is locked. Click to let the object change height as the page changes height.", + "Objektets höjd är låst. Klicka för att låta objektets höjd ändras när sidans höjd ändras.", + ), + ("Loop", "Loopa"), + ("Object-based", "Objektbaserad"), + ("Play on Page Load", "Spela upp när sidan läses in"), + ("Re-center", "Centrera igen"), + ("Show Controls", "Visa kontroller"), + ("Signature Field", "Signaturfält"), + ("Select text to apply a condition", "Markera text för att tillämpa ett villkor"), + ("Blinds", "Persienner"), + ("Box", "Ruta"), + ("Comb", "Kam"), + ("Cover", "Täck"), + ("Cover Image:", "Omslagsbild:"), + ("Choose…", "Välj…"), + ("A PNG or JPEG file; left empty, the first page is the cover.", "En PNG- eller JPEG-fil; lämnas den tom blir första sidan omslaget."), + ("Dissolve", "Övertoning"), + ("Fade", "Toning"), + ("Push", "Skjut"), + ("Split", "Dela"), + ("Uncover", "Avtäck"), + ("Wipe", "Ridå"), + ("Simple", "Enkel"), + ("Simple - Wide", "Enkel – bred"), + ("Triangle - Wide", "Triangel – bred"), + ("Barbed", "Hullingförsedd"), + ("Curved", "Krökt"), + ("Circle", "Cirkel"), + ("Circle - Solid", "Cirkel – enfärgad"), + ("Square", "Kvadrat"), + ("Square - Solid", "Kvadrat – enfärgad"), + ("Bar", "Lodstreck"), + ("Hide", "Dölj"), + ("Show", "Visa"), + ("Select", "Markera"), + ("Fit Text", "Anpassa text"), + ("Preview (W)", "Förhandsgranskning (W)"), + ("Normal (W)", "Normal (W)"), + ("Conveyor", "Transportband"), + ("objects", "objekt"), + ("pages", "sidor"), + ("stories", "artiklar"), + ("items", "objekt"), + ("warnings", "varningar"), + ("Render", "Rendera"), + ("Set", "Ange"), + ("unused", "oanvänd"), + ("⚠ Missing", "⚠ Saknas"), + ("▲ Modified", "▲ Ändrad"), + ("(Text Color)", "(Textfärg)"), + ("{count} glyphs — click to insert at the text cursor", "{count} glyfer – klicka för att infoga vid textmarkören"), + ("{pages} Pages in {spreads} Spreads", "{pages} sidor i {spreads} uppslag"), + ("Text Frame", "Textram"), + ("Text Style", "Textstil"), + (" (auto)", " (auto)"), + ("Absolute Numbering (instead of Section Numbering)", "Absolut numrering (i stället för avsnittsnumrering)"), + ( + "Add and remove pages as the primary text frame's story grows and shrinks", + "Lägg till och ta bort sidor när texten i den primära textramen växer och krymper", + ), + ("Added Text (highlighted)", "Tillagd text (färgmarkerad)"), + ("Adjust Layout (objects follow the new page size)", "Anpassa layout (objekt följer den nya sidstorleken)"), + ("Auto-Fit", "Autoanpassa"), + ("Case sensitive", "Skiftlägeskänslig"), + ("Custom Tracking/Kerning", "Anpassad spärrning/kerning"), + ("Dimensions Include Stroke Weight", "Dimensioner inkluderar konturtjocklek"), + ("Enable Autocorrect", "Aktivera autokorrigering"), + ("Enable Dynamic Spelling (underline misspelled words)", "Aktivera dynamisk stavning (stryk under felstavade ord)"), + ("Grids in Back", "Rutnät bakom"), + ("H&J Violations", "Avstavnings- och justeringsfel"), + ("Include Bleed", "Inkludera utfall"), + ("Include Stroke Weight", "Inkludera konturtjocklek"), + ("Keep Violations", "Behåll regelbrott"), + ("Overprint [Black] Swatch at 100%", "Övertryck färgrutan [Black] vid 100 %"), + ("Printer's Marks", "Tryckmärken"), + ("Remove Styles and Formatting from Text and Tables", "Ta bort format och formatering från text och tabeller"), + ("Ruby", "Ruby"), + ("Show Note Anchors in Layout View", "Visa anteckningsankare i layoutvyn"), + ("Spreads", "Uppslag"), + ("Export PDF", "Exportera PDF"), + ("Standard", "Standard"), + ("Standard:", "Standard:"), + ("Compress images", "Komprimera bilder"), + ("Flattener preset", "Förinställning för tillplattning"), + ("Include document bleed", "Inkludera dokumentets utfall"), + ("Crop Marks", "Skärmärken"), + ("Bleed Marks", "Utfallsmärken"), + ("Page Information", "Sidinformation"), + ("Page Range", "Sidomfång"), + ("Page Range:", "Sidomfång:"), + ("Tagged PDF", "Taggad PDF"), + ("Desktop Printing", "Skrivbordsutskrift"), + ("Commercial Printing", "Kommersiellt tryck"), + ("Screen and Email", "Skärm och e-post"), + ("PDF/X-4", "PDF/X-4"), + ("PDF/A-2b", "PDF/A-2b"), + ("Compression", "Komprimering"), + ("Marks and Bleeds", "Märken och utfall"), + ("Preset:", "Förinställning:"), + ("Substituted Fonts", "Ersatta typsnitt"), + ("Transformations are Totals", "Transformeringar anges som totalvärden"), + ("Use Black Point Compensation", "Använd svartpunktskompensering"), + ("Use Typographer's Quotes", "Använd typografiska citattecken"), + ("Whole word", "Hela ord"), + (" Close ", " Stäng "), + ("(Top Level)", "(Översta nivån)"), + ("(import)", "(import)"), + ("Accept All", "Acceptera alla"), + ("Add Document…", "Lägg till dokument…"), + ("Add to Previous Spread", "Lägg till i föregående uppslag"), + ("Align From:", "Justera från:"), + ("Align to baseline grid", "Justera mot baslinjerutnät"), + ("All Spots to Process", "Alla dekorfärger till processfärger"), + ("Allow Spread to Shuffle", "Tillåt omflyttning av uppslag"), + ("Alternating Row Fills", "Alternerande radfyllningar"), + ("Apply Gradient", "Använd gradient"), + ("Apply None (/)", "Använd ingen (/)"), + ("Apply Parent", "Använd mallsida"), + ("Apply to All Spreads", "Använd på alla uppslag"), + ("Apply to Stroke", "Använd på kontur"), + ("Art", "Konst"), + ("Baseline Grid", "Baslinjerutnät"), + ("Bleed and Slug", "Utfall och informationsområde"), + ("Body Rows", "Brödtextrader"), + ("Break Link to Style", "Bryt länk till stil"), + ("Bullets and Numbering", "Punkter och numrering"), + ("Business Card", "Visitkort"), + ("Buttons act in interactive PDF export.", "Knappar fungerar vid export till interaktiv PDF."), + ("Change to:", "Ändra till:"), + ("Character Settings", "Teckeninställningar"), + ("Characters", "Tecken"), + ("Checked by default", "Ikryssad som standard"), + ("Choices", "Val"), + ("Choose Image…", "Välj bild…"), + ("Choose a link.", "Välj en länk."), + ("Content Fitting", "Innehållsanpassning"), + ("Convert Shape", "Konvertera form"), + ("Copies:", "Kopior:"), + ("Count", "Antal"), + ("Create", "Skapa"), + ("Create Alternate Layout", "Skapa alternativ layout"), + ("Create Table…", "Skapa tabell…"), + ("Crop", "Beskär"), + ("Crop Amount", "Beskärningsmängd"), + ("Crop to:", "Beskär till:"), + ("Cross-Reference Format", "Korsreferensformat"), + ("Cursor Key:", "Piltangent:"), + ("Default Fill and Stroke", "Standardfyllning och -kontur"), + ("Default Fill and Stroke (D)", "Standardfyllning och -kontur (D)"), + ("Default View:", "Standardvy:"), + ("Delete Column", "Ta bort kolumn"), + ("Delete Condition", "Radera villkor"), + ("Delete Row", "Ta bort rad"), + ("Delete Swatch", "Radera färgruta"), + ("Delete Unused Layers", "Radera oanvända lager"), + ("Delete Workspace", "Radera arbetslayout"), + ("Document Grid", "Dokumentrutnät"), + ("Document Recovery Data", "Återställningsdata för dokument"), + ("Duplicate Spread", "Duplicera uppslag"), + ("Duplicate Spread at End of Document", "Duplicera uppslag i slutet av dokumentet"), + ("Delete Spread", "Radera uppslag"), + ("Parent Pages", "Mallsidor"), + ("Apply Parent to Pages", "Använd mallsida på sidor"), + ("Email", "E-post"), + ("Embed", "Bädda in"), + ("Fast", "Snabb"), + ("Fill (X)", "Fyllning (X)"), + ("Fill with Placeholder Text", "Fyll med platshållartext"), + ("Find All", "Sök alla"), + ("Find what:", "Sök efter:"), + ("Fit frame height to text", "Anpassa ramhöjden till texten"), + ("Fitting:", "Anpassning:"), + ("Flip Horizontal", "Vänd vågrätt"), + ("Flip Vertical", "Vänd lodrätt"), + ("Font Size:", "Teckenstorlek:"), + ("Form Field", "Formulärfält"), + ("Format:", "Format:"), + ("Height:", "Höjd:"), + ("Hide Bar", "Dölj fältet"), + ("High Quality", "Hög kvalitet"), + ("Highlight", "Högdager"), + ("Horizontal Gridline Every:", "Horisontell stödlinje var:"), + ("Horizontal Margins:", "Horisontella marginaler:"), + ("Import XML…", "Importera XML…"), + ("Increment Every:", "Öka var:"), + ("Ink Manager", "Tryckfärgshanterare"), + ("Insert Column", "Infoga kolumn"), + ("Insert Page After", "Infoga sida efter"), + ("Insert Row", "Infoga rad"), + ("Inset spacing", "Infällningsavstånd"), + ("Intent:", "Återgivningsmetod:"), + ("Invert", "Invertera"), + ("Keyboard Increments", "Tangentbordssteg"), + ("Kind:", "Typ:"), + ("Link To:", "Länka till:"), + ("Liquid guides", "Flytande stödlinjer"), + ("Load Profile…", "Läs in profil…"), + ("Load Swatches…", "Läs in färgrutor…"), + ("Load…", "Läs in…"), + ("Media", "Media"), + ("Merge into Active Layer", "Sammanfoga med aktivt lager"), + ("Misspelled word → Correction (one per line):", "Felstavat ord → Korrigering (ett per rad):"), + ("Mobile", "Mobil"), + ("More ArtCraft apps", "Fler ArtCraft-appar"), + ("Move Selection Here", "Flytta markeringen hit"), + ("Move to Color Group", "Flytta till färggrupp"), + ("Move to Group", "Flytta till grupp"), + ("Multiline", "Multilinje"), + ("Multiple Objects", "Flera objekt"), + ("Name:", "Namn:"), + ("New Bookmark for This Page", "Nytt bokmärke för den här sidan"), + ("New Book…", "Ny bok…"), + ("New Color Group", "Ny färggrupp"), + ("New Color Group with Swatch", "Ny färggrupp med färgruta"), + ("New Library…", "Nytt bibliotek…"), + ("New Workspace…", "Ny arbetslayout…"), + ("No articles: exports follow page order.", "Inga artiklar: exporten följer sidordningen."), + ("No book open.", "Ingen bok är öppen."), + ("No bookmarks. They become the PDF outline.", "Inga bokmärken. De blir PDF-filens disposition."), + ("No changes.", "Inga ändringar."), + ( + "No conditions. Create one, then select text and click it to apply.", + "Inga villkor. Skapa ett, markera sedan text och klicka på villkoret för att använda det.", + ), + ("No document.", "Inget dokument."), + ( + "No hyperlinks. Select text or a frame, type a URL and click New.", + "Inga hyperlänkar. Markera text eller en ram, skriv en URL och klicka på Nytt.", + ), + ("No library open.", "Inget bibliotek är öppet."), + ("No notes in this document.", "Det finns inga anteckningar i det här dokumentet."), + ("No placed graphics.", "Ingen placerad grafik."), + ("No scripts. Add one below.", "Inga skript. Lägg till ett nedan."), + ("No spot inks", "Inga dekorfärger"), + ("No text anchors in this document.", "Det finns inga textankare i det här dokumentet."), + ("Number of Pages:", "Antal sidor:"), + ("Number of Sides:", "Antal sidor:"), + ("Number of columns", "Antal spalter"), + ("OK changes all: text and paragraph/character styles.", "OK ändrar allt: text samt stycke- och teckenstilar."), + ("OVERSET", "ÖVERSKJUTANDE"), + ("On Release", "Vid släpp"), + ("On Screen:", "På skärmen:"), + ("Open source. No telemetry.", "Öppen källkod. Ingen telemetri."), + ("Options for Black on RGB and Grayscale Devices", "Alternativ för svart på RGB- och gråskaleenheter"), + ("Orientation:", "Orientering:"), + ("Page Numbering", "Sidnumrering"), + ("Page · PPI", "Sida · PPI"), + ("Pages:", "Sidor:"), + ("Part of the ArtCraft suite of open creative tools.", "Del av ArtCraft-sviten av öppna kreativa verktyg."), + ("Pasteboard Options", "Alternativ för montageyta"), + ("Path", "Bana"), + ("Place the insertion point in a table, or create one.", "Placera insättningspunkten i en tabell eller skapa en."), + ("Plain Text", "Oformaterad text"), + ("Plays in EPUB export; print and PDF show the poster.", "Spelas upp vid EPUB-export; utskrift och PDF visar affischen."), + ("Poster", "Affisch"), + ("Printer:", "Skrivare:"), + ("Printing / Exporting:", "Utskrift/export:"), + ("Properties Panel", "Egenskapspanel"), + ("Recently Used", "Senast använda"), + ("Reject All", "Avvisa alla"), + ("Relative To:", "Relativt till:"), + ("Relink to Folder…", "Länka om till mapp…"), + ("Relink…", "Länka om…"), + ("Remove Conditions from Selection", "Ta bort villkor från markeringen"), + ("Repeat Header Rows", "Upprepa rubrikrader"), + ("Replace With", "Ersätt med"), + ("Required", "Obligatoriskt"), + ("Reset All to Defaults", "Återställ alla till standardvärden"), + ("Reverse", "Omvänd"), + ("Right-click: stroke, groups", "Högerklicka: kontur, grupper"), + ("Rotate 90° Clockwise", "Rotera 90° medurs"), + ("Ruler Units", "Linjalenheter"), + ("Save Swatches for Exchange…", "Spara färgrutor för utbyte…"), + ("Save recovery data every:", "Spara återställningsdata var:"), + ("Saves which bars show and where the panels are.", "Sparar vilka fält som visas och var panelerna finns."), + ("Search:", "Sök:"), + ( + "Select a placed video or sound (File › Place a .mp4, .mov, .mp3, .wav …).", + "Markera en placerad video eller ett placerat ljud (Arkiv › Placera en .mp4, .mov, .mp3, .wav …).", + ), + ("Select an object or text to color its fill or stroke.", "Markera ett objekt eller text för att färglägga fyllning eller kontur."), + ("Select an object to give its fill a gradient.", "Markera ett objekt för att ge dess fyllning en gradient."), + ("Select an object to make it a button.", "Markera ett objekt för att göra det till en knapp."), + ("Select an object.", "Markera ett objekt."), + ("Select text or a text frame.", "Markera text eller en textram."), + ( + "Select two or more objects (or a group) to make a multi-state object.", + "Markera två eller fler objekt (eller en grupp) för att skapa ett objekt med flera lägen.", + ), + ("Show Tag Markers", "Visa taggmarkörer"), + ("Single-line", "Enradig"), + ("Smart Text Reflow", "Smart textomflödning"), + ("Speed", "Hastighet"), + ("Star Inset:", "Stjärninfällning:"), + ("Start Page #:", "Startsida nr:"), + ("Start:", "Start:"), + ("Status", "Status"), + ("Stroke (X)", "Kontur (X)"), + ("Style Mapping", "Stilmappning"), + ("Style Name Conflicts", "Stilnamnskonflikter"), + ("Stylistic Sets", "Stilistiska uppsättningar"), + ("Subdivisions:", "Underindelningar:"), + ("Swap Fill and Stroke", "Byt plats på fyllning och kontur"), + ("Swap Fill and Stroke (Shift+X)", "Byt plats på fyllning och kontur (Shift+X)"), + ("Synchronize", "Synkronisera"), + ("System default", "Systemstandard"), + ("Table Dimensions", "Tabelldimensioner"), + ("Text Display Options", "Alternativ för textvisning"), + ("Text Message", "Textmeddelande"), + ("The fill isn't a gradient.", "Fyllningen är inte en gradient."), + ("The library is empty. Select objects and Add Selection.", "Biblioteket är tomt. Markera objekt och välj Lägg till markering."), + ("This PDF has no layers.", "Den här PDF-filen har inga lager."), + ("Track Changes in All Stories", "Spåra ändringar i alla artiklar"), + ("Transitions play in interactive PDF (full-screen mode).", "Övergångar spelas upp i interaktiv PDF (helskärmsläge)."), + ("Trim", "Trimma"), + ("Type Options", "Textalternativ"), + ("Typical", "Normal"), + ("UI Size:", "Gränssnittsstorlek:"), + ("Ungroup Color Group", "Dela upp färggrupp"), + ("Unmerge", "Dela"), + ("Update Numbering", "Uppdatera numrering"), + ("Use Global Light", "Använd globalt ljus"), + ("User Dictionary (this document)", "Användarordlista (det här dokumentet)"), + ("Variable Font", "Variabelt typsnitt"), + ("Vertical Gridline Every:", "Vertikal stödlinje var:"), + ("Vertical Margins:", "Vertikala marginaler:"), + ("Vertical justification", "Vertikal justering"), + ("View Threshold:", "Visningströskel:"), + ("Web", "Webb"), + ("Web Hyperlink", "Webbhyperlänk"), + ("When Scaling", "Vid skalning"), + ("Words, one per line:", "Ord, ett per rad:"), + ("[Basic] (working) ▾", "[Grundläggande] (arbetar) ▾"), + ("[Black] Overprint", "[Svart] övertryck"), + ("[No Group]", "[Ingen grupp]"), + ("[None] — choose a colour on the ramp.", "[Ingen] — välj en färg på färgrampen."), + ("⌥-click clears overrides", "⌥-klicka för att rensa lokala ändringar"), + ("⚠ out of date", "⚠ inaktuell"), + ("Interface", "Gränssnitt"), + ("Advanced Type", "Avancerad typografi"), + ("Composition", "Komposition"), + ("Show Font Names in English", "Visa typsnittsnamn på engelska"), + ("Missing Glyphs", "Saknade glyfer"), + ("Draw Missing Glyphs from Fallback Fonts", "Rita saknade glyfer från reservtypsnitt"), + ("Units & Increments", "Enheter och steg"), + ("Grids", "Rutnät"), + ("Guides & Pasteboard", "Stödlinjer och montageyta"), + ("Dictionary", "Ordlista"), + ("Spelling", "Stavning"), + ("Autocorrect", "Autokorrigering"), + ("Track Changes", "Spåra ändringar"), + ("Story Editor Display", "Visning i artikelredigeraren"), + ("Display Performance", "Visningsprestanda"), + ("Appearance of Black", "Utseende för svart"), + ("File Handling", "Filhantering"), + ("Formatting affects container (J)", "Formateringen påverkar behållaren (J)"), + ("Formatting affects text (J)", "Formateringen påverkar texten (J)"), + ("Apply Color", "Använd färg"), + ("Repeat", "Upprepa"), + ("Count:", "Antal:"), + ("Create as a grid", "Skapa som rutnät"), + ("Rows:", "Rader:"), + ("Columns:", "Kolumner:"), + ("Vertical:", "Vertikalt:"), + ("Horizontal:", "Horisontellt:"), + ("Invalid value", "Ogiltigt värde"), + ("Isolate Blending", "Isolera blandning"), + ("Knockout Group", "Utsparningsgrupp"), + ("Object:", "Objekt:"), + ("Group:", "Grupp:"), + ("Sample Color (Eyedropper)", "Ta färgprov (pipett)"), + ("Apply None", "Använd ingen"), + ("Height Only", "Endast höjd"), + ("Width Only", "Endast bredd"), + ("Height and Width", "Höjd och bredd"), + ("Height only", "Endast höjd"), + ("Width only", "Endast bredd"), + ("Height and width", "Höjd och bredd"), + ("Version", "Version"), + ("{count} characters don't fit", "{count} tecken får inte plats"), + ("document", "dokument"), + ("story", "artikel"), + ("selection", "markering"), + ("top", "överkant"), + ("center", "center"), + ("bottom", "nederkant"), + ("justify", "marginaljustera"), + ("text", "text"), + ("graphic", "grafik"), + ("unassigned", "ej tilldelad"), + ("Perceptual", "Perceptuell"), + ("Relative Colorimetric", "Relativ kolorimetrisk"), + ("Saturation", "Mättnad"), + ("Absolute Colorimetric", "Absolut kolorimetrisk"), + ("No conflicts", "Inga konflikter"), + ("Page (1–{count}):", "Sida (1–{count}):"), + ("Fonts in Document: {count} Missing: {missing}", "Typsnitt i dokumentet: {count} Saknas: {missing}"), + ("Story Direction", "Artikelriktning"), + ("Tate-Chu-Yoko", "Tate-chu-yoko"), + ("Ruby…", "Ruby…"), + ("Kenten", "Kenten"), + ("Warichu", "Warichu"), + ("Tabs", "Tabbstopp"), + ("Story", "Artikel"), + ("Create Outlines", "Skapa konturbanor"), + ("Show Hidden Characters", "Visa dolda tecken"), + ("Unmerge Cells", "Dela celler"), + ("Snap to Guides", "Fäst mot stödlinjer"), + ("Snap to Document Grid", "Fäst mot dokumentrutnät"), + ("Align to Object Edges", "Justera mot objektens kanter"), + ("Align to Object Centers", "Justera mot objektens mittpunkter"), + ("Smart Dimensions", "Smarta mått"), + ("Smart Spacing", "Smarta avstånd"), + ("Snap to Zone", "Fäst mot zon"), + ("Info", "Info"), + ("Index", "Register"), + ("Articles", "Artiklar"), + ("Tags", "Taggar"), + ("Object States", "Objekttillstånd"), + ("Liquid Layout", "Flytande layout"), + ("Split Window", "Dela fönster"), + ("New Window", "Nytt fönster"), + ("CMYK", "CMYK"), + ("Lab", "Lab"), + ("OpenType", "OpenType"), + ("RGB", "RGB"), + ("Accept All Changes", "Acceptera alla ändringar"), + ("Activate Document", "Aktivera dokument"), + ("Add Anchor Point", "Lägg till ankarpunkt"), + ("Add Document", "Lägg till dokument"), + ("Add Hyphenation Exception", "Lägg till avstavningsundantag"), + ("Add Item", "Lägg till objekt"), + ("Add Page to Spread", "Lägg till sida i uppslag"), + ("Allow Selected Spread to Shuffle", "Tillåt omflyttning av markerat uppslag"), + ("Anchored Object Options…", "Alternativ för förankrat objekt…"), + ("Anchored Objects", "Förankrade objekt"), + ("Apply Attributes (Eyedropper)", "Använd attribut (pipett)"), + ("Apply Cell Style", "Använd cellstil"), + ("Apply Condition", "Använd villkor"), + ("Apply Parent to Pages…", "Använd mallsida på sidor…"), + ("ArtCraft App Page…", "ArtCrafts appsida…"), + ("ArtCraft Website…", "ArtCrafts webbplats…"), + ("Attributes", "Attribut"), + ("Bevel and Emboss", "Fasning och relief"), + ("Buttons", "Knappar"), + ("Cell Style Options…", "Alternativ för cellstil…"), + ("Change All (Object)", "Ändra alla (objekt)"), + ("Changes", "Ändringar"), + ("Character Formatting", "Teckenformatering"), + ("Clear Conveyor", "Rensa transportband"), + ("Clear Transformations", "Rensa transformeringar"), + ("Clipping Path…", "Klippbana…"), + ("Close Book", "Stäng bok"), + ("Close Library", "Stäng bibliotek"), + ("Collect", "Samla"), + ("Color Settings…", "Färginställningar…"), + ("Community & Project Links", "Länkar till gemenskap och projekt"), + ("Container", "Behållare"), + ("Content", "Innehåll"), + ("Contextual Task Bar", "Kontextuellt aktivitetsfält"), + ("Control", "Kontroll"), + ("Convert Cell to Graphic Cell", "Konvertera cell till grafikcell"), + ("Convert Cell to Graphic Cell…", "Konvertera cell till grafikcell…"), + ("Convert Cell to Text Cell", "Konvertera cell till textcell"), + ("Convert Color", "Konvertera färg"), + ("Convert Direction Point", "Konvertera riktningspunkt"), + ("Convert Note to Text", "Konvertera anteckning till text"), + ("Convert Table to Text", "Konvertera tabell till text"), + ("Convert Text to Table", "Konvertera text till tabell"), + ("Convert to Button", "Konvertera till knapp"), + ("Convert to Form Field", "Konvertera till formulärfält"), + ("Copy Link(s) To…", "Kopiera länk(ar) till…"), + ("Corner Options…", "Hörnalternativ…"), + ("Create Alternate Layout…", "Skapa alternativ layout…"), + ("Create Frame", "Skapa ram"), + ("Create Merged Document…", "Skapa sammanfogat dokument…"), + ("Cross-Reference Formats", "Korsreferensformat"), + ("Cross-References", "Korsreferenser"), + ("Data Merge Fields", "Fält för datasammanfogning"), + ("Define Cross-Reference Format…", "Definiera korsreferensformat…"), + ("Define Lists…", "Definiera listor…"), + ("Define Text Variable…", "Definiera textvariabel…"), + ("Delete All Guides on Spread", "Radera alla stödlinjer på uppslaget"), + ("Delete Anchor Point", "Radera ankarpunkt"), + ("Delete Item", "Radera objekt"), + ("Delete Text Variable", "Radera textvariabel"), + ("Delete Workspace…", "Radera arbetslayout…"), + ("DesignCraft Website…", "DesignCrafts webbplats…"), + ("DesignCraft on GitHub…", "DesignCraft på GitHub…"), + ("Detach All Objects from Parent", "Koppla bort alla objekt från mallsidan"), + ("Directional Feather", "Riktad fjädring"), + ("Distribute", "Fördela"), + ("Dock Panel", "Docka panel"), + ("Document Endnote Options…", "Alternativ för dokumentets slutnoter…"), + ("Document Preferences", "Inställningar för dokument"), + ("Document Presets", "Dokumentförinställningar"), + ("Document…", "Dokument…"), + ("Edit Parents", "Redigera mallsidor"), + ("Edit Story", "Redigera text"), + ("Endnotes", "Slutnoter"), + ("Entire Pasteboard", "Hela montageytan"), + ("Erase", "Radera"), + ("Export Book to PDF…", "Exportera bok till PDF…"), + ("Export EPUB (Reflowable)…", "Exportera EPUB (flytande)…"), + ("Export Selection as Snippet", "Exportera markering som fragment"), + ("Extend Text Selection", "Utöka textmarkering"), + ("Fast Display", "Snabb visning"), + ("Find Object", "Sök objekt"), + ("Find by Script Label", "Sök efter skriptetikett"), + ("First Object Above", "Första objektet ovanför"), + ("Fit Selection in Window", "Anpassa markeringen till fönstret"), + ("Flattener Preview", "Förhandsgranskning av tillplattning"), + ("Float Panel", "Gör panelen flytande"), + ("Fonts in Document", "Typsnitt i dokumentet"), + ("Form Fields", "Formulärfält"), + ("Frame Fitting Options…", "Alternativ för ramanpassning…"), + ("Generate Index…", "Generera register…"), + ("Generate QR Code…", "Generera QR-kod…"), + ("Generate Static Caption", "Generera statisk bildtext"), + ("Get Story", "Hämta artikel"), + ("Get Table", "Hämta tabell"), + ("Global Light…", "Globalt ljus…"), + ("Go To Link", "Gå till länk"), + ("Go To Source", "Gå till källa"), + ("Gradient Feather", "Gradientfjädring"), + ("Gridify", "Placera i rutnät"), + ("Group Transparency", "Grupptransparens"), + ("Hide Menu Item", "Dölj menyalternativ"), + ("Hide/Lock Others", "Dölj/lås övriga"), + ("High Quality Display", "Visning med hög kvalitet"), + ("History", "Historik"), + ("Import Options: Styles", "Importalternativ: Stilar"), + ("Index Page References", "Sidreferenser i registret"), + ("Inner Glow", "Inre glöd"), + ("Inner Shadow", "Inre skugga"), + ("Insert Anchored Object…", "Infoga förankrat objekt…"), + ("Insert Cross-Reference…", "Infoga korsreferens…"), + ("Insert Endnote", "Infoga slutnot"), + ("Insert Math Expression…", "Infoga matematiskt uttryck…"), + ("Insert Pages…", "Infoga sidor…"), + ("Insert Variable", "Infoga variabel"), + ("Inspect Document", "Inspektera dokumentet"), + ("Interface Color Theme", "Färgtema för gränssnittet"), + ("Join the ArtCraft Discord…", "Gå med i ArtCraft på Discord…"), + ("Last Object Below", "Sista objektet nedanför"), + ("Library Contents", "Biblioteksinnehåll"), + ("Library Items", "Biblioteksobjekt"), + ("Liquid Page Rule", "Regel för flytande sida"), + ("List Documents", "Lista dokument"), + ("List Styles", "Lista stilar"), + ("List Tools", "Lista verktyg"), + ("Load Place Cursor", "Läs in placeringsmarkör"), + ("Make Compound Path", "Skapa sammansatt bana"), + ("Map Styles to Tags", "Mappa stilar till taggar"), + ("Math Expression SVG", "SVG för matematiska uttryck"), + ("Media Options", "Mediealternativ"), + ("Menus…", "Menyer…"), + ("Merge Layers", "Sammanfoga lager"), + ("Move Caret", "Flytta textmarkören"), + ("Move Gap", "Flytta mellanrum"), + ("Move Row", "Flytta rad"), + ("New Book", "Ny bok"), + ("New Cell Style…", "Ny cellstil…"), + ("New Color Swatch…", "Ny färgruta…"), + ("New Condition…", "Nytt villkor…"), + ("New Guide", "Ny stödlinje"), + ("New Hyperlink Destination…", "Nytt hyperlänkmål…"), + ("New Hyperlink…", "Ny hyperlänk…"), + ("New Library", "Nytt bibliotek"), + ("New Page Reference…", "Ny sidreferens…"), + ("New Stroke Style…", "Ny konturstil…"), + ("New Table Style…", "Ny tabellstil…"), + ("Next Cell", "Nästa cell"), + ("Next Document", "Nästa dokument"), + ("Next Object Above", "Nästa objekt ovanför"), + ("Next Object Below", "Nästa objekt nedanför"), + ("Next Object in Group", "Nästa objekt i gruppen"), + ("Object Export Options", "Exportalternativ för objekt"), + ("Object Export Options…", "Exportalternativ för objekt…"), + ("Object Layer Options…", "Lageralternativ för objekt…"), + ("Object Layers", "Objektlager"), + ("Object Liquid Settings", "Inställningar för flexibel layout"), + ("Open Book", "Öppna bok"), + ("Open Bytes", "Öppna byte"), + ("Open IDML", "Öppna IDML"), + ("Open Library", "Öppna bibliotek"), + ("Outer Glow", "Yttre glöd"), + ("Overprint Preview", "Förhandsgranskning av övertryck"), + ("Override All Parent Page Items", "Ändra alla objekt från mallsidan lokalt"), + ("Page Transition", "Sidövergång"), + ("Page Transitions", "Sidövergångar"), + ("Paragraph Formatting", "Styckeformatering"), + ("Paste Into", "Klistra in i"), + ("Paste without Formatting", "Klistra in utan formatering"), + ("Place Caret", "Placera insättningspunkt"), + ("Place Item", "Placera objekt"), + ("Place Snippet", "Placera fragment"), + ("Place and Link", "Placera och länka"), + ("Polygon Settings…", "Polygoninställningar…"), + ("Preflight Document", "Förhandskontrollera dokument"), + ("Previous Cell", "Föregående cell"), + ("Previous Document", "Föregående dokument"), + ("Previous Object in Group", "Föregående objekt i gruppen"), + ("Printers", "Skrivare"), + ("Proof Colors", "Provtrycksfärger"), + ("Proof Setup", "Provtrycksinställningar"), + ("Quick Apply…", "Snabbanvänd…"), + ("Recover Documents", "Återställ dokument"), + ("Recovery Data", "Återställningsdata"), + ("Reject All Changes", "Avvisa alla ändringar"), + ("Release Compound Path", "Släpp sammansatt bana"), + ("Remove All Local Overrides", "Ta bort alla lokala ändringar"), + ("Remove Document", "Ta bort dokument"), + ("Report an Issue…", "Rapportera ett problem…"), + ("Reset Shortcuts", "Återställ kortkommandon"), + ("Reset Workspace", "Återställ arbetslayout"), + ("Resize", "Ändra storlek"), + ("Rotate Spread", "Rotera uppslag"), + ("Run Script", "Kör skript"), + ("Sample Document", "Exempeldokument"), + ("Satin", "Satin"), + ("Save Recovery Data", "Spara återställningsdata"), + ("Script Label", "Skriptetikett"), + ("Select All on Layer", "Markera allt på lagret"), + ("Select Cells", "Markera celler"), + ("Select Column", "Markera kolumn"), + ("Select Frame", "Markera ram"), + ("Select Row", "Markera rad"), + ("Select Table", "Markera tabell"), + ("Select Text", "Markera text"), + ("Select Tool", "Markeringsverktyg"), + ("Select Word", "Markera ord"), + ("Selection Attributes", "Markeringsattribut"), + ("Separations Preview", "Förhandsgranskning av separationer"), + ("Serialize", "Serialisera"), + ("Set Active Layer", "Ange aktivt lager"), + ("Set Shortcut", "Ange kortkommando"), + ("Set Visibility / Lock", "Ange synlighet/lås"), + ("Show Panel", "Visa panel"), + ("Show/Hide Baseline Grid", "Visa/dölj baslinjerutnät"), + ("Show/Hide Frame Edges", "Visa/dölj ramkanter"), + ("Show/Hide Guides", "Visa/dölj stödlinjer"), + ("Show/Hide Hidden Characters", "Visa/dölj dolda tecken"), + ("Show/Hide Panels", "Visa/dölj paneler"), + ("Show/Hide Panels Except Tools", "Visa/dölj paneler utom verktyg"), + ("Show/Hide Rulers", "Visa/dölj linjaler"), + ("Show/Hide Tagged Frames", "Visa/dölj taggade ramar"), + ("Show/Hide Text Threads", "Visa/dölj textflöden"), + ("Smooth", "Mjuk"), + ("Sort", "Sortera"), + ("Split Path", "Dela bana"), + ("Step and Repeat…", "Steg och upprepa…"), + ("Stroke Styles", "Konturstilar"), + ("Style Source", "Stilkälla"), + ("Synchronize Book", "Synkronisera bok"), + ("Table Panel", "Tabellpanel"), + ("Table Style Options…", "Alternativ för tabellstil…"), + ("Table of Contents Entries", "Poster i innehållsförteckningen"), + ("Table of Contents…", "Innehållsförteckning…"), + ("Text Anchors", "Textankare"), + ("Text Variables", "Textvariabler"), + ("Toggle Normal/Preview", "Växla normal/förhandsgranskning"), + ("Toggle Selection", "Växla markering"), + ("Tools: Double Column", "Verktyg: dubbel kolumn"), + ("Transform Panel", "Transformeringspanel"), + ("Transform Values", "Transformeringsvärden"), + ("Transparency Blend Space", "Blandningsrymd för transparens"), + ("Transparency Flattener Presets", "Förinställningar för transparensutjämning"), + ("Type on a Path", "Text på bana"), + ("Type on a Path Options…", "Alternativ för text på bana…"), + ("Typical Display", "Normal visning"), + ("Update Link", "Uppdatera länk"), + ("User Dictionary Words", "Ord i användarordlista"), + ("User Dictionary…", "Användarordlista…"), + ("Zoom To", "Zooma till"), + ("⇧", "⇧"), + ("⌃", "⌃"), + ("⌘", "⌘"), + ("⌥", "⌥"), + ("Accept Change", "Acceptera ändring"), + ("Add Anchor", "Lägg till ankare"), + ("Add Selection to Article", "Lägg till markering i artikel"), + ("Add Unnamed Colors", "Lägg till namnlösa färger"), + ("Add to Dictionary", "Lägg till i ordlistan"), + ("Align Center", "Centrera"), + ("Align Left", "Justera vänster"), + ("Align Right", "Justera höger"), + ("Apply Character Style", "Använd teckenstil"), + ("Apply Object Style", "Använd objektstil"), + ("Apply Paragraph Style", "Använd styckestil"), + ("Apply Table Style", "Använd tabellstil"), + ("Article Options", "Artikelalternativ"), + ("Basic Feather", "Grundläggande fjädring"), + ("Bold", "Fet"), + ("Character Style Options…", "Alternativ för teckenstil…"), + ("Close Path", "Stäng bana"), + ("Color Group Options", "Alternativ för färggrupp"), + ("Condition Options…", "Villkorsalternativ…"), + ("Convert to Liquid Guide", "Konvertera till flytande stödlinje"), + ("Convert to Object", "Konvertera till objekt"), + ("Create Line", "Skapa linje"), + ("Create Path", "Skapa bana"), + ("Decrease Point Size", "Minska punktstorlek"), + ("Delete Article", "Radera artikel"), + ("Delete Bookmark", "Ta bort bokmärke"), + ("Delete Endnote", "Radera slutnot"), + ("Delete Footnote", "Radera fotnot"), + ("Delete Guide", "Radera stödlinje"), + ("Delete Hyperlink", "Radera hyperlänk"), + ("Delete List", "Radera lista"), + ("Delete Note", "Radera anteckning"), + ("Delete Pages", "Radera sidor"), + ("Delete Paragraph Style", "Radera styckestil"), + ("Delete Stroke Style", "Radera konturstil"), + ("Delete Table", "Ta bort tabell"), + ("Delete Tag", "Radera tagg"), + ("Delete Text", "Radera text"), + ("Distribute Columns Evenly", "Fördela kolumner jämnt"), + ("Edit Endnote", "Redigera slutnot"), + ("Edit Note", "Redigera anteckning"), + ("Embed Link", "Bädda in länk"), + ("Flip", "Vänd"), + ("Hyperlink Options…", "Hyperlänkalternativ…"), + ("Increase Point Size", "Öka punktstorlek"), + ("Insert Column Left", "Infoga kolumn till vänster"), + ("Insert Column Right", "Infoga kolumn till höger"), + ("Insert Row Above", "Infoga rad ovanför"), + ("Insert Row Below", "Infoga rad nedanför"), + ("Italic", "Kursiv"), + ("Justify Left", "Marginaljustera vänster"), + ("Layer Options…", "Lageralternativ…"), + ("Move Anchors", "Flytta ankarpunkter"), + ("Move Column", "Flytta kolumn"), + ("Move Guide", "Flytta stödlinje"), + ("Move Layer", "Flytta lager"), + ("Move Pages…", "Flytta sidor…"), + ("Move to Layer", "Flytta till lager"), + ("New Article", "Ny artikel"), + ("New Bookmark", "Nytt bokmärke"), + ("New Layer…", "Nytt lager…"), + ("New Note", "Ny anteckning"), + ("New Object Style…", "Ny objektstil…"), + ("New Parent…", "Ny mallsida…"), + ("New Tag", "Ny tagg"), + ("Paste in Place", "Klistra in på plats"), + ("Place Loaded Graphic", "Placera inläst grafik"), + ("Reject Change", "Avvisa ändring"), + ("Release State to Objects", "Släpp tillstånd till objekt"), + ("Remove Hyphenation Exception", "Ta bort avstavningsundantag"), + ("Remove from Article", "Ta bort från artikel"), + ("Rename", "Byt namn"), + ("Rename Bookmark", "Byt namn på bokmärke"), + ("Rotate", "Rotera"), + ("Scale", "Skala"), + ("Set Cross-Reference Format", "Ange korsreferensformat"), + ("Set Footnote Text", "Ange fotnotstext"), + ("Set Story Text", "Ange artikeltext"), + ("Shear", "Skjuva"), + ("Show All on Spread", "Visa allt på uppslaget"), + ("Show State", "Visa tillstånd"), + ("State Options", "Tillståndsalternativ"), + ("Tag Frame", "Tagga ram"), + ("Tag Text", "Tagga text"), + ("Unlink", "Ta bort länk"), + ("Update Index", "Uppdatera register"), + ("Update Table of Contents", "Uppdatera innehållsförteckning"), + ("Bulleted and Numbered Lists", "Punktlistor och numrerade listor"), + ("Hyperlinks Cross-References", "Hyperlänkar, korsreferenser"), + ("Document Footnote Options...", "Alternativ för dokumentets fotnoter..."), + ("Anchored Object", "Förankrat objekt"), + ("Apply Attributes (Eyedropper", "Använd attribut (pipett"), + ("Captions", "Bildtexter"), + ("Change All (Object", "Ändra alla (objekt"), + ("Clipping Path", "Klippbana"), + ( + "Collect objects with the Content Collector (B); place them with the Content Placer.", + "Samla objekt med innehållsinsamlaren (B) och placera dem med innehållsplaceraren.", + ), + ("Convert Cell Type", "Konvertera celltyp"), + ("Copy Link(s", "Kopiera länk(ar"), + ("DesignCraft", "DesignCraft"), + ("Export EPUB (Fixed Layout", "Exportera EPUB (fast layout"), + ("Export EPUB (Reflowable", "Exportera EPUB (flytande"), + ("Hyperlinks & Cross-References", "Hyperlänkar och korsreferenser"), + ("Hyphens and Dashes", "Bindestreck och tankstreck"), + ("Markers", "Markörer"), + ("Other", "Annat"), + ("Output", "Utdata"), + ("Quotation Marks", "Citattecken"), + ("Symbols", "Symboler"), + ("Type & Tables", "Text och tabeller"), + ( + "Type a word with ~ at each allowed break (ex~am~ple); without ~ the word is never hyphenated.", + "Skriv ett ord med ~ vid varje tillåten avstavning (ex~em~pel); utan ~ avstavas ordet aldrig.", + ), + ("Utilities", "Verktyg"), + ("Paragraph Rules…", "Styckelinjer…"), + ("Paragraph Rules", "Styckelinjer"), + ("Rule Below", "Linje nedanför"), + ("90° CCW", "90° moturs"), + ("90° CW", "90° medurs"), + ("A, B, C, D...", "A, B, C, D..."), + ("All records", "Alla poster"), + ("Alpha Channel", "Alfakanal"), + ("Anchored Object Options", "Alternativ för förankrat objekt"), + ("ArtCraft App Page", "ArtCrafts appsida"), + ("ArtCraft Website", "ArtCrafts webbplats"), + ("Bind to frame", "Koppla till ramen"), + ("Black", "Svart"), + ("Bleed:", "Utfall:"), + ("Bullet Character", "Punkttecken"), + ("CMYK:", "CMYK:"), + ("Center in frame", "Centrera i ramen"), + ("Check Spelling", "Kontrollera stavning"), + ("Clear Rotation", "Rensa rotation"), + ("Closed Path", "Sluten bana"), + ("Color Blindness – Deuteranopia", "Färgblindhet – deuteranopi"), + ("Color Blindness – Protanopia", "Färgblindhet – protanopi"), + ("Column Break", "Kolumnbrytning"), + ("Column spacing", "Kolumnavstånd"), + ("Columns first", "Kolumner först"), + ("Conditional Text", "Villkorlig text"), + ("Copyright Symbol", "Upphovsrättssymbol"), + ("Corner Options", "Hörnalternativ"), + ("Create Guides", "Skapa stödlinjer"), + ("Cyan", "Cyan"), + ("Dark", "Mörk"), + ("Data Merge", "Datasammanfogning"), + ("Define Cross-Reference Format", "Definiera korsreferensformat"), + ("Define Lists", "Definiera listor"), + ("Define Text Variable", "Definiera textvariabel"), + ("Delete Type from Path", "Radera text från bana"), + ("DesignCraft Website", "DesignCrafts webbplats"), + ("DesignCraft on GitHub", "DesignCraft på GitHub"), + ("Detect Edges", "Identifiera kanter"), + ("Discretionary Hyphen", "Mjukt bindestreck"), + ("Document CMYK", "Dokument-CMYK"), + ("Document Endnote Options", "Alternativ för dokumentets slutnoter"), + ("Document Footnote Options", "Alternativ för dokumentets fotnoter"), + ("Document RGB", "Dokument-RGB"), + ("Double Left Quotation Marks", "Dubbla vänstercitattecken"), + ("Double Right Quotation Marks", "Dubbla högercitattecken"), + ("Edit/Interface Language", "Redigera/Gränssnittsspråk"), + ("Edit/Spelling", "Redigera/Stavning"), + ("Edit/Transparency Blend Space", "Redigera/Färgrymd för transparensblandning"), + ("Edit/Transparency Flattener Presets", "Redigera/Förinställningar för transparensutjämning"), + ("Ellipsis", "Ellips"), + ("Em Dash", "Långt tankstreck"), + ("Em Space", "Helfyrkant"), + ("En Dash", "Tankstreck"), + ("En Space", "Halvfyrkant"), + ("Export EPUB", "Exportera EPUB"), + ("Export EPUB (Fixed Layout)", "Exportera EPUB (fast layout)"), + ("Export EPUB (Reflowable)", "Exportera EPUB (flytande)"), + ("Export HTML", "Exportera HTML"), + ("Export IDML", "Exportera IDML"), + ("Export PDF (Interactive)", "Exportera PDF (interaktiv)"), + ("Export Page as PNG", "Exportera sidan som PNG"), + ("Export Text", "Exportera text"), + ("Export XML", "Exportera XML"), + ("Figure Space", "Siffermellanrum"), + ("File/Export", "Arkiv/Exportera"), + ("Fill proportionally", "Fyll proportionerligt"), + ("Fit content to frame", "Anpassa innehåll till ram"), + ("Fit proportionally", "Anpassa proportionellt"), + ("Flush Space", "Utfyllnadsblanksteg"), + ("Forced Line Break", "Tvingad radbrytning"), + ("Frame Break", "Rambrytning"), + ("GREP", "GREP"), + ("Generate Index", "Generera register"), + ("Generate Live Caption", "Generera dynamisk bildtext"), + ("Global Light", "Globalt ljus"), + ("Graphic", "Grafik"), + ("Hair Space", "Hårfint blanksteg"), + ("High Contrast", "Hög kontrast"), + ("High Resolution", "Hög upplösning"), + ("I, II, III, IV...", "I, II, III, IV..."), + ("Import XML", "Importera XML"), + ("Indent to Here", "Indrag hit"), + ("Ink Limit 300%", "Total färgmängd 300 %"), + ("Insert Anchored Object", "Infoga förankrat objekt"), + ("Insert Cross-Reference", "Infoga korsreferens"), + ("Insert Math Expression", "Infoga matematiskt uttryck"), + ("Insert Pages", "Infoga sidor"), + ("Insert field", "Infoga fält"), + ("Internet Standard RGB (sRGB)", "Internetstandard-RGB (sRGB)"), + ("Join the ArtCraft Discord", "Gå med i ArtCraft på Discord"), + ("Layout/Index", "Layout/Register"), + ("Layout/Pages", "Layout/Sidor"), + ("Legacy Macintosh RGB", "Äldre Macintosh RGB"), + ("Light", "Ljus"), + ("Limit", "Gränsvärde"), + ("Link images", "Länka bilder"), + ("Load DTD", "Läs in DTD"), + ("Load Swatches", "Läs in färgrutor"), + ("Low Resolution", "Låg upplösning"), + ("Magenta", "Magenta"), + ("Margins and Columns", "Marginaler och spalter"), + ("Margins:", "Marginaler:"), + ("Medium Dark", "Mellanmörk"), + ("Medium Light", "Mellanljus"), + ("Medium Resolution", "Medelhög upplösning"), + ("Menus", "Menyer"), + ("Merge options", "Sammanfogningsalternativ"), + ("Monitor RGB", "Bildskärms-RGB"), + ("Move Pages", "Flytta sidor"), + ("Multiple records", "Flera poster"), + ("New Condition", "Nytt villkor"), + ("New Hyperlink", "Ny hyperlänk"), + ("New Hyperlink Destination", "Nytt hyperlänkmål"), + ("New Page Reference", "Ny sidreferens"), + ("New Parent", "Ny mallsida"), + ("New Stroke Style", "Ny konturstil"), + ("Next", "Nästa"), + ("Next Page Number", "Nästa sidnummer"), + ("No data source", "Ingen datakälla"), + ("Nonbreaking Hyphen", "Hårt bindestreck"), + ("Nonbreaking Space", "Hårt blanksteg"), + ("None (keep transparency)", "Ingen (behåll transparens)"), + ("Numbering & Section Options", "Numrerings- och avsnittsalternativ"), + ("Object/Anchored Object", "Objekt/Förankrat objekt"), + ("Object/Arrange", "Objekt/Ordna"), + ("Object/Captions", "Objekt/Bildtexter"), + ("Object/Clipping Path", "Objekt/Klippbana"), + ("Object/Content", "Objekt/Innehåll"), + ("Object/Convert Shape", "Objekt/Konvertera form"), + ("Object/Effects", "Objekt/Effekter"), + ("Object/Fitting", "Objekt/Anpassning"), + ("Object/Pathfinder", "Objekt/Banfinnare"), + ("Object/Paths", "Objekt/Banor"), + ("Object/Select", "Objekt/Markera"), + ("Object/Transform", "Objekt/Transformera"), + ("Object/Transform Again", "Objekt/Transformera igen"), + ("One record", "En post"), + ("Package", "Paketera"), + ("Page Break", "Sidbrytning"), + ("Paragraph Return", "Styckeretur"), + ("Paragraph Symbol", "Styckesymbol"), + ("Pass csv, rows, or bytes from the control channel.", "Skicka csv, rader eller byte från kontrollkanalen."), + ("Picas", "Pica"), + ("Place with Import Options", "Placera med importalternativ"), + ("Preflight", "Förhandskontroll"), + ("Previous", "Föregående"), + ("Previous Page Number", "Föregående sidnummer"), + ("Print Booklet", "Skriv ut häfte"), + ("Punctuation Space", "Blanksteg för skiljetecken"), + ("Quarter Space", "Fjärdedels blanksteg"), + ("Quick Apply", "Snabbanvänd"), + ("RGB:", "RGB:"), + ("Range", "Omfång"), + ("Record", "Post"), + ("Records per page", "Poster per sida"), + ("Registered Trademark Symbol", "Symbol för registrerat varumärke"), + ("Relink", "Länka om"), + ("Relink to Folder", "Länka om till mapp"), + ("Report an Issue", "Rapportera ett problem"), + ("Right Indent Tab", "Högerindragstabb"), + ("Row spacing", "Radavstånd"), + ("Rows first", "Rader först"), + ("Save As", "Spara som"), + ("Save Swatches for Exchange", "Spara färgrutor för utbyte"), + ("Save a Copy", "Spara en kopia"), + ("Scripts", "Skript"), + ("Section Marker", "Avsnittsmarkör"), + ("Section Symbol", "Paragraftecken"), + ("Select Data Source…", "Välj datakälla…"), + ("Sentence case", "Inledande versal"), + ("Simulate Paper Color", "Simulera pappersfärg"), + ("Single Left Quotation Mark", "Enkelt vänster citattecken"), + ("Single Right Quotation Mark", "Enkelt höger citattecken"), + ("Single record", "En post"), + ("Sixth Space", "Sjättedelsblanksteg"), + ("Slug:", "Informationsområde:"), + ("Smart Guide Options", "Alternativ för smarta stödlinjer"), + ("Step and Repeat", "Steg och upprepa"), + ("Story Column Direction", "Artikelns spaltriktning"), + ("Straight Double Quotation Marks", "Raka dubbla citattecken"), + ("Straight Single Quotation Mark (Apostrophe)", "Rakt enkelt citattecken (apostrof)"), + ("Subscript:", "Nedsänkt:"), + ("Superscript:", "Upphöjd:"), + ("Tab", "Tabb"), + ("Table of Contents", "Innehållsförteckning"), + ("Table/Convert Cell Type", "Tabell/Konvertera celltyp"), + ("Table/Delete", "Tabell/Radera"), + ("Table/Insert", "Tabell/Infoga"), + ("Table/Select", "Tabell/Markera"), + ("Thin Space", "Smalt mellanrum"), + ("Third Space", "Tredjedelsmellanrum"), + ("Title Case", "Inledande versaler"), + ("Trademark Symbol", "Varumärkessymbol"), + ("Transform Again Individually", "Transformera igen individuellt"), + ("Transform Sequence Again", "Transformera sekvens igen"), + ("Transform Sequence Again Individually", "Transformera sekvens igen individuellt"), + ("Type on a Path Options", "Alternativ för text på bana"), + ("Type/Bulleted and Numbered Lists", "Text/Punktlistor och numrerade listor"), + ("Type/Change Case", "Text/Ändra skiftläge"), + ("Type/Hyperlinks & Cross-References", "Text/Hyperlänkar och korsreferenser"), + ("Type/Insert Break Character", "Text/Infoga brytningstecken"), + ("Type/Insert Special Character/Hyphens and Dashes", "Text/Infoga specialtecken/Bindestreck och tankstreck"), + ("Type/Insert Special Character/Markers", "Text/Infoga specialtecken/Markörer"), + ("Type/Insert Special Character/Other", "Text/Infoga specialtecken/Annat"), + ("Type/Insert Special Character/Quotation Marks", "Text/Infoga specialtecken/Citattecken"), + ("Type/Insert Special Character/Symbols", "Text/Infoga specialtecken/Symboler"), + ("Type/Insert White Space", "Text/Infoga blanktecken"), + ("Type/Proof Setup", "Text/Provtrycksinställningar"), + ("Type/Rotate Spread", "Text/Rotera uppslag"), + ("Type/Separations Preview", "Text/Förhandsgranskning av separationer"), + ("Type/Story Direction", "Text/Artikelriktning"), + ("Type/Text Variables", "Text/Textvariabler"), + ("Type/Track Changes", "Text/Spåra ändringar"), + ("Type/Type on a Path", "Text/Text på bana"), + ("URL:", "URL:"), + ("Unassigned", "Ej tilldelad"), + ("View/Display Performance", "Visa/Visningsprestanda"), + ("View/Grids & Guides", "Visa/Rutnät och stödlinjer"), + ("Window/Arrange", "Fönster/Ordna"), + ("Window/Interactive", "Fönster/Interaktivt"), + ("Window/Interface Color Theme", "Fönster/Färgtema för gränssnittet"), + ("Window/Output", "Fönster/Utdata"), + ("Window/Panels", "Fönster/Paneler"), + ("Window/Type & Tables", "Fönster/Text och tabeller"), + ("Window/Utilities", "Fönster/Tillbehör"), + ("Working CMYK", "Arbets-CMYK"), + ("Yellow", "Gul"), + ("a, b, c, d...", "a, b, c, d..."), + ("i, ii, iii, iv...", "i, ii, iii, iv..."), + ("lowercase", "gemener"), + ("All pages", "Alla sidor"), + ("Between Entry and Number:", "Mellan post och nummer:"), + ("Both pages of the parent spread", "Båda sidorna i mallsidans uppslag"), + ("Both pages of the spread", "Båda sidorna i uppslaget"), + ("Close Panel", "Stäng panel"), + ("Data Fields", "Datafält"), + ("Data Merge Options", "Alternativ för datasammanfogning"), + ("Distribute Rows Evenly", "Fördela rader jämnt"), + ("Don't Save", "Spara inte"), + ("Drag Rows or Columns", "Dra rader eller kolumner"), + ("Entry Style:", "Formatmall för post:"), + ("Even Page Break", "Brytning till jämn sida"), + ("Export PowerPoint…", "Exportera PowerPoint…"), + ("Formatting Affects Text", "Formateringen påverkar texten"), + ("Georgian", "Georgiska"), + ("Group with", "Gruppera med"), + ("Import Warnings", "Importvarningar"), + ("Include Paragraph Styles:", "Inkludera styckestilar:"), + ("Insert Field", "Infoga fält"), + ("Level:", "Nivå:"), + ("List Composite Fonts", "Lista sammansatta typsnitt"), + ("Odd Page Break", "Brytning till udda sida"), + ("Optical Margin Alignment", "Optisk marginaljustering"), + ("Other Styles:", "Andra stilar:"), + ("Page Number:", "Sidnummer:"), + ("Pin Bar Position", "Fäst fältets position"), + ("Presentation", "Presentation"), + ("Preview Record", "Förhandsgranska post"), + ("Remove Data Source", "Ta bort datakälla"), + ("Remove Existing Ruler Guides", "Ta bort befintliga stödlinjer"), + ("Remove Field", "Ta bort fält"), + ("Remove »", "Ta bort »"), + ("Replace Existing Table of Contents", "Ersätt befintlig innehållsförteckning"), + ("Reset Bar Position", "Återställ fältets position"), + ("Resize panel window", "Ändra storlek på panelfönster"), + ("Resize panels", "Ändra storlek på paneler"), + ("Select an object to color its fill or stroke.", "Markera ett objekt för att färglägga dess fyllning eller kontur."), + ("Set Composite Font", "Ange komposittypsnitt"), + ("Stop Preview", "Stoppa förhandsgranskning"), + ("Styles in Table of Contents", "Stilar i innehållsförteckningen"), + ("This page", "Den här sidan"), + ("This page is", "Den här sidan är"), + ("Title:", "Titel:"), + ("Unsaved Changes", "Osparade ändringar"), + ("Update Data Source", "Uppdatera datakälla"), + ("Validate from Root Element", "Validera från rotelement"), + ("View/Extras", "Visa/Extrafunktioner"), + ("View/Proof Setup", "Visa/Provtrycksinställningar"), + ("View/Rotate Spread", "Visa/Rotera uppslag"), + ("View/Screen Mode", "Visa/Skärmläge"), + ("View/Separations Preview", "Visa/Förhandsgranskning av separationer"), + ("View/Structure", "Visa/Struktur"), + ("« Add", "« Lägg till"), + ( + "“{}” has changes that are not saved. Save them before closing?", + "”{}” har ändringar som inte har sparats. Vill du spara dem innan du stänger?", + ), + ("Inches Decimal", "Tum decimal"), + ("Back", "Tillbaka"), + ("Warning", "Varning"), + ("Don't show again", "Visa inte igen"), + ("The affected page contains objects. Delete the page anyway?", "Sidan innehåller objekt. Vill du radera sidan ändå?"), + ("The affected pages contain objects. Delete the pages anyway?", "Sidorna innehåller objekt. Vill du radera sidorna ändå?"), + ("Capture", "Inspelning"), + ("Panel Locations", "Panelplaceringar"), + ("Close Tab Group", "Stäng flikgrupp"), + ("Minimize", "Minimera"), + ("Collapse to Icons", "Komprimera till ikoner"), + ("Panel Options", "Panelalternativ"), + ("Panel Options…", "Panelalternativ…"), + ("Show Thumbnails", "Visa miniatyrer"), + ("Show Vertically", "Visa vertikalt"), + ("Panel Layout", "Panellayout"), + ("Pages on Top", "Sidor överst"), + ("Parents on Top", "Mallsidor överst"), + ("Resize:", "Ändra storlek:"), + ("Extra Small", "Extra liten"), + ("Small", "Liten"), + ("Medium", "Medel"), + ("Large", "Stor"), + ("Extra Large", "Extra stor"), + ("Jumbo", "Jumbo"), + ("Proportional", "Proportionell"), + ("Pages Fixed", "Fasta sidor"), + ("Parents Fixed", "Fasta mallsidor"), + ("Parents", "Mallsidor"), + ("Settings…", "Inställningar…"), + ("Left-aligned tab", "Vänsterjusterad tabb"), + ("Centered tab", "Centrerad tabb"), + ("Right-aligned tab", "Högerjusterad tabb"), + ("Tab aligned on a character", "Tabb justerad mot ett tecken"), + ("X:", "X:"), + ("Leader:", "Utfyllnadstecken:"), + ("Align On:", "Justera mot:"), + ("Position panel above text frame", "Placera panelen ovanför textramen"), + ("Clear All", "Rensa alla"), + ("Delete Tab", "Radera tabb"), + ("Repeat Tab", "Upprepa tabb"), + ("Reset Indents", "Återställ indrag"), + ("Redefine Style", "Omdefiniera stil"), + ("Select; drag to move the selection to another layer", "Markera; dra för att flytta markeringen till ett annat lager"), + ("Alt-click clears overrides", "Alt-klick rensar lokala ändringar"), + ("Apply \"{name}\", Clear Overrides", "Använd \"{name}\", rensa lokala ändringar"), + ("Rotate 180°", "Rotera 180°"), + ("No matching commands", "Inga matchande kommandon"), + ("Kanji", "Kanji"), + ("Show CJK Features", "Visa CJK-funktioner"), + ("Page Size:", "Sidstorlek:"), + ("Adjust page elements to document changes", "Anpassa sidelement efter dokumentändringar"), + ("Apply the changes; objects follow the new page size and margins", "Använd ändringarna; objekten följer den nya sidstorleken och marginalerna"), + ("Preflight Panel…", "Panelen Förhandskontroll…"), + ("Recent", "Senaste"), + ("Saved", "Sparat"), + ("[Default]", "[Standard]"), + ("Your Recent Items", "Dina senaste objekt"), + ("Saved Presets", "Sparade förinställningar"), + ("Blank Document Presets", "Förinställningar för tomma dokument"), + ("Delete Preset", "Radera förinställning"), + ("Preset Details", "Detaljer för förinställning"), + ("Document Name", "Dokumentnamn"), + ("Save Document Preset", "Spara dokumentförinställning"), + ("Save Document Preset As:", "Spara dokumentförinställning som:"), + ("Save Preset", "Spara förinställning"), + ("Start #", "Startnr"), + ("Column Gutter", "Spaltmellanrum"), + ("Start Section", "Börja avsnitt"), + ("Automatic Page Numbering", "Automatisk sidnumrering"), + ("Start Page Numbering at:", "Börja sidnumrering vid:"), + ("Section Prefix:", "Avsnittsprefix:"), + ("Section Marker:", "Avsnittsmarkör:"), + ("Include Prefix when Numbering Pages", "Inkludera prefix vid sidnumrering"), + ("Document Chapter Numbering", "Kapitelnumrering för dokument"), + ("Automatic Chapter Numbering", "Automatisk kapitelnumrering"), + ("Start Chapter Numbering at:", "Börja kapitelnumrering vid:"), + ("Same as Previous Document in the Book", "Samma som föregående dokument i boken"), + ("Book Name:", "Boknamn:"), + ("N/A", "Saknas"), + ("Preset Name", "Namn på förinställning"), + ("Bounding Box (Visible Layers Only)", "Avgränsningsram (endast synliga lager)"), + ("Bounding Box (All Layers)", "Avgränsningsram (alla lager)"), + ("Origin:", "Origo:"), + ("Spine", "Rygg"), + ("Lock Zero Point", "Lås nollpunkt"), + ("Unlock Zero Point", "Lås upp nollpunkt"), + ("All", "Alla"), + ("All Lines in Paragraph", "Alla rader i stycket"), + ("Anywhere", "Var som helst"), + ("At Start/End of Paragraph", "Vid styckets början/slut"), + ("Bullet Character:", "Punkttecken:"), + ("Bullets", "Punktlista"), + ("Continue from Previous Number", "Fortsätt från föregående nummer"), + ("End:", "Slut:"), + ("In Next Column", "I nästa spalt"), + ("In Next Frame", "I nästa ram"), + ("Inside Gutter:", "Inre spaltmellanrum:"), + ("Keep Lines Together", "Håll ihop raderna"), + ("Keep Options", "Alternativ för att hålla ihop"), + ("Keep with Next:", "Håll ihop med nästa:"), + ("List Type:", "Listtyp:"), + ("List:", "Lista:"), + ("Mode:", "Läge:"), + ("Numbers", "Siffror"), + ("On Next Even Page", "På nästa jämna sida"), + ("On Next Odd Page", "På nästa udda sida"), + ("On Next Page", "På nästa sida"), + ("Outside Gutter:", "Yttre spaltmellanrum:"), + ("Paragraph Border", "Styckekantlinje"), + ("Paragraph Layout:", "Styckelayout:"), + ("Paragraph Shading", "Styckeskuggning"), + ("Single Column", "Enkel spalt"), + ("Span Columns", "Sträck över spalter"), + ("Split Column", "Dela spalt"), + ("Start At", "Börja vid"), + ("Start Paragraph:", "Första stycke:"), + ("Text After:", "Text efter:"), + ("(Paragraph Font)", "(Styckets typsnitt)"), + ("Any Previous Level", "Valfri föregående nivå"), + ("Bottom Edge:", "Nederkant:"), + ("Bottom Left:", "Nere till vänster:"), + ("Bottom Right:", "Nere till höger:"), + ("Bullets and Numbering…", "Punkter och numrering…"), + ("Clip to Frame", "Beskär till ram"), + ("Corner Size and Shape", "Hörnstorlek och form"), + ("Descent", "Underlängd"), + ("Display Border if Paragraph Splits Across Frames/Columns", "Visa kant om stycket delas mellan ramar/spalter"), + ("Do not Print or Export", "Skriv inte ut eller exportera"), + ("Em Box Bottom", "Em-ruta nederkant"), + ("Em Box Top", "Em-ruta överkant"), + ("Fancy", "Dekorativ"), + ("Gap Tint:", "Färgton för mellanrum:"), + ("Strokes and Fills…", "Konturer och fyllningar…"), + ("Cell Stroke", "Cellkontur"), + ("Cell Fill", "Cellfyllning"), + ("Cell edges", "Cellkanter"), + ( + "Click a line to include or exclude it. Double-click selects all outer or all inner lines; triple-click selects or clears all.", + "Klicka på en linje för att ta med eller utesluta den. Dubbelklicka för att markera alla yttre eller alla inre linjer; trippelklicka för att markera eller rensa alla.", + ), + ("Glyphs…", "Glyfer…"), + ("Inset", "Infällning"), + ("Inverse Rounded", "Omvänt rundad"), + ("Merge Consecutive Borders and Shading", "Sammanfoga intilliggande kantlinjer och skuggning"), + ("Paragraph Borders and Shading", "Kantlinjer och skuggning för stycke"), + ("Paragraph Borders and Shading…", "Kantlinjer och skuggning för stycke…"), + ("Restart Numbers at This Level After:", "Börja om numreringen på den här nivån efter:"), + ("Rounded", "Rundad"), + ("Space After Span:", "Avstånd efter sträckning:"), + ("Space After Split:", "Avstånd efter delning:"), + ("Space Before Span:", "Avstånd före sträckning:"), + ("Space Before Split:", "Avstånd före delning:"), + ("Span Columns…", "Sträck över spalter…"), + ("Tab Position:", "Tabbposition:"), + ("Top Edge:", "Överkant:"), + ("Top Left:", "Uppe till vänster:"), + ("Top Right:", "Uppe till höger:"), + ("Absorb Ideographic Space at Line End", "Absorbera ideografiskt blanksteg vid radslut"), + ("Align Only First Line to Grid", "Justera endast första raden mot rutnätet"), + ("Black Circle", "Svart cirkel"), + ("Black Triangle", "Svart triangel"), + ("Bullseye", "Måltavla"), + ("Bunri-Kinshi", "Bunri-kinshi"), + ("Character:", "Tecken:"), + ("Characters After Break:", "Tecken efter brytning:"), + ("Characters Before Break:", "Tecken före brytning:"), + ("Default Rules", "Standardregler"), + ("Double Circle", "Dubbel cirkel"), + ("Em Box Bottom/Left", "Em-ruta nederkant/vänster"), + ("Em Box Center", "Em-ruta mitt"), + ("Em Box Center (Down)", "Em-ruta mitt (ned)"), + ("Em Box Top/Right", "Em-ruta överkant/höger"), + ("Force", "Tvinga"), + ("Grid Alignment:", "Rutnätsjustering:"), + ("Grid Settings", "Rutnätsinställningar"), + ("Hanging Type:", "Hängningstyp:"), + ("Hard Kinsoku", "Hård kinsoku"), + ("Japanese Composition Settings", "Inställningar för japansk komposition"), + ("Kenten Settings", "Kenten-inställningar"), + ("Kenten Type:", "Kenten-typ:"), + ("Kinsoku Set:", "Kinsoku-uppsättning:"), + ("Kinsoku Type:", "Kinsoku-typ:"), + ("Korean Kinsoku", "Koreansk kinsoku"), + ("Leading Model:", "Radavståndsmodell:"), + ("Left/Right Position:", "Vänster/höger-position:"), + ("Line Break Options", "Alternativ för radbrytning"), + ("Line Spacing:", "Radavstånd:"), + ("Mojikumi Set:", "Mojikumi-uppsättning:"), + ("Prioritize Adjustment Amount", "Prioritera justeringsmängd"), + ("Push In First", "Skjut in först"), + ("Push Out First", "Skjut ut först"), + ("Push Out Only", "Skjut endast ut"), + ("Rensuuji", "Rensuuji"), + ("Roman Baseline", "Baslinje (roman)"), + ("Sesame Dot", "Sesampunkt"), + ("Simplified Chinese Kinsoku", "Kinsoku för förenklad kinesiska"), + ("Small Black Circle", "Liten svart cirkel"), + ("Small White Circle", "Liten vit cirkel"), + ("Soft Kinsoku", "Mjuk kinsoku"), + ("Tate-chu-yoko Settings", "Inställningar för tate-chu-yoko"), + ("Traditional Chinese Kinsoku", "Kinsoku för traditionell kinesiska"), + ("Up/Down Position:", "Position upp/ned:"), + ("Warichu Settings", "Warichu-inställningar"), + ("Warichu Size:", "Warichu-storlek:"), + ("White Circle", "Vit cirkel"), + ("White Sesame Dot", "Vit sesampunkt"), + ("White Triangle", "Vit triangel"), + ("1 Parent Character", "1 bastecken"), + ("1 Ruby Character", "1 ruby-tecken"), + ("1 Ruby Character Aki", "1 ruby-tecken aki"), + ("1-2-1 (JIS) Rule", "1-2-1-regel (JIS)"), + ("1-2-1 Aki", "1-2-1 aki"), + ("1/2 Parent Character", "1/2 bastecken"), + ("1/2 Ruby Character", "1/2 ruby-tecken"), + ("Above/Right", "Ovanför/höger"), + ("Across the Line:", "Tvärs över raden:"), + ("Adjust Rotation", "Justera rotation"), + ("Adjust Tsume", "Justera tsume"), + ("Adjustment When Ruby Is Longer Than Parent", "Justering när ruby är längre än bastexten"), + ("Along the Line:", "Längs raden:"), + ("Auto Align at Line Start/End", "Automatisk justering vid radbörjan/radslut"), + ("Auto Tate-chu-yoko Settings", "Inställningar för automatisk tate-chu-yoko"), + ("Below/Left", "Nedanför/vänster"), + ("Both Sides", "Båda sidor"), + ("Centered", "Centrerad"), + ("Distance from Parent:", "Avstånd från överordnad:"), + ("Equal Aki", "Lika aki"), + ("Fisheye", "Fisköga"), + ("Fit to Width", "Anpassa till bredd"), + ("Full Justify", "Full marginaljustering"), + ("Group Ruby", "Grupp-ruby"), + ("Gyoudori:", "Gyoudori:"), + ("ICF Bottom", "ICF nederkant"), + ("ICF Top", "ICF överkant"), + ("Include Roman Characters", "Inkludera romerska tecken"), + ("Kenten Color", "Kenten-färg"), + ("Left/Top", "Vänster/överkant"), + ("Magnification:", "Förstoring:"), + ("Minimum Scale:", "Minsta skala:"), + ("No Adjustment", "Ingen justering"), + ("Offset from Parent", "Förskjutning från bastext"), + ("Parent Character Spacing:", "Teckenavstånd för bastext:"), + ("Parent's Font", "Bastextens typsnitt"), + ("Per-Character Ruby", "Ruby per tecken"), + ("Right/Bottom", "Höger/nederkant"), + ("Roman Word Break", "Ordbrytning (roman)"), + ("Rotate Roman Characters in Vertical Text", "Rotera romerska tecken i lodrät text"), + ("Ruby Color", "Rubyfärg"), + ("Ruby Font and Size", "Rubytypsnitt och storlek"), + ("Ruby Overhang:", "Ruby-överhäng:"), + ("Ruby Placement and Spacing", "Rubyplacering och avstånd"), + ("Ruby Type:", "Rubytyp:"), + ("Shatai", "Shatai"), + ("Shrink Ruby Width Automatically", "Krymp rubybredd automatiskt"), + ("Tate-chu-yoko Digits:", "Tate-chu-yoko-siffror:"), + ("Text Color", "Textfärg"), + ("Unlimited", "Obegränsat"), + ("Use OpenType Pro Ruby Glyphs", "Använd OpenType Pro-rubyglyfer"), + ("Use Paragraph Gyoudori", "Använd styckets gyoudori"), + ("Swatch Options…", "Färgrutealternativ…"), + ("Swatch Options", "Färgrutealternativ"), + ("Color Group Options…", "Alternativ för färggrupp…"), + ("Layer Options", "Lageralternativ"), + ("Layer Options for \"{name}\"…", "Lageralternativ för \"{name}\"…"), + ("Swatch Name:", "Färgrutenamn:"), + ("Name with Color Value", "Namn med färgvärde"), + ("Color Type:", "Färgtyp:"), + ("Process", "Process"), + ("Spot", "Dekor"), + ("Color Mode:", "Färgläge:"), + ("Base Color:", "Basfärg:"), + ("Edit the gradient's stops in the Gradient panel.", "Redigera gradientens stopp i panelen Gradient."), + ("This swatch can't be edited.", "Den här färgrutan kan inte redigeras."), + ("Light Blue", "Ljusblå"), + ("Red", "Röd"), + ("Green", "Grön"), + ("Blue", "Blå"), + ("Orange", "Orange"), + ("Dark Green", "Mörkgrön"), + ("Teal", "Blågrön"), + ("Tan", "Sandbrun"), + ("Brown", "Brun"), + ("Violet", "Violett"), + ("Gold", "Guld"), + ("Show Layer", "Visa lager"), + ("Lock Layer", "Lås lager"), + ("Print Layer", "Skriv ut lager"), + ("Suppress Text Wrap When Layer is Hidden", "Undertryck textombrytning när lagret är dolt"), + ("Text Anchor", "Textankare"), + ("Open Recent", "Öppna senaste"), + ("Open Recent File", "Öppna senaste fil"), + ("Clear Recent Files", "Rensa senaste filer"), + ("Recent files", "Senaste filer"), + ("Number of Recent Items to Display:", "Antal senaste objekt att visa:"), + ("Paste Vector Art", "Klistra in vektorgrafik"), + ("Baseline Options", "Baslinjealternativ"), + ("Fixed Number", "Fast antal"), + ("Fixed Width", "Fast bredd"), + ("Inset Spacing", "Infällningsavstånd"), + ("Vertical Justification", "Vertikal justering"), + ("Align:", "Justera:"), + ("Ignore Text Wrap", "Ignorera textombrytning"), + ("Technique:", "Teknik:"), + ("Softer", "Mjukare"), + ("Precise", "Exakt"), + ("Corners:", "Hörn:"), + ("Sharp", "Skarp"), + ("Diffused", "Diffus"), + ("Effects…", "Effekter…"), + ("Settings for:", "Inställningar för:"), + ("Transparency…", "Transparens…"), + ("Basic Blending", "Grundläggande blandning"), + ("Opacity:", "Opacitet:"), + ("Blending", "Blandning"), + ("Distance:", "Avstånd:"), + ("X Offset:", "X-förskjutning:"), + ("Y Offset:", "Y-förskjutning:"), + ("Spread:", "Spridning:"), + ("Choke:", "Krympning:"), + ("Source:", "Källa:"), + ("Edge", "Kant"), + ("Depth:", "Djup:"), + ("Highlight:", "Högdager:"), + ("Shadow:", "Skugga:"), + ("Feather Width:", "Fjädringsbredd:"), + ("Feather Widths", "Fjädringsbredder"), + ("Gradient Stops", "Gradientstopp"), + ("Start Opacity:", "Startopacitet:"), + ("End Opacity:", "Slutopacitet:"), + ("(no effects)", "(inga effekter)"), + ("OBJECT", "OBJEKT"), + ("Add an object effect to the selected target", "Lägg till en objekteffekt på det markerade målet"), + ("Load Paragraph Styles…", "Läs in styckestilar…"), + ("Load Character Styles…", "Läs in teckenstilar…"), + ("Load All Text Styles…", "Läs in alla textstilar…"), + ("Load Styles", "Läs in stilar"), + ("Differs from this document", "Skiljer sig från det här dokumentet"), + ("Redefine Document Style", "Omdefiniera dokumentstil"), + ("Use Document Style Definition", "Använd dokumentets formatdefinition"), + ("Auto Rename", "Byt namn automatiskt"), + ("Toggle Presentation", "Visa/dölj presentation"), + ("Balance Columns", "Balansera spalter"), + ("Digits:", "Siffror:"), + ("Justification…", "Justering…"), + ("Apply To:", "Använd på:"), + ("Frame", "Ram"), + ("Filters:", "Filter:"), + ("All Classes", "Alla klasser"), + ("Serif", "Serif"), + ("Slab Serif", "Slab serif"), + ("Sans Serif", "Sans serif"), + ("Script", "Skrivstil"), + ("Monospaced", "Fast teckenbredd"), + ("Decorative", "Dekorativ"), + ("Show Recently Added", "Visa nyligen tillagda"), + ("Small Sample Text Size", "Liten exempeltextstorlek"), + ("Medium Sample Text Size", "Medelstor exempeltext"), + ("Large Sample Text Size", "Stor storlek på exempeltext"), + ("Selected Text", "Markerad text"), + ("Sample", "Exempel"), + ("Similar to {family}", "Likartade {family}"), + ("No fonts match", "Inga typsnitt matchar"), + ("Show Similar Fonts", "Visa likartade typsnitt"), + ("Loading fonts…", "Läser in typsnitt…"), + ("Show Styles", "Visa stilar"), + ("Hide Styles", "Dölj stilar"), + ("TrueType font", "TrueType-typsnitt"), + ("OpenType font", "OpenType-typsnitt"), + ("Variable font", "Variabelt typsnitt"), + ("Color font", "Färgtypsnitt"), + ("Sort Entries in Alphabetical Order", "Sortera poster i bokstavsordning"), + ("% of all commits", "% av alla commits"), + ("(any)", "(valfri)"), + ("(unchanged)", "(oförändrad)"), + ("[No Language]", "[Inget språk]"), + ("About", "Om"), + ("Activate Panel", "Aktivera panel"), + ("Add Anchor Point Tool", "Verktyget Lägg till ankarpunkt"), + ("Align To", "Justera mot"), + ("Alternate layout", "Alternativ layout"), + ("Binary assets added", "Binära resurser tillagda"), + ("Binary assets removed", "Binära resurser borttagna"), + ("Cell Number:", "Cellnummer:"), + ("Change fill to:", "Ändra fyllning till:"), + ("Change Panel Layout", "Ändra panellayout"), + ("Chinese: Simplified", "Kinesiska: förenklad"), + ("Chinese: Traditional", "Kinesiska: traditionell"), + ("Color Theme Tool", "Färgtemaverktyg"), + ("Commits", "Commits"), + ("Company", "Företag"), + ("Content Collector Tool", "Innehållsinsamlingsverktyg"), + ("Content Placer Tool", "Innehållsplaceringsverktyg"), + ("Contributors", "Bidragsgivare"), + ("Convert Rows", "Konvertera rader"), + ("Corner Size", "Hörnstorlek"), + ("Couldn't write recovery data: {error}", "Kunde inte skriva återställningsdata: {error}"), + ("Delete Anchor Point Tool", "Verktyg för att radera ankarpunkt"), + ("DesignCraft page", "DesignCraft-sida"), + ("Direct Selection Tool", "Direktmarkeringsverktyg"), + ("Display name", "Visningsnamn"), + ("Ellipse Frame Tool", "Ellipsramverktyg"), + ("Ellipse Tool", "Ellipsverktyg"), + ("Email Address:", "E-postadress:"), + ( + "Equal fixed heights for the selected rows; cell text may become overset.", + "Lika fasta höjder för de markerade raderna; celltexten kan bli överskjutande.", + ), + ("Erase Tool", "Raderingsverktyg"), + ( + "Export a PDF, IDML or package to share — no cloud account needed.", + "Exportera en PDF, IDML eller ett paket för att dela – inget molnkonto behövs.", + ), + ("Eyedropper Tool", "Pipettverktyg"), + ("Find fill:", "Sök fyllning:"), + ("First commit", "Första commit"), + ("Found", "Hittade"), + ("Free Transform Tool", "Verktyg för fri transformering"), + ("Gap Tool", "Mellanrumsverktyg"), + ("Grab bag", "Blandat"), + ("Gradient Feather Tool", "Verktyg för gradientfjädring"), + ("Gradient Swatch Tool", "Verktyg för gradientfärgruta"), + ("Hand Tool", "Handverktyg"), + ("image editing", "bildredigering"), + ("Import cancelled: the originating document has closed", "Importen avbröts: ursprungsdokumentet har stängts"), + ("Invalid panel layout; restored the default dock", "Ogiltig panellayout; standarddockningen har återställts"), + ("Key Object", "Nyckelobjekt"), + ("Last commit", "Senaste commit"), + ("layer", "lager"), + ("layers", "lager"), + ("Line delta", "Raddifferens"), + ("Line Tool", "Linjeverktyg"), + ("Lines +/−", "Rader +/−"), + ("Lines added", "Tillagda rader"), + ("Lines deleted", "Borttagna rader"), + ("Measure Tool", "Verktyget Mät"), + ("Merged PRs", "Sammanslagna PR:er"), + ("Message:", "Meddelande:"), + ("Models", "Modeller"), + ("motion graphics and visual effects", "rörlig grafik och visuella effekter"), + ("Move Panel", "Flytta panel"), + ("Name (A–Z)", "Namn (A–Ö)"), + ("No contributor data was built into this copy.", "Inga uppgifter om bidragsgivare finns inbyggda i den här kopian."), + ("No matches", "Inga träffar"), + ("No model credits were built into this copy.", "Inga modellkrediteringar finns inbyggda i den här kopian."), + ("Note Tool", "Anteckningsverktyg"), + ("Organization:", "Organisation:"), + ("Packaged {count} files into {dir}", "Paketerade {count} filer i {dir}"), + ("Page Tool", "Sidverktyg"), + ("PDF import cancelled: the originating document has closed", "PDF-importen avbröts: ursprungsdokumentet har stängts"), + ("Pen Tool", "Pennverktyg"), + ("Pencil Tool", "Blyertsverktyg"), + ("Phone:", "Telefon:"), + ("photo library and raw development", "fotobibliotek och raw-framkallning"), + ("Polygon Frame Tool", "Polygonramverktyg"), + ("Polygon Tool", "Polygonverktyg"), + ("Press keys…", "Tryck på tangenter…"), + ("Range:", "Omfång:"), + ("reading, organizing and protecting PDFs", "läsa, ordna och skydda PDF:er"), + ("Real name", "Riktigt namn"), + ("Rectangle Frame Tool", "Rektangelramverktyg"), + ("Rectangle Tool", "Rektangelverktyg"), + ("Relinked {count}", "Omlänkade {count}"), + ("Reverse the order", "Vänd på ordningen"), + ("Rotate Tool", "Rotationsverktyg"), + ("Scale Tool", "Skalningsverktyg"), + ("Scissors Tool", "Saxverktyg"), + ("Selection Tool", "Markeringsverktyg"), + ("Sent {count} page(s) to {printer}", "Skickade {count} sida/sidor till {printer}"), + ("Shear Tool", "Skjuvningsverktyg"), + ("Show/Hide Document Grid", "Visa/dölj dokumentrutnät"), + ("Smooth Tool", "Utjämningsverktyg"), + ("Story Editor — {count} words", "Artikelredigerare – {count} ord"), + ("Story {story} ← story {parent}", "Artikel {story} ← artikel {parent}"), + ("Stroke Weight", "Konturtjocklek"), + ("Subject:", "Ämne:"), + ("Symbol", "Symbol"), + ("Text:", "Text:"), + ("the default printer", "standardskrivaren"), + ("To Body", "Till brödtext"), + ("To Footer", "Till sidfot"), + ("To Header", "Till sidhuvud"), + ("Type on a Path Tool", "Verktyg för text på bana"), + ("Type Tool", "Textverktyg"), + ("Username", "Användarnamn"), + ("vector illustration", "vektorillustration"), + ("Vertical Type on a Path Tool", "Verktyg för vertikal text på bana"), + ("Vertical Type Tool", "Vertikalt textverktyg"), + ("video editing, color and sound", "videoredigering, färg och ljud"), + ("Zoom Tool", "Zoomverktyg"), + ("{contributors} contributors · {commits} commits", "Bidragsgivare: {contributors} · Commits: {commits}"), + ("{count} conflict(s): {names}", "Konflikter ({count}): {names}"), + ("{count} match(es)", "Träffar: {count}"), + ("{count} replacement(s) made", "Antal ersättningar: {count}"), + ("{label}: {name} (double-click: Color Picker)", "{label}: {name} (dubbelklicka för färgväljare)"), + ( + "{name}: import cancelled while a dialog is open; choose the file again", + "{name}: importen avbröts eftersom en dialogruta är öppen; välj filen igen", + ), + ("{name}: {count} step(s) done", "{name}: klart med {count} steg"), + ("Add Tab", "Lägg till tabbstopp"), + ("Auto Tate-Chu-Yoko", "Automatisk tate-chu-yoko"), + ("Break Thread", "Bryt textlänk"), + ("Cancel Preview", "Avbryt förhandsgranskning"), + ("Change Tab", "Ändra tabbstopp"), + ("Character Style", "Teckenstil"), + ("Clear All Tabs", "Rensa alla tabbstopp"), + ("Clear Object Style Overrides", "Rensa lokala ändringar av objektstil"), + ("Clipping Path Options…", "Alternativ för klippbana…"), + ("Close Window", "Stäng fönster"), + ("Color Theme…", "Färgtema…"), + ("Commit Preview", "Godkänn förhandsgranskning"), + ("Convert Direction Point Tool", "Verktyget Konvertera riktningspunkt"), + ("Delete Character Style", "Radera teckenstil"), + ("Delete Document Preset", "Radera dokumentförinställning"), + ("Delete Parent", "Radera mallsida"), + ("Deselect Guides", "Avmarkera stödlinjer"), + ("Email:", "E-post:"), + ("Extract Color Theme", "Extrahera färgtema"), + ("Get Cell Stroke", "Hämta cellkontur"), + ("Get Tabs", "Hämta tabbstopp"), + ("Import InDesign Shortcut Set", "Importera kortkommandon från InDesign"), + ("Join", "Foga samman"), + ("Kerning/Tracking Step", "Steg för kerning/spärrning"), + ("List Styles in File", "Lista stilar i filen"), + ("Maximize/Restore Window", "Maximera/återställ fönster"), + ("Minimize Window", "Minimera fönster"), + ("Move Column Guide", "Flytta spaltstödlinje"), + ("Move Tab", "Flytta tabbstopp"), + ("Move Window", "Flytta fönster"), + ("New Cell Style", "Ny cellstil"), + ("New Table Style", "Ny tabellstil"), + ("Object Style", "Objektstil"), + ("Object Style Options…", "Alternativ för objektstil…"), + ("PRs", "PR:er"), + ("Page Options", "Sidalternativ"), + ("Paragraph Style", "Styckestil"), + ("Parent Options…", "Alternativ för mallsida…"), + ("Preview Change", "Förhandsgranska ändring"), + ("Redefine Character Style", "Definiera om teckenstil"), + ("Redefine Paragraph Style", "Definiera om styckestil"), + ("Reload Fonts", "Läs in typsnitt igen"), + ("Remove Frame from Thread", "Ta bort ram från länkningen"), + ("Reorder Objects", "Ändra ordning på objekt"), + ("Report an issue", "Rapportera ett problem"), + ("Resize Row or Column", "Ändra storlek på rad eller kolumn"), + ("Resize Window", "Ändra storlek på fönster"), + ("Ruler Zero Point", "Linjalens nollpunkt"), + ("Select Guide", "Markera stödlinje"), + ("Select Page", "Markera sida"), + ("Select Paragraph", "Markera stycke"), + ("Select Sentence", "Markera mening"), + ("Select Story", "Markera artikel"), + ("Select Text Unit", "Markera textenhet"), + ("Set OpenType Features", "Ange OpenType-funktioner"), + ("Swap Text Fill and Stroke", "Byt plats på textfyllning och kontur"), + ("Thread Text Frames", "Länka textramar"), + ("Select All Guides", "Markera alla stödlinjer"), + ("Ruler Guide", "Linjalstödlinje"), + ("Lock Column Guides", "Lås spaltstödlinjer"), + ("Character Formatting Controls", "Kontroller för teckenformatering"), + ("Paragraph Formatting Controls", "Kontroller för styckeformatering"), + ("Font Size", "Teckenstorlek"), + ("Vertical Scale", "Vertikal skala"), + ("Horizontal Scale", "Horisontell skala"), + ("Baseline Shift", "Baslinjeförskjutning"), + ("Skew (False Italic)", "Skevning (falsk kursiv)"), + ("Left Indent", "Vänsterindrag"), + ("Right Indent", "Högerindrag"), + ("First Line Left Indent", "Vänsterindrag för första raden"), + ("Last Line Right Indent", "Högerindrag för sista raden"), + ("Drop Cap Number of Lines", "Antal rader för anfang"), + ("Drop Cap One or More Characters", "Antal tecken för anfang"), + ("Align to Baseline Grid", "Justera mot baslinjerutnät"), + ("Go Back", "Gå bakåt"), + ("Go Forward", "Gå framåt"), + ("Restart Numbering", "Starta om numrering"), + ("Continue Numbering", "Fortsätt numrering"), + ("Keep Options…", "Alternativ för att hålla ihop…"), + ("Space Between Paragraphs Using Same Style", "Avstånd mellan stycken med samma format"), + ("Ignore", "Ignorera"), + ("Do Not Align to Baseline Grid", "Justera inte mot baslinjerutnät"), + ("Only Align First Line to Grid", "Justera endast första raden mot rutnätet"), + ("Balance Ragged Lines", "Balansera ojämna rader"), + ("Drop Caps and Nested Styles…", "Anfanger och kapslade stilar…"), + ("GREP Styles…", "GREP-format…"), + ("Restart/Continue Numbering", "Starta om/fortsätt numrering"), + ("Convert Bullets and Numbering to Text", "Konvertera punkter och numrering till text"), + ("Hyphenation…", "Avstavning…"), + ("Hyphenation Settings", "Avstavningsinställningar"), + ("hyphens", "bindestreck"), + ("Hyphenation Zone:", "Avstavningszon:"), + ("Better Spacing", "Bättre avstånd"), + ("Fewer Hyphens", "Färre bindestreck"), + ("Hyphenate Capitalized Words", "Avstava ord med inledande versal"), + ("Hyphenate Last Word", "Avstava sista ordet"), + ("Hyphenate Across Column", "Avstava mellan spalter"), + ("Convert to Note", "Konvertera till anteckning"), + ("Apply Bullets", "Använd punktlista"), + ("Apply Numbers", "Använd numrering"), + ("Convert Variable to Text", "Konvertera variabel till text"), + ("Next Note", "Nästa anteckning"), + ("Previous Note", "Föregående anteckning"), + ("Optical Margin Size", "Storlek för optisk marginal"), + ("Dismiss", "Stäng"), + ("Show/Hide Depth Ruler", "Visa/dölj djuplinjal"), + ("Split Note", "Dela anteckning"), + ("Arrangement", "Arrangemang"), + ("All Inks", "Alla tryckfärger"), + ("Apply Numbering & Section Options", "Använd numrerings- och avsnittsalternativ"), + ("Apply Text Attributes (Eyedropper)", "Använd textattribut (pipett)"), + ("Convert Point", "Konvertera punkt"), + ("Load Styles…", "Läs in stilar…"), + ("Recent Files", "Senaste filer"), + ("Apply to:", "Använd på:"), + ("Auto-Size", "Automatisk storlek"), + ("Create Links When Placing Text Files", "Skapa länkar när textfiler placeras"), + ("Kerning/Tracking:", "Kerning/spärrning:"), + ("Replace All", "Ersätt alla"), + ( + "Replace All updates this font throughout text and styles. Done keeps remaining fallback fonts.", + "Ersätt alla uppdaterar det här typsnittet i all text och alla stilar. Klart behåller återstående reservtypsnitt.", + ), + ("Replace with:", "Ersätt med:"), + ("Stroke Behind Fill", "Kontur bakom fyllning"), + ("Delete the character style \"{name}\"?", "Radera teckenstilen ”{name}”?"), + ("Delete the paragraph style \"{name}\"?", "Radera styckestilen ”{name}”?"), + ("Currently Rendered With", "Återges för närvarande med"), + ( + "“{}” has been edited in this layout. Updating replaces those edits with the text from the file.", + "”{}” har ändrats i den här layouten. Om du uppdaterar ersätts ändringarna med texten från filen.", + ), + ("Proportional Metrics", "Proportionella mått"), + ("Horizontal Kana", "Horisontell kana"), + ("Localized Forms", "Lokaliserade former"), + ("Mark Positioning", "Placering av diakritiska tecken"), + ("Historical Forms", "Historiska former"), + ("Stylistic Alternates", "Stilistiska alternativ"), + ("Justification Alternates", "Alternativa justeringstecken"), + ("Roman Italics", "Roman kursiv"), + ("Letter", "Letter"), + ("Legal", "Legal"), + ("Tabloid", "Tabloid"), + ("Letter - Half", "Letter - halv"), + ("Postcard", "Vykort"), + ("Web 1920 × 1080", "Webb 1920 × 1080"), + ("Web 1366 × 768", "Webb 1366 × 768"), + ("Web 1024 × 768", "Webb 1024 × 768"), + ("Mobile 1080 × 1920", "Mobil 1080 × 1920"), + ("Mobile 390 × 844", "Mobil 390 × 844"), + ("Tablet 1024 × 1366", "Surfplatta 1024 × 1366"), + ("Action", "Åtgärd"), + ("Advance seconds", "Sekunder till nästa"), + ("Affix in", "Fäst i"), + ("Allow", "Tillåt"), + ("Anchor", "Ankare"), + ("Artifact", "Artefakt"), + ("Asset", "Resurs"), + ("Assets", "Resurser"), + ("At end", "I slutet"), + ("Attrs", "Attribut"), + ("Author", "Författare"), + ("Auto fit", "Anpassa automatiskt"), + ("Auto size", "Automatisk storlek"), + ("Auto size reference", "Referenspunkt för automatisk storlek"), + ("Axis", "Axel"), + ("Bands", "Band"), + ("Bookmarks panel", "Panelen Bokmärken"), + ("Case", "Skiftläge"), + ("Columns kind", "Spalttyp"), + ("Continue across stories", "Fortsätt över artiklar"), + ("Cover image", "Omslagsbild"), + ("Definition", "Definition"), + ("Dir", "Riktning"), + ("Dry run", "Testkörning"), + ("Embed fonts", "Bädda in typsnitt"), + ("End", "End"), + ("Extension", "Förlängning"), + ("Family", "Familj"), + ("First baseline min", "Min. första baslinje"), + ("Fit to", "Anpassa till"), + ("Fixed layout", "Fast layout"), + ("Flatten", "Platta till"), + ("Font size", "Teckenstorlek"), + ("From", "Från"), + ("Full screen", "Helskärm"), + ("Grep", "GREP"), + ("Heading", "Rubrik"), + ("Heading style", "Rubrikstil"), + ("Hidden", "Dold"), + ("Identifier", "Identifierare"), + ("Idml", "IDML"), + ("Ids", "Id:n"), + ("Include inside edges", "Ta med inre kanter"), + ("Include prefix", "Ta med prefix"), + ("Instructions", "Instruktioner"), + ("Keep within column", "Håll inom spalten"), + ("Kind", "Typ"), + ("Label", "Etikett"), + ("Latex", "Latex"), + ("Layout page", "Layoutsida"), + ("Line breaks", "Radbrytningar"), + ("Load cursor", "Laddad markör"), + ("Marks", "Märken"), + ("Minimum height", "Minsta höjd"), + ("Minimum width", "Minsta bredd"), + ("Missing only", "Endast saknade"), + ("Mode", "Läge"), + ("Object point", "Objektpunkt"), + ("Org", "Origo"), + ("Page break before", "Sidbrytning före"), + ("Page layout", "Sidlayout"), + ("Para style", "Styckeformat"), + ("Pattern", "Mönster"), + ("Pdf", "PDF"), + ("Pdf crop", "PDF-beskärning"), + ("Pdf page", "PDF-sida"), + ("Pos", "Pos"), + ("Primary text frame", "Primär textram"), + ("Rasterize", "Rastrera"), + ("Rect", "Rekt"), + ("Redefine styles", "Omdefiniera stilar"), + ("Ref", "Ref"), + ("Ref char style", "Ref. teckenformat"), + ("Ref position", "Ref. position"), + ("Remove existing", "Ta bort befintliga"), + ("Restart", "Starta om"), + ("Row gutter", "Radmellanrum"), + ("Rule", "Linje"), + ("Run in", "Inlagd"), + ("Scope", "Omfång"), + ("Section headings", "Avsnittsrubriker"), + ("Shape", "Form"), + ("Space", "Blanksteg"), + ("Start number", "Startnummer"), + ("Start page", "Startsida"), + ("Subpath", "Delbana"), + ("Suggestions", "Förslag"), + ("Tagged", "Taggad"), + ("Target", "Mål"), + ("Threshold", "Tröskel"), + ("To family", "Till familj"), + ("To spread", "Till uppslag"), + ("To style", "Till stil"), + ("Toc title", "Rubrik för innehållsförteckning"), + ("Tolerance", "Tolerans"), + ("Topics", "Ämnen"), + ("Url", "URL"), + ("Use", "Använd"), + ("Use minimum height", "Använd minsta höjd"), + ("Use minimum width", "Använd minsta bredd"), + ("Value", "Värde"), + ("Visible", "Synlig"), + ("Word", "Ord"), + ("X offset", "X-förskjutning"), + ("X relative", "X relativt"), + ("Y offset", "Y-förskjutning"), + (" Save ", " Spara "), + (" Update ", " Uppdatera "), + ("(cycles)", "(växlar)"), + ("(modified)", "(ändrad)"), + ("+Bin", "+Bin"), + ("+LOC", "+LOC"), + ("A command can have at most {n} shortcuts", "Ett kommando kan ha högst {n} kortkommandon"), + ("A set with this name exists", "Det finns redan en uppsättning med det här namnet"), + ("Add a shortcut to {label}", "Lägg till ett kortkommando för {label}"), + ("All Commands", "Alla kommandon"), + ("Also used by {label}", "Används även av {label}"), + ("Alt", "Alt"), + ("Application", "Program"), + ("Approximated", "Approximerad"), + ("Assign", "Tilldela"), + ("Assign to Selected", "Tilldela markerade"), + ("Assign {chord} to {label}", "Tilldela {chord} till {label}"), + ("Auto-Collapse Iconic Panels", "Fäll ihop ikonpaneler automatiskt"), + ("Based on:", "Baserat på:"), + ("Built-in sets cannot be changed", "Inbyggda uppsättningar kan inte ändras"), + ("Cancel Import", "Avbryt import"), + ("Cancel the new shortcut", "Avbryt det nya kortkommandot"), + ("Caps Lock", "Caps Lock"), + ("Check for Updates…", "Sök efter uppdateringar…"), + ("Choose a command…", "Välj ett kommando…"), + ("Click a key to see its shortcuts", "Klicka på en tangent för att se dess kortkommandon"), + ("Close Other Panels in Group", "Stäng andra paneler i gruppen"), + ("Close panel window", "Stäng panelfönstret"), + ("Command", "Kommando"), + ("Command for {item}", "Kommando för {item}"), + ("Commands", "Kommandon"), + ("Conflicts", "Konflikter"), + ("Context:", "Sammanhang:"), + ("Copy Report", "Kopiera rapport"), + ("Ctrl", "Ctrl"), + ("Current shortcuts", "Aktuella kortkommandon"), + ("Current shortcuts:", "Aktuella kortkommandon:"), + ("Currently assigned to:", "Tilldelat för närvarande:"), + ("Default: {chord}", "Standard: {chord}"), + ("Delete Surrounding Text", "Radera omgivande text"), + ("Delete the set “{set}”?", "Radera uppsättningen ”{set}”?"), + ("Detail pane size", "Storlek på detaljrutan"), + ("EPUB Split Document", "EPUB – dela dokument"), + ("Expand", "Expandera"), + ("Expand Panels", "Expandera paneler"), + ("File Info", "Filinformation"), + ("File Info…", "Filinformation…"), + ("Fixed Keys", "Fasta tangenter"), + ("Float Tab Group", "Gör flikgruppen flytande"), + ("Go to Conflict", "Gå till konflikt"), + ("Group Tab Group with", "Gruppera flikgruppen med"), + ("Import", "Importera"), + ("Import as New Set", "Importera som ny uppsättning"), + ("Import “{set}”", "Importera ”{set}”"), + ("Imported", "Importerad"), + ("Imported as “{set}”", "Importerad som ”{set}”"), + ("Import…", "Importera…"), + ("Keep Both", "Behåll båda"), + ("Key: {key}", "Tangent: {key}"), + ("Keyboard", "Tangentbord"), + ("Keyboard Shortcuts (JSON)", "Kortkommandon (JSON)"), + ("Keyboard: US", "Tangentbord: amerikanskt"), + ("Keyboard: US (macOS)", "Tangentbord: amerikanskt (macOS)"), + ("Lock Workspace", "Lås arbetslayout"), + ("Matched without change", "Matchad utan ändring"), + ("Maximize Frame", "Maximera ram"), + ("Modifier on", "Modifierartangent på"), + ("More", "Mer"), + ("Move Guides", "Flytta stödlinjer"), + ("Move Panel to New Window", "Flytta panelen till ett nytt fönster"), + ("Move panel column", "Flytta panelkolumn"), + ("Move panel window", "Flytta panelfönster"), + ("Move tab group", "Flytta flikgrupp"), + ("New Set…", "Ny uppsättning…"), + ("New shortcut:", "Nytt kortkommando:"), + ("New shortcut: {chord}", "Nytt kortkommando: {chord}"), + ("Not assigned", "Inte tilldelad"), + ("Not changed", "Inte ändrad"), + ("Not matched", "Inte matchad"), + ("Nothing uses this key", "Ingenting använder den här tangenten"), + ("Now used by {label}", "Används nu av {label}"), + ("PDF/X-1a:2003", "PDF/X-1a:2003"), + ("Panel or context", "Panel eller sammanhang"), + ("Remove {chord} from {label}", "Ta bort {chord} från {label}"), + ("Reset All", "Återställ alla"), + ("Reset All…", "Återställ alla…"), + ("Restore Frame Size", "Återställ ramstorlek"), + ("Restore every default shortcut in this set?", "Vill du återställa alla standardkortkommandon i den här uppsättningen?"), + ("Restore the default for {label}", "Återställ standardvärdet för {label}"), + ("Revert Set", "Återgå till sparad uppsättning"), + ("Save changes to “{set}”?", "Vill du spara ändringarna i ”{set}”?"), + ("Search by key", "Sök efter tangent"), + ("Select a command first", "Markera ett kommando först"), + ("Select a command to see and change its shortcuts", "Markera ett kommando för att se och ändra dess kortkommandon"), + ("Set name:", "Namn på uppsättning:"), + ("Set:", "Uppsättning:"), + ("Shift", "Shift"), + ("Shortcut", "Kortkommando"), + ("Shortcut Sets", "Uppsättningar av kortkommandon"), + ("Show:", "Visa:"), + ("Skipped", "Överhoppade"), + ("Story Options", "Artikelalternativ"), + ("Suggestions ({n})", "Förslag ({n})"), + ("Summarize…", "Sammanfatta…"), + ("Super", "Super"), + ("Take It", "Ta över"), + ("Take It and Go to Conflict", "Ta över och gå till konflikt"), + ("Taken from other commands", "Övertagna från andra kommandon"), + ("The file is too large", "Filen är för stor"), + ("The name is empty, too long or has control characters", "Namnet är tomt, för långt eller innehåller kontrolltecken"), + ("The scope {scope} is unknown", "Omfånget {scope} är okänt"), + ("The set was not found", "Uppsättningen hittades inte"), + ("There are too many sets", "Det finns för många uppsättningar"), + ("This command is unknown", "Det här kommandot är okänt"), + ("This file is from a newer version (format {n})", "Den här filen kommer från en nyare version (format {n})"), + ("This is not a craft-keymap file", "Det här är inte en craft-keymap-fil"), + ("This is not a shortcut set file", "Det här är inte en fil med en kortkommandouppsättning"), + ("Too many shortcuts are assigned", "För många kortkommandon är tilldelade"), + ("Tools", "Verktyg"), + ("Transparency Flattener", "Utplattning av transparens"), + ("Type…", "Text…"), + ("Unreadable", "Oläsbar"), + ("Use Default", "Använd standard"), + ("Will change", "Ändras"), + ("Win", "Win"), + ("With Shortcut", "Med kortkommando"), + ("empty shortcut", "tomt kortkommando"), + ("in a context that is not imported", "i ett sammanhang som inte importeras"), + ("key sequences are not supported", "tangentsekvenser stöds inte"), + ("line {n}", "rad {n}"), + ("modifier {key} given twice", "modifierartangenten {key} anges två gånger"), + ("more than one command fits", "fler än ett kommando passar"), + ("no command has this name", "inget kommando har det här namnet"), + ("numeric keypad key read as main key", "tangent på det numeriska tangentbordet tolkas som huvudtangent"), + ("only a command of the other kind fits (tool or menu command)", "endast ett kommando av den andra typen passar (verktyg eller menykommando)"), + ("press keys", "tryck på tangenter"), + ("reserved by the system", "reserverad av systemet"), + ("taken by a later entry", "upptaget av en senare post"), + ("taken by {label}", "upptaget av {label}"), + ("the scope is unknown", "omfånget är okänt"), + ("the scope name is not valid", "omfångets namn är inte giltigt"), + ("the set's shortcuts could not be added", "uppsättningens kortkommandon kunde inte läggas till"), + ("the shortcut has no key", "kortkommandot har ingen tangent"), + ("the shortcut is too long", "kortkommandot är för långt"), + ("the shortcuts could not be removed", "kortkommandona kunde inte tas bort"), + ("unknown key {key}", "okänd tangent {key}"), + ("unknown modifier {key}", "okänd modifierartangent {key}"), + ("unsupported modifier {key}", "modifierartangenten {key} stöds inte"), + ("{chord} (Default)", "{chord} (Standard)"), + ("{chord} (press again for a double press)", "{chord} (tryck igen för dubbeltryck)"), + ("{chord} ({scope})", "{chord} ({scope})"), + ("{chord} is reserved by the system", "{chord} är reserverat av systemet"), + ("{chord} was removed from {label}", "{chord} togs bort från {label}"), + ("{chord}: {label}", "{chord}: {label}"), + ("{key}: listed more than once", "{key}: finns med mer än en gång"), + ("{key}: no command has this name", "{key}: inget kommando har det här namnet"), + ("{key}: part of the entry was dropped", "{key}: en del av posten ignorerades"), + ("{key}: the scope in “{text}” is unknown", "{key}: omfånget i ”{text}” är okänt"), + ("{key}: {text} is reserved by the system", "{key}: {text} är reserverat av systemet"), + ("{key}: “{text}” is not a shortcut ({error})", "{key}: ”{text}” är inte ett kortkommando ({error})"), + ("{label} (Changed)", "{label} (Ändrad)"), + ("{label} (cycles)", "{label} (växlar)"), + ("{label} ({scope})", "{label} ({scope})"), + ("{label} ({scope}, both work)", "{label} ({scope}, båda fungerar)"), + ("{label} keeps its shortcuts (matched by a panel menu name)", "{label} behåller sina kortkommandon (matchat via ett namn i en panelmeny)"), + ("{label}: press keys", "{label}: tryck på tangenter"), + ("{label}: {chord}", "{label}: {chord}"), + ("{label}: {chord} (Now used by {holder})", "{label}: {chord} (används nu av {holder})"), + ("{menu} Menu", "Menyn {menu}"), + ("{n} command changed", "{n} kommando ändrades"), + ("{n} command will change", "{n} kommando kommer att ändras"), + ("{n} commands changed", "{n} kommandon ändrades"), + ("{n} commands will change", "{n} kommandon kommer att ändras"), + ("{n} entries cut at a limit", "{n} poster kapades vid en gräns"), + ("{n} entries were ignored: too many commands", "{n} poster ignorerades: för många kommandon"), + ("{n} entry cut at a limit", "{n} post kapades vid en gräns"), + ("{n} entry was ignored: too many commands", "{n} post ignorerades: för många kommandon"), + ("{n} item could not be read", "{n} objekt kunde inte läsas"), + ("{n} item not matched", "{n} objekt matchades inte"), + ("{n} item without a shortcut", "{n} objekt utan kortkommando"), + ("{n} items could not be read", "{n} objekt kunde inte läsas"), + ("{n} items not matched", "{n} objekt matchades inte"), + ("{n} items without a shortcut", "{n} objekt utan kortkommando"), + ("{n} key read approximately", "{n} tangent lästes ungefärligt"), + ("{n} keys read approximately", "{n} tangenter lästes ungefärligt"), + ("{n} saved shortcut could not be used", "{n} sparat kortkommando kunde inte användas"), + ("{n} saved shortcuts could not be used", "{n} sparade kortkommandon kunde inte användas"), + ("{n} shortcut also works in another scope", "{n} kortkommando fungerar även i ett annat omfång"), + ("{n} shortcut is assigned more than once", "{n} kortkommando är tilldelat mer än en gång"), + ("{n} shortcuts also work in another scope", "{n} kortkommandon fungerar även i ett annat omfång"), + ("{n} shortcuts are assigned more than once", "{n} kortkommandon är tilldelade mer än en gång"), + ("{panel} Panel", "Panelen {panel}"), + ("{title} ({n})", "{title} ({n})"), + ("{title}: {set}", "{title}: {set}"), + ("ΔLOC", "ΔLOC"), + ("−Bin", "−Bin"), + ("−LOC", "−LOC"), + ("⚠ {chord} is used by {label}", "⚠ {chord} används av {label}"), + ("Y:", "Y:"), + ("Show the Typography panel", "Visa panelen Typografi"), + ( + "This story was edited in the layout: you'll be asked before those edits are replaced", + "Den här artikeln har ändrats i layouten: du får en fråga innan de ändringarna ersätts", + ), + ("Fewer Options", "Färre alternativ"), + ("Tab Character", "Tabbtecken"), + ("Keep In Frame", "Behåll i ramen"), + ("New Paragraph Style", "Ny styckestil"), + ("New Character Style", "Ny teckenstil"), + ("Next Style:", "Nästa stil:"), + ("[Same style]", "[Samma stil]"), + ("Reset To Base", "Återställ till bas"), + ("Apply Style to Selection", "Använd stil på markeringen"), + ("Change/Find", "Ändra/Sök"), + ("Find Format:", "Sök format:"), + ("Find Format Settings", "Formatinställningar för sökning"), + ("Specify attributes to find", "Ange attribut att söka efter"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "Med Sök format hittas bara text som har formatet; om Sök efter är tomt hittas all text som har det", + ), + ("Change Format:", "Ändra format:"), + ("Change Format Settings", "Formatinställningar för ändring"), + ("Style Options", "Stilalternativ"), + ("[Any Style]", "[Valfri stil]"), + ("Specify attributes to change", "Ange attribut att ändra"), + ("Clear specified attributes", "Rensa angivna attribut"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "Om Ändra format är angivet och Ändra till är tomt behålls den hittade texten och formateras bara", + ), + ("Double Column", "Dubbel kolumn"), + ("Tools Panel:", "Verktygspanel:"), + ("Color Theme:", "Färgtema:"), + ("At End of Document", "I slutet av dokumentet"), + ("At Start of Document", "I början av dokumentet"), + ("Before Page", "Före sidan"), + ("After Page", "Efter sidan"), + ("Parent:", "Mallsida:"), + ("Insert:", "Infoga:"), + ("Stroke Type", "Konturtyp"), + ("Corner Shape", "Hörnform"), + ("Corner Size and Shape:", "Hörnstorlek och form:"), + ("New Section", "Nytt avsnitt"), + ("Clear Frame Fitting Options", "Rensa alternativ för ramanpassning"), + ("Cannot be edited", "Kan inte redigeras"), + ("Maximum:", "Maximum:"), + ("Paragraph Spacing Limit:", "Gränsvärde för styckeavstånd:"), + ("Overprint", "Övertryck"), + ("Top Left Corner", "Övre vänstra hörnet"), + ("Top Right Corner", "Övre högra hörnet"), + ("Bottom Left Corner", "Nedre vänstra hörnet"), + ("Bottom Right Corner", "Nedre högra hörnet"), + ("Anchor Point(s)", "Ankarpunkt(er)"), + ("Convert Points", "Konvertera punkter"), + ("Delete Point(s)", "Radera punkt(er)"), + ("Convert Text to Table…", "Konvertera text till tabell…"), + ("Convert Table to Text…", "Konvertera tabell till text…"), + ("Column Separator", "Kolumnavgränsare"), + ("Row Separator", "Radavgränsare"), + ("Number of Columns", "Antal spalter"), + ("Comma", "Komma"), + ("RGB Color Space View", "Vy för RGB-färgrymd"), + ("Lab Color Space View", "Vy för Lab-färgrymd"), + ("Add CMYK Swatch", "Lägg till CMYK-färgruta"), + ("Added swatch", "Tillagd färgruta"), + ("Eyedropper: drag it onto the page", "Pipett: dra den till sidan"), + ("PDF Preset:", "PDF-förinställning:"), + ("Continue", "Fortsätt"), + ("Printing Instructions", "Utskriftsinstruktioner"), + ("Show Problems Only", "Visa endast problem"), + ("Include PDF (Print)", "Inkludera PDF (utskrift)"), + ("Include IDML", "Inkludera IDML"), + ("Create Printing Instructions", "Skapa utskriftsinstruktioner"), + ("Entire Publication", "Hela publikationen"), + ("Scope:", "Omfång:"), + ("Source", "Källa"), + ("Installed", "Installerade"), + ("Bundled", "Medföljande"), + ("Substituted", "Ersatt"), + ("Modified", "Ändrad"), + ("Missing", "Saknas"), + ("Colors and Inks: {process} process, {spot} spot", "Färger och tryckfärger: {process} processfärger, {spot} dekorfärger"), + ("Embedded images: {embedded}", "Inbäddade bilder: {embedded}"), + ("Instructions:", "Instruktioner:"), + ("Fax:", "Fax:"), + ("Address:", "Adress:"), + ("Company:", "Företag:"), + ("Contact:", "Kontakt:"), + ("Process Black", "Processvart"), + ("Process Yellow", "Processgul"), + ("Process Magenta", "Processmagenta"), + ("Process Cyan", "Processcyan"), + ("Colors and Inks", "Färger och tryckfärger"), + ("Links and Images", "Länkar och bilder"), + ("Fonts", "Typsnitt"), + ("Summary", "Sammanfattning"), + ("Color spectrum", "Färgspektrum"), + ("Object/Convert Point", "Objekt/Konvertera punkt"), + ("Fonts: {used} in use, {missing} unavailable", "Typsnitt: {used} används, {missing} inte tillgängliga"), + ( + "Links and Images: {found} linked, {modified} changed, {missing} not found", + "Länkar och bilder: {found} länkade, {modified} ändrade, {missing} hittades inte", + ), +]; + +pub(super) const CONTEXT: &[(&str, &str, &str)] = &[ + ("Group", "selection", "Gruppera"), + ("Columns", "table", "Kolumner"), + ("Name", "contributors", "Namn"), + ("Number:", "count", "Antal:"), + ("Columns:", "grid", "Kolumner:"), + ("Span Columns", "paragraphLayout", "Sträck över spalter"), +]; diff --git a/crates/ui-egui/src/i18n/table.rs b/crates/ui-egui/src/i18n/table.rs index fc1618bf2..27f5cf389 100644 --- a/crates/ui-egui/src/i18n/table.rs +++ b/crates/ui-egui/src/i18n/table.rs @@ -1,5 +1,42 @@ /// English → [German, French, Spanish, Japanese, Simplified Chinese]. pub(super) const TABLE: &[(&str, [&str; 5])] = &[ + ("Keep In Frame", ["Im Rahmen behalten", "Conserver dans le bloc", "Mantener en el marco", "フレーム内に保持", "保持在框架内"]), + ("Next Style:", ["Nächstes Format:", "Style suivant :", "Estilo siguiente:", "次のスタイル :", "下一样式:"]), + ("[Same style]", ["[Gleiches Format]", "[Même style]", "[Mismo estilo]", "[同じスタイル]", "[相同样式]"]), + ("Reset To Base", ["Auf Basis zurücksetzen", "Rétablir la base", "Restablecer a base", "基準に戻す", "重置为基准"]), + ("New Paragraph Style", ["Neues Absatzformat", "Nouveau style de paragraphe", "Nuevo estilo de párrafo", "新規段落スタイル", "新建段落样式"]), + ("New Character Style", ["Neues Zeichenformat", "Nouveau style de caractère", "Nuevo estilo de carácter", "新規文字スタイル", "新建字符样式"]), + ( + "Apply Style to Selection", + [ + "Format auf Auswahl anwenden", + "Appliquer le style à la sélection", + "Aplicar estilo a la selección", + "選択範囲にスタイルを適用", + "将样式应用于选区", + ], + ), + ( + "Clear Frame Fitting Options", + [ + "Rahmeneinpassungsoptionen löschen", + "Effacer les options d’ajustement du bloc", + "Borrar opciones de encaje de marco", + "フレーム調整オプションを消去", + "清除框架适合选项", + ], + ), + ("Maximum:", ["Maximum:", "Maximum :", "Máximo:", "最大 :", "最大值:"]), + ( + "Paragraph Spacing Limit:", + [ + "Abstandsgrenze für Absätze:", + "Limite d'espacement des paragraphes :", + "Límite de espaciado de párrafo:", + "段落スペース最大値 :", + "段落间距限制:", + ], + ), ( "Character Formatting Controls", [ @@ -115,12 +152,66 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[ ("File Info…", ["Dateiinformationen …", "Informations sur le fichier…", "Información de archivo…", "ファイル情報…", "文件信息…"]), ("File Info", ["Dateiinformationen", "Informations sur le fichier", "Información de archivo", "ファイル情報", "文件信息"]), ("Package…", ["Verpacken …", "Assemblage…", "Empaquetar…", "パッケージ…", "打包…"]), + ("Summary", ["Übersicht", "Résumé", "Resumen", "概要", "摘要"]), + ("Fonts", ["Schriften", "Polices", "Fuentes", "フォント", "字体"]), + ("Links and Images", ["Verknüpfungen und Bilder", "Liens et images", "Vínculos e imágenes", "リンクと画像", "链接和图像"]), + ("Colors and Inks", ["Farben und Druckfarben", "Couleurs et encres", "Colores y tintas", "カラーとインキ", "颜色和油墨"]), + ("Process Cyan", ["Prozess-Cyan", "Cyan quadri", "Cian de cuatricromía", "プロセスシアン", "印刷青色"]), + ("Process Magenta", ["Prozess-Magenta", "Magenta quadri", "Magenta de cuatricromía", "プロセスマゼンタ", "印刷洋红色"]), + ("Process Yellow", ["Prozess-Gelb", "Jaune quadri", "Amarillo de cuatricromía", "プロセスイエロー", "印刷黄色"]), + ("Process Black", ["Prozess-Schwarz", "Noir quadri", "Negro de cuatricromía", "プロセスブラック", "印刷黑色"]), + ("Contact:", ["Kontakt:", "Contact :", "Contacto:", "連絡先:", "联系人:"]), + ("Company:", ["Firma:", "Société :", "Empresa:", "会社名:", "公司:"]), + ("Address:", ["Adresse:", "Adresse :", "Dirección:", "住所:", "地址:"]), + ("Phone:", ["Telefon:", "Téléphone :", "Teléfono:", "電話:", "电话:"]), + ("Fax:", ["Fax:", "Fax :", "Fax:", "FAX:", "传真:"]), + ("Email:", ["E-Mail:", "E-mail :", "Correo electrónico:", "メール:", "电子邮件:"]), + ("Instructions:", ["Anweisungen:", "Instructions :", "Instrucciones:", "指示:", "说明:"]), + ("Missing", ["Fehlt", "Manquant", "Ausente", "見つかりません", "缺失"]), + ("Modified", ["Geändert", "Modifié", "Modificado", "変更", "已修改"]), + ("Substituted", ["Ersetzt", "Remplacée", "Sustituida", "代替", "已替换"]), + ("Bundled", ["Mitgeliefert", "Fournie", "Incluida", "同梱", "内置"]), + ("Installed", ["Installiert", "Installée", "Instalada", "インストール済み", "已安装"]), + ("Source", ["Quelle", "Source", "Origen", "ソース", "来源"]), + ("Scope:", ["Bereich:", "Étendue :", "Ámbito:", "範囲:", "范围:"]), + ("Entire Publication", ["Gesamte Publikation", "Publication entière", "Toda la publicación", "出版物全体", "整个出版物"]), + ( + "Create Printing Instructions", + ["Druckanweisungen erstellen", "Créer des instructions d’impression", "Crear instrucciones de impresión", "印刷指示を作成", "创建打印说明"], + ), + ("Include IDML", ["IDML einschließen", "Inclure l’IDML", "Incluir IDML", "IDML を含める", "包括 IDML"]), + ( + "Include PDF (Print)", + ["PDF einschließen (Druck)", "Inclure le PDF (impression)", "Incluir PDF (impresión)", "PDF を含める(印刷)", "包括 PDF(打印)"], + ), + ("PDF Preset:", ["PDF-Vorgabe:", "Paramètre prédéfini PDF :", "Ajuste preestablecido de PDF:", "PDF プリセット:", "PDF 预设:"]), + ("Show Problems Only", ["Nur Probleme anzeigen", "Afficher uniquement les problèmes", "Mostrar solo problemas", "問題のみ表示", "仅显示问题"]), + ("Printing Instructions", ["Druckanweisungen", "Instructions d’impression", "Instrucciones de impresión", "印刷指示", "打印说明"]), + ("Continue", ["Weiter", "Continuer", "Continuar", "続行", "继续"]), + ( + "Packaged {count} files into {dir}", + [ + "{count} Dateien in {dir} verpackt", + "{count} fichiers assemblés dans {dir}", + "{count} archivos empaquetados en {dir}", + "{count} 個のファイルを {dir} にパッケージしました", + "已将 {count} 个文件打包到 {dir}", + ], + ), // Edit. ("Undo", ["Rückgängig", "Annuler", "Deshacer", "取り消し", "还原"]), ("Redo", ["Wiederholen", "Rétablir", "Rehacer", "やり直し", "重做"]), ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ("Stroke Type", ["Stroke Type", "Type de contour", "Tipo de trazo", "Stroke Type", "描边类型"]), + ("Corner Shape", ["Corner Shape", "Forme d'angle", "Forma de esquina", "Corner Shape", "角形状"]), + ( + "Corner Size and Shape:", + ["Eckengröße und -form:", "Taille et forme des angles:", "Tamaño y forma de esquinas:", "角のサイズとシェイプ:", "角大小和形状:"], + ), + ("Join", ["Join", "Joindre", "Unir", "Join", "连接"]), + ("Convert Point", ["Convert Point", "Convertir le point", "Convertir punto", "Convert Point", "转换点"]), ("Hide Rulers", ["Lineale ausblenden", "Masquer les règles", "Ocultar reglas", "定規を隠す", "隐藏标尺"]), ("Inches Decimal", ["Zoll dezimal", "Pouces décimaux", "Pulgadas decimales", "インチ(10 進)", "十进制英寸"]), ( @@ -276,6 +367,28 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[ ("Pages Fixed", ["Seiten fixiert", "Pages fixes", "Páginas fijas", "ページを固定", "页面固定"]), ("Parents Fixed", ["Übergeordnete fixiert", "Gabarits fixes", "Páginas principales fijas", "親ページを固定", "父页面固定"]), ("Parents", ["Übergeordnete Seiten", "Gabarits", "Páginas principales", "親ページ", "父页面"]), + ( + "RGB Color Space View", + [ + "RGB-Farbraumansicht", + "Affichage de l’espace colorimétrique RVB", + "Vista de espacio de color RGB", + "RGB カラースペース表示", + "RGB 色彩空间视图", + ], + ), + ( + "Lab Color Space View", + [ + "Lab-Farbraumansicht", + "Affichage de l’espace colorimétrique Lab", + "Vista de espacio de color Lab", + "Lab カラースペース表示", + "Lab 色彩空间视图", + ], + ), + ("Add CMYK Swatch", ["CMYK-Farbfeld hinzufügen", "Ajouter une nuance CMJN", "Añadir muestra CMYK", "CMYK スウォッチを追加", "添加 CMYK 色板"]), + ("Added swatch", ["Farbfeld hinzugefügt", "Nuance ajoutée", "Muestra añadida", "スウォッチを追加しました", "已添加色板"]), ( "Bulleted & Numbered Lists", [ @@ -375,6 +488,9 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[ ("Pathfinder:", ["Pathfinder:", "Pathfinder :", "Buscatrazos:", "パスファインダー:", "路径查找器:"]), ("Convert Shape:", ["Form konvertieren:", "Convertir la forme :", "Convertir forma:", "シェイプを変換:", "转换形状:"]), ("Convert Point:", ["Punkt konvertieren:", "Convertir le point :", "Convertir punto:", "ポイントを変換:", "转换点:"]), + ("Anchor Point(s)", ["Ankerpunkt(e)", "Point(s) d’ancrage", "Punto(s) de ancla", "アンカーポイント", "锚点"]), + ("Convert Points", ["Punkte konvertieren", "Convertir les points", "Convertir puntos", "ポイントを変換", "转换点"]), + ("Delete Point(s)", ["Punkt(e) löschen", "Supprimer le(s) point(s)", "Eliminar punto(s)", "ポイントを削除", "删除点"]), ("Join Path", ["Pfad zusammenfügen", "Joindre le tracé", "Unir trazado", "パスを連結", "连接路径"]), ("Open Path", ["Pfad öffnen", "Ouvrir le tracé", "Abrir trazado", "パスをオープン", "开放路径"]), ("Plain", ["Einfach", "Simple", "Normal", "プレーン", "普通"]), @@ -953,12 +1069,86 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[ ("Center Content", ["Center Content", "Centrer le contenu", "Center Content", "Center Content", "内容居中"]), ("Change All", ["Change All", "Tout remplacer", "Change All", "Change All", "全部更改"]), ("Change to:", ["Change to:", "Remplacer par :", "Change to:", "Change to:", "更改为:"]), + ("Change Format:", ["Format ersetzen:", "Remplacer le format :", "Cambiar formato:", "置換形式 :", "更改格式:"]), + ("Change/Find", ["Ersetzen/Suchen", "Remplacer/Rechercher", "Cambiar/Buscar", "置換して検索", "更改/查找"]), + ("Find Format:", ["Format suchen:", "Rechercher le format :", "Buscar formato:", "検索形式 :", "查找格式:"]), + ( + "Find Format Settings", + ["Einstellungen für Format suchen", "Paramètres de recherche de format", "Ajustes de búsqueda de formato", "検索形式の設定", "查找格式设置"], + ), + ( + "Specify attributes to find", + [ + "Zu suchende Attribute festlegen", + "Indiquer les attributs à rechercher", + "Especificar atributos que buscar", + "検索する属性を指定", + "指定要查找的属性", + ], + ), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + [ + "Mit einem Suchformat wird nur Text gefunden, der es hat; bei leerem Feld „Suchen nach“ jeder Text, der es hat", + "Avec un format de recherche, seul le texte qui l’a est trouvé ; si « Rechercher » est vide, tout le texte qui l’a", + "Con un formato de búsqueda solo se encuentra el texto que lo tiene; con «Buscar» vacío, todo el texto que lo tiene", + "検索形式があるときは、その書式のテキストだけが見つかります。検索文字列が空のときは、その書式のテキストすべてが見つかります", + "设置了查找格式时,只查找具有该格式的文本;“查找内容”为空时,查找所有具有该格式的文本", + ], + ), + ( + "Change Format Settings", + [ + "Einstellungen für Format ersetzen", + "Paramètres de remplacement du format", + "Ajustes de cambio de formato", + "置換形式の設定", + "更改格式设置", + ], + ), + ("Style Options", ["Formatoptionen", "Options de style", "Opciones de estilo", "スタイルオプション", "样式选项"]), + ("[Any Style]", ["[Beliebiges Format]", "[Tout style]", "[Cualquier estilo]", "[任意のスタイル]", "[任意样式]"]), + ( + "Specify attributes to change", + [ + "Zu ersetzende Attribute festlegen", + "Indiquer les attributs à remplacer", + "Especificar atributos que cambiar", + "置換する属性を指定", + "指定要更改的属性", + ], + ), + ( + "Clear specified attributes", + ["Festgelegte Attribute löschen", "Effacer les attributs indiqués", "Borrar atributos especificados", "指定した属性を消去", "清除指定的属性"], + ), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + [ + "Mit einem Ersetzungsformat und leerem Feld „Ersetzen durch“ bleibt der gefundene Text erhalten und wird nur formatiert", + "Avec un format de remplacement et « Remplacer par » vide, le texte trouvé reste et seule sa mise en forme change", + "Con un formato de cambio y «Cambiar a» vacío, el texto encontrado se conserva y solo cambia su formato", + "置換形式があり置換文字列が空のときは、検索したテキストはそのままで書式だけが変わります", + "设置了更改格式且“更改为”为空时,找到的文本保持不变,只更改其格式", + ], + ), ("Character Style:", ["Character Style:", "Style de caractère :", "Character Style:", "Character Style:", "字符样式:"]), ("Checked by default", ["Checked by default", "Cochée par défaut", "Checked by default", "Checked by default", "默认选中"]), ("Choices", ["Choices", "Choix", "Choices", "Choices", "选项"]), ("Choose Image…", ["Choose Image…", "Choisir une image…", "Choose Image…", "Choose Image…", "选择图像…"]), ("Class:", ["Class:", "Classe :", "Class:", "Class:", "类别:"]), ("Color Picker", ["Color Picker", "Sélecteur de couleurs", "Color Picker", "Color Picker", "拾色器"]), + ("Color spectrum", ["Farbspektrum", "Spectre de couleurs", "Espectro de color", "カラースペクトル", "色谱"]), + ( + "Eyedropper: drag it onto the page", + [ + "Pipette: auf die Seite ziehen", + "Pipette : faites-la glisser sur la page", + "Cuentagotas: arrástrelo a la página", + "スポイト : ページにドラッグ", + "吸管:将其拖到页面上", + ], + ), ("Color Settings", ["Color Settings", "Réglages des couleurs", "Color Settings", "Color Settings", "颜色设置"]), ("Color:", ["Color:", "Couleur :", "Color:", "Color:", "颜色:"]), ("Columns", ["Columns", "Colonnes", "Columns", "Columns", "栏"]), @@ -1128,6 +1318,27 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[ ], ), ("Load Styles", ["Formate laden", "Charger les styles", "Cargar estilos", "スタイルを読み込み", "加载样式"]), + ("New Section", ["Neuer Abschnitt", "Nouvelle section", "Nueva sección", "新規セクション", "新建章节"]), + ("Cannot be edited", ["Nicht bearbeitbar", "Non modifiable", "No editable", "編集不可", "不可编辑"]), + ("Selection", ["Auswahl", "Sélection", "Selección", "選択範囲", "选区"]), + ("Overprint", ["Überdrucken", "Surimpression", "Sobreimprimir", "オーバープリント", "叠印"]), + ("Top Left Corner", ["Ecke oben links", "Angle supérieur gauche", "Esquina superior izquierda", "左上角", "左上角"]), + ("Top Right Corner", ["Ecke oben rechts", "Angle supérieur droit", "Esquina superior derecha", "右上角", "右上角"]), + ("Bottom Left Corner", ["Ecke unten links", "Angle inférieur gauche", "Esquina inferior izquierda", "左下角", "左下角"]), + ("Bottom Right Corner", ["Ecke unten rechts", "Angle inférieur droit", "Esquina inferior derecha", "右下角", "右下角"]), + ("Tab", ["Tabulator", "Tabulation", "Tabulación", "タブ", "制表符"]), + ( + "Convert Text to Table…", + ["Text in Tabelle umwandeln …", "Convertir le texte en tableau…", "Convertir texto en tabla…", "テキストを表に変換…", "将文本转换为表格…"], + ), + ( + "Convert Table to Text…", + ["Tabelle in Text umwandeln …", "Convertir le tableau en texte…", "Convertir tabla en texto…", "表をテキストに変換…", "将表格转换为文本…"], + ), + ("Column Separator", ["Spaltentrennzeichen", "Séparateur de colonnes", "Separador de columnas", "列分解文字", "列分隔符"]), + ("Row Separator", ["Zeilentrennzeichen", "Séparateur de rangs", "Separador de filas", "行分解文字", "行分隔符"]), + ("Number of Columns", ["Spaltenanzahl", "Nombre de colonnes", "Número de columnas", "列数", "列数"]), + ("Comma", ["Komma", "Virgule", "Coma", "カンマ", "逗号"]), ("Restart Numbering", ["Nummerierung neu beginnen", "Recommencer la numérotation", "Reiniciar numeración", "番号の振り直し", "重新开始编号"]), ("Continue Numbering", ["Nummerierung fortführen", "Poursuivre la numérotation", "Continuar numeración", "番号の継続", "继续编号"]), ("Keep Options…", ["Umbruchoptionen …", "Options d'enchaînement…", "Opciones de separación…", "段落分離禁止オプション…", "保持选项…"]), @@ -1581,7 +1792,8 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[ "适合框架高度到文本", ], ), - ("More Options", ["More Options", "Plus d'options", "More Options", "More Options", "更多选项"]), + ("More Options", ["Weitere Optionen", "Plus d'options", "Más opciones", "その他のオプション", "更多选项"]), + ("Fewer Options", ["Weniger Optionen", "Moins d'options", "Menos opciones", "オプションを減らす", "较少选项"]), ("Flip Horizontal", ["Flip Horizontal", "Retourner horizontalement", "Flip Horizontal", "Flip Horizontal", "水平翻转"]), ( "Rotate 90° Clockwise", @@ -2079,6 +2291,13 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[ ["Insert Math Expression…", "Insérer une expression mathématique…", "Insert Math Expression…", "Insert Math Expression…", "插入数学公式…"], ), ("Insert Pages…", ["Insert Pages…", "Insérer des pages…", "Insert Pages…", "Insert Pages…", "插入页面…"]), + ("Insert Pages", ["Seiten einfügen", "Insérer des pages", "Insertar páginas", "ページを挿入", "插入页面"]), + ("Insert:", ["Einfügen:", "Insérer :", "Insertar:", "挿入:", "插入:"]), + ("Parent:", ["Vorlagenseite:", "Gabarit :", "Página principal:", "親ページ:", "主页:"]), + ("After Page", ["Nach Seite", "Après la page", "Después de la página", "ページの後", "页面后"]), + ("Before Page", ["Vor Seite", "Avant la page", "Antes de la página", "ページの前", "页面前"]), + ("At Start of Document", ["Am Dokumentanfang", "Au début du document", "Al principio del documento", "ドキュメントの先頭", "文档开头"]), + ("At End of Document", ["Am Dokumentende", "À la fin du document", "Al final del documento", "ドキュメントの末尾", "文档末尾"]), ("Insert Variable", ["Insert Variable", "Insérer une variable", "Insert Variable", "Insert Variable", "插入变量"]), ( "Convert Variable to Text", @@ -2424,6 +2643,9 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[ ), ("Typical Display", ["Typical Display", "Affichage standard", "Typical Display", "Typical Display", "典型显示"]), ("UI Scaling", ["UI Scaling", "Mise à l'échelle de l'interface", "UI Scaling", "UI Scaling", "UI 缩放"]), + ("Color Theme:", ["Farbdesign:", "Thème de couleur :", "Tema de color:", "カラーテーマ:", "颜色主题:"]), + ("Tools Panel:", ["Werkzeugbedienfeld:", "Panneau Outils :", "Panel Herramientas:", "ツールパネル:", "工具面板:"]), + ("Double Column", ["Doppelte Spalte", "Double colonne", "Columna doble", "2 列", "双栏"]), ("Update Link", ["Update Link", "Mettre à jour le lien", "Update Link", "Update Link", "更新链接"]), ( "User Dictionary Words", @@ -3885,6 +4107,18 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[ ("Box", ["Box", "Boîte", "Box", "Box", "方框"]), ("Comb", ["Comb", "Peigne", "Comb", "Comb", "梳状"]), ("Cover", ["Cover", "Recouvrir", "Cover", "Cover", "覆盖"]), + ("Cover Image:", ["Titelbild:", "Image de couverture :", "Imagen de portada:", "表紙画像 :", "封面图像:"]), + ("Choose…", ["Auswählen …", "Choisir…", "Elegir…", "選択…", "选择…"]), + ( + "A PNG or JPEG file; left empty, the first page is the cover.", + [ + "Eine PNG- oder JPEG-Datei; bleibt das Feld leer, ist die erste Seite das Titelbild.", + "Un fichier PNG ou JPEG ; laissé vide, la première page sert de couverture.", + "Un archivo PNG o JPEG; si se deja vacío, la primera página es la portada.", + "PNG または JPEG ファイル。空のままにすると最初のページが表紙になります。", + "PNG 或 JPEG 文件;留空时第一页即为封面。", + ], + ), ("Dissolve", ["Dissolve", "Dissolution", "Dissolve", "Dissolve", "溶解"]), ("Fade", ["Fade", "Fondu", "Fade", "Fade", "淡入淡出"]), ("Push", ["Push", "Pousser", "Push", "Push", "推入"]), @@ -4664,6 +4898,7 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[ ("OpenType font", ["OpenType-Schrift", "Police OpenType", "Fuente OpenType", "OpenType フォント", "OpenType 字体"]), ("Variable font", ["Variable Schrift", "Police variable", "Fuente variable", "バリアブルフォント", "可变字体"]), ("Color font", ["Farbschrift", "Police couleur", "Fuente de color", "カラーフォント", "彩色字体"]), + ("Tab Character", ["Tabulatorzeichen", "Caractère de tabulation", "Carácter de tabulación", "タブ文字", "制表符"]), // Table of Contents. ( "Sort Entries in Alphabetical Order", diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index db13138cf..b676a0c5f 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -7,18 +7,28 @@ const LANGUAGE_TABLES: &[(&str, &[(&str, &str)])] = &[ ("cs", cs::TABLE), ("de", de::TABLE), ("fr", fr::TABLE), + ("hu", hu::TABLE), ("it", it::TABLE), ("ja", ja::TABLE), ("ka", ka::TABLE), ("pl", pl::TABLE), ("pt-br", pt_br::TABLE), ("ru", ru::TABLE), + ("sv", sv::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)]; +const CONTEXT_TABLES: &[(&str, &[(&str, &str, &str)])] = &[ + ("ar", ar::CONTEXT), + ("cs", cs::CONTEXT), + ("hu", hu::CONTEXT), + ("ja", ja::CONTEXT), + ("pl", pl::CONTEXT), + ("ru", ru::CONTEXT), + ("sv", sv::CONTEXT), + ("uk", uk::CONTEXT), +]; /// The languages of the shared tables' columns, in column order. const SHARED_COLUMNS: [&str; 5] = ["de", "fr", "es", "ja", "zh"]; @@ -31,6 +41,7 @@ fn or_english<'a>(text: &'a str, english: &'a str) -> &'a str { if text.trim().is_empty() { english } else { text } } +/// Checks cached catalog lookup across language switches, including source-text fallback. #[test] fn cached_lookup_preserves_every_translation_across_language_switches() { let own = |lang: &str, key: &str| { @@ -61,6 +72,167 @@ fn cached_lookup_preserves_every_translation_across_language_switches() { } } +/// Checks Hungarian catalog integrity, context captions and count substitutions, including +/// English fallback and preservation of user-defined style and workspace names. +#[test] +fn hungarian_catalog_preserves_placeholders_context_and_counts() { + let catalog: HashMap<_, _> = hu::TABLE.iter().copied().collect(); + assert_eq!(catalog.len(), hu::TABLE.len(), "duplicate Hungarian keys"); + let placeholders = |s: &str| { + let mut names: Vec<_> = s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect(); + names.sort(); + names + }; + for (key, translation) in hu::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}"); + } + assert!(!is_rtl("hu")); + assert_eq!(tr("hu", "File"), "Fájl"); + assert_eq!(tr("hu", "Bleed"), "Kifutó"); + assert_eq!(tr("hu", "Arrangement"), "Elrendezés"); + assert_eq!(tr("hu", "Unknown label"), "Unknown label"); + assert_eq!(tr("hu", "Columns"), "Hasábok"); + assert_eq!(tr_context("hu", "Columns", "table"), "Oszlopok"); + assert_eq!(tr("hu", "Columns:"), "Hasábok:"); + assert_eq!(tr_context("hu", "Columns:", "grid"), "Oszlopok:"); + assert_eq!(tr_context("hu", "Columns:", "unknown"), "Hasábok:"); + assert_eq!(tr("hu", "Group"), "Csoportosítás"); + assert_eq!(tr_context("hu", "Group", "selection"), "Csoport"); + assert_eq!(tr_context("hu", "Columns", "unknown"), "Hasábok"); + assert_eq!(tr_context("", "Columns", "table"), "Columns"); + for n in [0, 1, 2, 5, 21] { + assert_eq!(count_label("hu", "pages", n), format!("{n} oldal")); + assert_eq!(count_label("hu", "objects", n), format!("{n} objektum")); + assert_eq!(count_label("hu", "errors", n), format!("{n} hiba")); + assert_eq!( + tr("hu", "{pages} Pages in {spreads} Spreads").replace("{pages}", &n.to_string()).replace("{spreads}", &n.to_string()), + format!("{n} oldal, {n} oldalpáron") + ); + } + assert_eq!(style_name("hu", "[Basic Paragraph]"), "[Alapbekezdés]"); + assert_eq!(style_name("hu", "Saját stílus"), "Saját stílus"); + assert_eq!(workspace_name("hu", "Essentials"), "Alapok"); + assert_eq!(workspace_name("hu", "Saját munkaterület"), "Saját munkaterület"); +} + +/// Checks every Latin catalog letter and all Hungarian accented vowels against the +/// configured bundled UI faces, with system and optional craft-fonts fallbacks disabled. +#[test] +fn hungarian_catalog_glyphs_exist_in_the_bundled_ui_fonts() { + let definitions = crate::theme::font_definitions(&[], "hu"); + let db = designcraft_fonts::FontDb::with_font_dirs(Vec::new()); + db.set_system_fallback(false); + let letters: std::collections::BTreeSet<_> = hu::TABLE + .iter() + .flat_map(|(_, text)| text.chars()) + .chain(hu::CONTEXT.iter().flat_map(|(_, _, text)| text.chars())) + .chain("ÁáÉéÍíÓóÖöŐőÚúÜüŰű".chars()) + .filter(|c| c.is_alphabetic() && *c <= '\u{024f}') + .collect(); + for (name, family, style, ui_family) in [ + ("ui", "Source Sans 3", "Regular", egui::FontFamily::Proportional), + ("ui-semibold", "Source Sans 3", "Semibold", egui::FontFamily::Name("semibold".into())), + ("mono", "JetBrains Mono", "Regular", egui::FontFamily::Monospace), + ] { + assert_eq!(definitions.families[&ui_family].first().map(String::as_str), Some(name)); + let face = db.face(family, style); + assert_eq!(face.data(), definitions.font_data[name].bytes(), "configured UI face {name}"); + for letter in &letters { + assert_ne!(face.glyph_for(*letter), 0, "missing {letter} in {family} {style}"); + } + } + eprintln!( + "Hungarian glyph coverage: {} Latin catalog letters, including ŐőŰű, in Source Sans 3 Regular/Semibold and JetBrains Mono Regular; no system/craft fallback", + letters.len() + ); + if let Some(dir) = std::env::var_os("DESIGNCRAFT_HU_REVIEW_SHOTS") { + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(820.0, 260.0)).build_ui_state( + |ui, ready: &mut bool| { + if !*ready { + ui.ctx().set_fonts(crate::theme::font_definitions(&[], "hu")); + *ready = true; + return; + } + ui.style_mut().visuals = egui::Visuals::light(); + ui.painter().rect_filled(ui.ctx().content_rect(), 0.0, egui::Color32::WHITE); + ui.heading("Magyar ékezetek · ő, ű / Ő, Ű"); + for (name, family) in [ + ("Source Sans 3 Regular", egui::FontFamily::Proportional), + ("Source Sans 3 Semibold", egui::FontFamily::Name("semibold".into())), + ("JetBrains Mono Regular", egui::FontFamily::Monospace), + ] { + ui.label(egui::RichText::new(name).color(egui::Color32::DARK_GRAY)); + ui.label( + egui::RichText::new("Árvíztűrő tükörfúrógép · Őő Űű · ÁÉÍÓÖŐÚÜŰ") + .font(egui::FontId::new(22.0, family)) + .color(egui::Color32::BLACK), + ); + } + }, + false, + ); + h.run_steps(3); + h.render().unwrap().save(std::path::Path::new(&dir).join("hungarian-bundled-ui-fonts.png")).unwrap(); + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.ui.language = "hu".into(); + app.run("file.new", serde_json::json!({"pages": 1})).unwrap(); + app.run( + "frame.create", + serde_json::json!({"rect": [60, 60, 420, 180], "content": "text", "text": "Magyar kiadvány +Árvíztűrő tükörfúrógép +Őő Űű · ÁÉÍÓÖŐÚÜŰ"}), + ) + .unwrap(); + app.run("type.char", serde_json::json!({"attrs": {"fontFamily": "Source Sans 3", "size": 28}})).unwrap(); + app.run("window.panel.activate", serde_json::json!({"panel": "character"})).unwrap(); + let mut interface = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); + interface.render().unwrap().save(std::path::Path::new(&dir).join("designcraft-hungarian-interface.png")).unwrap(); + } +} + +/// Checks translated labels and source-text fallback, plus key uniqueness and nonempty rows +/// in the shared, Arabic, Brazilian Portuguese and Italian catalogs. +#[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}"); + } + 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 (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}"); + } +} + /// The Data Merge arrangement combo takes its accessible name from `tr(lang, "Arrangement")`. /// A row in only the shared table leaves the languages whose lookup returns directly with the /// English name. @@ -77,6 +249,7 @@ fn arrangement_accessible_name_is_localized_in_every_interface_language() { } } +/// Checks language-specific context precedence and fallback to the plain translation. #[test] fn context_captions_come_from_their_context_or_the_plain_translation() { for (lang, table) in CONTEXT_TABLES { @@ -110,6 +283,38 @@ fn context_captions_come_from_their_context_or_the_plain_translation() { } } +/// Keeps quantity labels distinct from numeric values and checks translated Pages-panel captions. +#[test] +fn a_column_count_and_a_list_number_read_differently() { + for (lang, count, numeral) in [ + ("hu", "Darabszám:", "Szám:"), + ("de", "Anzahl:", "Zahl:"), + ("ja", "数:", "番号:"), + ("uk", "Кількість:", "Номер:"), + ("ar", "العدد:", "الرقم:"), + ("fr", "Nombre :", "Numéro:"), + ("zh", "数量:", "编号:"), + ] { + assert_eq!((tr_context(lang, "Number:", "count"), tr(lang, "Number:")), (count, numeral), "{lang}"); + } + 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); + } + assert_ne!(tr("ar", "Apply Parent to Pages"), "Apply Parent to Pages"); +} + /// The tables `tr` reads, as (language, key, translation), context captions included. fn entries() -> Vec<(&'static str, &'static str, &'static str)> { let mut entries: Vec<_> = LANGUAGE_TABLES.iter().flat_map(|(lang, table)| table.iter().map(|(key, text)| (*lang, *key, *text))).collect(); @@ -263,6 +468,23 @@ fn interface_strings() -> HashSet<&'static str> { keys.extend(crate::panel_docking::DOCK_LABELS.iter().map(|(_, english)| *english)); // UI labels passed to `tr` that no other catalog has yet. keys.extend([ + "180°", + "Create Links When Placing Text Files", + "Delete Character Style…", + "Delete Paragraph Style…", + "Delete the character style \"{name}\"?", + "Delete the paragraph style \"{name}\"?", + "IT", + "Replace with:", + "Show the Typography panel", + "Stroke Behind Fill", + "Stylistic Set", + "The selected fonts have no OpenType features.", + "This story was edited in the layout: you'll be asked before those edits are replaced", + "This style is in use. Text using it will be reassigned to:", + "VA", + "Y:", + "“{}” has been edited in this layout. Updating replaces those edits with the text from the file.", "(any)", "(unchanged)", "[No Language]", diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 8f9e67d02..03918520f 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -2,6 +2,11 @@ //! Common phrases are adapted from the MIT OR Apache-2.0 ArtCraft sibling catalogs //! (VectorCraft, PhotoCraft, WordCraft and DeckCraft); see ASSETS.md for pinned sources. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Fewer Options", "Менше параметрів"), + ("Tab Character", "Символ табуляції"), + ("Right Indent Tab", "Табуляція правого відступу"), + ("Em Space", "Кругла шпація"), + ("En Space", "Півкругла шпація"), ("File", "Файл"), ("Edit", "Редагування"), ("Layout", "Макет"), @@ -30,6 +35,103 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Keep In Frame", "Зберегти у фреймі"), + ("New Paragraph Style", "Новий стиль абзацу"), + ("New Character Style", "Новий стиль символів"), + ("Next Style:", "Наступний стиль:"), + ("[Same style]", "[Той самий стиль]"), + ("Reset To Base", "Скинути до основи"), + ("Apply Style to Selection", "Застосувати стиль до виділеного"), + ("Change/Find", "Замінити/Знайти"), + ("Find Format:", "Знайти формат:"), + ("Find Format Settings", "Параметри пошуку формату"), + ("Specify attributes to find", "Задати атрибути для пошуку"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "З форматом пошуку знаходиться лише текст із цим форматом; якщо поле «Знайти» порожнє — увесь такий текст", + ), + ("Change Format:", "Замінити формат:"), + ("Change Format Settings", "Параметри заміни формату"), + ("Style Options", "Параметри стилю"), + ("[Any Style]", "[Будь-який стиль]"), + ("Specify attributes to change", "Задати атрибути для заміни"), + ("Clear specified attributes", "Очистити задані атрибути"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "Якщо задано формат заміни, а поле «Замінити на» порожнє, знайдений текст лишається, змінюється лише його формат", + ), + ("Double Column", "Подвійна колонка"), + ("Tools Panel:", "Панель інструментів:"), + ("Color Theme:", "Колірна тема:"), + ("At End of Document", "У кінець документа"), + ("At Start of Document", "На початок документа"), + ("Before Page", "Перед сторінкою"), + ("After Page", "Після сторінки"), + ("Parent:", "Шаблон:"), + ("Insert:", "Вставити:"), + ("Insert Pages", "Вставити сторінки"), + ("Stroke Type", "Тип обведення"), + ("Corner Shape", "Форма кута"), + ("Corner Size and Shape:", "Розмір і форма кутів:"), + ("Join", "Зʼєднати"), + ("Convert Point", "Перетворити точку"), + ("New Section", "Новий розділ"), + ("Clear Frame Fitting Options", "Очистити параметри припасування фрейму"), + ("Cannot be edited", "Не можна змінити"), + ("Maximum:", "Максимум:"), + ("Paragraph Spacing Limit:", "Межа інтервалу абзаців:"), + ("Overprint", "Накладання"), + ("Top Left Corner", "Лівий верхній кут"), + ("Top Right Corner", "Правий верхній кут"), + ("Bottom Left Corner", "Лівий нижній кут"), + ("Bottom Right Corner", "Правий нижній кут"), + ("Anchor Point(s)", "Опорні точки"), + ("Convert Points", "Перетворити точки"), + ("Delete Point(s)", "Видалити точки"), + ("Tab", "Табуляція"), + ("Convert Text to Table…", "Перетворити текст на таблицю…"), + ("Convert Table to Text…", "Перетворити таблицю на текст…"), + ("Column Separator", "Роздільник стовпців"), + ("Row Separator", "Роздільник рядків"), + ("Number of Columns", "Кількість стовпців"), + ("Comma", "Кома"), + ("RGB Color Space View", "Перегляд колірного простору RGB"), + ("Lab Color Space View", "Перегляд колірного простору Lab"), + ("Add CMYK Swatch", "Додати зразок CMYK"), + ("Added swatch", "Зразок додано"), + ("Eyedropper: drag it onto the page", "Піпетка: перетягніть її на сторінку"), + ("PDF Preset:", "Набір параметрів PDF:"), + ("Packaged {count} files into {dir}", "Запаковано файлів: {count} у {dir}"), + ("Continue", "Продовжити"), + ("Printing Instructions", "Інструкції для друку"), + ("Package", "Пакування"), + ("Show Problems Only", "Показувати лише проблеми"), + ("Include PDF (Print)", "Включити PDF (друк)"), + ("Include IDML", "Включити IDML"), + ("Create Printing Instructions", "Створити інструкції для друку"), + ("Entire Publication", "Уся публікація"), + ("Scope:", "Область:"), + ("Source", "Джерело"), + ("Installed", "Встановлений"), + ("Bundled", "Вбудований"), + ("Substituted", "Замінено"), + ("Modified", "Змінено"), + ("Missing", "Бракує"), + ("Instructions:", "Інструкції:"), + ("Email:", "Ел. пошта:"), + ("Fax:", "Факс:"), + ("Phone:", "Телефон:"), + ("Address:", "Адреса:"), + ("Company:", "Компанія:"), + ("Contact:", "Контакт:"), + ("Process Black", "Тріадний чорний"), + ("Process Yellow", "Тріадний жовтий"), + ("Process Magenta", "Тріадний пурпуровий"), + ("Process Cyan", "Тріадний блакитний"), + ("Colors and Inks", "Кольори й фарби"), + ("Links and Images", "Зв’язки й зображення"), + ("Fonts", "Шрифти"), + ("Summary", "Зведення"), ("Restart Numbering", "Почати нумерацію знову"), ("Continue Numbering", "Продовжити нумерацію"), ("Keep Options…", "Параметри утримання…"), @@ -308,6 +410,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Choose Image…", "Вибрати зображення…"), ("Class:", "Клас:"), ("Color Picker", "Палітра кольорів"), + ("Color spectrum", "Колірний спектр"), ("Color Settings", "Налаштування кольору"), ("Color:", "Колір:"), ("Columns", "Колонки"), @@ -1521,6 +1624,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Box", "Рамка"), ("Comb", "Гребінка"), ("Cover", "Накриття"), + ("Cover Image:", "Зображення обкладинки:"), + ("Choose…", "Вибрати…"), + ("A PNG or JPEG file; left empty, the first page is the cover.", "Файл PNG або JPEG; якщо залишити порожнім, обкладинкою буде перша сторінка."), ("Dissolve", "Розчинення"), ("Fade", "Згасання"), ("Push", "Зсув"), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 7ffbbee5a..c8eeee911 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -735,23 +735,40 @@ fn paint_shapes(pen: &mut Pen, name: &str) -> bool { pen.line(&[(10.0, 3.0), (10.0, 17.0)]); pen.line(&[(3.0, 10.0), (17.0, 10.0)]); } - // Pathfinder panel › Convert Point: a point on a path, with its direction handles. - "point-plain" | "point-corner" | "point-smooth" | "point-symmetrical" => { - match name { - "point-plain" | "point-corner" => pen.line(&[(3.0, 16.0), (10.0, 8.0), (17.0, 16.0)]), - _ => pen.line(&[(3.0, 16.0), (5.5, 11.5), (10.0, 8.0), (14.5, 11.5), (17.0, 16.0)]), + // Convert Point (Pathfinder panel, Properties › Convert Points): the anchor square on its + // path. Plain and Corner sit on a corner, Corner with a knob on each arm; Smooth and + // Symmetrical sit on a curve with a straight handle through them, its arms equal for + // Symmetrical. + "point-plain" | "point-corner" => { + pen.line(&[(17.0, 6.0), (6.0, 6.0), (6.0, 17.0)]); + if name == "point-corner" { + pen.fcircle(13.0, 6.0, 1.8); + pen.fcircle(6.0, 13.0, 1.8); } - let handles: &[(f32, f32)] = match name { - "point-corner" => &[(4.5, 4.0), (16.0, 5.0)], - "point-smooth" => &[(5.0, 8.0), (17.5, 8.0)], - "point-symmetrical" => &[(4.0, 8.0), (16.0, 8.0)], - _ => &[], + pen.frect(4.0, 4.0, 8.0, 8.0); + } + "point-smooth" | "point-symmetrical" => { + // A curve through the anchor (a quadratic sampled as a polyline), and the handle. + let ((p0, p2), (h0, h1)) = if name == "point-smooth" { + (((3.0, 6.5), (12.5, 18.0)), ((5.0, 4.0), (15.5, 15.5))) + } else { + (((7.0, 18.0), (17.5, 6.0)), ((5.0, 15.0), (15.0, 5.0))) }; - for &(hx, hy) in handles { - pen.line(&[(10.0, 8.0), (hx, hy)]); - pen.fcircle(hx, hy, 1.4); + let m = (10.0f32, 10.0f32); + let c = (2.0 * m.0 - (p0.0 + p2.0) / 2.0, 2.0 * m.1 - (p0.1 + p2.1) / 2.0); + let curve: Vec<(f32, f32)> = (0..=12) + .map(|k| { + let t = k as f32 / 12.0; + let u = 1.0 - t; + (u * u * p0.0 + 2.0 * u * t * c.0 + t * t * p2.0, u * u * p0.1 + 2.0 * u * t * c.1 + t * t * p2.1) + }) + .collect(); + pen.line(&curve); + pen.line(&[h0, h1]); + for (x, y) in [h0, h1] { + pen.frect(x - 1.4, y - 1.4, x + 1.4, y + 1.4); } - pen.frect(8.5, 6.5, 11.5, 9.5); + pen.frect(8.2, 8.2, 11.8, 11.8); } // ---- Properties panel glyphs (original drawings) "clock" => { @@ -857,6 +874,33 @@ fn paint_page(pen: &mut Pen, name: &str) -> bool { _ => {} } } + // Properties › Align To, one glyph per target, with the drop-down mark: a page with its + // margins, a page, a spread of two pages, a key object drawn bold. + "align-to-margins" | "align-to-page" | "align-to-spread" | "align-to-key" => { + match name { + "align-to-margins" => { + pen.closed(&[(4.0, 2.5), (11.5, 2.5), (14.5, 5.5), (14.5, 16.0), (4.0, 16.0)]); + pen.w = 1.0; + pen.rect(6.5, 6.0, 12.0, 13.5); + } + "align-to-page" => { + pen.closed(&[(4.0, 2.5), (11.5, 2.5), (14.5, 5.5), (14.5, 16.0), (4.0, 16.0)]); + pen.line(&[(11.5, 2.5), (11.5, 5.5), (14.5, 5.5)]); + } + "align-to-spread" => { + pen.rect(1.5, 4.0, 8.5, 15.0); + pen.rect(8.5, 4.0, 15.5, 15.0); + } + _ => { + pen.rect(3.0, 4.0, 15.0, 14.0); + pen.frect(3.0, 4.0, 15.0, 6.0); + pen.frect(3.0, 12.0, 15.0, 14.0); + pen.frect(3.0, 4.0, 5.0, 14.0); + pen.frect(13.0, 4.0, 15.0, 14.0); + } + } + pen.fill(&[(16.0, 17.0), (19.5, 17.0), (17.75, 19.0)]); + } "align-to" | "frame-dashed" => { pen.w = 1.0; for (a, b) in [((3.0, 3.0), (15.0, 3.0)), ((15.0, 3.0), (15.0, 15.0)), ((15.0, 15.0), (3.0, 15.0)), ((3.0, 15.0), (3.0, 3.0))] { @@ -982,6 +1026,18 @@ fn paint_text(pen: &mut Pen, name: &str) -> bool { pen.line(&[(15.0, 13.5), (17.0, 15.5), (15.0, 17.5)]); } } + // A box with one corner marked (Top Left, Top Right, Bottom Left, Bottom Right Corner). + "corner-tl" | "corner-tr" | "corner-bl" | "corner-br" => { + pen.w = 1.0; + pen.rect(4.0, 4.0, 16.0, 16.0); + let (x, y) = match name { + "corner-tl" => (2.5, 2.5), + "corner-tr" => (13.5, 2.5), + "corner-bl" => (2.5, 13.5), + _ => (13.5, 13.5), + }; + pen.frect(x, y, x + 4.0, y + 4.0); + } "vscale" => { pen.text(13.0, 10.5, "T", 15.0); pen.line(&[(4.0, 3.0), (4.0, 17.0)]); @@ -1178,6 +1234,17 @@ fn paint_text(pen: &mut Pen, name: &str) -> bool { pen.rect(3.0, 4.0, 17.0, 16.0); pen.line(&[(3.0, 7.0), (17.0, 7.0)]); } + // Find/Change's Specify Attributes: a letter under a magnifier. + "find-format" => { + pen.text(7.5, 8.5, "A", 12.0); + pen.circle(13.0, 13.0, 3.2); + pen.line(&[(15.3, 15.3), (18.0, 18.0)]); + } + "info" => { + pen.circle(10.0, 10.0, 7.0); + pen.fcircle(10.0, 6.6, 1.0); + pen.line(&[(10.0, 9.0), (10.0, 14.2)]); + } _ => return false, } true @@ -1302,6 +1369,20 @@ mod tests { assert!(!arrow_filled("tool-direct"), "Direct Selection draws a filled arrow"); } + /// Each Align To target has its own glyph, none the placeholder. + #[test] + fn align_to_glyphs_are_distinct() { + let names = ["align-to", "align-to-key", "align-to-margins", "align-to-page", "align-to-spread"]; + let unknown = drawing("no-such-icon"); + let glyphs: Vec = names.iter().map(|n| drawing(n)).collect(); + for (k, g) in glyphs.iter().enumerate() { + assert_ne!(*g, unknown, "{} falls back to the placeholder", names[k]); + for (j, other) in glyphs.iter().enumerate().skip(k + 1) { + assert_ne!(g, other, "{} and {} draw alike", names[k], names[j]); + } + } + } + #[test] fn distribute_icons_are_distinct_from_align_and_each_other() { // `objalign-*` ship upstream and back the Properties Align row; only the diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0221aa257..ef8629896 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -6,6 +6,15 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +/// DesignCraft as craft-update knows it, with its 128 px icon and accent colour: the update +/// prompt shows both, and an AppImage's menu entry gets the icon. +#[cfg(not(target_arch = "wasm32"))] +pub const UPDATE_APP: craft_update::app::App = craft_update::app::App { + icon_png: Some(include_bytes!("../../../assets/app-icon/hicolor/128x128/apps/ai.storyteller.designcraft.png")), + accent: Some([0x7b, 0xb5, 0x1c]), + ..craft_update::suite::DESIGNCRAFT +}; + pub mod about; pub mod canvas; pub mod chrome; @@ -22,6 +31,7 @@ pub mod menus; mod modal_drag; mod new_document; mod object_control; +pub mod package_dialog; mod page_history; mod panel_docking; pub mod panels; @@ -61,7 +71,7 @@ use app::browser_import_tests; pub use app::opens_as_document; pub use clock::now_ms; pub use key_names::{drop_key_name_text, is_key_name}; -pub use services::{ClipboardFn, DownloadFn, Inbox, OpenAsyncFn, ReadFn, Services, WriteFn}; +pub use services::{ClipboardFn, DownloadFn, Inbox, OpenAsyncFn, ReadFn, Services, WindowFrameFn, WriteFn}; pub(crate) use state::second_window_viewport; pub use state::{CanvasCache, HiddenPanels, ImportRequest, ImportedFile, PdfExportSettings, Perf, SavedWorkspace, ScreenMode, Shown, UiState, View}; @@ -304,6 +314,9 @@ pub fn cjk_features(app: &DesignApp) -> bool { #[cfg(test)] mod tests; +#[cfg(test)] +mod window_frame_tests; + /// The whole window in a headless test harness, at a chosen window size. #[cfg(test)] pub(crate) mod test_window { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index ffcfa0988..a31c7a8f0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -39,7 +39,8 @@ pub use tables::{CLASSIC_GROUPS, MENUS, TYPOGRAPHY_GROUPS, WORKSPACE_OPEN_PANELS #[cfg(test)] use tree::menu_items; pub use tree::{ - AppMenuItem, MAC_APP_MENU, OPEN_RECENT, all_menu_items, menu_key, menu_tree, native_label, native_menu_tree, recent_items, recent_label, + AppMenuItem, MAC_APP_MENU, OPEN_RECENT, all_menu_items, has_shown_item, menu_key, menu_tree, native_label, native_menu_tree, recent_items, + recent_label, }; pub use ui_commands::{CJK_ONLY, UI_COMMANDS, shown, ui_label}; @@ -64,3 +65,37 @@ fn cmd_item(id: &str, params: Value) -> Item { #[cfg(test)] mod tests; + +#[cfg(test)] +mod swedish_tests { + use super::*; + + #[test] + fn swedish_interface_is_selectable_and_translates_every_menu_label() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + run_ui(&mut app, "app.language", &json!({"lang": "sv"})).unwrap().unwrap(); + assert_eq!(app.ui.language, "sv"); + assert_eq!(checked(&app, "app.language", &json!({"lang": "sv"})), Some(true)); + assert_eq!(crate::i18n::tr("sv", "File"), "Arkiv"); + // Every menu title, item and submenu has a Swedish entry. + 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()), + _ => {} + } + } + // Swedish shares many words with English ("Layout", "Cyan"), so look for a catalog entry. + 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::swedish_has(l))); + } + assert!(untranslated.is_empty(), "untranslated Swedish menu labels: {untranslated:?}"); + } +} diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index d11b8deaa..b9d35539d 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -66,6 +66,11 @@ fn activate_inner(app: &mut DesignApp, id: &str, params: &Value) { crate::section_options::open(app); return; } + if params.is_null() && id == "layout.pages.insert" { + let cur = crate::canvas::current_page(app).unwrap_or(0); + crate::dialogs::open_insert_pages(app, cur); + return; + } if params.is_null() && id == "layout.createGuides" { crate::dialogs::open_create_guides(app); return; @@ -146,9 +151,24 @@ pub fn activate_native(app: &mut DesignApp, ctx: &egui::Context, id: &str, param if params.is_null() && app.session.wants_text() && matches!(id, "edit.copy" | "edit.cut" | "edit.paste") && forward_to_text_edit(app, ctx, id) { return; } + // Zoom In/Out by key equivalent: the pointer is still in the window. A click in the menu bar + // takes it out, so that zooms around the centre. + if params.is_null() + && let Some(p) = pointer_zoom_params(ctx, id) + { + activate(app, id, &p); + return; + } activate(app, id, params); } +/// Zoom In/Out from a key: the pointer's position (`at`), so the zoom keeps the point under it +/// when it is over the canvas. `None` for other commands or with no pointer in the window. +pub(super) fn pointer_zoom_params(ctx: &egui::Context, id: &str) -> Option { + let at = ctx.pointer_hover_pos()?; + matches!(id, "view.zoomIn" | "view.zoomOut").then(|| json!({"at": [at.x, at.y]})) +} + /// The editing commands a focused text field takes from the native menu bar. pub(super) const TEXT_FIELD_COMMANDS: [&str; 6] = ["edit.selectAll", "edit.copy", "edit.cut", "edit.paste", "edit.undo", "edit.redo"]; diff --git a/crates/ui-egui/src/menus/keys.rs b/crates/ui-egui/src/menus/keys.rs index b7e1fa539..8b2179469 100644 --- a/crates/ui-egui/src/menus/keys.rs +++ b/crates/ui-egui/src/menus/keys.rs @@ -1,4 +1,4 @@ -use super::invoke::activate; +use super::invoke::{activate, pointer_zoom_params}; use super::{DesignApp, ScreenMode, Value, json}; /// The command a paste event stands for besides plain Paste. egui-winit turns every ⌘+V chord into @@ -39,7 +39,7 @@ pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) { } let typing = app.session.wants_text(); let keys = ctx.input_mut(|i| take_keys(app, i, typing)); - run_keys(app, keys, typing); + run_keys(app, ctx, keys, typing); } /// Keys that a floating panel in its own OS window left over (craft-ui's @@ -59,7 +59,7 @@ pub fn forwarded_shortcuts(app: &mut DesignApp, ctx: &egui::Context, events: Vec input.events = events; input.modifiers = modifiers; let keys = take_keys(app, &mut input, typing); - run_keys(app, keys, typing); + run_keys(app, ctx, keys, typing); crate::canvas::forwarded_keys(app, input.events, modifiers); } @@ -112,7 +112,7 @@ fn take_keys(app: &mut DesignApp, input: &mut egui::InputState, typing: bool) -> } /// Runs what [`take_keys`] took, then Esc's return to the Selection tool. -fn run_keys(app: &mut DesignApp, keys: Keys, typing: bool) { +fn run_keys(app: &mut DesignApp, ctx: &egui::Context, keys: Keys, typing: bool) { for step in keys.presentation { match step { None => { @@ -138,7 +138,11 @@ fn run_keys(app: &mut DesignApp, keys: Keys, typing: bool) { app.select_tool(&tool); } else { // Like choosing the menu item: "…" commands open their dialog. - activate(app, &hit.command.id, &hit.command.params); + // Zoom In/Out by key zoom around the pointer while it is over the canvas. + match pointer_zoom_params(ctx, &hit.command.id) { + Some(p) => activate(app, &hit.command.id, &p), + None => activate(app, &hit.command.id, &hit.command.params), + } } } // Esc with no shortcut of its own goes back to the Selection tool. diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 6a8d797b5..717e1332e 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -1,6 +1,6 @@ use super::export::{download_document, export_bytes, export_epub, export_idml, export_pdf, export_png, separations_view}; use super::invoke::guide_spread; -use super::state::{is_window_control, window_control_available}; +use super::state::{is_window_control, menu_enabled, window_control_available}; use super::tables::{CLASSIC_GROUPS, TYPOGRAPHY_GROUPS, workspace_open_panel}; use super::tree::recent_label; use super::{DesignApp, ScreenMode, Value, json}; @@ -116,7 +116,9 @@ fn run_ui_part1(app: &mut DesignApp, id: &str, p: &Value) -> Option { - app.ui.dialog = Some(crate::dialogs::Dialog::new("newDocument", json!({}))); + // In the default units (Preferences with no document open). + let fields = crate::new_document::defaults_for(&app.session.default_new_document()); + app.ui.dialog = Some(crate::dialogs::Dialog::new("newDocument", fields)); Ok(Value::Null) } "app.openDialog" => match p.get("path").and_then(Value::as_str) { @@ -245,12 +247,9 @@ fn run_ui_part1(app: &mut DesignApp, id: &str, p: &Value) -> Option export_bytes(app, p, "rtf", "file.exportText"), "app.packageDialog" => { + // Without a folder, the Package dialog: what the package carries, then the folder. let Some(dir) = p.get("dir").and_then(Value::as_str) else { - let name = app.session.active().map(|d| format!("{} Folder", d.doc.title)).unwrap_or_default(); - let p = p.clone(); - return Some(app.ask_save(&name, Bound::Document, move |app, dir| { - run_ui(app, "app.packageDialog", &with_path(&p, "dir", dir)).unwrap_or(Ok(Value::Null)) - })); + return Some(crate::package_dialog::open(app).map(|()| Value::Null)); }; let r = app.run("file.package", json!({"dir": dir})); if let Ok(v) = &r { @@ -347,6 +346,20 @@ fn run_ui_part2(app: &mut DesignApp, id: &str, p: &Value) -> Option { + if !menu_enabled(app, id) { + return Some(Err("select the text to convert to a table".into())); + } + app.ui.dialog = Some(crate::dialogs::Dialog::new("convertTextToTable", p.clone())); + Ok(Value::Null) + } + "app.convertTableToTextDialog" => { + if !menu_enabled(app, id) { + return Some(Err("place the insertion point in a table to convert it to text".into())); + } + app.ui.dialog = Some(crate::dialogs::Dialog::new("convertTableToText", p.clone())); + Ok(Value::Null) + } "app.insertTableDialog" => { if app.session.active().is_none_or(|d| d.selection.text.is_none()) { return Some(Err("place the insertion point in a text frame to create a table".into())); @@ -449,7 +462,11 @@ fn run_ui_part2(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 page = match crate::canvas::current_page(app) { + Some(page) => page, + None if !st.editing_parents => 0, + None => return Some(Err("select a document page".into())), + }; let r = if id == "app.deletePage" { crate::dialogs::delete_pages(app, vec![page]) } else { @@ -510,6 +527,15 @@ fn run_ui_part2(app: &mut DesignApp, id: &str, p: &Value) -> Option Option Option { if let Some(r) = rect { - crate::canvas::zoom_at(app, r.center(), if id == "view.zoomIn" { 1.5 } else { 1.0 / 1.5 }); + // A keyboard zoom names the pointer ("at"); it counts only over the canvas. + let at = p + .get("at") + .and_then(Value::as_array) + .filter(|a| a.len() == 2) + .and_then(|a| Some(egui::pos2(a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32))); + let at = at.filter(|at| r.contains(*at)).unwrap_or_else(|| r.center()); + crate::canvas::zoom_at(app, at, if id == "view.zoomIn" { 1.5 } else { 1.0 / 1.5 }); } Ok(Value::Null) } @@ -984,7 +1021,10 @@ fn run_ui_part5(app: &mut DesignApp, id: &str, p: &Value) -> Option flag(&mut app.ui.tools_double_column), + "window.toolsDoubleColumn" => { + app.ui.tools_double_column = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.tools_double_column); + Ok(json!(app.ui.tools_double_column)) + } "window.newWorkspace" => { let Some(name) = p.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()) else { app.ui.dialog = diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index f9e78c28f..edf7e1c6f 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -175,6 +175,15 @@ pub fn menu_enabled(app: &DesignApp, id: &str) -> bool { if id == "guide.selectAll" && (!app.ui.guides || app.ui.guides_locked) { return false; } + // As InDesign's: Convert Text to Table wants selected text outside a table, Convert Table to + // Text a table. + if id == "app.convertTextToTableDialog" { + let selected = app.session.active().and_then(|d| d.selection.text).is_some_and(|t| t.cell.is_none() && !t.is_caret()); + return selected && enabled(app, "table.convertFromText"); + } + if id == "app.convertTableToTextDialog" { + return enabled(app, "table.convertToText"); + } if id == "view.goBack" || id == "view.goForward" { return crate::canvas::can_go_through_history(app, if id == "view.goBack" { -1 } else { 1 }); } diff --git a/crates/ui-egui/src/menus/tables.rs b/crates/ui-egui/src/menus/tables.rs index 4d63d485f..e7907e7da 100644 --- a/crates/ui-egui/src/menus/tables.rs +++ b/crates/ui-egui/src/menus/tables.rs @@ -36,6 +36,8 @@ pub const MENUS: &[(&str, &[&str])] = &[ "-", "cmd:layout.documentSetup", "cmd:file.info", + "-", + "ui:app.quit", ], ), ( @@ -274,6 +276,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:type.storyDirection|Horizontal|{\"vertical\": false}", "cmd:type.storyDirection|Vertical|{\"vertical\": true}", "<", + "-", "cmd:type.tateChuYoko", "ui:app.rubyDialog", "cmd:type.kenten", @@ -299,57 +302,12 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:transform.again|Transform Sequence Again|{\"sequence\": true}", "cmd:transform.again|Transform Sequence Again Individually|{\"sequence\": true, \"individually\": true}", "<", - ">Flex Layout", - "cmd:flex.add", - "cmd:flex.convert", - "cmd:flex.remove", - "-", - "ui:window.panel|Show Flex Controls|{\"panel\": \"flexLayout\"}", - "<", - ">Paths", - "cmd:object.makeCompoundPath", - "cmd:object.releaseCompoundPath", - "-", - "cmd:path.reverse", - "<", - ">Pathfinder", - "cmd:object.pathfinder|Add|{\"op\": \"add\"}", - "cmd:object.pathfinder|Subtract|{\"op\": \"subtract\"}", - "cmd:object.pathfinder|Intersect|{\"op\": \"intersect\"}", - "cmd:object.pathfinder|Exclude Overlap|{\"op\": \"exclude\"}", - "cmd:object.pathfinder|Minus Back|{\"op\": \"minusBack\"}", - "<", ">Arrange", "cmd:object.bringToFront", "cmd:object.bringForward", "cmd:object.sendBackward", "cmd:object.sendToBack", "<", - "-", - "cmd:object.group", - "cmd:object.ungroup", - "cmd:object.lock", - "cmd:object.unlockAll", - "cmd:object.hide", - "cmd:object.showAll", - "-", - "cmd:object.textFrameOptions", - "ui:app.layerOptions", - "cmd:object.exportOptions", - "cmd:object.primaryTextFrame", - "cmd:object.cornerOptions", - "-", - ">Effects", - "ui:effects.dialog|Transparency…|{\"page\": \"transparency\"}", - "cmd:object.dropShadow", - "cmd:object.innerShadow", - "cmd:object.outerGlow", - "cmd:object.innerGlow", - "cmd:object.bevel", - "cmd:object.satin", - "cmd:object.feather", - "cmd:object.directionalFeather", - "<", ">Select", "cmd:select.firstAbove", "cmd:select.nextAbove", @@ -362,6 +320,90 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:select.previousInGroup", "cmd:select.nextInGroup", "<", + "-", + "cmd:object.group", + "cmd:object.ungroup", + "cmd:object.lock", + "cmd:object.unlockAll", + "cmd:object.hide", + "cmd:object.showAll", + "-", + "ui:app.qrCode", + "-", + "cmd:object.textFrameOptions", + "cmd:object.primaryTextFrame", + ">Anchored Object", + "cmd:anchored.options", + "cmd:anchored.release", + "<", + "-", + ">Fitting", + "ui:fit.fillFrameProportionally", + "ui:fit.contentProportionally", + "-", + "ui:fit.frameToContent", + "ui:fit.contentToFrame", + "ui:fit.centerContent", + "-", + "cmd:object.clearFittingOptions", + "-", + "ui:app.fittingOptions", + "<", + ">Content", + "cmd:object.content|Graphic|{\"type\":\"graphic\"}", + "cmd:object.content|Text|{\"type\":\"text\"}", + "cmd:object.content|Unassigned|{\"type\":\"unassigned\"}", + "<", + "-", + ">Effects", + "ui:effects.dialog|Transparency…|{\"page\": \"transparency\"}", + "cmd:object.dropShadow", + "cmd:object.innerShadow", + "cmd:object.outerGlow", + "cmd:object.innerGlow", + "cmd:object.bevel", + "cmd:object.satin", + "cmd:object.feather", + "cmd:object.directionalFeather", + "<", + "cmd:object.cornerOptions", + "-", + "ui:app.layerOptions", + "cmd:object.exportOptions", + ">Captions", + "cmd:object.caption", + "cmd:object.caption|Generate Live Caption|{\"live\": true}", + "<", + ">Clipping Path", + "cmd:object.clippingPathOptions|None|{\"type\": \"none\"}", + "cmd:object.clippingPath|Alpha Channel|{\"type\": \"alpha\"}", + "cmd:object.clippingPath|Detect Edges|{\"type\": \"edges\"}", + "<", + "-", + ">Flex Layout", + "cmd:flex.add", + "cmd:flex.convert", + "cmd:flex.remove", + "-", + "ui:window.panel|Show Flex Controls|{\"panel\": \"flexLayout\"}", + "<", + "-", + ">Paths", + "cmd:path.join", + "cmd:object.convertShape|Open Path|{\"to\": \"openPath\"}", + "cmd:object.convertShape|Close Path|{\"to\": \"closedPath\"}", + "cmd:path.reverse", + "-", + "cmd:object.makeCompoundPath", + "cmd:object.releaseCompoundPath", + "<", + ">Pathfinder", + "cmd:object.pathfinder|Add|{\"op\": \"add\"}", + "cmd:object.pathfinder|Subtract|{\"op\": \"subtract\"}", + "cmd:object.pathfinder|Intersect|{\"op\": \"intersect\"}", + "cmd:object.pathfinder|Exclude Overlap|{\"op\": \"exclude\"}", + "cmd:object.pathfinder|Minus Back|{\"op\": \"minusBack\"}", + "<", ">Convert Shape", "cmd:object.convertShape|Rectangle|{\"to\": \"rectangle\"}", "cmd:object.convertShape|Rounded Rectangle|{\"to\": \"roundedRectangle\"}", @@ -372,46 +414,26 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:object.convertShape|Polygon|{\"to\": \"polygon\"}", "cmd:object.convertShape|Line|{\"to\": \"line\"}", "cmd:object.convertShape|Orthogonal Line|{\"to\": \"orthogonalLine\"}", - "-", - "cmd:object.convertShape|Open Path|{\"to\": \"openPath\"}", - "cmd:object.convertShape|Closed Path|{\"to\": \"closedPath\"}", "<", - "ui:app.qrCode", - ">Clipping Path", - "cmd:object.clippingPathOptions|None|{\"type\": \"none\"}", - "cmd:object.clippingPath|Alpha Channel|{\"type\": \"alpha\"}", - "cmd:object.clippingPath|Detect Edges|{\"type\": \"edges\"}", - "<", - ">Captions", - "cmd:object.caption", - "cmd:object.caption|Generate Live Caption|{\"live\": true}", - "<", - ">Anchored Object", - "cmd:anchored.options", - "cmd:anchored.release", - "<", - ">Fitting", - "ui:fit.fillFrameProportionally", - "ui:fit.contentProportionally", - "ui:fit.frameToContent", - "ui:fit.contentToFrame", - "ui:fit.centerContent", - "-", - "ui:app.fittingOptions", - "<", - ">Content", - "cmd:object.content|Graphic|{\"type\":\"graphic\"}", - "cmd:object.content|Text|{\"type\":\"text\"}", - "cmd:object.content|Unassigned|{\"type\":\"unassigned\"}", + ">Convert Point", + "cmd:path.convertPoints|Plain|{\"kind\": \"plain\"}", + "cmd:path.convertPoints|Corner|{\"kind\": \"corner\"}", + "cmd:path.convertPoints|Smooth|{\"kind\": \"smooth\"}", + "cmd:path.convertPoints|Symmetrical|{\"kind\": \"symmetrical\"}", "<", ], ), ( + // InDesign's order. The Table panel is in Window › Type & Tables (Shift+F9). "Table", &[ "ui:app.insertTableDialog", - "cmd:table.convertFromText", - "cmd:table.convertToText", + "ui:app.convertTextToTableDialog", + "ui:app.convertTableToTextDialog", + "-", + ">Cell Options", + "ui:app.cellOptionsDialog", + "<", "-", ">Insert", "cmd:table.insertRowAbove", @@ -430,26 +452,20 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:table.selectTable", "<", "-", - ">Cell Options", - "ui:app.cellOptionsDialog", - "<", "cmd:table.merge", "cmd:table.unmerge", - ">Convert Cell Type", + "cmd:table.splitHorizontally", + "cmd:table.splitVertically", "ui:app.graphicCell|Convert Cell to Graphic Cell…", "cmd:table.textCell", - "<", ">Convert Rows", "cmd:table.convertRowsToHeader", "cmd:table.convertRowsToBody", "cmd:table.convertRowsToFooter", "<", - "cmd:table.splitHorizontally", - "cmd:table.splitVertically", - "cmd:table.distributeColumns", - "cmd:table.distributeRows", "-", - "ui:app.tablePanel", + "cmd:table.distributeRows", + "cmd:table.distributeColumns", ], ), ( diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 5e82f6d3f..3fbd56424 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -6,6 +6,81 @@ fn menu_ids(title: &str) -> Vec<&'static str> { items.iter().filter_map(|e| e.split_once(':').map(|(_, rest)| rest.split('|').next().unwrap_or(rest))).collect() } +/// The Object menu's top level in InDesign's order and groups (as InDesign 2023 shows it in +/// Learn Skills Daily's course), with Paths holding Join, Open and Close Path, Convert Shape +/// only shapes, and a Convert Point submenu. +#[test] +fn the_object_menu_is_in_indesigns_order() { + let items = MENUS.iter().find(|(t, _)| *t == "Object").map(|(_, items)| *items).unwrap(); + // The top-level entries: submenus by name, separators as "-", items by command id. + let mut top = Vec::new(); + let mut depth = 0; + for e in items { + match *e { + "<" => depth -= 1, + e if e.starts_with('>') => { + if depth == 0 { + top.push(e.trim_start_matches('>')); + } + depth += 1; + } + e if depth == 0 => top.push(e.split_once(':').map_or(e, |(_, r)| r.split('|').next().unwrap_or(r))), + _ => {} + } + } + assert_eq!( + top, + [ + "Transform", + "Transform Again", + "Arrange", + "Select", + "-", + "object.group", + "object.ungroup", + "object.lock", + "object.unlockAll", + "object.hide", + "object.showAll", + "-", + "app.qrCode", + "-", + "object.textFrameOptions", + "object.primaryTextFrame", + "Anchored Object", + "-", + "Fitting", + "Content", + "-", + "Effects", + "object.cornerOptions", + "-", + "app.layerOptions", + "object.exportOptions", + "Captions", + "Clipping Path", + "-", + "Flex Layout", + "-", + "Paths", + "Pathfinder", + "Convert Shape", + "Convert Point", + ] + ); + let ids = menu_ids("Object"); + for id in ["path.join", "path.reverse", "path.convertPoints"] { + assert!(ids.contains(&id), "{id}"); + } + let sub = |name: &str| { + let i = items.iter().position(|e| *e == format!(">{name}")).unwrap(); + items[i + 1..].iter().take_while(|e| **e != "<").copied().collect::>() + }; + assert!(sub("Paths").iter().any(|e| e.contains("|Open Path|")) && sub("Paths").iter().any(|e| e.contains("|Close Path|"))); + assert!(!sub("Convert Shape").iter().any(|e| e.contains("openPath") || e.contains("closedPath")), "shapes only"); + assert_eq!(sub("Convert Point").len(), 4); +} + #[test] fn view_items_are_in_the_view_menu_as_in_indesign() { let (view, typ) = (menu_ids("View"), menu_ids("Type")); @@ -29,6 +104,30 @@ fn view_items_are_in_the_view_menu_as_in_indesign() { assert_eq!(ui_label("view.togglePreview").and_then(|c| c.1), Some("W")); } +/// Object › Fitting in InDesign's groups: the proportional fits, the frame and content fits, +/// Clear Frame Fitting Options, then the dialog. +#[test] +fn the_fitting_menu_is_grouped_as_indesigns() { + let items = MENUS.iter().find(|(t, _)| *t == "Object").map(|(_, items)| *items).unwrap(); + let start = items.iter().position(|e| *e == ">Fitting").unwrap() + 1; + let end = start + items[start..].iter().position(|e| *e == "<").unwrap(); + assert_eq!( + &items[start..end], + [ + "ui:fit.fillFrameProportionally", + "ui:fit.contentProportionally", + "-", + "ui:fit.frameToContent", + "ui:fit.contentToFrame", + "ui:fit.centerContent", + "-", + "cmd:object.clearFittingOptions", + "-", + "ui:app.fittingOptions", + ] + ); +} + /// The submenu paths of a menu bar menu ("View/Screen Mode"), as Edit › Menus keys them. fn submenu_paths(title: &str) -> Vec { let items = MENUS.iter().find(|(t, _)| *t == title).map(|(_, items)| *items).unwrap_or_default(); @@ -55,6 +154,7 @@ fn define_lists_command_path_matches_the_type_menu() { assert!(menu_ids("Type").contains(&"list.define")); } +/// Checks that screen-mode menu checkmarks follow the current Normal or Preview mode. #[test] fn screen_mode_shows_the_current_mode() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); @@ -64,6 +164,24 @@ fn screen_mode_shows_the_current_mode() { assert_eq!((mode(&app, "normal"), mode(&app, "preview")), (Some(false), Some(true))); } +/// Checks that translated customization paths match the visible submenu captions, while +/// allowing uncatalogued paths to keep their English fallback. +#[test] +fn hungarian_menu_paths_match_the_visible_submenu_labels() { + // These paths are displayed by menu customization; they must use the same + // captions as the actual menu tree, including nested special characters. + for title in ["Edit", "Type", "View"] { + for path in submenu_paths(title) { + let translated = crate::i18n::tr("hu", &path); + if translated == path { + continue; // New, uncatalogued paths still fall back to English. + } + let visible = path.split('/').map(|part| crate::i18n::tr("hu", part)).collect::>().join("/"); + assert_eq!(translated, visible, "{path}"); + } + } +} + fn walk(items: &[Item], out: &mut Vec<(String, String, Value)>) { for it in items { match it { @@ -549,6 +667,7 @@ fn split_window_contextual_bars_have_independent_widget_ids() { assert!(!bars[0].intersects(bars[1]), "each pane keeps its own bar: {bars:?}"); } +/// Checks the document-grid menu label, shortcut, checkmark and repeated toggle behavior. #[test] fn show_document_grid_toggles_from_the_grids_and_guides_menu() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); @@ -567,6 +686,8 @@ fn show_document_grid_toggles_from_the_grids_and_guides_menu() { assert!(!app.ui.document_grid); } +/// Checks language selection and rejection of unknown language codes, Preferences dispatch, +/// translated menu labels and preservation of document data when switching to Arabic. #[test] fn interface_language_switches_and_validates() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); @@ -584,6 +705,113 @@ fn interface_language_switches_and_validates() { 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}"); + } + } +} + +/// Checks Hungarian selection, menu captions and saved UI preferences without changing +/// document data or translating user-defined names. +#[test] +fn hungarian_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 · Árvíztűrő tükörfúrógép"})).unwrap(); + let before = serde_json::to_value(&app.session.doc().unwrap().doc).unwrap(); + run_ui(&mut app, "app.language", &json!({"lang": "hu"})).unwrap().unwrap(); + assert_eq!(checked(&app, "app.language", &json!({"lang": "hu"})), 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("hu", 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, "hu"); + assert_eq!(crate::i18n::tr(&restored.language, "File"), "Fájl"); + assert_eq!(crate::i18n::tr(&restored.language, "A custom name"), "A custom name"); + assert!(crate::i18n::LANGUAGES.contains(&("hu", "Magyar")), "Preferences lists Hungarian by its own name"); +} + +/// Checks Ukrainian selection, menu captions and saved UI preferences without changing +/// document data or translating user-defined names. +#[test] +fn ukrainian_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": "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}"); + } + 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"); +} + +/// Checks Russian selection, menu captions and saved UI preferences without changing +/// document data or translating user-defined names. +#[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] @@ -1953,7 +2181,7 @@ fn searched_ui_commands_run_without_parameters_and_keep_preferences() { let mut scratch = designcraft_engine::Session::new(); scratch.execute("file.new", &json!({})).unwrap(); let pdf = scratch.execute("file.exportPdf", &json!({})).unwrap()["base64"].clone(); - let contexts = ["nothing selected", "text insertion point", "text frame selected", "table cell", "placed PDF selected"]; + let contexts = ["nothing selected", "text insertion point", "text frame selected", "table cell", "placed PDF selected", "text selected"]; // A two-page document, a saved workspace and non-default preferences, a host with window // controls, and file dialogs that are dismissed. let setup = |context: usize| { @@ -1987,6 +2215,11 @@ fn searched_ui_commands_run_without_parameters_and_keep_preferences() { app.run("file.place", json!({"base64": pdf, "name": "a.pdf", "frame": f})).unwrap(); app.run("selection.set", json!({"ids": [f]})).unwrap(); } + // Convert Text to Table needs text selected. + 5 => { + let f = app.run("frame.create", json!({"rect": [72, 72, 400, 400], "content": "text", "text": "One Two"})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 7})).unwrap(); + } _ => {} } app.ui.dialog = None; @@ -2107,6 +2340,48 @@ fn context_menus_hold_commands_and_menu_bar_submenus() { } } +#[test] +fn the_table_menu_follows_indesigns_order() { + // InDesign's Table menu with a text insertion point (Adobe's "How to make a table"). + let table = menu_tree().into_iter().find(|(m, _)| *m == "Table").unwrap().1; + let labels: Vec = table + .iter() + .map(|i| match i { + Item::Sep => "-".to_string(), + Item::Sub(n, _) => format!("{n} >"), + Item::Cmd { label, .. } => label.clone(), + }) + .collect(); + let expected = [ + "Insert Table…", + "Convert Text to Table…", + "Convert Table to Text…", + "-", + "Cell Options >", + "-", + "Insert >", + "Delete >", + "Select >", + "-", + "Merge Cells", + "Unmerge Cells", + "Split Cell Horizontally", + "Split Cell Vertically", + "Convert Cell to Graphic Cell…", + "Convert Cell to Text Cell", + "Convert Rows >", + "-", + "Distribute Rows Evenly", + "Distribute Columns Evenly", + ]; + assert_eq!(labels, expected); + // Cmd+Alt+Shift+T opens the dialog, as InDesign's; the engine command has no shortcut of + // its own. The Table panel is in Window › Type & Tables. + assert_eq!(ui_label("app.insertTableDialog").and_then(|c| c.1), Some("Cmd+Alt+Shift+T")); + assert_eq!(designcraft_engine::find_command("table.insert").unwrap().shortcut, None); + assert_eq!(ui_label("app.tablePanel").and_then(|c| c.1), Some("Shift+F9")); +} + #[test] fn the_type_menu_follows_indesigns_order() { // InDesign's Type menu, as Chris Nielsen opens it in "Numbering Page Sections" (2:09). @@ -2437,6 +2712,22 @@ fn cjk_menu_items_follow_show_cjk_features() { assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("ruby"), "hidden, not removed"); } +/// The CJK commands sit in a group of their own, after a separator, and hiding them leaves no +/// separator at the end of the menu. +#[test] +fn cjk_menu_items_have_their_own_group() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let ty = menu_tree().into_iter().find(|(m, _)| *m == "Type").unwrap().1; + let first = ty.iter().position(|i| matches!(i, Item::Cmd { id, .. } if CJK_ONLY.contains(&id.as_str()))).unwrap(); + assert!(matches!(ty[first - 1], Item::Sep), "separator before the CJK group"); + assert!(matches!(ty[first - 2], Item::Sub(..)), "the group comes after Story Direction"); + app.ui.language = String::new(); + app.run("prefs.set", json!({"cjkFeatures": false})).unwrap(); + assert!(!has_shown_item(&app, &ty[first..]), "nothing left after the separator"); + app.run("prefs.set", json!({"cjkFeatures": true})).unwrap(); + assert!(has_shown_item(&app, &ty[first..])); +} + /// Preferences › Type › Show CJK Features: left as it is, it keeps following the interface /// language; changed, it is kept in prefs.json. #[test] @@ -2976,3 +3267,84 @@ fn forwarded_keys_run_shortcuts() { forwarded_shortcuts(&mut app, &ctx, press(Key::Delete), Modifiers::NONE); assert!(app.session.active().unwrap().doc.item(designcraft_doc::ItemId(frame["id"].as_u64().unwrap())).is_none(), "Delete clears the selection"); } + +#[test] +fn file_menu_ends_with_quit_but_the_macos_menu_bar_leaves_it_to_the_app_menu() { + let file = |tree: Vec<(&'static str, Vec)>| { + let mut out = Vec::new(); + walk(&tree.into_iter().find(|(t, _)| *t == "File").map(|(_, e)| e).unwrap_or_default(), &mut out); + out.into_iter().map(|(_, id, _)| id).collect::>() + }; + assert_eq!(file(menu_tree()).last().map(String::as_str), Some("app.quit"), "File ends with the quit item"); + assert!(!file(native_menu_tree()).iter().any(|id| id == "app.quit"), "macOS has Quit only in the app menu"); + let native = native_menu_tree(); + let file_items = native.iter().find(|(t, _)| *t == "File").map(|(_, e)| e); + assert!(!matches!(file_items.and_then(|e| e.last()), Some(Item::Sep)), "no separator left at the end of File"); +} + +#[test] +fn zoom_commands_zoom_around_the_pointer_when_it_is_over_the_canvas() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({})).unwrap(); + let ctx = egui::Context::default(); + let frame = |app: &mut crate::DesignApp, events: Vec| { + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + out.textures_delta.clear(); + }; + frame(&mut app, vec![]); + frame(&mut app, vec![]); + let rect = app.canvas_rect.unwrap(); + let pointer = rect.center() + egui::vec2(200.0, 120.0); + let under = |app: &crate::DesignApp, at: egui::Pos2| crate::canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_canvas(at); + let before = under(&app, pointer); + let zoom = app.view().unwrap().zoom; + + // The command with a pointer position keeps the point under it fixed. + app.run("view.zoomIn", json!({"at": [pointer.x, pointer.y]})).unwrap(); + assert!(app.view().unwrap().zoom > zoom); + let after = under(&app, pointer); + assert!((after.x - before.x).abs() < 1e-3 && (after.y - before.y).abs() < 1e-3); + app.run("view.zoomOut", json!({"at": [pointer.x, pointer.y]})).unwrap(); + let after = under(&app, pointer); + assert!((after.x - before.x).abs() < 1e-3 && (after.y - before.y).abs() < 1e-3); + + // A position outside the canvas (or none: the menu) zooms around the centre. + let centre = under(&app, rect.center()); + app.run("view.zoomIn", json!({"at": [-50.0, -50.0]})).unwrap(); + let kept = under(&app, rect.center()); + assert!((kept.x - centre.x).abs() < 1e-3 && (kept.y - centre.y).abs() < 1e-3); + app.run("view.zoomIn", json!({})).unwrap(); + let kept = under(&app, rect.center()); + assert!((kept.x - centre.x).abs() < 1e-3 && (kept.y - centre.y).abs() < 1e-3); + + // The keyboard shortcut uses the pointer. + app.run("view.zoom", json!({"zoom": 1.0})).unwrap(); + let before = under(&app, pointer); + frame(&mut app, vec![egui::Event::PointerMoved(pointer)]); + let key = egui::Event::Key { key: egui::Key::Equals, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }; + frame(&mut app, vec![key]); + assert!(app.view().unwrap().zoom > 1.0); + let after = under(&app, pointer); + assert!((after.x - before.x).abs() < 1e-3 && (after.y - before.y).abs() < 1e-3); + + // The native menu bar's key equivalent (macOS) leaves the pointer in the window and uses it. + app.run("view.zoom", json!({"zoom": 1.0})).unwrap(); + let before = under(&app, pointer); + native_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pointer)], Some("view.zoomIn")); + assert!(app.view().unwrap().zoom > 1.0); + let after = under(&app, pointer); + assert!((after.x - before.x).abs() < 1e-3 && (after.y - before.y).abs() < 1e-3); + // A click in the menu bar takes the pointer out of the window: the centre stays. + let centre = under(&app, rect.center()); + native_frame(&mut app, &ctx, vec![egui::Event::PointerGone], Some("view.zoomOut")); + let kept = under(&app, rect.center()); + assert!((kept.x - centre.x).abs() < 1e-3 && (kept.y - centre.y).abs() < 1e-3); +} diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index b711b8376..6ab2e1765 100644 --- a/crates/ui-egui/src/menus/tree.rs +++ b/crates/ui-egui/src/menus/tree.rs @@ -175,13 +175,25 @@ pub fn menu_key(menu: &str, label: &str) -> String { format!("{menu}/{label}") } +/// Whether any shown item is in `rest`, the entries after a separator: the separator is drawn only then, so hidden +/// CJK commands leave no separator at the end of a menu (in the window and in the native macOS menu bar). +pub fn has_shown_item(app: &DesignApp, rest: &[Item]) -> bool { + rest.iter().any(|e| match e { + Item::Cmd { id, .. } => shown(app, id), + Item::Sep => false, + Item::Sub(..) => true, + }) +} + /// Draws a menu's items; returns how many were hidden. pub(super) fn menu_items(app: &mut DesignApp, ui: &mut egui::Ui, items: &[Item], path: &str) -> usize { let mut hidden = 0; - for it in items { + for (i, it) in items.iter().enumerate() { match it { Item::Sep => { - ui.separator(); + if has_shown_item(app, &items[i + 1..]) { + ui.separator(); + } } Item::Sub(name, children) => { let sub = format!("{path}/{name}"); diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 649469f30..f24d1d548 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -35,7 +35,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ None, "{page?: transparency|dropShadow|innerShadow|outerGlow|innerGlow|bevel|satin|feather|directionalFeather|gradientFeather} — the Effects dialog for the selected objects", ), - ("app.packageDialog", "Package…", Some("Cmd+Alt+Shift+P"), "{dir?} — choose the package folder to create (file.package)"), + ( + "app.packageDialog", + "Package…", + Some("Cmd+Alt+Shift+P"), + "{dir?} — the Package dialog (what the package carries, printing instructions), then the folder to create; with dir, packages into it at once (file.package)", + ), ("app.saveSwatches", "Save Swatches for Exchange…", None, "{path?} — write the colour swatches as .ase"), ("app.exportPng", "Export Page as PNG…", Some("Cmd+E"), "{}"), ("app.exportIdml", "Export IDML…", None, "{path?} — InDesign Markup (IDML) package"), @@ -66,7 +71,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ "app.language", "Interface Language", None, - "{lang?: \"\"|de|fr|es|ja|zh|ar|pt-br|it|uk|ru|cs|pl|ka} — menus and panel names (the macOS menu bar follows at once); without lang, opens Preferences, where it is the first setting", + "{lang?: \"\"|de|fr|es|ja|zh|ar|pt-br|it|uk|ru|cs|pl|ka|hu|sv} — menus and panel names (the macOS menu bar follows at once); without lang, opens Preferences, where it is the first setting", ), ( "app.flattener", @@ -109,7 +114,14 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ "{path | text} — a set saved by InDesign's Show Set (.txt), imported as a new shortcut set, which becomes active: matched commands, panels (Window menu items) and tools (Tools items) take its Default shortcuts (an item without Default keys takes its Text keys), or none when every item matched to them has none; other commands lose keys it takes unless they share them as defaults → {set, imported, changed [{id, label, item, line, from, to}], conflictsCleared [{id, label, shortcut}], skipped [{id, item, line, shortcut, takenBy, reason}] (an earlier item whose keys a later one took, or a reserved key), failed [{id, label, change, shortcuts}], unmatched [{item, line, keys, reason: noMatch | ambiguous | tool | context}], unmatchedWithoutShortcut, unreadable [{item, line, keys, reason}], approximated [{item, line, keys, note}] (numeric keypad keys bound as the main keys), suggestions [{item, line, candidates}] (never applied), matchedWithoutChange [{id, label, item, line, reason}] (a keyless panel-menu item that clears nothing), truncated, shortcutSet (the new set's name)}; ids are command keys (`window.panel {\"panel\":\"layers\"}`)", ), ("app.findChange", "Find/Change…", Some("Cmd+F"), "{}"), - ("app.insertTableDialog", "Create Table…", None, "{} — Insert Table dialog (body/header/footer rows, columns)"), + ("app.insertTableDialog", "Insert Table…", Some("Cmd+Alt+Shift+T"), "{} — Insert Table dialog (body/header/footer rows, columns, table style)"), + ( + "app.convertTextToTableDialog", + "Convert Text to Table…", + None, + "{} — Convert Text to Table dialog (column and row separators, number of columns, table style)", + ), + ("app.convertTableToTextDialog", "Convert Table to Text…", None, "{} — Convert Table to Text dialog (column and row separators)"), ( "app.cellOptionsDialog", "Strokes and Fills…", @@ -166,8 +178,8 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.paragraphStylesPanel", "Paragraph Styles", Some("Cmd+F11"), "{} — the Paragraph Styles panel"), ("app.insertCurrentPageNumber", "Current Page Number", Some("Cmd+Alt+Shift+N"), "{} — the Current Page Number marker at the insertion point"), ("app.storyEditor", "Edit in Story Editor", Some("Cmd+Y"), "{story?}"), - ("view.zoomIn", "Zoom In", Some("Cmd+="), "{}"), - ("view.zoomOut", "Zoom Out", Some("Cmd+-"), "{}"), + ("view.zoomIn", "Zoom In", Some("Cmd+="), "{at?: [x, y] window pixels, kept in place when over the canvas}"), + ("view.zoomOut", "Zoom Out", Some("Cmd+-"), "{at?: [x, y] window pixels, kept in place when over the canvas}"), ("view.fitPage", "Fit Page in Window", Some("Cmd+0"), "{}"), ("view.fitSelection", "Fit Selection in Window", Some("Cmd+Alt+="), "{}"), ("view.fitSpread", "Fit Spread in Window", Some("Cmd+Alt+0"), "{}"), @@ -372,7 +384,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ "{open?: true, tab?: \"about\"|\"contributors\"|\"models\"} — the About window: splash with community links, contributor and model credits (open: false closes it)", ), ("help.checkForUpdates", "Check for Updates…", None, "{} — checks for newer releases and offers to update"), - ("window.toolsDoubleColumn", "Tools: Double Column", None, "{}"), + ("window.toolsDoubleColumn", "Tools: Double Column", None, "{on?: bool (default: toggle)} → whether the Tools panel has two columns"), ( "window.workspace", "Workspace", diff --git a/crates/ui-egui/src/new_document.rs b/crates/ui-egui/src/new_document.rs index 08b1a3e69..0acdb36f4 100644 --- a/crates/ui-egui/src/new_document.rs +++ b/crates/ui-egui/src/new_document.rs @@ -59,7 +59,13 @@ fn fields_for(nd: &NewDocument) -> Map { /// The fields a new New Document dialog starts with: the factory settings on the Recent tab. pub(crate) fn defaults() -> Value { - let mut f = fields_for(&NewDocument::default()); + defaults_for(&NewDocument::default()) +} + +/// The fields New Document starts with for `nd` (the session's default new document, in the +/// default units), on the Recent tab. +pub(crate) fn defaults_for(nd: &NewDocument) -> Value { + let mut f = fields_for(nd); f.insert("tab".into(), json!("recent")); f.insert("preset".into(), json!(DEFAULT_CARD)); f.insert("name".into(), json!("")); @@ -327,7 +333,8 @@ fn cards(app: &DesignApp, tab: &str) -> Vec { Card { key, label, nd, custom } }; // Recent and Saved both start with the factory settings, as in InDesign. - let default = Card { key: DEFAULT_CARD.into(), label: crate::i18n::tr(lang, DEFAULT_CARD).into(), nd: NewDocument::default(), custom: false }; + let default = + Card { key: DEFAULT_CARD.into(), label: crate::i18n::tr(lang, DEFAULT_CARD).into(), nd: app.session.default_new_document(), custom: false }; match tab { "recent" => std::iter::once(default) .chain(app.ui.recent_new_documents.iter().enumerate().map(|(i, nd)| named(format!("recent:{i}"), nd.clone()))) @@ -397,7 +404,7 @@ fn card(ui: &mut Ui, c: &Card, units: Unit, selected: bool) -> egui::Response { let (r, resp) = ui.allocate_exact_size(CARD, Sense::click()); if selected { ui.painter().rect(r.shrink(1.0), 3.0, t.hover, Stroke::new(2.0, t.accent), StrokeKind::Inside); - } else if resp.hovered() { + } else if ui.rect_contains_pointer(r) { ui.painter().rect_filled(r, 3.0, t.hover); } let page = Rect::from_center_size(pos2(r.center().x, r.top() + 56.0), vec2(72.0, 72.0)); @@ -503,13 +510,20 @@ fn presets(app: &mut DesignApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.spacing_mut().item_spacing = vec2(10.0, 10.0); for c in &list { let resp = card(ui, c, card_units, current == c.key); - if resp.clicked() || resp.double_clicked() { - select(d, c); - } - if resp.double_clicked() { - create = true; - } + let mut remove = false; if tab == "saved" && c.key != DEFAULT_CARD { + if current == c.key || ui.rect_contains_pointer(resp.rect) { + let x = if rtl { resp.rect.left() + 4.0 } else { resp.rect.right() - 26.0 }; + let rect = Rect::from_min_size(pos2(x, resp.rect.top() + 4.0), vec2(22.0, 22.0)); + let button = ui.interact(rect, ui.scope_id().with(("delete-preset", &c.key)), Sense::click()); + let label = format!("{}: {}", crate::i18n::tr(&lang, "Delete Preset"), c.label); + button.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label.as_str())); + if button.hovered() { + ui.painter().rect_filled(rect, 2.0, t.hover); + } + crate::icons::paint(ui.painter(), rect.shrink(3.0), "trash", t.icon); + remove = button.on_hover_text(label).clicked(); + } resp.context_menu(|ui| { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&lang, "Delete Preset"))).clicked() { delete = Some(c.nd.title.clone()); @@ -517,6 +531,12 @@ fn presets(app: &mut DesignApp, ui: &mut Ui, d: &mut Dialog) -> bool { } }); } + if remove { + delete = Some(c.nd.title.clone()); + } else if resp.clicked() || resp.double_clicked() { + select(d, c); + create |= resp.double_clicked(); + } } }); if let Some(name) = delete diff --git a/crates/ui-egui/src/new_document/tests.rs b/crates/ui-egui/src/new_document/tests.rs index 5785aed54..bf63b9c87 100644 --- a/crates/ui-egui/src/new_document/tests.rs +++ b/crates/ui-egui/src/new_document/tests.rs @@ -216,6 +216,43 @@ fn enter_in_the_preset_popup_saves_the_preset_and_keeps_the_dialog() { assert!(!egui::Popup::is_any_open(&h.ctx), "the popup closed"); } +#[test] +fn saved_preset_delete_buttons_keep_the_preview_and_never_create_a_document() { + use egui_kittest::kittest::Queryable as _; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.savePreset", json!({"name":"Portrait draft","pages":3,"facingPages":false})).unwrap(); + app.run("file.savePreset", json!({"name":"Landscape draft","width":792,"height":612,"pages":2})).unwrap(); + app.run("app.newDocumentDialog", json!({})).unwrap(); + let mut h = crate::test_window::open(app, vec2(1440.0, 900.0)); + h.run_steps(4); + let saved = h.get_by_label("Saved").rect().center(); + crate::test_window::click_at(&mut h, saved); + let portrait = h.get_by_label("Portrait draft").rect().center(); + crate::test_window::click_at(&mut h, portrait); + h.get_by_label("Preview").click(); + h.run_steps(20); + assert!(h.query_by_label("Delete Preset: Portrait draft").is_some(), "the selected preset has its action"); + assert!(h.query_by_label("Delete Preset: [Default]").is_none(), "the factory card cannot be deleted"); + let before = h.state().app.session.active().unwrap().doc.clone(); + let preview = h.state().app.session.new_document_preview(); + let count = h.state().app.session.documents().len(); + let fields = params(h.state().app.ui.dialog.as_ref().unwrap()); + let landscape = h.get_by_label("Landscape draft").rect().center(); + h.event(egui::Event::PointerMoved(landscape)); + h.run_steps(3); + let remove = h.get_by_label("Delete Preset: Landscape draft").rect().center(); + crate::test_window::click_at(&mut h, remove); + assert_eq!(h.state().app.session.prefs.document_presets.iter().map(|p| p.title.as_str()).collect::>(), ["Portrait draft"]); + let remove = h.get_by_label("Delete Preset: Portrait draft").rect().center(); + crate::test_window::click_at(&mut h, remove); + let app = &h.state().app; + assert!(app.session.prefs.document_presets.is_empty()); + assert_eq!(app.session.new_document_preview(), preview); + assert_eq!(app.session.documents().len(), count); + assert_eq!(*app.session.active().unwrap().doc, *before); + assert_eq!(params(app.ui.dialog.as_ref().unwrap()), fields, "deletion does not select another preset or create the preview"); +} + #[test] fn escape_in_the_preset_popup_closes_only_the_popup() { let mut h = window_with_preset_popup(); diff --git a/crates/ui-egui/src/package_dialog.rs b/crates/ui-egui/src/package_dialog.rs new file mode 100644 index 000000000..c176d1b82 --- /dev/null +++ b/crates/ui-egui/src/package_dialog.rs @@ -0,0 +1,622 @@ +//! File › Package…: InDesign's Package dialog. Summary counts what the package carries (fonts, +//! links and images, inks), with a warning where something is missing or modified; Fonts, Links +//! and Images and Colors and Inks list them (Show Problems Only). Package… asks for the folder to +//! create and writes the package (`file.package`), after the Printing Instructions when Create +//! Printing Instructions is on. + +use serde_json::{Value, json}; + +use crate::DesignApp; +use crate::app::file_dialogs::Bound; +use crate::dialogs::Dialog; +use crate::theme::semibold; + +/// The Package dialog's id. +pub const ID: &str = "package"; +/// The Printing Instructions dialog's id. +pub const INSTRUCTIONS: &str = "printingInstructions"; + +/// The dialog's pages: (key, label). +const PAGES: [(&str, &str); 4] = [("summary", "Summary"), ("fonts", "Fonts"), ("links", "Links and Images"), ("inks", "Colors and Inks")]; + +/// The process inks every package prints with. +const PROCESS_INKS: [&str; 4] = ["Process Cyan", "Process Magenta", "Process Yellow", "Process Black"]; + +/// The Printing Instructions' fields: (key, label, lines). +const INSTRUCTION_FIELDS: [(&str, &str, usize); 7] = [ + ("contact", "Contact:", 1), + ("company", "Company:", 1), + ("address", "Address:", 3), + ("phone", "Phone:", 1), + ("fax", "Fax:", 1), + ("email", "Email:", 1), + ("instructions", "Instructions:", 4), +]; + +/// Open the Package dialog on its Summary, with the document's fonts, links and spot inks. +pub fn open(app: &mut DesignApp) -> Result<(), String> { + if app.session.active().is_none() { + return Err("no document open".into()); + } + let fonts: Vec = app + .session + .execute("font.list", &json!({})) + .ok() + .and_then(|v| v.as_array().cloned()) + .unwrap_or_default() + .iter() + .map(|f| { + let status = font_status(f); + let name = format!("{} {}", f["family"].as_str().unwrap_or(""), f["style"].as_str().unwrap_or("")).trim().to_string(); + json!({"name": name, "source": f["source"].as_str().unwrap_or(""), "status": status}) + }) + .collect(); + let links: Vec = app + .session + .execute("links.list", &json!({})) + .ok() + .and_then(|v| v.as_array().cloned()) + .unwrap_or_default() + .iter() + .map(|l| { + let page = l["uses"].as_array().and_then(|u| u.iter().find_map(|u| u["page"].as_str())).unwrap_or(""); + json!({"name": l["name"].as_str().unwrap_or(""), "page": page, "status": l["status"].as_str().unwrap_or("ok")}) + }) + .collect(); + // Spot inks print on their own plates unless the Ink Manager makes them process. + let inks = app.session.execute("ink.list", &json!({})).unwrap_or_default(); + let all_process = inks["allToProcess"].as_bool().unwrap_or(false); + let spot: Vec = inks["inks"] + .as_array() + .map(|a| { + a.iter() + .filter(|i| !all_process && !i["process"].as_bool().unwrap_or(false)) + .filter_map(|i| i["name"].as_str()) + .map(|n| json!(n)) + .collect() + }) + .unwrap_or_default(); + // The packaged PDF's preset: the one last used for Export PDF, as InDesign suggests it. + let last = app.ui.pdf_export.preset.clone(); + let preset = crate::dialogs::pdf_presets().find(|p| *p == last).unwrap_or("Desktop Printing"); + let doc = app.session.active().map(|d| d.uid); + app.ui.dialog = Some(Dialog::new( + ID, + json!({"page": "summary", "doc": doc, "fonts": fonts, "links": links, "spot": spot, "instructions": false, "idml": true, "pdf": false, + "pdfPreset": preset, "problemsOnly": false}), + )); + Ok(()) +} + +/// A `font.list` entry's status here: a missing font, or a style its family lacks, is missing +/// (as `file.package` counts them); a style it stands in for is substituted. +fn font_status(f: &Value) -> &'static str { + if f["missing"].as_bool().unwrap_or(false) || f["styleMissing"].as_bool().unwrap_or(false) { + "missing" + } else if f["matchStatus"] == "styleSubstitute" { + "substituted" + } else { + "ok" + } +} + +/// The document the dialog was opened on is still the active one (automation can switch +/// documents while it is open, and the package must be of the one summarised). +fn same_document(app: &DesignApp, d: &Dialog) -> Result<(), String> { + match app.session.active() { + Some(doc) if d.fields.get("doc").and_then(Value::as_u64) == Some(doc.uid) => Ok(()), + _ => Err("the active document changed after the Package dialog opened".into()), + } +} + +fn rows(d: &Dialog, key: &str) -> Vec { + d.fields.get(key).and_then(Value::as_array).cloned().unwrap_or_default() +} + +/// How many of `rows` have one of `statuses`. +fn count(rows: &[Value], statuses: &[&str]) -> usize { + rows.iter().filter(|r| r["status"].as_str().is_some_and(|s| statuses.contains(&s))).count() +} + +/// The Summary's lines: (text, whether it warns). +fn summary_lines(lang: &str, d: &Dialog) -> Vec<(String, bool)> { + let tr = |s: &str| crate::i18n::tr(lang, s).to_string(); + let (fonts, links) = (rows(d, "fonts"), rows(d, "links")); + let missing_fonts = count(&fonts, &["missing"]); + let (modified, missing) = (count(&links, &["modified"]), count(&links, &["missing"])); + vec![ + ( + tr("Fonts: {used} in use, {missing} unavailable") + .replace("{used}", &fonts.len().to_string()) + .replace("{missing}", &missing_fonts.to_string()), + missing_fonts > 0, + ), + ( + tr("Links and Images: {found} linked, {modified} changed, {missing} not found") + .replace("{found}", &links.len().to_string()) + .replace("{modified}", &modified.to_string()) + .replace("{missing}", &missing.to_string()), + modified + missing > 0, + ), + (tr("Embedded images: {embedded}").replace("{embedded}", &count(&links, &["embedded"]).to_string()), false), + ( + tr("Colors and Inks: {process} process, {spot} spot") + .replace("{process}", &PROCESS_INKS.len().to_string()) + .replace("{spot}", &rows(d, "spot").len().to_string()), + false, + ), + ] +} + +/// How a status is shown: coloured with the theme's warning or error colour. +#[derive(Clone, Copy, PartialEq, Debug)] +enum Level { + Warn, + Bad, +} + +impl Level { + fn color(self, ui: &egui::Ui) -> egui::Color32 { + match self { + Level::Warn => ui.visuals().warn_fg_color, + Level::Bad => ui.visuals().error_fg_color, + } + } +} + +fn status_text(lang: &str, status: &str) -> (String, Option) { + let tr = |s: &str| crate::i18n::tr(lang, s).to_string(); + match status { + "missing" => (format!("\u{26A0} {}", tr("Missing")), Some(Level::Bad)), + "modified" => (format!("\u{25B2} {}", tr("Modified")), Some(Level::Warn)), + "substituted" => (tr("Substituted"), Some(Level::Warn)), + "embedded" => (tr("Embedded"), None), + _ => (tr("OK"), None), + } +} + +/// A table: a header row and `cells` rows (each with an optional level colouring its last cell). +fn table(ui: &mut egui::Ui, id: &str, header: &[String], cells: &[(Vec, Option)]) { + let t = crate::theme::Tokens::get(ui.ctx()); + egui::Frame::NONE.stroke(egui::Stroke::new(1.0, t.field_border)).inner_margin(6.0).show(ui, |ui| { + egui::ScrollArea::vertical().id_salt(id).max_height(220.0).auto_shrink([false, true]).show(ui, |ui| { + egui::Grid::new(id).num_columns(header.len()).spacing([18.0, 4.0]).striped(true).show(ui, |ui| { + for h in header { + crate::rtl::label(ui, egui::RichText::new(h).font(semibold(11.5))); + } + ui.end_row(); + for (row, color) in cells { + for (i, c) in row.iter().enumerate() { + let mut text = egui::RichText::new(c); + if i + 1 == row.len() + && let Some(color) = color + { + text = text.color(color.color(ui)); + } + crate::rtl::label(ui, text); + } + ui.end_row(); + } + }); + }); + }); +} + +/// The Package dialog: the pages on the left, the page on the right. +pub fn show(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); + let page = d.fields.get("page").and_then(Value::as_str).unwrap_or("summary").to_string(); + ui.horizontal_top(|ui| { + // A fixed height, as Preferences': the divider between the pages and the page fills it, + // and the dialog keeps its size from page to page. + ui.set_min_height(320.0); + ui.set_max_height(320.0); + ui.vertical(|ui| { + ui.set_width(150.0); + for (key, label) in PAGES { + if ui.selectable_label(page == key, crate::rtl::widget(ui, tr(label))).clicked() { + d.fields.insert("page".into(), json!(key)); + } + } + }); + ui.separator(); + ui.vertical(|ui| { + ui.set_min_width(460.0); + let title = PAGES.iter().find(|p| p.0 == page).map_or("Summary", |p| p.1); + crate::rtl::label(ui, egui::RichText::new(tr(title)).font(semibold(13.0))); + ui.add_space(6.0); + let problems_only = d.b("problemsOnly"); + match page.as_str() { + "fonts" => { + let fonts = rows(d, "fonts"); + let cells: Vec<_> = fonts + .iter() + .filter(|f| !problems_only || f["status"] != "ok") + .map(|f| { + let (status, color) = status_text(&lang, f["status"].as_str().unwrap_or("ok")); + let source = match f["source"].as_str().unwrap_or("") { + "bundled" => tr("Bundled"), + "installed" => tr("Installed"), + "document" => tr("Document"), + "added" => tr("Added"), + _ => String::new(), + }; + (vec![f["name"].as_str().unwrap_or("").to_string(), source, status], color) + }) + .collect(); + table(ui, "package_fonts", &[tr("Name"), tr("Source"), tr("Status")], &cells); + } + "links" => { + let links = rows(d, "links"); + let cells: Vec<_> = links + .iter() + .filter(|l| !problems_only || matches!(l["status"].as_str(), Some("missing" | "modified"))) + .map(|l| { + let (status, color) = status_text(&lang, l["status"].as_str().unwrap_or("ok")); + (vec![l["name"].as_str().unwrap_or("").to_string(), l["page"].as_str().unwrap_or("").to_string(), status], color) + }) + .collect(); + table(ui, "package_links", &[tr("Name"), tr("Page"), tr("Status")], &cells); + } + "inks" => { + let mut cells: Vec<_> = PROCESS_INKS.iter().map(|n| (vec![tr(n), tr("Process")], None)).collect(); + cells.extend(rows(d, "spot").iter().filter_map(Value::as_str).map(|n| (vec![n.to_string(), tr("Spot")], None))); + // The ink's kind: the "Type:" key (the "Type" key is the menu's, which reads Text). + let kind = tr("Type:").trim_end_matches([':', ':']).trim_end().to_string(); + table(ui, "package_inks", &[tr("Name"), kind], &cells); + } + _ => { + ui.horizontal(|ui| { + crate::rtl::label(ui, tr("Scope:")); + crate::rtl::label(ui, tr("Entire Publication")); + }); + ui.add_space(6.0); + for (line, warn) in summary_lines(&lang, d) { + ui.horizontal(|ui| { + let sign = egui::RichText::new(if warn { "\u{26A0}" } else { " " }).color(Level::Warn.color(ui)); + ui.add_sized([14.0, 16.0], egui::Label::new(sign)); + crate::rtl::label(ui, line); + }); + } + ui.add_space(10.0); + for (key, label) in [("instructions", "Create Printing Instructions"), ("idml", "Include IDML"), ("pdf", "Include PDF (Print)")] { + let mut on = d.b(key); + if ui.checkbox(&mut on, crate::rtl::widget(ui, tr(label))).changed() { + d.fields.insert(key.into(), json!(on)); + } + } + // The PDF's preset, as InDesign's Select PDF Preset. + ui.add_enabled_ui(d.b("pdf"), |ui| { + ui.horizontal(|ui| { + ui.add_space(24.0); + let name = crate::rtl::label(ui, tr("PDF Preset:")); + let cur = d.s("pdfPreset"); + craft_ui::ComboBox::from_id_salt("package_pdf_preset") + .selected_text(crate::rtl::widget(ui, tr(&cur))) + .width(180.0) + .show_ui(ui, |ui| { + for p in crate::dialogs::pdf_presets() { + if ui.selectable_label(cur == p, crate::rtl::widget(ui, tr(p))).clicked() { + d.fields.insert("pdfPreset".into(), json!(p)); + } + } + }) + .response + .labelled_by(name.id); + }); + }); + } + } + if matches!(page.as_str(), "fonts" | "links") { + ui.add_space(4.0); + let mut on = problems_only; + if ui.checkbox(&mut on, crate::rtl::widget(ui, tr("Show Problems Only"))).changed() { + d.fields.insert("problemsOnly".into(), json!(on)); + } + } + }); + }); +} + +/// The `pdf` of `file.package`: false, or the options of the chosen PDF preset. +fn pdf_param(d: &Dialog) -> Value { + if d.b("pdf") { crate::dialogs::pdf_preset_params(&d.s("pdfPreset")) } else { json!(false) } +} + +/// Package…: the Printing Instructions first when asked for, else the folder. +pub fn confirm(app: &mut DesignApp, d: &Dialog) -> Result { + same_document(app, d)?; + let (idml, pdf) = (d.b("idml"), pdf_param(d)); + if d.b("instructions") { + app.ui.dialog = Some(Dialog::new(INSTRUCTIONS, json!({"idml": idml, "pdf": pdf, "doc": d.fields.get("doc")}))); + return Ok(Value::Null); + } + package_to_folder(app, idml, pdf, None, d.clone()) +} + +/// The Printing Instructions dialog, as InDesign's: who to contact and what to do. +pub fn show_instructions(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let lang = app.ui.language.clone(); + egui::Grid::new("printing_instructions").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + for (key, label, lines) in INSTRUCTION_FIELDS { + let name = crate::rtl::label(ui, crate::i18n::tr(&lang, label)); + let mut text = d.s(key); + let edit = if lines > 1 { egui::TextEdit::multiline(&mut text).desired_rows(lines) } else { egui::TextEdit::singleline(&mut text) }; + // Named by its label, for screen readers. + let resp = ui.add(edit.desired_width(320.0)).labelled_by(name.id); + if resp.changed() { + d.fields.insert(key.into(), json!(text)); + } + // Enter there makes a new line: the footer mustn't take it for Continue. + if lines > 1 && resp.has_focus() { + crate::dialogs::set_multiline_focus(ui.ctx()); + } + ui.end_row(); + } + }); +} + +/// The instructions as they go into the package's Instructions.txt: the contact lines, then the +/// instructions. Empty fields are left out. +fn instructions_text(d: &Dialog) -> String { + let mut out = String::new(); + for (key, label) in + [("contact", "Contact"), ("company", "Company"), ("address", "Address"), ("phone", "Phone"), ("fax", "Fax"), ("email", "Email")] + { + let v = d.s(key); + let v = v.trim(); + if !v.is_empty() { + out += &format!("{label}: {}\n", v.replace('\n', "\n ")); + } + } + let ins = d.s("instructions"); + if !ins.trim().is_empty() { + if !out.is_empty() { + out.push('\n'); + } + out += ins.trim(); + out.push('\n'); + } + out +} + +/// Continue: the folder, with the instructions. +pub fn confirm_instructions(app: &mut DesignApp, d: &Dialog) -> Result { + same_document(app, d)?; + let text = instructions_text(d); + package_to_folder(app, d.b("idml"), d.fields.get("pdf").cloned().unwrap_or(json!(false)), Some(text).filter(|t| !t.is_empty()), d.clone()) +} + +/// Ask for the folder to create (" Folder") and package the document into it once it is +/// chosen (Cancel packages nothing). Packaging that fails reopens `asking`, the dialog that asked, +/// with its options. `pdf`: +/// false, true or the PDF's `file.exportPdf` options. +pub fn package_to_folder(app: &mut DesignApp, idml: bool, pdf: Value, instructions: Option<String>, asking: Dialog) -> Result<Value, String> { + let name = app.session.active().map(|d| format!("{} Folder", d.doc.title)).unwrap_or_default(); + app.ask_save(&name, Bound::Document, move |app, dir| { + let mut p = json!({"dir": dir, "idml": idml, "pdf": pdf}); + if let Some(t) = instructions { + p["instructions"] = json!(t); + } + let r = app.run("file.package", p); + match &r { + Ok(v) => app.status( + crate::i18n::tr(&app.ui.language, "Packaged {count} files into {dir}") + .replace("{count}", &v["files"].as_array().map_or(0, |f| f.len()).to_string()) + .replace("{dir}", v["dir"].as_str().unwrap_or("")), + ), + Err(_) if app.ui.dialog.is_none() => app.ui.dialog = Some(asking), + Err(_) => {} + } + r + }) +} + +#[cfg(test)] +mod tests { + use egui_kittest::{Harness, kittest::Queryable}; + + use super::*; + + /// A document with a text frame and an image whose file has gone, in a temporary folder. + fn app_with_missing_link(dir: &std::path::Path) -> DesignApp { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"title": "Packed"})).unwrap(); + app.run("frame.create", json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Hello"})).unwrap(); + let png = designcraft_render::Rendered { width: 4, height: 3, pixels: [30, 90, 180, 255].repeat(12) }.to_png(); + let img = dir.join("gone.png"); + std::fs::write(&img, &png).unwrap(); + app.run("file.place", json!({"path": img.to_string_lossy(), "x": 40, "y": 260, "width": 40})).unwrap(); + std::fs::remove_file(&img).unwrap(); + app + } + + fn harness(app: DesignApp) -> Harness<'static, DesignApp> { + Harness::builder().with_size(egui::vec2(1100.0, 760.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::unique("package_test_fonts"); + if ui.data(|d| d.get_temp::<bool>(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + crate::dialogs::show(app, ui.ctx()); + }, + app, + ) + } + + fn temp_dir(tag: &str) -> std::path::PathBuf { + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("dc-package-dialog-{tag}-{}-{nonce}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + /// The course's chapter 27: File › Package opens InDesign's Package dialog. Summary counts the + /// fonts, links (one missing, with a warning) and inks; Links and Images lists the link, and + /// Show Problems Only keeps it; Create Printing Instructions leads to Printing Instructions, + /// whose Continue asks for the folder and writes the package with the instructions. + #[test] + fn package_shows_a_summary_then_the_printing_instructions() { + let dir = temp_dir("summary"); + let mut app = app_with_missing_link(&dir); + let out = dir.join("Packed Folder"); + let picked = out.to_string_lossy().to_string(); + app.answer_dialogs(crate::app::file_dialogs::testing::answer_with(Some(picked.clone()))); + crate::menus::activate(&mut app, "app.packageDialog", &Value::Null); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some(ID)); + let mut h = harness(app); + h.run_steps(3); + assert!(h.query_by_label("Scope:").is_some()); + assert!(h.query_by_label("Links and Images: 1 linked, 0 changed, 1 not found").is_some()); + assert!(h.query_by_label("Colors and Inks: 4 process, 0 spot").is_some()); + let fonts = h.state().ui.dialog.as_ref().unwrap().fields["fonts"].as_array().unwrap().len(); + assert!(h.query_by_label(&format!("Fonts: {fonts} in use, 0 unavailable")).is_some()); + // Links and Images: the link, missing; Show Problems Only keeps it. + h.get_by_label("Links and Images").click(); + h.run_steps(2); + assert!(h.query_by_label("gone.png").is_some()); + assert!(h.query_by_label("\u{26A0} Missing").is_some()); + h.get_by_label("Show Problems Only").click(); + h.run_steps(2); + assert!(h.query_by_label("gone.png").is_some()); + // Back on Summary: Create Printing Instructions, then Package…. + h.get_by_label("Summary").click(); + h.run_steps(2); + h.get_by_label("Create Printing Instructions").click(); + h.run_steps(2); + h.get_by_label("Package…").click(); + h.run_steps(3); + h.state_mut().settle_dialogs(); + assert_eq!(h.state().ui.dialog.as_ref().map(|d| d.id.as_str()), Some(INSTRUCTIONS)); + for (key, value) in [("contact", "Dave"), ("email", "dave@example.com"), ("instructions", "Call me if anything is wrong.")] { + h.state_mut().ui.dialog.as_mut().unwrap().fields.insert(key.into(), json!(value)); + } + h.run_steps(2); + h.get_by_label("Continue").click(); + h.run_steps(3); + h.state_mut().settle_dialogs(); + assert!(h.state().ui.dialog.is_none()); + let report = std::fs::read_to_string(out.join("Instructions.txt")).unwrap(); + assert!( + report.contains("Contact: Dave") && report.contains("Email: dave@example.com") && report.contains("Call me if anything is wrong."), + "{report}" + ); + assert!(out.join("Packed.idml").exists(), "Include IDML is on by default"); + assert!(!out.join("Packed.pdf").exists(), "Include PDF is off by default"); + std::fs::remove_dir_all(dir).unwrap(); + } + + /// Without instructions, Package… goes straight to the folder; Include PDF adds the PDF, made + /// with the PDF preset chosen, and Include IDML off leaves the IDML out. + #[test] + fn package_options_choose_the_idml_and_pdf() { + let dir = temp_dir("options"); + let mut app = app_with_missing_link(&dir); + let out = dir.join("Packed Folder"); + let picked = out.to_string_lossy().to_string(); + app.answer_dialogs(crate::app::file_dialogs::testing::answer_with(Some(picked.clone()))); + open(&mut app).unwrap(); + let mut h = harness(app); + h.run_steps(3); + h.get_by_label("Include IDML").click(); + h.run_steps(2); + h.get_by_label("Include PDF (Print)").click(); + h.run_steps(2); + // The PDF's preset: PDF/X-4. + h.get_by_value("Desktop Printing").click(); + h.run_steps(2); + h.get_by_label("PDF/X-4").click(); + h.run_steps(2); + h.get_by_label("Package…").click(); + h.run_steps(3); + h.state_mut().settle_dialogs(); + assert!(h.state().ui.dialog.is_none()); + assert!(out.join("Packed.pdf").exists() && !out.join("Packed.idml").exists()); + let pdf = std::fs::read(out.join("Packed.pdf")).unwrap(); + assert!(pdf.windows(8).any(|w| w == b"GTS_PDFX"), "the PDF/X-4 preset's PDF"); + std::fs::remove_dir_all(dir).unwrap(); + } + + /// Packaging that fails (here, into a folder under a file) keeps the dialog open with its + /// options, and the status line says why. Without a document there's nothing to open. + #[test] + fn a_failed_package_keeps_the_dialog_open() { + let dir = temp_dir("fails"); + let mut app = app_with_missing_link(&dir); + let blocker = dir.join("blocker"); + std::fs::write(&blocker, b"a file, not a folder").unwrap(); + let picked = blocker.join("Packed Folder").to_string_lossy().to_string(); + app.answer_dialogs(crate::app::file_dialogs::testing::answer_with(Some(picked.clone()))); + open(&mut app).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("pdf".into(), json!(true)); + crate::dialogs::confirm(&mut app).unwrap(); + // The folder is chosen on a later frame; packaging into it fails then. + app.settle_dialogs(); + let d = app.ui.dialog.as_ref().expect("the dialog opens again"); + assert_eq!((d.id.as_str(), d.b("pdf")), (ID, true), "with its options"); + assert!(!app.ui.status.is_empty()); + app.run("file.close", json!({})).unwrap(); + app.ui.dialog = None; + assert!(app.run("app.packageDialog", json!({})).is_err()); + assert!(app.ui.dialog.is_none()); + std::fs::remove_dir_all(dir).unwrap(); + } + + /// A style the font's family lacks counts as missing, as `file.package` counts it. + #[test] + fn a_missing_font_style_is_a_missing_font() { + assert_eq!(font_status(&json!({"missing": true})), "missing"); + assert_eq!(font_status(&json!({"missing": false, "styleMissing": true})), "missing"); + assert_eq!(font_status(&json!({"missing": false, "matchStatus": "styleSubstitute"})), "substituted"); + assert_eq!(font_status(&json!({"missing": false})), "ok"); + } + + /// The package is of the document the dialog summarised: when another document became active + /// meanwhile, Package… and Continue refuse, and nothing is written. + #[test] + fn package_refuses_when_the_active_document_changed() { + let dir = temp_dir("switched"); + let mut app = app_with_missing_link(&dir); + let out = dir.join("Packed Folder"); + let picked = out.to_string_lossy().to_string(); + app.answer_dialogs(crate::app::file_dialogs::testing::answer_with(Some(picked.clone()))); + open(&mut app).unwrap(); + let d = app.ui.dialog.take().unwrap(); + app.run("file.new", json!({"title": "Other"})).unwrap(); + assert!(confirm(&mut app, &d).is_err()); + let ins = Dialog::new(INSTRUCTIONS, json!({"idml": true, "pdf": false, "doc": d.fields["doc"]})); + assert!(confirm_instructions(&mut app, &ins).is_err()); + assert!(!out.exists(), "nothing packaged"); + std::fs::remove_dir_all(dir).unwrap(); + } + + /// Enter in Address or Instructions makes a new line there; in a one-line field it continues. + #[test] + fn enter_in_a_multiline_instruction_field_is_a_new_line() { + let dir = temp_dir("enter"); + let mut app = app_with_missing_link(&dir); + let doc = app.session.active().unwrap().uid; + app.ui.dialog = Some(Dialog::new(INSTRUCTIONS, json!({"idml": true, "pdf": false, "doc": doc}))); + let mut h = harness(app); + h.run_steps(3); + let field = |h: &Harness<'static, DesignApp>, n: usize| { + h.query_all_by(|x| x.role() == egui::accesskit::Role::TextInput || x.role() == egui::accesskit::Role::MultilineTextInput) + .nth(n) + .unwrap() + .rect() + .center() + }; + // Address is the third field. + let address = field(&h, 2); + h.event(egui::Event::PointerButton { pos: address, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.event(egui::Event::PointerButton { pos: address, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(h.state().ui.dialog.as_ref().map(|d| d.id.as_str()), Some(INSTRUCTIONS), "still open"); + std::fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 5f1b8829c..2efdd9ec7 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -36,6 +36,9 @@ use crate::theme::Tokens; pub struct SelInfo { /// Bounds measured from the rulers' zero point (as the X/Y fields show them). pub page_rect: Rect, + /// Width and height of the selected objects themselves (with points selected, `page_rect` + /// measures the points). + pub object_size: (f64, f64), pub count: usize, pub fill: String, pub stroke: String, @@ -82,6 +85,19 @@ pub fn color_target(app: &mut DesignApp) -> Option<(String, f32, String, f32)> { Some((i.fill, sel_tint(app, false), i.stroke, sel_tint(app, true))) } +/// [`color_target`], or with nothing selected the colours new shapes get (the Color and Swatches +/// panels then edit those, as InDesign's do). +pub fn color_target_or_defaults(app: &mut DesignApp) -> Option<(String, f32, String, f32)> { + color_target(app).or_else(|| { + let state = app.session.active()?; + if !state.selection.items.is_empty() || state.selection.text.is_some() || state.selection.cells.is_some() { + return None; + } + let colors = &state.doc.settings.new_object; + Some((colors.fill.clone(), colors.fill_tint, colors.stroke.clone(), colors.stroke_tint)) + }) +} + /// Apply a swatch (and tint) to the fill or stroke of what colour edits apply to. pub fn apply_swatch(app: &mut DesignApp, stroke: bool, swatch: &str, tint: Option<f32>) -> Result<Value, String> { remember_swatch(app, swatch); @@ -162,6 +178,22 @@ pub fn guide_field(app: &DesignApp) -> Option<GuideField> { value.is_finite().then_some(GuideField { guide: *g, orientation: guide.orientation, value, origin, unit }) } +/// The anchor points selected with Direct Selection, each with where it is in its spread. +pub fn selected_anchor_points(app: &DesignApp) -> Vec<((designcraft_doc::ItemId, usize, usize), designcraft_geom::Point)> { + let Some(st) = app.session.active() else { return vec![] }; + let d = &st.doc; + st.selection + .anchors + .iter() + .filter_map(|&(id, si, ai)| { + let loc = d.find(id)?; + let it = d.item(id)?; + let a = it.path.subpaths.get(si)?.anchors.get(ai)?; + Some(((id, si, ai), (d.parent_xf(&loc) * it.xf) * a.p)) + }) + .collect() +} + pub fn sel_info(app: &DesignApp) -> Option<SelInfo> { let st = app.session.active()?; if st.selection.items.is_empty() { @@ -183,7 +215,13 @@ pub fn sel_info(app: &DesignApp) -> Option<SelInfo> { }; b = Some(b.map_or(ib, |r| r.union(ib))); } - let b = b?; + let object_size = b.map_or((0.0, 0.0), |r| (r.width(), r.height())); + // Points selected with Direct Selection: Transform measures the points, not their paths. + let points = selected_anchor_points(app); + let b = match points.split_first() { + Some(((_, p0), rest)) => rest.iter().fold(Rect::from_points(*p0, *p0), |r, (_, p)| r.union_pt(*p)), + None => b?, + }; let first = d.item(st.selection.items[0])?; let loc = d.find(first.id)?; let o = d.ruler_origin(loc.spread, b.center().x)?; @@ -201,6 +239,7 @@ pub fn sel_info(app: &DesignApp) -> Option<SelInfo> { }; Some(SelInfo { page_rect: Rect::new(b.x0 - o.x, b.y0 - o.y, b.x1 - o.x, b.y1 - o.y), + object_size, count: st.selection.items.len(), fill: first.fill.swatch.clone(), stroke: first.stroke.swatch.clone(), diff --git a/crates/ui-egui/src/panels/color_panel_tests.rs b/crates/ui-egui/src/panels/color_panel_tests.rs index cd102b2e3..153216100 100644 --- a/crates/ui-egui/src/panels/color_panel_tests.rs +++ b/crates/ui-egui/src/panels/color_panel_tests.rs @@ -81,3 +81,42 @@ fn a_typed_tint_is_kept_while_typing_and_applied_on_enter_or_leaving_the_field() h.run_steps(3); assert_eq!(fill_tint(h.state(), id), 0.0, "dragging the slider to the left end applies 0 %"); } + +/// The course's chapter 24: with a gradient fill, the Color panel shows the stop selected in the +/// Gradient panel (black, then) and its C/M/Y/K fields colour that stop, keeping the others. +#[test] +fn the_color_panel_edits_the_gradient_stop_selected_in_the_gradient_panel() { + use super::swatches::{GRAD_SEL, RampSel}; + 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": [36, 36, 200, 200], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + // InDesign's default White → Black. + app.run("object.gradient", json!({})).unwrap(); + let mut h = harness(app); + h.run_steps(2); + h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique(GRAD_SEL), RampSel::Stop(1))); + h.run_steps(3); + assert!(h.query_by_label("R=0 G=0 B=0").is_some(), "the black stop's values"); + // C: the topmost field. + let at = h + .query_all_by(|n| matches!(n.role(), Role::SpinButton | Role::TextInput)) + .map(|n| n.rect()) + .min_by(|a, b| a.min.y.total_cmp(&b.min.y)) + .unwrap() + .center(); + h.hover_at(at); + h.drag_at(at); + h.drop_at(at); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("100".into())); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(3); + let doc = h.state().session.doc().unwrap().doc.clone(); + let fill = &doc.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch; + let g = designcraft_color::swatch::resolve_gradient(&doc.swatches, fill).cloned().expect("still a gradient"); + assert_eq!(g.stops[1].color, designcraft_color::Color::cmyk(1.0, 0.0, 0.0, 1.0), "C 100 on the black stop"); + assert_eq!(g.stops[0].color, designcraft_color::Gradient::default().stops[0].color, "the white stop stays"); +} diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index 22f93ae8c..7272dfeed 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -204,6 +204,25 @@ const THUMB_H: f32 = 37.0; /// Parent rows. const ROW_H: f32 = 29.0; +/// A spread's label under its thumbnails, as InDesign's: one name for a single page, "2–3" for +/// pages numbered on in one section (an en dash), and the names apart ("2, 1", "8, A") when a section starts +/// inside the spread. +pub(crate) fn spread_label(app: &DesignApp, doc: &designcraft_doc::Document, first: usize, n: usize) -> String { + let names: Vec<String> = (first..first + n).map(|abs| app.session.page_label(abs)).collect(); + let starts = |abs: usize| abs == 0 || doc.sections.iter().any(|s| s.start == abs); + match (names.first(), names.last()) { + (Some(a), Some(b)) if names.len() > 1 => { + if !app.session.prefs.absolute_page_numbers && (first + 1..first + n).any(starts) { + names.join(", ") + } else { + format!("{a}\u{2013}{b}") + } + } + (Some(a), _) => a.clone(), + _ => String::new(), + } +} + /// A row of the Pages panel's parents list: [None] (`parent` = `None`) or a parent. struct ParentRow { label: String, @@ -263,6 +282,7 @@ fn parent_icon(app: &mut DesignApp, ui: &egui::Ui, right: egui::Pos2, row: &Pare pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; + let uid = st.uid; let doc = st.doc.clone(); let editing_parents = st.editing_parents; let aspect = (doc.settings.page_width / doc.settings.page_height.max(1.0)) as f32; @@ -407,6 +427,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { // ---- Document spreads. let cur = crate::canvas::current_page(app).unwrap_or(0); let cur_spread = doc.page_loc(cur).map(|l| l.0); + let followed_id = egui::Id::unique("pages_panel_followed"); let dragging = egui::DragAndDrop::has_any_payload(ui.ctx()); let tallest = doc.spreads.iter().flat_map(|sp| sp.pages.iter().map(|p| p.height)).fold(doc.settings.page_height, f64::max).max(1.0); let th = opts.page_size.page_h(); @@ -428,22 +449,13 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let spine = sp.spine_x() as f32; let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 8.0 + row_h + 22.0), Sense::hover()); let top = row.min.y + 8.0; - // Marker above the current spread. if cur_spread == Some(si) && !editing_parents { - let c = egui::pos2(row.min.x + center, row.min.y + 3.5); - ui.painter().add(egui::Shape::convex_polygon( - vec![c + vec2(-5.0, -3.0), c + vec2(5.0, -3.0), c + vec2(0.0, 3.0)], - t.icon, - Stroke::NONE, - )); + let key = (uid, cur); + if ui.data(|d| d.get_temp::<(u64, usize)>(followed_id)) != Some(key) { + ui.scroll_to_rect(row, None); + ui.data_mut(|d| d.insert_temp(followed_id, key)); + } } - // Pages narrower than their numbers ("10" under an Extra Small page) are labelled - // once for the spread. - let narrow = sp.pages.len() > 1 - && (first..first + sp.pages.len()).zip(&sp.pages).any(|(abs, p)| { - let w = ui.painter().layout_no_wrap(app.session.page_label(abs), crate::theme::semibold(11.0), Color32::WHITE).size().x; - (p.width as f32 * scale) < w + 6.0 - }); for (pi, p) in sp.pages.iter().enumerate() { let abs = first + pi; let x0 = row.min.x + center + (p.x as f32 - spine) * scale; @@ -464,6 +476,35 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } } ui.painter().rect_stroke(pr, 0.0, Stroke::new(0.5, Color32::from_gray(0x4a)), StrokeKind::Outside); + if abs == 0 || doc.sections.iter().any(|s| s.start == abs) { + let c = egui::pos2(pr.center().x, row.min.y + 4.0); + ui.painter().add(egui::Shape::convex_polygon( + vec![c + vec2(-4.5, -3.0), c + vec2(4.5, -3.0), c + vec2(0.0, 3.0)], + t.icon, + Stroke::NONE, + )); + let marker = + ui.interact(egui::Rect::from_center_size(c, vec2(12.0, 8.0)), ui.scope_id().with(("section", abs)), Sense::click()); + let label = format!( + "{}: {}", + crate::i18n::tr(&app.ui.language, "Numbering & Section Options…").trim_end_matches('…'), + app.session.page_label(abs) + ); + marker.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label)); + // A double click, or the keyboard or a screen reader activating it (a + // single pointer click only selects, as on a page). + let activated = marker.double_clicked() || marker.clicked() && !marker.clicked_by(egui::PointerButton::Primary); + if activated { + if editing_parents { + let _ = app.run("layout.parents.edit", json!({"on": false})); + if let Some(view) = app.view_mut() { + view.fitted = false; + } + } + crate::canvas::go_to_page(app, abs); + crate::menus::activate(app, "layout.section", &serde_json::Value::Null); + } + } // Parent prefix in the outer top corner (black on white). if let Some(pid) = p.parent && let Some(pp) = doc.parents.iter().find(|x| x.id == pid) @@ -501,33 +542,29 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { crate::canvas::go_to_page(app, abs); } resp.context_menu(|ui| page_menu(app, ui, &doc, abs, si)); - // Page number under the page: white on a blue badge when selected. Pages too - // narrow for their numbers share one label under the spread (below). - if narrow { - continue; - } - let name = app.session.page_label(abs); - let g = ui.painter().layout_no_wrap(name, crate::theme::semibold(11.0), Color32::WHITE); - let c = egui::pos2(pr.center().x, pr.max.y + 11.0); - if selected { - let br = egui::Rect::from_center_size(c, vec2(g.size().x.max(7.0) + 7.0, 12.5)); - ui.painter().rect_filled(br, 0.0, BADGE); - } - ui.painter().galley(c - g.size() / 2.0, g, Color32::WHITE); } - if narrow { - let names: Vec<String> = (first..first + sp.pages.len()).map(|abs| app.session.page_label(abs)).collect(); - let label = match (names.first(), names.last()) { - (Some(a), Some(b)) if names.len() > 1 => format!("{a}\u{2013}{b}"), - (Some(a), _) => a.clone(), - _ => String::new(), - }; - let g = ui.painter().layout_no_wrap(label, crate::theme::semibold(11.0), Color32::WHITE); - let c = egui::pos2(row.min.x + center, top + row_h + 11.0); - if cur_spread == Some(si) && !editing_parents { - ui.painter().rect_filled(egui::Rect::from_center_size(c, vec2(g.size().x + 7.0, 12.5)), 0.0, BADGE); + let label = spread_label(app, &doc, first, sp.pages.len()); + // White on the badge; the theme's text colour elsewhere, so it reads on a light panel. + let badge = cur_spread == Some(si) && !editing_parents; + let ink = if badge { Color32::WHITE } else { t.text_strong }; + let g = ui.painter().layout_no_wrap(label.clone(), crate::theme::semibold(11.0), ink); + let c = egui::pos2(row.min.x + center, top + row_h + 11.0); + let br = egui::Rect::from_center_size(c, vec2(g.size().x + 7.0, 12.5)); + if badge { + ui.painter().rect_filled(br, 0.0, BADGE); + } + ui.painter().galley(c - g.size() / 2.0, g, ink); + let lr = ui.interact(br, ui.scope_id().with(("spread_label", si)), Sense::click()); + // A click goes to the spread, so assistive technology hears a button. + lr.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label)); + if lr.clicked() { + if editing_parents { + let _ = app.run("layout.parents.edit", json!({"on": false})); + if let Some(view) = app.view_mut() { + view.fitted = false; + } } - ui.painter().galley(c - g.size() / 2.0, g, Color32::WHITE); + crate::canvas::go_to_page(app, first); } } // Right-click on the empty part of the panel: the short menu. @@ -646,7 +683,7 @@ fn empty_menu(app: &mut DesignApp, ui: &mut egui::Ui) { let cur = crate::canvas::current_page(app).unwrap_or(0); let item = |ui: &mut egui::Ui, label: &str| ui.button(crate::rtl::widget(ui, crate::i18n::tr(&lang, label))).clicked(); if item(ui, "Insert Pages…") { - app.ui.dialog = Some(crate::dialogs::Dialog::new("cmd:layout.pages.insert", json!({"after": cur, "count": 1}))); + crate::dialogs::open_insert_pages(app, cur); ui.close(); } if item(ui, "Move Pages…") { @@ -680,7 +717,7 @@ fn page_menu(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Docu let item = |ui: &mut egui::Ui, label: &str| ui.button(crate::rtl::widget(ui, tr(label))).clicked(); // InDesign's order: pages, parents, layouts and numbering, shuffling. if item(ui, "Insert Pages…") { - app.ui.dialog = Some(crate::dialogs::Dialog::new("cmd:layout.pages.insert", json!({"after": abs, "count": 1}))); + crate::dialogs::open_insert_pages(app, abs); ui.close(); } if item(ui, "Move Pages…") { @@ -780,3 +817,140 @@ pub(crate) fn drag_ghost(ui: &egui::Ui, label: &str, t: &Tokens) { #[cfg(test)] mod tests; + +#[cfg(test)] +mod spread_label_tests { + use egui_kittest::{Harness, kittest::Queryable}; + use serde_json::json; + + use super::*; + + /// Section-marker activation targets its page and leaves parent editing without editing the document. + #[test] + fn double_clicking_a_section_marker_opens_that_pages_numbering_options() { + let mut app = app(); + app.run("layout.section", json!({"page": 1, "startNumber": 1, "style": "lowerRoman"})).unwrap(); + app.run("layout.section", json!({"page": 3, "startNumber": 1, "style": "arabic"})).unwrap(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1000.0, 800.0))); + app.run("layout.parents.edit", json!({"on": true})).unwrap(); + let mut h = Harness::builder().with_size(vec2(280.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::unique("section_marker_test_fonts"); + if ui.data(|d| d.get_temp::<bool>(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + let pos = h.get_by_label("Numbering & Section Options: 1").rect().center(); + let before = h.state().session.doc().unwrap().doc.clone(); + for _ in 0..2 { + for pressed in [true, false] { + h.event(egui::Event::PointerMoved(pos)); + h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + } + } + let dialog = h.state().ui.dialog.as_ref().unwrap(); + assert_eq!(dialog.id, "numberingSection"); + assert_eq!(dialog.fields.get("page"), Some(&json!(3))); + assert!(!h.state().session.doc().unwrap().editing_parents); + assert!(std::sync::Arc::ptr_eq(&h.state().session.doc().unwrap().doc, &before), "opening options does not edit the section"); + } + + #[test] + fn a_section_marker_opens_its_options_from_the_keyboard_but_not_on_one_click() { + let mut app = app(); + app.run("layout.section", json!({"page": 3, "startNumber": 5})).unwrap(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1000.0, 800.0))); + let mut h = Harness::builder().with_size(vec2(280.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::unique("section_marker_key_test_fonts"); + if ui.data(|d| d.get_temp::<bool>(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + h.get_by_label("Numbering & Section Options: 5").click(); + h.run_steps(2); + assert!(h.state().ui.dialog.is_none(), "one pointer click doesn't open the options"); + h.run_steps(30); + h.get_by_label("Numbering & Section Options: 5").click_accesskit(); + h.run_steps(2); + assert_eq!(h.state().ui.dialog.as_ref().map(|d| d.id.as_str()), Some("numberingSection")); + } + + /// Create a facing-page document for section navigation tests. + fn app() -> DesignApp { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"facingPages": true, "pages": 9})).unwrap(); + app + } + + /// Read the label displayed for each document spread. + fn labels(app: &DesignApp) -> Vec<String> { + let doc = app.session.doc().unwrap().doc.clone(); + (0..doc.spreads.len()).map(|si| spread_label(app, &doc, doc.first_page_of_spread(si), doc.spreads[si].pages.len())).collect() + } + + /// Section boundaries split a spread label instead of implying continuous numbering. + #[test] + fn spreads_are_labelled_as_indesign_labels_them() { + // Chris Nielsen's "Numbering Page Sections": sections starting at 1 on pages 3 and 7, + // the second in letters, label their spreads "2, 1" and "4, A". + let mut app = app(); + assert_eq!(labels(&app), ["1", "2–3", "4–5", "6–7", "8–9"]); + app.run("layout.section", json!({"page": 3, "startNumber": 1})).unwrap(); + app.run("layout.section", json!({"page": 7, "startNumber": 1, "style": "upperLetters"})).unwrap(); + assert_eq!(labels(&app), ["1", "2, 1", "2–3", "4, A", "B–C"]); + // Absolute numbering: positions, always a range. + app.session.prefs.absolute_page_numbers = true; + assert_eq!(labels(&app), ["1", "2–3", "4–5", "6–7", "8–9"]); + } + + /// Labels navigate to document spreads, including when parent editing was active. + #[test] + fn the_panel_shows_spread_labels_and_a_click_on_one_goes_to_its_spread() { + let mut app = app(); + app.run("layout.section", json!({"page": 3, "startNumber": 1})).unwrap(); + // The canvas the panel's clicks move (not drawn here). + app.canvas_rect = Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), vec2(1000.0, 800.0))); + let mut h = Harness::builder().with_size(vec2(260.0, 900.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::unique("test_fonts"); + if ui.data(|d| d.get_temp::<bool>(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + egui::ScrollArea::vertical().show(ui, |ui| show(app, ui)); + }, + app, + ); + h.run_steps(3); + // A spread label is a button: a click goes to the spread. + let _ = h.get_by(|n| n.role() == egui::accesskit::Role::Button && n.label().as_deref() == Some("2, 1")); + assert!(h.query_by_label("2 1").is_none()); + // Each page is named for screen readers: two pages are numbered 1. + assert_eq!(h.query_all_by_label("Page 1").count(), 2); + h.state_mut().run("layout.parents.edit", json!({"on": true})).unwrap(); + if let Some(view) = h.state_mut().view_mut() { + view.fitted = true; + } + h.run_steps(3); + h.get_by_label("2–3").click(); + h.run_steps(3); + assert!(!h.state().session.doc().unwrap().editing_parents); + assert!(!h.state().view().unwrap().fitted); + assert_eq!(h.state().session.doc().unwrap().doc.page_loc(crate::canvas::current_page(h.state()).unwrap()).unwrap().0, 2); + } +} diff --git a/crates/ui-egui/src/panels/pages/tests.rs b/crates/ui-egui/src/panels/pages/tests.rs index e70c16f9b..b0dcafb06 100644 --- a/crates/ui-egui/src/panels/pages/tests.rs +++ b/crates/ui-egui/src/panels/pages/tests.rs @@ -343,7 +343,7 @@ fn the_panel_menu_follows_indesigns_order() { assert_eq!(h.state().session.doc().unwrap().doc.page_count(), 3); h.get_by_label("Insert Pages…").click(); h.run_steps(3); - assert!(h.state().ui.dialog.as_ref().is_some_and(|d| d.id == "cmd:layout.pages.insert")); + assert!(h.state().ui.dialog.as_ref().is_some_and(|d| d.id == "insertPages")); } #[test] diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index a05eaccc5..9b949f547 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -30,10 +30,12 @@ pub use flex::flex_panel; use flex::flex_section; pub(crate) use links::run_update_links; pub use links::{info_panel, links_panel, preflight_panel, update_links}; +#[cfg(test)] +use object::align_to_icon; use object::{align_row, appearance_section, guide_section, quick_actions_object, transform_section, wrap_section}; -pub(crate) use object::{corner_type_dropdown, ref_point_proxy, stroke_type_dropdown}; -use object_panels::pathfinder_row; +pub(crate) use object::{corner_shape_button, corner_type_dropdown, paint_corner_shape, preview_row, ref_point_proxy, stroke_type_dropdown}; pub use object_panels::{align_panel, attributes_panel, effects_panel, pathfinder_panel, stroke_panel, wrap_panel}; +use object_panels::{anchor_point_sections, pathfinder_row}; pub(crate) use text::{LANGUAGES, kerning_control, language_menu}; use text::{ bullets_section, character_section, paragraph_section, quick_actions_text, text_appearance_section, text_frame_section, text_style_section, @@ -86,6 +88,9 @@ fn selection_title(app: &DesignApp, info: &Option<SelInfo>) -> String { if st.selection.text.is_some() { return crate::i18n::tr(&app.ui.language, "Characters").into(); } + if !st.selection.anchors.is_empty() { + return crate::i18n::tr(&app.ui.language, "Anchor Point(s)").into(); + } if info.is_none() && !st.selection.guides.is_empty() { return crate::i18n::tr(&app.ui.language, "Ruler Guide").into(); } @@ -142,17 +147,20 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::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)); - if let Some(page) = page { - page_sections(app, ui, page); - return; + if info.is_none() && (app.session.tool_id() == "page" || app.session.active().is_some_and(|st| st.editing_parents)) { + if let Some((spread, page)) = crate::canvas::page_target(app) { + page_sections(app, ui, spread, page); } + return; } let Some(i) = info else { document_sections(app, ui); return; }; + if app.session.active().is_some_and(|st| !st.selection.anchors.is_empty()) { + anchor_point_sections(app, ui, &i); + return; + } section(ui, crate::i18n::tr(&app.ui.language, "Transform"), true); transform_section(app, ui, &i); divider(ui); @@ -202,7 +210,21 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let _ = app.run("object.fit", json!({"mode": mode})); } } + // Frame Fitting Options…, as InDesign's Options button beside the fitting buttons. + if outline_button(ui, crate::i18n::tr(&app.ui.language, "Options"), 64.0).clicked() { + crate::menus::activate(app, "app.fittingOptions", &Value::Null); + } }); + // Auto-Fit: the graphic is refitted whenever its frame is resized. + let mut auto = app + .session + .active() + .and_then(|st| st.selection.items.first().and_then(|id| st.doc.item(*id))) + .and_then(|it| it.graphic()) + .is_some_and(|g| g.auto_fit != designcraft_doc::Fitting::None); + if widgets::checkbox(ui, &mut auto, crate::i18n::tr(&app.ui.language, "Auto-Fit")).changed() { + let _ = app.run("object.fittingOptions", json!({"autoFit": auto})); + } divider(ui); } section(ui, crate::i18n::tr(&app.ui.language, "Text Wrap"), true); diff --git a/crates/ui-egui/src/panels/properties/document.rs b/crates/ui-egui/src/panels/properties/document.rs index 0a7fdbc5a..450123568 100644 --- a/crates/ui-egui/src/panels/properties/document.rs +++ b/crates/ui-egui/src/panels/properties/document.rs @@ -59,15 +59,14 @@ fn page_preset_row(ui: &mut Ui, lang: &str, w: f64, h: f64) -> Option<(f64, f64) /// Properties with the Page tool (Properties › Edit Page), as InDesign's: a "Page" band with Back, /// Page Size for this page alone (which page, preset, orientation, W/H), Margins and Columns and -/// Quick Actions. Back returns to the Selection tool and the document's properties. -pub(super) fn page_sections(app: &mut DesignApp, ui: &mut Ui, page: usize) { +/// Quick Actions. Back leaves parent editing and returns to the Selection tool and document properties. +pub(super) fn page_sections(app: &mut DesignApp, ui: &mut Ui, spread: designcraft_doc::SpreadRef, page: usize) { let u = units(app); let lang = app.ui.language.clone(); let Some(st) = app.session.active() else { return }; let d = st.doc.clone(); - let Some((si, pi)) = d.page_loc(page) else { return }; - let Some(pg) = d.spreads.get(si).and_then(|s| s.pages.get(pi)).cloned() else { return }; - let n = page + 1; + let Some(sp) = d.spread(spread) else { return }; + let Some(pg) = sp.pages.get(page).cloned() else { return }; let t = Tokens::get(ui.ctx()); // The band: "Page", and Back at its end. let band = block(ui, 33.0); @@ -84,6 +83,9 @@ pub(super) fn page_sections(app: &mut DesignApp, ui: &mut Ui, page: usize) { }); rule(ui); if back { + if matches!(spread, designcraft_doc::SpreadRef::Parent(_)) { + let _ = app.run("layout.parents.edit", json!({"on": false})); + } let _ = app.run("layout.selectPage", json!({"page": null})); app.select_tool("selection"); return; @@ -91,13 +93,25 @@ pub(super) fn page_sections(app: &mut DesignApp, ui: &mut Ui, page: usize) { section(ui, crate::i18n::tr(&lang, "Page Size"), true); // Which page. let b = block(ui, FIELD_H); - let names: Vec<String> = (0..d.page_count()).map(|i| app.session.page_label(i)).collect(); + let (names, selected): (Vec<String>, usize) = match spread { + designcraft_doc::SpreadRef::Doc(si) => ((0..d.page_count()).map(|i| app.session.page_label(i)).collect(), d.first_page_of_spread(si) + page), + designcraft_doc::SpreadRef::Parent(_) => { + ((0..sp.pages.len()).map(|i| format!("{} · {}", sp.parent.as_ref().map(|p| p.label()).unwrap_or_default(), i + 1)).collect(), page) + } + }; let picked = place(ui, sub(b, 0.0, 0.0, 93.0, FIELD_H), |ui| { - widgets::dropdown_list(ui, names.get(page).map_or("", String::as_str), 93.0, &names, Some(page)) + widgets::dropdown_list(ui, names.get(selected).map_or("", String::as_str), 93.0, &names, Some(selected)) }); if let Some(p) = picked { - let _ = app.run("layout.selectPage", json!({"page": p + 1})); - crate::canvas::go_to_page(app, p); + match spread { + designcraft_doc::SpreadRef::Doc(_) => { + let _ = app.run("layout.selectPage", json!({"page": p + 1})); + crate::canvas::go_to_page(app, p); + } + designcraft_doc::SpreadRef::Parent(_) => { + let _ = app.run("layout.selectParentPage", json!({"parent": sp.id.0, "page": p})); + } + } return; } ui.add_space(3.0); @@ -116,13 +130,22 @@ pub(super) fn page_sections(app: &mut DesignApp, ui: &mut Ui, page: usize) { size = Some(if key == "width" { (nv, h) } else { (w, nv) }); } } - if let Some((nw, nh)) = size { - let _ = app.run("layout.pageSize", json!({"pages": [n], "width": nw, "height": nh, "anchor": app.ui.ref_point.min(8)})); + if let Some((nw, nh)) = size + && let Some((cmd, params)) = + designcraft_tools::layout::page_size_command(&d, spread, page, json!({"width": nw, "height": nh, "anchor": app.ui.ref_point.min(8)})) + { + let _ = app.run(cmd, params); + } + if matches!(spread, designcraft_doc::SpreadRef::Parent(_)) { + margin_section(app, ui); } ui.add_space(6.0); let fw = full_width(ui); if outline_button(ui, crate::i18n::tr(&lang, "Margins and Columns"), fw).clicked() { - crate::canvas::go_to_page(app, page); + match spread { + designcraft_doc::SpreadRef::Doc(si) => crate::canvas::go_to_page(app, d.first_page_of_spread(si) + page), + designcraft_doc::SpreadRef::Parent(_) => crate::canvas::show_spread(app, spread), + } crate::menus::activate(app, "layout.marginsAndColumns", &Value::Null); } divider(ui); @@ -172,6 +195,70 @@ fn page_preset_menu(ui: &mut Ui, pw: f64, ph: f64) -> Option<(f64, f64)> { preset } +/// As the status bar's page box: the page, or the parent shown ("A-Parent"); then the parents. +fn page_box_field(app: &mut DesignApp, ui: &mut Ui, b: Rect) { + let Some(pb) = crate::canvas::page_box(app) else { return }; + let picked = place(ui, sub(b, 0.0, 0.0, 93.0, FIELD_H), |ui| widgets::dropdown_list(ui, pb.current_name(), 93.0, &pb.names, Some(pb.current))); + if let Some(i) = picked { + crate::canvas::page_box_go(app, &pb, i); + } +} + +fn margin_section(app: &mut DesignApp, ui: &mut Ui) { + let u = units(app); + let Some(st) = app.session.active() else { return }; + let d = st.doc.clone(); + let t = Tokens::get(ui.ctx()); + section(ui, crate::i18n::tr(&app.ui.language, "Margins"), true); + let cur = crate::canvas::current_page(app).unwrap_or(0); + // While parent spreads are edited the fields belong to the parent page in view; they used to + // show (and change) the document pages instead, so nothing changed on the parent. + let parent_target = crate::canvas::current_parent_page(app); + let shown = match parent_target { + Some((pi, qi)) => d.parents.get(pi).and_then(|s| s.pages.get(qi)).cloned(), + None => d.page(cur).cloned(), + }; + if let Some(p) = shown { + let linked = temp_bool(ui, "margins_linked", true); + let b = block(ui, 2.0 * FIELD_H + 5.0); + let mut set: Option<designcraft_doc::Margins> = None; + let fields = [ + ("margin-top", "Top", p.margins.top, 0.0, 0.0), + ("margin-bottom", "Bottom", p.margins.bottom, 0.0, 26.0), + ("margin-outside", if d.settings.facing_pages { "Outside" } else { "Right" }, p.margins.outside, 127.0, 0.0), + ("margin-inside", if d.settings.facing_pages { "Inside" } else { "Left" }, p.margins.inside, 127.0, 26.0), + ]; + for (k, (icon, tip, v, x, y)) in fields.into_iter().enumerate() { + let ir = sub(b, x, y + 2.0, 17.0, 17.0); + icons::paint(ui.painter(), ir, icon, t.icon); + ui.interact(ir, ui.scope_id().with(("micon", k)), Sense::hover()).on_hover_ui(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, tip)); + }); + let nv = place(ui, sub(b, x + 20.0, y, 73.0, FIELD_H), |ui| { + NumField::measure(tip, Some(v), u).width(73.0).spinner().range(0.0, 8000.0).show(ui) + }); + if let Some(nv) = nv { + set = Some(edited_margins(p.margins, linked, k, nv)); + } + } + let lr = sub(b, 99.0, 12.5, 22.0, FIELD_H); + let clicked = place(ui, lr, |ui| { + widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, "Make all settings the same", vec2(22.0, 21.0)) + .clicked() + }); + if clicked { + set_temp_bool(ui, "margins_linked", !linked); + } + if let Some(m) = set { + let params = match parent_target { + Some((pi, qi)) => json!({"margins": m, "parent": pi, "pages": [qi]}), + None => json!({"margins": m}), + }; + let _ = app.run("layout.marginsAndColumns", params); + } + } +} + pub(super) fn document_sections(app: &mut DesignApp, ui: &mut Ui) { let u = units(app); let Some(st) = app.session.active() else { return }; @@ -225,59 +312,8 @@ pub(super) fn document_sections(app: &mut DesignApp, ui: &mut Ui) { if changed { let _ = app.run("layout.documentSetup", json!({"facingPages": facing})); } - // Margins. - section(ui, crate::i18n::tr(&app.ui.language, "Margins"), true); + margin_section(app, ui); let cur = crate::canvas::current_page(app).unwrap_or(0); - // While parent spreads are edited the fields belong to the parent page in view; they used to - // show (and change) the document pages instead, so nothing changed on the parent. - let parent_target = crate::canvas::current_parent_page(app); - let shown = match parent_target { - Some((pi, qi)) => d.parents.get(pi).and_then(|s| s.pages.get(qi)).cloned(), - None => d.page(cur).cloned(), - }; - if let Some(p) = shown { - let linked = temp_bool(ui, "margins_linked", true); - let b = block(ui, 2.0 * FIELD_H + 5.0); - let mut set: Option<designcraft_doc::Margins> = None; - let fields = [ - ("margin-top", "Top", p.margins.top, 0.0, 0.0), - ("margin-bottom", "Bottom", p.margins.bottom, 0.0, 26.0), - ("margin-outside", if d.settings.facing_pages { "Outside" } else { "Right" }, p.margins.outside, 127.0, 0.0), - ("margin-inside", if d.settings.facing_pages { "Inside" } else { "Left" }, p.margins.inside, 127.0, 26.0), - ]; - for (k, (icon, tip, v, x, y)) in fields.into_iter().enumerate() { - let ir = sub(b, x, y + 2.0, 17.0, 17.0); - icons::paint(ui.painter(), ir, icon, t.icon); - ui.interact(ir, ui.scope_id().with(("micon", k)), Sense::hover()).on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, tip)); - }); - let nv = place(ui, sub(b, x + 20.0, y, 73.0, FIELD_H), |ui| { - NumField::measure(tip, Some(v), u).width(73.0).spinner().range(0.0, 8000.0).show(ui) - }); - if let Some(nv) = nv { - set = Some(edited_margins(p.margins, linked, k, nv)); - } - } - let lr = sub(b, 99.0, 12.5, 22.0, FIELD_H); - let clicked = place(ui, lr, |ui| { - widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, "Make all settings the same", vec2(22.0, 21.0)) - .clicked() - }); - if clicked { - set_temp_bool(ui, "margins_linked", !linked); - } - if let Some(m) = set { - let params = match parent_target { - // Both pages of a facing parent spread, so the margins show wherever the parent is used. - Some((pi, _)) => { - let n = app.session.active().and_then(|st| st.doc.parents.get(pi)).map_or(1, |sp| sp.pages.len()); - json!({"margins": m, "parent": pi, "pages": (0..n).collect::<Vec<_>>()}) - } - None => json!({"margins": m}), - }; - let _ = app.run("layout.marginsAndColumns", params); - } - } ui.add_space(6.0); let fw = full_width(ui); if outline_button(ui, crate::i18n::tr(&app.ui.language, "Adjust Layout"), fw).clicked() { @@ -287,13 +323,7 @@ pub(super) fn document_sections(app: &mut DesignApp, ui: &mut Ui) { // Page. section(ui, crate::i18n::tr(&app.ui.language, "Page"), true); let b = block(ui, FIELD_H); - let names: Vec<String> = (0..d.page_count()).map(|i| app.session.page_label(i)).collect(); - let picked = place(ui, sub(b, 0.0, 0.0, 93.0, FIELD_H), |ui| { - widgets::dropdown_list(ui, &names[cur.min(names.len().saturating_sub(1))], 93.0, &names, Some(cur)) - }); - if let Some(pg) = picked { - crate::canvas::go_to_page(app, pg); - } + page_box_field(app, ui, b); let (add, del) = place(ui, sub(b, 172.0, 0.0, 50.0, FIELD_H), |ui| { ui.spacing_mut().item_spacing.x = 4.0; let a = widgets::icon_toggle_sized(ui, "style-new", false, "Insert Page", vec2(22.0, 21.0)).clicked(); diff --git a/crates/ui-egui/src/panels/properties/flex.rs b/crates/ui-egui/src/panels/properties/flex.rs index 591ce4cfd..35429cc45 100644 --- a/crates/ui-egui/src/panels/properties/flex.rs +++ b/crates/ui-egui/src/panels/properties/flex.rs @@ -171,7 +171,7 @@ pub(super) fn flex_section(app: &mut DesignApp, ui: &mut Ui) { let cur = serde_json::to_value(cur).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); let icon = FLEX_JUSTIFY.iter().find(|j| j.0 == cur).map(|j| j.1).unwrap_or("flex-start"); let resp = place(ui, sub(b, 0.0, 0.0, 58.0, FIELD_H), |ui| { - preview_dropdown(ui, 58.0, |p, r| icons::paint(p, r.shrink2(vec2(6.0, 2.0)), icon, egui::Color32::from_gray(60))) + preview_dropdown(ui, 58.0, &tr("Justification"), |p, r| icons::paint(p, r.shrink2(vec2(6.0, 2.0)), icon, egui::Color32::from_gray(60))) }); egui::Popup::menu(&resp).show(|ui| { ui.set_min_width(150.0); diff --git a/crates/ui-egui/src/panels/properties/object.rs b/crates/ui-egui/src/panels/properties/object.rs index 3819894fe..a68974f81 100644 --- a/crates/ui-egui/src/panels/properties/object.rs +++ b/crates/ui-egui/src/panels/properties/object.rs @@ -91,6 +91,75 @@ pub(super) fn transform_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { } } +/// Properties › Transform for points selected with Direct Selection: X/Y/W/H of the points' +/// bounds (from the reference point); X and Y move the points, W and H spread or gather them +/// about the reference point. No angles: those turn objects. +pub(super) fn anchor_transform_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { + let u = units(app); + let rp = app.ui.ref_point; + let b = block(ui, 2.0 * FIELD_H + 4.0); + let r = i.page_rect; + let (fx, fy) = ((rp % 3) as f64 / 2.0, (rp / 3) as f64 / 2.0); + let (x, y) = (r.x0 + r.width() * fx, r.y0 + r.height() * fy); + if let Some(p) = ref_point_proxy(ui, sub(b, 5.0, 10.0, 26.0, 26.0), rp) { + app.ui.ref_point = p; + } + let mut set = serde_json::Map::new(); + // A single point has no size: as InDesign, W and H show its object's, greyed out. + let single = crate::panels::selected_anchor_points(app).len() == 1; + let (w, h) = if single { i.object_size } else { (r.width(), r.height()) }; + for (row, (lx, vx, kx, lw, vw, kw)) in [("X:", x, "x", "W:", w, "width"), ("Y:", y, "y", "H:", h, "height")].into_iter().enumerate() { + let yy = row as f32 * 25.0; + place(ui, sub(b, 40.0, yy, 18.0, FIELD_H), |ui| widgets::caption_w(ui, lx, 18.0)); + if let Some(v) = place(ui, sub(b, 59.0, yy, 54.0, FIELD_H), |ui| measure(ui, &format!("pt_{kx}"), Some(vx), u, 54.0)) { + set.insert(kx.into(), json!(v)); + } + place(ui, sub(b, 120.0, yy, 19.0, FIELD_H), |ui| widgets::caption_w(ui, lw, 19.0)); + let id = format!("pt_{kw}"); + let field = NumField::measure(&id, Some(vw), u).width(54.0).enabled(!single); + if let Some(v) = place(ui, sub(b, 140.0, yy, 54.0, FIELD_H), |ui| field.show(ui)) { + set.insert(kw.into(), json!(v)); + } + } + if !set.is_empty() { + let _ = transform_anchors(app, &Value::Object(set)); + } + if more_options(ui, &app.ui.language).clicked() { + show_panel(app, "info"); + } +} + +/// Move the selected points as Properties › Transform's `set` asks ({x?, y?, width?, height?}, +/// in the page coordinates the fields show, from the reference point): one `path.moveAnchors` +/// step, each point by its own movement. A width or height of a single point (0) can't scale. +pub(crate) fn transform_anchors(app: &mut DesignApp, set: &Value) -> Result<Value, String> { + let points = crate::panels::selected_anchor_points(app); + let Some(i) = crate::panels::sel_info(app) else { return Ok(Value::Null) }; + if points.is_empty() { + return Ok(Value::Null); + } + let rp = app.ui.ref_point; + let r = i.page_rect; + let (fx, fy) = ((rp % 3) as f64 / 2.0, (rp / 3) as f64 / 2.0); + // Page coordinates and spread coordinates differ by the ruler origin only. + let b = points.iter().skip(1).fold(designcraft_geom::Rect::from_points(points[0].1, points[0].1), |b, (_, p)| b.union_pt(*p)); + let origin = (b.x0 - r.x0, b.y0 - r.y0); + let reference = (r.x0 + r.width() * fx + origin.0, r.y0 + r.height() * fy + origin.1); + let num = |k: &str| set.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()); + let scale = |new: Option<f64>, old: f64| new.filter(|_| old > 1e-9).map_or(1.0, |v| v.max(0.0) / old); + let (kx, ky) = (scale(num("width"), r.width()), scale(num("height"), r.height())); + let shift = (num("x").map_or(0.0, |v| v - (r.x0 + r.width() * fx)), num("y").map_or(0.0, |v| v - (r.y0 + r.height() * fy))); + let moved: Vec<Value> = points + .iter() + .map(|&((id, s, a), p)| { + let nx = reference.0 + (p.x - reference.0) * kx + shift.0; + let ny = reference.1 + (p.y - reference.1) * ky + shift.1; + json!([id.0, s, a, nx - p.x, ny - p.y]) + }) + .collect(); + app.run("path.moveAnchors", json!({"points": moved})) +} + /// Properties › Transform for selected ruler guides: X for one vertical guide, Y for one /// horizontal guide; with several guides both fields are empty. pub(super) fn guide_section(app: &mut DesignApp, ui: &mut Ui) { @@ -173,11 +242,11 @@ pub(crate) fn stroke_type_dropdown(app: &mut DesignApp, ui: &mut Ui, cur: &str, ("Thin - Thin", "thinThin"), ("Thick - Thick", "thickThick"), ]; - let resp = preview_dropdown(ui, w, |p, r| paint_stroke_type(p, r, cur)); + let resp = preview_dropdown(ui, w, crate::i18n::tr(&app.ui.language, "Stroke Type"), |p, r| paint_stroke_type(p, r, cur, egui::Color32::BLACK)); egui::Popup::menu(&resp).show(|ui| { - ui.set_min_width(140.0); + ui.set_min_width(170.0); for (label, kind) in TYPES { - if ui.selectable_label(*kind == cur, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { + if preview_row(ui, *kind == cur, crate::i18n::tr(&app.ui.language, label), 40.0, |p, r, c| paint_stroke_type(p, r, kind, c)).clicked() { let ty = if *kind == "dashed" { json!({"kind": "dashed", "pattern": [12.0, 4.0]}) } else { json!({"kind": kind}) }; let _ = app.run("object.stroke", json!({"type": ty})); ui.close(); @@ -186,8 +255,7 @@ pub(crate) fn stroke_type_dropdown(app: &mut DesignApp, ui: &mut Ui, cur: &str, }); } -fn paint_stroke_type(p: &egui::Painter, r: Rect, kind: &str) { - let c = egui::Color32::BLACK; +fn paint_stroke_type(p: &egui::Painter, r: Rect, kind: &str, c: egui::Color32) { let (x0, x1, y) = (r.min.x + 5.0, r.max.x - 5.0, r.center().y); match kind { "dashed" => { @@ -220,10 +288,44 @@ fn paint_stroke_type(p: &egui::Painter, r: Rect, kind: &str) { } } -/// A dropdown whose face is a light preview box (stroke type, corner shape). -pub(super) fn preview_dropdown(ui: &mut Ui, w: f32, paint: impl FnOnce(&egui::Painter, Rect)) -> egui::Response { +/// A row of a preview dropdown's list, as InDesign's: a small preview of the choice (`preview_w` +/// wide), then its name. Selected rows are highlighted. +pub(crate) fn preview_row( + ui: &mut Ui, + selected: bool, + label: &str, + preview_w: f32, + paint: impl FnOnce(&egui::Painter, Rect, egui::Color32), +) -> egui::Response { + let t = Tokens::get(ui.ctx()); + // As wide as the preview and its name, translated (at least 130 pt for the name), and as the + // popup once its widest row has set it: the rows' highlights then end together. + let font = egui::FontId::proportional(12.0); + let text_w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_string(), font, egui::Color32::WHITE).size().x); + let w = (preview_w + 12.0 + text_w + 12.0).max(preview_w + 130.0).max(ui.available_width().min(600.0)); + let (r, resp) = ui.allocate_exact_size(vec2(w, 22.0), Sense::click()); + if selected || resp.hovered() { + ui.painter().rect_filled(r, 2.0, if selected { t.row_selected } else { t.hover }); + } + let ink = if selected { t.text_strong } else { t.text }; + paint(ui.painter(), Rect::from_min_size(r.min + vec2(4.0, 0.0), vec2(preview_w, 22.0)), ink); + crate::rtl::paint( + ui.painter(), + pos2(r.min.x + preview_w + 12.0, r.center().y), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(12.0), + ink, + ); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, label)); + resp +} + +/// A dropdown whose face is a light preview box (stroke type, corner shape), named `label`. +pub(super) fn preview_dropdown(ui: &mut Ui, w: f32, label: &str, paint: impl FnOnce(&egui::Painter, Rect)) -> egui::Response { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(w, FIELD_H), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, label)); let face = Rect::from_min_max(r.min, pos2(r.max.x - widgets::SEG_W, r.max.y)); ui.painter().rect_filled(face, 0.0, egui::Color32::from_gray(0xdf)); paint(ui.painter(), face); @@ -234,38 +336,62 @@ pub(super) fn preview_dropdown(ui: &mut Ui, w: f32, paint: impl FnOnce(&egui::Pa resp } -fn paint_corner_shape(p: &egui::Painter, r: Rect, shape: CornerShape) { - let c = egui::Color32::BLACK; - let s = Stroke::new(1.5, c); - let (x0, y0, x1, y1) = (r.min.x + 4.0, r.min.y + 4.0, r.max.x - 9.0, r.max.y - 3.0); - let k = 8.0; - let pts: Vec<egui::Pos2> = match shape { - CornerShape::None => vec![pos2(x0, y0), pos2(x1, y0), pos2(x1, y1)], - CornerShape::Bevel => vec![pos2(x0, y0), pos2(x1 - k, y0), pos2(x1, y0 + k), pos2(x1, y1)], - CornerShape::Inset => vec![pos2(x0, y0), pos2(x1 - k, y0), pos2(x1 - k, y0 + k), pos2(x1, y0 + k), pos2(x1, y1)], - CornerShape::Rounded | CornerShape::InverseRounded | CornerShape::Fancy => { - let mut v = vec![pos2(x0, y0)]; - for i in 0..=8 { - let a = i as f32 / 8.0 * std::f32::consts::FRAC_PI_2; - v.push(if shape == CornerShape::InverseRounded { - pos2(x1 - k * a.cos(), y0 + k * a.sin()) - } else { - pos2(x1 - k + k * a.sin(), y0 + k - k * a.cos()) - }); - } - v.push(pos2(x1, y1)); - v - } +/// A corner shape's glyph, as InDesign draws it in its corner lists: the top edge running into the +/// corner and the right edge leaving it, in a square centred in `r`. +pub(crate) fn paint_corner_shape(p: &egui::Painter, r: Rect, shape: CornerShape, c: egui::Color32) { + let side = (r.width() - 3.0).min(r.height() - 3.0).max(4.0); + let o = pos2(r.center().x - side / 2.0, r.center().y - side / 2.0); + let at = |x: f32, y: f32| pos2(o.x + x * side, o.y + y * side); + let quarter = |cx: f32, cy: f32, rad: f32, from: f32, to: f32| -> Vec<egui::Pos2> { + (0..=10).map(|i| from + (to - from) * i as f32 / 10.0).map(|a| at(cx + rad * a.cos(), cy + rad * a.sin())).collect() }; - p.add(egui::Shape::line(pts, s)); + use std::f32::consts::{FRAC_PI_2, PI}; + let stroke = Stroke::new(1.6, c); + let line = |pts: Vec<egui::Pos2>| { + p.add(egui::Shape::line(pts, stroke)); + }; + match shape { + CornerShape::None => line(vec![at(0.0, 0.0), at(1.0, 0.0), at(1.0, 1.0)]), + // The corner cut by a diagonal, half the side. + CornerShape::Bevel => line(vec![at(0.0, 0.0), at(0.5, 0.0), at(1.0, 0.5), at(1.0, 1.0)]), + // A square notch. + CornerShape::Inset => line(vec![at(0.0, 0.0), at(0.5, 0.0), at(0.5, 0.45), at(1.0, 0.45), at(1.0, 1.0)]), + // A concave quarter circle centred on the corner. + CornerShape::InverseRounded => { + let mut v = vec![at(0.0, 0.0)]; + v.extend(quarter(1.0, 0.0, 0.5, PI, FRAC_PI_2)); + v.push(at(1.0, 1.0)); + line(v) + } + // A large convex arc. + CornerShape::Rounded => { + let mut v = vec![at(0.0, 0.0)]; + v.extend(quarter(0.08, 0.92, 0.92, -FRAC_PI_2, 0.0)); + v.push(at(1.0, 1.0)); + line(v) + } + // Steps into the corner with a small square loop between them. + CornerShape::Fancy => { + line(vec![at(0.0, 0.0), at(0.38, 0.0), at(0.38, 0.2), at(0.52, 0.2)]); + p.rect_stroke(Rect::from_min_max(at(0.52, 0.06), at(0.76, 0.3)), 0.0, stroke, StrokeKind::Middle); + line(vec![at(0.76, 0.3), at(0.76, 0.48), at(1.0, 0.48), at(1.0, 1.0)]); + } + } +} + +/// A corner shape picker's face: the shape's glyph and the dropdown arrow (the menu is the caller's). +pub(crate) fn corner_shape_button(ui: &mut Ui, shape: CornerShape, width: f32) -> egui::Response { + // The caller names the control (widget_info) once it knows the corner. + preview_dropdown(ui, width, "", |p, r| paint_corner_shape(p, r, shape, egui::Color32::BLACK)) } pub(crate) fn corner_type_dropdown(app: &mut DesignApp, ui: &mut Ui, shape: CornerShape, size: f64, width: f32) { - let resp = preview_dropdown(ui, width, |p, r| paint_corner_shape(p, r, shape)); + let resp = + preview_dropdown(ui, width, crate::i18n::tr(&app.ui.language, "Corner Shape"), |p, r| paint_corner_shape(p, r, shape, egui::Color32::BLACK)); egui::Popup::menu(&resp).show(|ui| { - ui.set_min_width(140.0); + ui.set_min_width(150.0); for s in CornerShape::ALL { - if ui.selectable_label(s == shape, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, s.label()))).clicked() { + if preview_row(ui, s == shape, crate::i18n::tr(&app.ui.language, s.label()), 22.0, |p, r, c| paint_corner_shape(p, r, s, c)).clicked() { let _ = app.run("object.cornerOptions", json!({"shape": s, "size": if size > 0.0 { size } else { 12.0 }})); ui.close(); } @@ -276,6 +402,10 @@ pub(crate) fn corner_type_dropdown(app: &mut DesignApp, ui: &mut Ui, shape: Corn pub(super) fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { let t = Tokens::get(ui.ctx()); let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return }; + // Every selected object is a text frame: the popups offer Apply To Frame or Text, for one + // frame or several, as InDesign's do. + let text_frames = + i.is_text && app.session.active().is_some_and(|st| st.selection.items.iter().all(|id| doc.item(*id).is_some_and(|it| it.is_text_frame()))); // Fill. let b = block(ui, FIELD_H); let (fc, fg) = widgets::swatch_colors(&doc, &i.fill, 1.0); @@ -288,7 +418,10 @@ pub(super) fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) i.fill )); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Fill swatch")); - swatch_menu(app, &resp, &doc, &i.fill, false, i.is_text && i.count == 1, false); + swatch_menu(app, &resp, &doc, &i.fill, false, text_frames, false); + if resp.double_clicked() { + open_color_picker(app, &doc, &i.fill, false, false); + } crate::rtl::paint( ui.painter(), b.min + vec2(34.0, FIELD_H / 2.0), @@ -310,7 +443,10 @@ pub(super) fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) i.stroke )); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Stroke swatch")); - swatch_menu(app, &resp, &doc, &i.stroke, true, i.is_text && i.count == 1, false); + swatch_menu(app, &resp, &doc, &i.stroke, true, text_frames, false); + if resp.double_clicked() { + open_color_picker(app, &doc, &i.stroke, true, false); + } if place(ui, sub(b, 34.0, 0.0, 56.0, FIELD_H), |ui| link_label(ui, crate::i18n::tr(&app.ui.language, "Stroke"), 56.0)).clicked() { show_panel(app, "stroke"); } @@ -327,7 +463,8 @@ pub(super) fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) let b = block(ui, FIELD_H); icons::paint(ui.painter(), sub(b, 3.0, 1.0, 19.0, 19.0), "corner", t.accent); if place(ui, sub(b, 34.0, 0.0, 56.0, FIELD_H), |ui| link_label(ui, crate::i18n::tr(&app.ui.language, "Corner"), 56.0)).clicked() { - app.ui.dialog = Some(crate::dialogs::Dialog::new("frameSize", json!({}))); + // Corner Options, as Object › Corner Options… opens it. + crate::menus::activate(app, "object.cornerOptions", &Value::Null); } let (shape, size) = i.corner; if let Some(v) = place(ui, sub(b, 96.0, 0.0, 69.0, FIELD_H), |ui| { @@ -355,9 +492,8 @@ pub(super) fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) let mresp = ui.interact(more, ui.scope_id().with("opmore"), Sense::click()); ui.painter().rect(more, 0.0, if mresp.hovered() { t.hover } else { t.input }, Stroke::new(1.0, t.field_border), StrokeKind::Inside); icons::paint(ui.painter(), more.shrink(4.0), "chevron-right", t.icon); - if mresp.clicked() { - show_panel(app, "effects"); - } + mresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, crate::i18n::tr(&app.ui.language, "Opacity"))); + opacity_slider(app, &mresp, i.opacity); ui.add_space(6.0); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 10.0; @@ -370,6 +506,54 @@ pub(super) fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) }); } +/// The Color Picker for a Fill or Stroke chip (a double-click on it, as in InDesign), with the +/// chip's colour: the objects', or with `text` the selected text's. +pub(super) fn open_color_picker(app: &mut DesignApp, doc: &designcraft_doc::Document, swatch: &str, stroke: bool, text: bool) { + let mut fields = crate::panels::swatches::picker_fields(doc.resolve_color(swatch, 1.0), stroke); + fields["text"] = json!(text); + app.ui.dialog = Some(crate::dialogs::Dialog::new("colorPicker", fields)); +} + +/// The Opacity slider that the arrow beside the field opens, as in InDesign: a drag shows the +/// new opacity live and is one undo step when let go. +fn opacity_slider(app: &mut DesignApp, resp: &egui::Response, opacity: f64) { + let (mut slid, mut held, mut shown) = (None, false, false); + egui::Popup::menu(resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + shown = true; + // A thin rail and a round knob, as InDesign draws it. + ui.spacing_mut().slider_width = 130.0; + ui.spacing_mut().slider_rail_height = 2.0; + let mut v = (opacity * 100.0).round(); + let slider = ui.add( + egui::Slider::new(&mut v, 0.0..=100.0).integer().show_value(false).handle_shape(egui::style::HandleShape::Circle).trailing_fill(false), + ); + // The slider has no visible caption (the field beside the arrow is the label), so it's + // named for assistive technology only. + let label = crate::i18n::tr(&app.ui.language, "Opacity"); + slider.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), v, label)); + // The release frame can move the value too; it stays part of the drag's preview, which + // the next frame commits as the one undo step. + let pressed = slider.is_pointer_button_down_on() || slider.dragged() || slider.drag_stopped(); + if slider.changed() { + slid = Some((v, pressed)); + } + held = pressed; + }); + match slid { + Some((v, true)) => app.live_preview("Opacity", "object.opacity", json!({"opacity": v / 100.0})), + Some((v, false)) => { + // The keyboard (arrow keys on the focused slider): applied at once. + app.end_live_preview(); + let _ = app.run("object.opacity", json!({"opacity": v / 100.0})); + } + None if held => app.hold_live_preview(), + // Shown but let go without a change (a click outside, Escape): the drag's preview is + // committed now, before whatever that click does, so that keeps its own undo step. + None if shown => app.end_live_preview(), + None => {} + } +} + /// The Fill (or Stroke) popup of Properties › Appearance, as InDesign's: Apply To (a text frame's /// frame or its text), the tint, then the swatches. `cur` is the frame's swatch. With `text_only` /// (text selected) it colours the selected text. @@ -446,6 +630,17 @@ pub(super) fn swatch_menu( } } +/// The Align To button's glyph shows what the objects align to, as InDesign's. +pub(super) fn align_to_icon(to: &str) -> &'static str { + match to { + "keyObject" => "align-to-key", + "margins" => "align-to-margins", + "page" => "align-to-page", + "spread" => "align-to-spread", + _ => "align-to", + } +} + pub(super) fn align_row(app: &mut DesignApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); ui.horizontal(|ui| { @@ -462,7 +657,7 @@ pub(super) fn align_row(app: &mut DesignApp, ui: &mut Ui) { other => other, }; let tip = format!("{}: {}", crate::i18n::tr(&app.ui.language, "Align To"), crate::i18n::tr(&app.ui.language, to_label)); - let resp = icon_toggle(ui, "align-to", false, &tip); + let resp = icon_toggle(ui, align_to_icon(&to), false, &tip); egui::Popup::menu(&resp).show(|ui| { for (label, id) in [ ("Align to Selection", "selection"), @@ -516,14 +711,75 @@ pub(super) fn wrap_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { let enabled = i.wrap != "none"; let r = ui.add_enabled_ui(enabled, |ui| widgets::checkbox(ui, &mut inv, crate::i18n::tr(&app.ui.language, "Invert"))).inner; if r.changed() { - let _ = app.run("object.textWrap", json!({"mode": i.wrap, "invert": inv})); + // Each object keeps its own mode. + let _ = app.run("object.textWrap", json!({"mode": null, "invert": inv})); } }); + wrap_offsets(app, ui, i); if more_options(ui, &app.ui.language).clicked() { show_panel(app, "textWrap"); } } +/// The wrap offsets under the wrap buttons, as InDesign's Properties panel shows them: Top and +/// Bottom, the link that makes all four the same, then Left and Right. Greyed with no wrap. +fn wrap_offsets(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { + let t = Tokens::get(ui.ctx()); + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); + let u = units(app); + let enabled = i.wrap != "none"; + let offsets = i.wrap_offsets; + ui.add_space(4.0); + let b = block(ui, 2.0 * FIELD_H + 5.0); + let mut set = None; + // The link's state belongs to the selection: another selection starts linked again when its + // four offsets are the same. + let selected: Vec<u64> = app.session.active().map(|st| st.selection.items.iter().map(|i| i.0).collect()).unwrap_or_default(); + let link_id = egui::Id::unique(("prop_wrap_linked", selected)); + ui.add_enabled_ui(enabled, |ui| { + // Linked while the four are the same, until the link is clicked. + let same = offsets.iter().all(|v| (v - offsets[0]).abs() < 1e-9); + let linked = ui.data(|d| d.get_temp::<bool>(link_id)).unwrap_or(same); + // Offsets are [top, left, bottom, right]. + for (k, key, icon, tip, x, y) in [ + (0, "wrapTop", "margin-top", "Top", 0.0, 0.0), + (2, "wrapBottom", "margin-bottom", "Bottom", 0.0, FIELD_H + 5.0), + (1, "wrapLeft", "margin-inside", "Left", 140.0, 0.0), + (3, "wrapRight", "margin-outside", "Right", 140.0, FIELD_H + 5.0), + ] { + let ir = sub(b, x, y + 2.0, 17.0, 17.0); + icons::paint(ui.painter(), ir, icon, if enabled { t.icon } else { t.text_dim }); + ui.interact(ir, ui.scope_id().with(("prop_wrap_off", k)), Sense::hover()).on_hover_ui(|ui| { + crate::rtl::label(ui, tr(tip)); + }); + let nv = place(ui, sub(b, x + 20.0, y, 76.0, FIELD_H), |ui| { + NumField::measure(key, Some(offsets[k]), u).width(76.0).spinner().range(-8000.0, 8000.0).enabled(enabled).show(ui) + }); + if let Some(v) = nv { + // Linked, all four; else this side alone, each object keeping its other offsets. + let mut o = [None; 4]; + if linked { + o = [Some(v); 4]; + } else { + o[k] = Some(v); + } + set = Some(o); + } + } + if place(ui, sub(b, 106.0, (FIELD_H + 5.0) / 2.0, 22.0, FIELD_H), |ui| { + widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, &tr("Make all settings the same"), vec2(22.0, 21.0)) + .clicked() + }) { + ui.data_mut(|d| d.insert_temp(link_id, !linked)); + } + }); + if let Some(o) = set { + // Each object keeps its own mode: only the offsets change. + let _ = app.run("object.textWrap", json!({"mode": null, "offset": o})); + } +} + fn arrange_menu(app: &mut DesignApp, resp: &egui::Response) { egui::Popup::menu(resp).show(|ui| { for (label, cmd) in [ diff --git a/crates/ui-egui/src/panels/properties/object_panels.rs b/crates/ui-egui/src/panels/properties/object_panels.rs index 9d02bec3f..8e6c210de 100644 --- a/crates/ui-egui/src/panels/properties/object_panels.rs +++ b/crates/ui-egui/src/panels/properties/object_panels.rs @@ -1,6 +1,7 @@ +use super::object::{anchor_transform_section, appearance_section}; use super::{ - Arrowhead, Cap, DesignApp, Join, Sense, Stroke, StrokeAlign, StrokeType, Tokens, Ui, Value, caption, divider, icon_toggle, icons, json, measure, - number, pos2, sel_info, text_attrs, units, vec2, widgets, + Arrowhead, Cap, DesignApp, Join, SelInfo, Sense, Stroke, StrokeAlign, StrokeType, Tokens, Ui, Value, caption, divider, full_width, icon_toggle, + icons, json, measure, number, outline_button, pos2, rule, section, section_row, sel_info, text_attrs, units, vec2, widgets, }; /// Stroke panel: weight, cap, miter limit, join, alignment, type, arrowheads, gap colour. @@ -364,6 +365,37 @@ const CONVERT_SHAPE: [(&str, &str, &str); 9] = [ ("shape-orthogonal", "orthogonalLine", "Orthogonal Line"), ]; +/// Points selected with Direct Selection, as InDesign's Properties shows them: Transform, +/// Appearance, Convert Points (the point type of the selected points) and Delete Point(s). +pub(super) fn anchor_point_sections(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { + section(ui, crate::i18n::tr(&app.ui.language, "Transform"), true); + anchor_transform_section(app, ui, i); + divider(ui); + section(ui, crate::i18n::tr(&app.ui.language, "Appearance"), true); + appearance_section(app, ui, i); + divider(ui); + let mut kind = None; + let lang = app.ui.language.clone(); + section_row(ui, crate::i18n::tr(&lang, "Convert Points"), |ui| { + ui.spacing_mut().item_spacing.x = 14.0; + // Laid out from the right: last first, so the row reads Plain … Symmetrical. + for (icon, k, tip) in CONVERT_POINT.iter().rev() { + if icon_toggle(ui, icon, false, crate::i18n::tr(&lang, tip)).clicked() { + kind = Some(k); + } + } + }); + if let Some(k) = kind { + let _ = app.run("path.convertPoints", json!({"kind": k})); + } + rule(ui); + section(ui, crate::i18n::tr(&app.ui.language, "Quick Actions"), true); + let fw = full_width(ui); + if outline_button(ui, crate::i18n::tr(&app.ui.language, "Delete Point(s)"), fw).clicked() { + let _ = app.run("path.deletePoints", json!({})); + } +} + /// Pathfinder panel › Convert Point: (icon, kind, tooltip). const CONVERT_POINT: [(&str, &str, &str); 4] = [ ("point-plain", "plain", "Plain"), diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 095bd5690..7b3f2edb3 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -3,6 +3,7 @@ use egui_kittest::{ kittest::{NodeT, Queryable}, }; +use super::object::transform_anchors; use super::*; /// A link to a panel whose flyout is open leaves the flyout open. @@ -638,6 +639,66 @@ fn clicking_a_swatch_chip_in_the_appearance_fill_menu_applies_it() { assert_eq!(after, name, "clicking the swatch chip applies it"); } +/// The course's chapter 23: a double-click on Properties › Appearance › Fill opens the Color +/// Picker with the fill's colour, as InDesign's (a click opens the swatches). For selected +/// text, the text's Fill chip opens it for the text. +#[test] +fn double_clicking_a_fill_chip_opens_the_color_picker() { + let double_click = |h: &mut Harness<'_, DesignApp>, label: &str| { + let at = h.get_by_label(label).rect().center(); + h.hover_at(at); + h.step(); + for pressed in [true, false, true, false] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + h.step(); + } + h.run_steps(3); + }; + // Frames 1 ms apart, so two clicks make a double-click. + let panel = |app: DesignApp| { + let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).with_step_dt(0.001).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::unique("test_fonts"); + if ui.data(|d| d.get_temp::<bool>(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + h + }; + let frame = || { + 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": [72, 72, 300, 200], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + app.run("object.fill", json!({"swatch": "C=0 M=0 Y=100 K=0"})).unwrap(); + app + }; + let mut h = panel(frame()); + double_click(&mut h, "Fill swatch"); + assert!(h.query_by_label("Tint:").is_none(), "the swatches popup isn't left open too"); + let d = h.state().ui.dialog.clone().expect("the Color Picker"); + assert_eq!((d.id.as_str(), d.s("target"), d.s("model"), d.b("text")), ("colorPicker", "fill".into(), "cmyk".into(), false)); + assert_eq!(d.fields["cmyk"], json!([0.0, 0.0, 100.0, 0.0])); + let mut h = panel(frame()); + double_click(&mut h, "Stroke swatch"); + assert_eq!(h.state().ui.dialog.as_ref().map(|d| d.s("target")), Some("stroke".into())); + // Selected text: its own Fill chip. + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Lemon"})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 5})).unwrap(); + let mut h = panel(app); + double_click(&mut h, "Text fill swatch"); + let d = h.state().ui.dialog.clone().expect("the Color Picker"); + assert_eq!((d.id.as_str(), d.s("target"), d.b("text")), ("colorPicker", "fill".into(), true)); +} + fn panel(app: DesignApp) -> Harness<'static, DesignApp> { let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state( |ui, app: &mut DesignApp| { @@ -655,6 +716,22 @@ fn panel(app: DesignApp) -> Harness<'static, DesignApp> { h } +/// The course's chapter 21: Properties › Bullets and Numbering › Options opens the Bullets +/// and Numbering dialog, as InDesign's, not the Paragraph panel. +#[test] +fn bullets_options_open_the_bullets_and_numbering_dialog() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "4 eggs"})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 6})).unwrap(); + let mut h = panel(app); + h.get_by_label("Options").click(); + h.run_steps(3); + let d = h.state().ui.dialog.clone().expect("a dialog"); + assert_eq!((d.id.as_str(), d.s("section").as_str()), ("paragraphLocal", "bullets")); + assert_ne!(h.state().ui.open_panel.as_deref(), Some("paragraph")); +} + /// The course's chapter 21: Properties' Bulleted List button gives the selected paragraphs /// bullets with InDesign's hanging indent; pressed again, both go. #[test] @@ -710,6 +787,205 @@ fn several_shapes_show_pathfinder_and_intersect_combines_them() { assert!((r.x0 - 200.0).abs() < 0.5 && (r.y1 - 200.0).abs() < 0.5 && (r.x1 - 300.0).abs() < 0.5, "{r:?}"); } +/// The arrow beside Opacity opens a slider, as in InDesign: a drag shows the opacity live and +/// is one undo step; the Opacity link still opens the Effects panel. +#[test] +fn the_opacity_arrow_opens_a_slider_and_a_drag_is_one_undo_step() { + 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": [72, 72, 300, 200], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let opacity = |app: &DesignApp| app.session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().opacity; + let steps = undo_steps(&app); + let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::unique("test_fonts"); + if ui.data(|d| d.get_temp::<bool>(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + app.settle_live_preview(); + show(app, ui); + }, + app, + ); + h.run_steps(3); + assert!(h.query_by_role(egui::accesskit::Role::Slider).is_none()); + h.get_by(|n| n.role() == egui::accesskit::Role::Button && n.label().as_deref() == Some("Opacity")).click(); + h.run_steps(3); + assert!( + h.query_by(|n| n.role() == egui::accesskit::Role::Slider && n.label().as_deref() == Some("Opacity")).is_some(), + "assistive technology names the slider" + ); + let track = h.get_by_role(egui::accesskit::Role::Slider).rect(); + let y = track.center().y; + h.hover_at(egui::pos2(track.right() - 1.0, y)); + h.run_steps(1); + h.drag_at(egui::pos2(track.right() - 1.0, y)); + h.run_steps(1); + for k in 1..=5 { + h.hover_at(egui::pos2(track.right() - track.width() * 0.05 * k as f32, y)); + h.run_steps(1); + assert_eq!(undo_steps(h.state()), steps, "no undo step while dragging"); + } + let previewed = opacity(h.state()); + assert!(previewed < 0.9 && previewed > 0.5, "the drag shows the opacity live: {previewed}"); + // Let go further along than the last move: the release frame changes the value too. + h.drop_at(egui::pos2(track.right() - track.width() * 0.3, y)); + h.run_steps(3); + assert_eq!(undo_steps(h.state()), steps + 1, "the whole drag is one undo step"); + assert!(opacity(h.state()) < previewed, "the release position is applied: {previewed} → {}", opacity(h.state())); + assert_ne!(h.state().ui.open_panel.as_deref(), Some("effects"), "the arrow doesn't open Effects"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(opacity(h.state()), 1.0); +} + +/// Properties › Frame Fitting has Auto-Fit and an Options button, as InDesign's: ticking +/// Auto-Fit makes the picture refit when its frame is resized. +#[test] +fn properties_frame_fitting_sets_auto_fit_and_opens_its_options() { + 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": [72, 72, 272, 172]})).unwrap()["id"].as_u64().unwrap(); + let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png(); + app.run("file.place", json!({"base64": designcraft_engine::cmd::base64_encode(&png), "name": "p.png", "frame": id})).unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let graphic = |app: &DesignApp| app.session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().graphic().unwrap().clone(); + assert_eq!(graphic(&app).auto_fit, designcraft_doc::Fitting::None); + let mut h = panel(app); + // A checkbox its section disables says so: Invert, with no text wrap. + use egui_kittest::kittest::NodeT; + assert!(h.get_by_label("Invert").accesskit_node().is_disabled()); + assert!(!h.get_by_label("Auto-Fit").accesskit_node().is_disabled()); + h.get_by_label("Auto-Fit").click(); + h.run_steps(3); + assert_ne!(graphic(h.state()).auto_fit, designcraft_doc::Fitting::None, "Auto-Fit is on"); + // The frame resized: the picture follows it. + h.state_mut().run("transform.resize", json!({"from": [72, 72, 272, 172], "to": [72, 72, 372, 272]})).unwrap(); + let g = graphic(h.state()); + let r = g.xf.transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1)); + assert!(r.width() > 250.0, "refitted to the larger frame: {r:?}"); + h.get_by_label("Options").click(); + h.run_steps(3); + assert!(h.state().ui.dialog.is_some(), "Frame Fitting Options opens"); +} + +/// Properties › Text Wrap shows the four offsets under the wrap buttons, as InDesign's +/// Properties panel does: linked at first, so one value sets all four; unlinked, one side. +#[test] +fn properties_text_wrap_shows_and_sets_the_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": [72, 72, 300, 200], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + app.run("object.textWrap", json!({"mode": "boundingBox", "offset": 0})).unwrap(); + let offsets = |app: &DesignApp| app.session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().wrap.offsets; + let mut h = panel(app); + // The fields between the Text Wrap heading and Quick Actions: Top, Left, Bottom, Right. + let fields = |h: &Harness<'_, DesignApp>| { + let (top, bottom) = (h.get_by_label("Text Wrap").rect().bottom(), h.get_by_label("Quick Actions").rect().top()); + let mut v: Vec<egui::Rect> = + h.get_all_by_role(egui::accesskit::Role::TextInput).map(|n| n.rect()).filter(|r| r.center().y > top && r.center().y < bottom).collect(); + v.sort_by(|a, b| (a.top(), a.left()).partial_cmp(&(b.top(), b.left())).unwrap()); + v + }; + let f = fields(&h); + assert_eq!(f.len(), 4, "four offsets: {f:?}"); + let type_in = |h: &mut Harness<'_, DesignApp>, at: egui::Rect, text: &str| { + h.hover_at(at.center()); + h.drag_at(at.center()); + h.drop_at(at.center()); + h.run_steps(2); + h.event(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }); + h.event(egui::Event::Text(text.into())); + h.key_press(egui::Key::Enter); + h.run_steps(3); + }; + // Linked: the top offset sets all four. + type_in(&mut h, f[0], "12pt"); + assert_eq!(offsets(h.state()), [12.0; 4]); + // Unlinked: the bottom one alone. + let (top, bottom) = (h.get_by_label("Text Wrap").rect().bottom(), h.get_by_label("Quick Actions").rect().top()); + h.get_all_by_label("Make all settings the same").find(|n| n.rect().center().y > top && n.rect().center().y < bottom).unwrap().click(); + h.run_steps(2); + let f = fields(&h); + type_in(&mut h, f[2], "6pt"); + assert_eq!(offsets(h.state()), [12.0, 12.0, 6.0, 12.0], "[top, left, bottom, right]"); + // Two objects with different offsets: a side typed in changes that side on each, and + // each keeps its other three. + let other = h.state_mut().run("frame.create", json!({"rect": [72, 300, 300, 400], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); + h.state_mut().run("object.textWrap", json!({"mode": "contour", "offset": [3, 4, 5, 6], "ids": [other]})).unwrap(); + let of = + |h: &Harness<'_, DesignApp>, id: u64| h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().wrap.offsets; + h.state_mut().run("selection.set", json!({"ids": [id, other]})).unwrap(); + h.run_steps(3); + let f = fields(&h); + type_in(&mut h, f[0], "9pt"); + assert_eq!((of(&h, id), of(&h, other)), ([9.0, 12.0, 6.0, 12.0], [9.0, 4.0, 5.0, 6.0]), "the top alone, on each"); + let mode = + |h: &Harness<'_, DesignApp>, id: u64| h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().wrap.mode; + assert_eq!((mode(&h, id), mode(&h, other)), (designcraft_doc::WrapMode::BoundingBox, designcraft_doc::WrapMode::Contour), "each keeps its mode"); + // An object whose offsets are the same starts linked, though the link was off before. + let third = h.state_mut().run("frame.create", json!({"rect": [320, 72, 500, 200], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); + h.state_mut().run("object.textWrap", json!({"mode": "boundingBox", "offset": 4, "ids": [third]})).unwrap(); + h.state_mut().run("selection.set", json!({"ids": [third]})).unwrap(); + h.run_steps(3); + let f = fields(&h); + type_in(&mut h, f[2], "8pt"); + assert_eq!(of(&h, third), [8.0; 4], "linked again for this selection"); +} + +/// A row is as wide as its translated name needs: a long one isn't cut at a fixed width. +#[test] +fn preview_rows_fit_long_translated_names() { + let ctx = egui::Context::default(); + let long = "Скругленный прямоугольник с обратным углом"; + let mut widths = (0.0, 0.0); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + egui::Area::new(egui::Id::unique("rows")).show(ui.ctx(), |ui| { + ui.set_max_width(10.0); + let r = preview_row(ui, false, long, 40.0, |_, _, _| {}); + let text = ui.fonts_mut(|f| f.layout_no_wrap(long.to_string(), egui::FontId::proportional(12.0), egui::Color32::WHITE).size().x); + widths = (r.rect.width(), 40.0 + 12.0 + text); + }); + }); + out.textures_delta.clear(); + assert!(widths.0 >= widths.1, "{widths:?}"); +} + +/// The corner shape list shows each shape's glyph beside its name, as InDesign's; a row picks +/// the shape and the current one is marked. +#[test] +fn the_corner_shape_list_has_glyph_rows_and_picks_a_shape() { + 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": [72, 72, 300, 200], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let mut h = panel(app); + h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Corner Shape").click(); + h.run_steps(3); + let rows = ["None", "Fancy", "Bevel", "Inset", "Inverse Rounded", "Rounded"]; + for r in rows { + let _ = h.get_by_role_and_label(egui::accesskit::Role::Button, r); + } + h.get_by_role_and_label(egui::accesskit::Role::Button, "Bevel").click(); + h.run_steps(3); + let it = h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().clone(); + assert!(it.corners.corners.iter().all(|c| c.shape == designcraft_geom::corners::CornerShape::Bevel), "{:?}", it.corners); + h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Corner Shape").click(); + h.run_steps(3); + use egui_kittest::kittest::NodeT; + let n = h.get_by_role_and_label(egui::accesskit::Role::Button, "Bevel"); + assert_eq!(n.accesskit_node().toggled(), Some(egui::accesskit::Toggled::True), "the current shape is marked"); + // The stroke type list has the same rows, by name. + h.key_press(egui::Key::Escape); + h.run_steps(2); + h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Stroke Type").click(); + h.run_steps(3); + let _ = h.get_by_role_and_label(egui::accesskit::Role::Button, "Thick - Thin"); +} + const CYAN: &str = "C=100 M=0 Y=0 K=0"; #[test] @@ -786,6 +1062,121 @@ fn apply_to_text_colours_a_text_frames_text_not_the_frame() { assert_eq!(doc.item(designcraft_doc::ids::ItemId(id)).unwrap().fill.swatch, "[None]", "the frame isn't"); } +/// The Align To button shows the target picked in its menu. +#[test] +fn the_align_to_button_shows_its_target() { + assert_eq!(align_to_icon("selection"), "align-to"); + assert_eq!(align_to_icon("keyObject"), "align-to-key"); + assert_eq!(align_to_icon("margins"), "align-to-margins"); + assert_eq!(align_to_icon("page"), "align-to-page"); + assert_eq!(align_to_icon("spread"), "align-to-spread"); +} + +#[test] +fn apply_to_text_colours_the_text_of_several_text_frames() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let mut ids = vec![]; + let mut stories = vec![]; + for x in [72, 250, 430] { + let r = app.run("frame.create", json!({"rect": [x, 72, x + 150, 140], "content": "text", "text": "75%"})).unwrap(); + ids.push(r["id"].as_u64().unwrap()); + stories.push(designcraft_doc::StoryId(r["story"].as_u64().unwrap())); + } + app.run("selection.set", json!({"ids": ids})).unwrap(); + let mut h = panel(app); + h.get_by_label("Fill swatch").click(); + h.run_steps(3); + // Three text frames, as one: Apply To is offered. + assert!(h.query_by_label("Apply To:").is_some()); + h.get_by_label("Text").click(); + h.run_steps(3); + h.get_by_label(CYAN).click(); + h.run_steps(3); + let doc = &h.state().session.active().unwrap().doc; + for (id, story) in ids.iter().zip(&stories) { + assert_eq!(doc.story(*story).unwrap().char_format_at(0).over.fill.as_deref(), Some(CYAN), "every frame's text is cyan"); + assert_eq!(doc.item(designcraft_doc::ids::ItemId(*id)).unwrap().fill.swatch, "[None]", "the frames aren't"); + } +} + +/// Points selected with Direct Selection: InDesign's "Anchor Point(s)" panel, with Convert +/// Points and the Delete Point(s) quick action, as in the Zero2Graphic tutorial (1:25). +#[test] +fn selected_points_show_convert_points_and_delete_points() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let a = app.run("frame.create", json!({"rect": [72, 72, 172, 172]})).unwrap()["id"].as_u64().unwrap(); + let b = app.run("frame.create", json!({"rect": [200, 72, 300, 172]})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"anchors": [[a, 0, 0], [b, 0, 0]]})).unwrap(); + assert_eq!(selection_title(&app, &super::super::sel_info(&app)), "Anchor Point(s)"); + let mut h = panel(app); + // The four point types (Appearance has a Corner row too). + for tip in ["Plain", "Corner", "Smooth", "Symmetrical"] { + assert!(h.get_all_by_label(tip).next().is_some(), "{tip}"); + } + assert!(h.query_by_label("Align").is_none(), "only the sections InDesign shows for points"); + h.get_by_label("Delete Point(s)").click(); + h.run_steps(3); + let d = &h.state().session.active().unwrap().doc; + for id in [a, b] { + assert_eq!(d.item(designcraft_doc::ids::ItemId(id)).unwrap().path.subpaths[0].anchors.len(), 3, "one point gone from each"); + } +} + +/// With points selected, Transform measures and moves the points: one corner of a frame shows +/// that corner, not the frame, and X moves the corner alone; W spreads two points apart. +#[test] +fn transform_measures_and_moves_the_selected_points() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let a = app.run("frame.create", json!({"rect": [72, 72, 172, 172]})).unwrap()["id"].as_u64().unwrap(); + let corner = |app: &DesignApp, i: usize| { + let d = &app.session.active().unwrap().doc; + let it = d.item(designcraft_doc::ids::ItemId(a)).unwrap(); + it.xf * it.path.subpaths[0].anchors[i].p + }; + let first = corner(&app, 0); + app.run("selection.set", json!({"anchors": [[a, 0, 0]]})).unwrap(); + let i = super::super::sel_info(&app).unwrap(); + assert_eq!((i.page_rect.width(), i.page_rect.height()), (0.0, 0.0), "a point, not the frame"); + app.ui.ref_point = 0; + transform_anchors(&mut app, &json!({"x": i.page_rect.x0 + 10.0})).unwrap(); + assert_eq!(corner(&app, 0), first + designcraft_geom::Vec2::new(10.0, 0.0), "the corner moved"); + assert_eq!(corner(&app, 2), designcraft_geom::Point::new(172.0, 172.0), "the opposite corner stayed"); + // Two points 100 pt apart, W 200 from the left: the right one goes 100 further. + app.run("selection.set", json!({"anchors": [[a, 0, 2], [a, 0, 3]]})).unwrap(); + let (p2, p3) = (corner(&app, 2), corner(&app, 3)); + let w = super::super::sel_info(&app).unwrap().page_rect.width(); + transform_anchors(&mut app, &json!({"width": w * 2.0})).unwrap(); + let (left, right) = if p2.x < p3.x { (2, 3) } else { (3, 2) }; + let (pl, pr) = if left == 2 { (p2, p3) } else { (p3, p2) }; + assert_eq!(corner(&app, left), pl, "the reference side stays"); + assert!((corner(&app, right).x - (pr.x + w)).abs() < 1e-9, "spread to twice the width"); +} + +/// One point has no size: W and H show its frame's, greyed out, as InDesign; two points +/// measure (and set) their own. +#[test] +fn one_selected_point_shows_its_frames_size_greyed() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let a = app.run("frame.create", json!({"rect": [72, 72, 172, 222]})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"anchors": [[a, 0, 0]]})).unwrap(); + assert_eq!(super::super::sel_info(&app).unwrap().object_size, (100.0, 150.0)); + let mut h = panel(app); + h.run_steps(2); + let greyed = |h: &Harness<'_, DesignApp>| { + h.query_all_by(|n| n.role() == egui::accesskit::Role::TextInput && n.is_disabled()).filter_map(|n| n.value()).collect::<Vec<_>>() + }; + let u = units(h.state()); + let f = |v| designcraft_geom::units::format_measure(v, u); + assert_eq!(greyed(&h), [f(100.0), f(150.0)], "the frame's W and H, greyed"); + h.state_mut().run("selection.set", json!({"anchors": [[a, 0, 0], [a, 0, 2]]})).unwrap(); + h.run_steps(2); + assert!(greyed(&h).is_empty(), "two points have a size of their own"); +} + #[test] fn transform_flips_and_rotates_from_properties() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/panels/properties/text.rs b/crates/ui-egui/src/panels/properties/text.rs index 342ad8dda..5cbd62c65 100644 --- a/crates/ui-egui/src/panels/properties/text.rs +++ b/crates/ui-egui/src/panels/properties/text.rs @@ -1,4 +1,4 @@ -use super::object::swatch_menu; +use super::object::{open_color_picker, swatch_menu}; use super::typography::{para_field_min, show_typography_panel}; use super::{ Align, DesignApp, FIELD_H, NumField, Rect, Sense, Stroke, StrokeKind, Tokens, Ui, Value, block, full_width, icon_toggle, icons, json, link_label, @@ -128,7 +128,11 @@ pub(super) fn text_appearance_section(app: &mut DesignApp, ui: &mut Ui) { ui.painter().text(chip.center(), egui::Align2::CENTER_CENTER, "T", crate::theme::semibold(12.0), tc); } let resp = ui.interact(chip, ui.scope_id().with(("tchip", k)), Sense::click()).on_hover_text(format!("{label}: {sw}")); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, if k == 0 { "Text fill swatch" } else { "Text stroke swatch" })); swatch_menu(app, &resp, &doc, &sw, k != 0, false, true); + if resp.double_clicked() { + open_color_picker(app, &doc, &sw, k != 0, true); + } if k == 0 { crate::rtl::paint( ui.painter(), @@ -480,8 +484,9 @@ pub(super) fn bullets_section(app: &mut DesignApp, ui: &mut Ui) { } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.add_space(8.0); + // As InDesign's: the Bullets and Numbering dialog for the selected paragraphs. if outline_button(ui, crate::i18n::tr(&app.ui.language, "Options"), 66.0).clicked() { - show_panel(app, "paragraph"); + let _ = app.run("app.bulletsNumberingDialog", json!({})); } }); }); diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index fba3eb5b7..680391e03 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -11,7 +11,18 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); - let target = super::color_target(app); + // Another document (switched to, or moved to this one's place by a close) drops a rename in + // place: a swatch of the same name there isn't the one being renamed. + let uid = st.uid; + if ui.data(|d| d.get_temp::<u64>(egui::Id::unique(RENAME_DOC))) != Some(uid) { + ui.data_mut(|d| { + d.remove_temp::<(String, String)>(egui::Id::unique(RENAMING)); + d.remove_temp::<(String, f64)>(egui::Id::unique(RENAME_CLICK)); + d.insert_temp(egui::Id::unique(RENAME_DOC), uid); + }); + } + // With nothing selected, the proxy, Tint field and highlighted row are those of new shapes. + let target = super::color_target_or_defaults(app); header(app, ui, &doc, target.as_ref()); // The row of the colour in front of the proxy is highlighted. let fill_cur = target.map(|(fill, _, stroke, _)| if app.ui.stroke_front { stroke } else { fill }); @@ -154,6 +165,13 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { /// Temp-data key of the swatch last clicked (Delete Swatch removes it). const PICKED: &str = "swatches_picked"; +/// Temp-data key of the swatch renamed in place and its name being typed. +const RENAMING: &str = "swatches_renaming"; +/// Temp-data key of a click on the picked swatch's name: (swatch, time). Unless it turns into a +/// double-click (Swatch Options), it starts renaming. +const RENAME_CLICK: &str = "swatches_rename_click"; +/// Temp-data key of the document (its uid) the panel last showed: a rename belongs to it. +const RENAME_DOC: &str = "swatches_rename_doc"; /// The Swatches panel's top row, as InDesign's: a Fill/Stroke proxy (click: the other in front), /// Formatting Affects Container / Text, and the tint of the colour in front. `target` is @@ -206,7 +224,8 @@ fn header(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Documen } ui.add_space(6.0); crate::widgets::caption(ui, crate::i18n::tr(&app.ui.language, "Tint:")); - let tint = target.map(|(_, ft, _, st)| f64::from(if stroke_front { *st } else { *ft }) * 100.0); + // With nothing selected InDesign's field reads 100. + let tint = target.map_or(Some(100.0), |(_, ft, _, st)| Some(f64::from(if stroke_front { *st } else { *ft }) * 100.0)); if let Some(v) = crate::widgets::number(ui, "swatches_tint", tint, "", 44.0, 0) { let _ = set_tint(app, v); } @@ -242,6 +261,47 @@ fn folder_button(ui: &mut egui::Ui, tip: &str) -> egui::Response { }) } +/// The name field of a swatch being renamed in place, over `rect`; false when `sw` isn't. Enter or +/// a click elsewhere renames it (`swatch.options`), Escape leaves it as it was. +fn rename_field(app: &mut DesignApp, ui: &mut egui::Ui, sw: &designcraft_color::swatch::Swatch, rect: egui::Rect) -> bool { + let key = egui::Id::unique(RENAMING); + let Some((name, mut text)) = ui.data(|d| d.get_temp::<(String, String)>(key)).filter(|(n, _)| *n == sw.name) else { return false }; + let id = egui::Id::unique(("swatch_rename", &sw.name)); + let edit = egui::TextEdit::singleline(&mut text).id(id).font(egui::FontId::proportional(12.5)).margin(vec2(2.0, 1.0)); + let resp = ui.put(rect, edit); + if !resp.has_focus() && !resp.lost_focus() { + // The field opens with the name selected. + resp.request_focus(); + if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), id) { + state + .cursor + .set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count())))); + state.store(ui.ctx(), id); + } + } + if resp.lost_focus() { + ui.data_mut(|d| d.remove_temp::<(String, String)>(key)); + let to = text.trim(); + if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && !to.is_empty() && to != name { + match app.run("swatch.options", json!({"name": name, "to": to})) { + // Still picked under its new name, unless the click that ended the rename picked + // another swatch. + Ok(_) => ui.data_mut(|d| { + if d.get_temp::<String>(egui::Id::unique(PICKED)).is_none_or(|p| p == name) { + d.insert_temp(egui::Id::unique(PICKED), to.to_string()); + } + }), + Err(e) => { + app.status_error("Swatch Options", e); + } + } + } + } else { + ui.data_mut(|d| d.insert_temp(key, (name, text))); + } + true +} + /// The swatch's chip following the pointer while the row is dragged. fn paint_drag_chip(ui: &egui::Ui, doc: &designcraft_doc::Document, name: &str, p: egui::Pos2) { let t = Tokens::get(ui.ctx()); @@ -252,20 +312,153 @@ fn paint_drag_chip(ui: &egui::Ui, doc: &designcraft_doc::Document, name: &str, p painter.rect_stroke(chip, 0.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Outside); } -/// The type indicator shown at the right of a swatch row: colour mode (spot dot), gradient or tint. -fn swatch_kind_label(lang: &str, value: &SwatchValue) -> String { - match value { +/// What a Swatches panel row marks on its right, as InDesign's columns. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct RowMarks { + /// [None], [Registration] and [Black] can't be edited ([Paper] can). + not_editable: bool, + kind: Option<ColorType>, + mode: Option<RowMode>, +} + +/// The colour-mode column: the ink model, or the special swatches' own glyphs. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RowMode { + Cmyk, + Rgb, + Gray, + None, + Registration, +} + +fn row_marks(sw: &designcraft_color::swatch::Swatch) -> RowMarks { + use designcraft_color::Color; + // Paper's colour can be edited, whatever the swatch is called (as the options dialog allows). + let not_editable = sw.locked && !matches!(sw.value, SwatchValue::Paper { .. }); + match &sw.value { SwatchValue::Color { color, color_type } => { - let m = match color { - designcraft_color::Color::Cmyk { .. } => "CMYK", - designcraft_color::Color::Rgb { .. } => "RGB", - designcraft_color::Color::Gray { .. } => crate::i18n::tr(lang, "Gray"), + let mode = match color { + Color::Cmyk { .. } => RowMode::Cmyk, + Color::Rgb { .. } => RowMode::Rgb, + Color::Gray { .. } => RowMode::Gray, }; - format!("{}{}", if *color_type == ColorType::Spot { "● " } else { "" }, m) + RowMarks { not_editable, kind: Some(*color_type), mode: Some(mode) } + } + SwatchValue::None => RowMarks { not_editable, kind: None, mode: Some(RowMode::None) }, + SwatchValue::Registration => RowMarks { not_editable, kind: None, mode: Some(RowMode::Registration) }, + _ => RowMarks { not_editable, kind: None, mode: None }, + } +} + +/// A row's accessible name: the swatch's name, then what its marks show (colour type, colour +/// mode, that it can't be edited). [None] and [Registration] say their mode by name. +fn row_label(lang: &str, name: &str, marks: RowMarks) -> String { + let tr = |s: &str| crate::i18n::tr(lang, s).to_string(); + let mut parts = vec![name.to_string()]; + match marks.kind { + Some(ColorType::Process) => parts.push(tr("Process")), + Some(ColorType::Spot) => parts.push(tr("Spot")), + None => {} + } + match marks.mode { + Some(RowMode::Cmyk) => parts.push(tr("CMYK")), + Some(RowMode::Rgb) => parts.push(tr("RGB")), + Some(RowMode::Gray) => parts.push(tr("Gray")), + Some(RowMode::None | RowMode::Registration) | None => {} + } + if marks.not_editable { + parts.push(tr("Cannot be edited")); + } + parts.join(", ") +} + +/// Whether accessible name `label` is swatch `name`'s row (its name, then its marks). +#[cfg(test)] +pub(crate) fn names_swatch(label: &str, name: &str) -> bool { + label.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with(", ")) +} + +/// A pencil with a stroke through it: the swatch can't be edited. Drawn in code. +fn paint_not_editable(p: &egui::Painter, r: egui::Rect, ink: egui::Color32) { + // The pencil: its tip at the lower left, its body up to the right. + let tip = r.left_bottom() + vec2(1.5, -1.5); + let d = vec2(1.0, -1.0).normalized(); + let n = vec2(d.y, -d.x) * 1.9; + let (b0, b1) = (tip + d * 3.4, tip + d * 12.5); + p.add(egui::Shape::convex_polygon(vec![tip, b0 + n, b0 - n], ink, egui::Stroke::NONE)); + p.add(egui::Shape::convex_polygon(vec![b0 + n, b1 + n, b1 - n, b0 - n], ink, egui::Stroke::NONE)); + // The stroke through it, with a gap of the panel's colour around it so it reads on the body. + let (s0, s1) = (r.left_top() + vec2(1.5, 1.5), r.right_bottom() - vec2(1.5, 1.5)); + p.line_segment([s0, s1], egui::Stroke::new(3.2, egui::Color32::from_black_alpha(140))); + p.line_segment([s0, s1], egui::Stroke::new(1.2, ink)); +} + +/// The colour type: a process colour is a halftone square, a spot colour a disc in a square. +fn paint_color_type(p: &egui::Painter, r: egui::Rect, kind: ColorType, ink: egui::Color32) { + let sq = egui::Rect::from_center_size(r.center(), vec2(10.0, 10.0)); + p.rect_filled(sq, 0.0, ink); + if kind == ColorType::Spot { + p.circle_filled(sq.center(), 3.2, egui::Color32::from_black_alpha(110)); + } else { + // A fine halftone: a grey square with a light dot screen. + let dot = egui::Color32::from_black_alpha(70); + for i in 0..4u8 { + for j in 0..4u8 { + let o = if j % 2 == 0 { 1.5 } else { 2.6 }; + p.circle_filled(sq.min + vec2(o + 2.2 * f32::from(i), 1.6 + 2.3 * f32::from(j)), 0.6, dot); + } + } + } +} + +/// The colour mode: CMYK's four inks meeting in the middle, RGB's three primaries, a grey ramp, +/// [None]'s red slash and [Registration]'s target. +fn paint_mode(p: &egui::Painter, r: egui::Rect, mode: RowMode, ink: egui::Color32) { + let sq = egui::Rect::from_center_size(r.center(), vec2(11.0, 11.0)); + let c = sq.center(); + let rgb = egui::Color32::from_rgb; + match mode { + RowMode::Cmyk => { + let corners = [sq.left_top(), sq.right_top(), sq.right_bottom(), sq.left_bottom()]; + // Top, right, bottom, left: magenta, yellow, black, cyan. + let inks = [rgb(236, 0, 140), rgb(255, 230, 0), rgb(30, 30, 30), rgb(0, 174, 239)]; + for (k, colour) in inks.into_iter().enumerate() { + let (a, b) = (corners[k], corners[(k + 1) % 4]); + p.add(egui::Shape::convex_polygon(vec![a, b, c], colour, egui::Stroke::NONE)); + } + p.rect_stroke(sq, 0.0, egui::Stroke::new(1.0, ink), egui::StrokeKind::Outside); + } + RowMode::Rgb => { + let w = sq.width() / 3.0; + for (k, colour) in [rgb(230, 40, 40), rgb(40, 190, 60), rgb(40, 90, 230)].into_iter().enumerate() { + let x = sq.min.x + w * k as f32; + p.rect_filled(egui::Rect::from_min_max(egui::pos2(x, sq.min.y), egui::pos2(x + w, sq.max.y)), 0.0, colour); + } + p.rect_stroke(sq, 0.0, egui::Stroke::new(1.0, ink), egui::StrokeKind::Outside); + } + RowMode::Gray => { + let w = sq.width() / 4.0; + for k in 0..4u8 { + let x = sq.min.x + w * f32::from(k); + p.rect_filled( + egui::Rect::from_min_max(egui::pos2(x, sq.min.y), egui::pos2(x + w, sq.max.y)), + 0.0, + egui::Color32::from_gray(255 - 70 * k), + ); + } + p.rect_stroke(sq, 0.0, egui::Stroke::new(1.0, ink), egui::StrokeKind::Outside); + } + RowMode::None => { + p.rect_filled(sq, 0.0, egui::Color32::WHITE); + p.line_segment([sq.left_bottom(), sq.right_top()], egui::Stroke::new(1.5, rgb(220, 30, 30))); + p.rect_stroke(sq, 0.0, egui::Stroke::new(1.0, ink), egui::StrokeKind::Outside); + } + RowMode::Registration => { + p.rect_stroke(sq, 0.0, egui::Stroke::new(1.0, ink), egui::StrokeKind::Inside); + p.circle_stroke(c, 3.0, egui::Stroke::new(1.0, ink)); + p.line_segment([c - vec2(4.5, 0.0), c + vec2(4.5, 0.0)], egui::Stroke::new(1.0, ink)); + p.line_segment([c - vec2(0.0, 4.5), c + vec2(0.0, 4.5)], egui::Stroke::new(1.0, ink)); } - SwatchValue::Gradient { .. } => "Gradient".into(), - SwatchValue::Tint { tint, .. } => format!("{:.0}%", tint * 100.0), - _ => String::new(), } } @@ -283,7 +476,10 @@ fn swatch_row( let t = Tokens::get(ui.ctx()); // Rows can be dragged onto the Gradient panel's ramp. let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click_and_drag()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &sw.name)); + // What the row's marks show, for screen readers too: "Cyan, Process, CMYK". + let marks = row_marks(sw); + let spoken = row_label(&app.ui.language, &sw.name, marks); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &spoken)); resp.dnd_set_drag_payload(SwatchDrag(sw.name.clone())); if resp.dragged() && let Some(p) = ui.ctx().pointer_interact_pos() @@ -298,21 +494,61 @@ fn swatch_row( let (c, g) = crate::widgets::swatch_colors(doc, &sw.name, 1.0); let chip = egui::Rect::from_min_size(row.min + vec2(4.0 + indent, 3.0), vec2(16.0, 16.0)); crate::widgets::paint_chip(ui.painter(), chip, c, g); - ui.painter().text(row.min + vec2(28.0 + indent, 11.0), egui::Align2::LEFT_CENTER, &sw.name, egui::FontId::proportional(12.5), t.text); - // Type indicators: spot dot / process square, colour mode. - let kind = swatch_kind_label(&app.ui.language, &sw.value); - if sw.locked { - icons::paint(ui.painter(), egui::Rect::from_min_size(egui::pos2(row.max.x - 64.0, row.min.y + 4.0), vec2(13.0, 13.0)), "lock", t.text_dim); + let name_rect = egui::Rect::from_min_max(row.min + vec2(26.0 + indent, 1.0), egui::pos2(row.max.x - 72.0, row.max.y - 1.0)); + if !rename_field(app, ui, sw, name_rect) { + ui.painter().text(row.min + vec2(28.0 + indent, 11.0), egui::Align2::LEFT_CENTER, &sw.name, egui::FontId::proportional(12.5), t.text); + } + // InDesign's columns on the right: not editable, colour type, colour mode. + let col = |k: f32| egui::Rect::from_min_size(egui::pos2(row.max.x - 19.0 - 18.0 * k, row.min.y + 4.5), vec2(13.0, 13.0)); + if marks.not_editable { + paint_not_editable(ui.painter(), col(2.0), t.text_dim); + } + if let Some(kind) = marks.kind { + paint_color_type(ui.painter(), col(1.0), kind, t.text_dim); + } + if let Some(mode) = marks.mode { + paint_mode(ui.painter(), col(0.0), mode, t.text_dim); + } + let label = match &sw.value { + SwatchValue::Gradient { .. } => crate::i18n::tr(&app.ui.language, "Gradient").to_string(), + SwatchValue::Tint { tint, .. } => format!("{:.0}%", tint * 100.0), + _ => String::new(), + }; + if !label.is_empty() { + ui.painter().text(row.right_center() - vec2(6.0, 0.0), egui::Align2::RIGHT_CENTER, label, egui::FontId::proportional(10.5), t.text_dim); } - ui.painter().text(row.right_center() - vec2(6.0, 0.0), egui::Align2::RIGHT_CENTER, kind, egui::FontId::proportional(10.5), t.text_dim); if resp.clicked() && !resp.double_clicked() { + // A click on the name of the swatch picked already renames it in place, as InDesign's + // (a slow double-click), unless it turns into a double-click. + let picked = ui.data(|d| d.get_temp::<String>(egui::Id::unique(PICKED))).is_some_and(|p| p == sw.name); + // A click on another swatch cancels a rename still waiting. + ui.data_mut(|d| d.remove_temp::<(String, f64)>(egui::Id::unique(RENAME_CLICK))); + if picked && !sw.locked && resp.interact_pointer_pos().is_some_and(|p| name_rect.contains(p)) { + let now = ui.input(|i| i.time); + ui.data_mut(|d| d.insert_temp(egui::Id::unique(RENAME_CLICK), (sw.name.clone(), now))); + } ui.data_mut(|d| d.insert_temp(egui::Id::unique(PICKED), sw.name.clone())); let _ = super::apply_swatch(app, app.ui.stroke_front, &sw.name, None); } // Double-click: Swatch Options (the first click has applied the swatch). if resp.double_clicked() { + ui.data_mut(|d| d.remove_temp::<(String, f64)>(egui::Id::unique(RENAME_CLICK))); let _ = app.run("app.swatchOptionsDialog", json!({"name": sw.name})); } + if let Some((name, at)) = ui.data(|d| d.get_temp::<(String, f64)>(egui::Id::unique(RENAME_CLICK))).filter(|(n, _)| *n == sw.name) { + let wait = ui.ctx().options(|o| o.input_options.max_double_click_delay); + if ui.data(|d| d.get_temp::<String>(egui::Id::unique(PICKED))).is_none_or(|p| p != name) { + // Another swatch picked since: no rename. + ui.data_mut(|d| d.remove_temp::<(String, f64)>(egui::Id::unique(RENAME_CLICK))); + } else if ui.input(|i| i.time) - at > wait { + ui.data_mut(|d| { + d.remove_temp::<(String, f64)>(egui::Id::unique(RENAME_CLICK)); + d.insert_temp(egui::Id::unique(RENAMING), (name.clone(), name)); + }); + } else { + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(wait)); + } + } resp.context_menu(|ui| { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Swatch Options…"))).clicked() { let _ = app.run("app.swatchOptionsDialog", json!({"name": sw.name})); @@ -360,7 +596,8 @@ pub enum ColorMode { /// swatch), the spectrum ramp, Add to Swatches. Edits apply unnamed colours (`object.color`). pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let Some((fill, fill_tint, stroke_sw, stroke_tint)) = super::color_target(app) else { + let target = super::color_target_or_defaults(app); + let Some((fill, fill_tint, stroke_sw, stroke_tint)) = target else { ui.label(crate::rtl::widget( ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Select an object or text to color its fill or stroke.")).color(t.text_dim), @@ -418,6 +655,11 @@ pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) { }); app.ui.stroke_front = stroke; ui.data_mut(|d| d.insert_temp(mode_id, mode)); + // A gradient: the stop selected in the Gradient panel, as InDesign's Color panel shows it. + if !text && let Some(g) = designcraft_color::swatch::resolve_gradient(&doc.swatches, &name).cloned() { + gradient_stop(app, ui, &g, target, mode); + return; + } let sw = doc.swatch(&name).cloned(); let Some(c) = doc.resolve_color(&name, 1.0) else { ui.label(crate::rtl::widget( @@ -570,9 +812,82 @@ fn cmyk_ramp(k: usize, x: f32) -> egui::Color32 { egui::Color32::from_rgb((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8) } +/// The Color panel on a gradient fill (or stroke): the stop selected in the Gradient panel (the +/// stop before a selected midpoint), in the panel's colour mode, as InDesign's. Its fields and the +/// spectrum colour that stop. +fn gradient_stop(app: &mut DesignApp, ui: &mut egui::Ui, g: &designcraft_color::Gradient, target: &str, mode: ColorMode) { + use designcraft_color::Color; + let t = Tokens::get(ui.ctx()); + let sel: RampSel = ui.data(|d| d.get_temp(egui::Id::unique(GRAD_SEL))).unwrap_or(RampSel::Stop(0)); + let i = match sel { + RampSel::Stop(i) | RampSel::Mid(i) => i, + } + .min(g.stops.len().saturating_sub(1)); + let Some(stop) = g.stops.get(i) else { return }; + let c = stop.color; + let pct = |v: f32| (v * 100.0).round(); + let values = match c { + Color::Cmyk { c, m, y, k } => format!("C={} M={} Y={} K={}", pct(c), pct(m), pct(y), pct(k)), + Color::Gray { k } => format!("K={}", pct(k)), + Color::Rgb { .. } => { + let [r, g, b, _] = c.to_rgba8(1.0); + format!("R={r} G={g} B={b}") + } + }; + ui.label(egui::RichText::new(values).size(11.0).color(t.text_dim)); + let mut new = None; + match mode { + ColorMode::Cmyk => { + let mut v = c.to_cmyk(); + let mut done = false; + for (k, label) in ["C", "M", "Y", "K"].iter().enumerate() { + done |= channel(ui, label, &mut v[k], 100.0, "%", |x| cmyk_ramp(k, x)); + } + if done { + new = Some(Color::cmyk(v[0], v[1], v[2], v[3])); + } + } + ColorMode::Rgb => { + let mut v = c.to_rgb(); + let mut done = false; + for (k, label) in ["R", "G", "B"].iter().enumerate() { + done |= channel(ui, label, &mut v[k], 255.0, "", |x| { + let mut p = [0.0; 3]; + p[k] = x; + egui::Color32::from_rgb((p[0] * 255.0) as u8, (p[1] * 255.0) as u8, (p[2] * 255.0) as u8) + }); + } + if done { + new = Some(Color::rgb(v[0], v[1], v[2])); + } + } + ColorMode::Lab => { + let lab = c.to_lab(); + ui.label(egui::RichText::new(format!("L {:.0} a {:.0} b {:.0}", lab.l, lab.a, lab.b)).color(t.text_dim)); + } + } + if let Some([r, gr, b]) = spectrum_pick(ui, &app.ui.language) { + new = Some(Color::rgb(r, gr, b)); + } + if let Some(c) = new { + let mut stops = stops_json(g); + set_stop_color(&mut stops, i, color_json(&c)); + let _ = app.run("object.gradient", json!({"stops": stops, "target": target})); + } +} + /// The spectrum ramp: hue across, light to dark down; click to apply. fn spectrum(app: &mut DesignApp, ui: &mut egui::Ui, target: &str) { + if let Some([r, g, b]) = spectrum_pick(ui, &app.ui.language) { + let text = super::colors_affect_text(app); + let _ = app.run("object.color", json!({"color": {"model": "rgb", "r": r, "g": g, "b": b}, "target": target, "text": text})); + } +} + +/// The spectrum ramp; a click on it gives that colour (RGB, 0–1, in 1/255 steps). +fn spectrum_pick(ui: &mut egui::Ui, language: &str) -> Option<[f32; 3]> { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 36.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, crate::i18n::tr(language, "Color spectrum"))); let at = |u: f32, v: f32| -> [f32; 3] { let c = egui::ecolor::Hsva::new(u, (v * 2.0).min(1.0), (2.0 - v * 2.0).min(1.0), 1.0).to_rgb(); [c[0], c[1], c[2]] @@ -588,14 +903,9 @@ fn spectrum(app: &mut DesignApp, ui: &mut egui::Ui, target: &str) { ui.painter().rect_filled(cell, 0.0, egui::Color32::from_rgb((c[0] * 255.0) as u8, (c[1] * 255.0) as u8, (c[2] * 255.0) as u8)); } } - if resp.clicked() - && let Some(p) = resp.interact_pointer_pos() - { - let c = at(((p.x - r.min.x) / r.width()).clamp(0.0, 1.0), ((p.y - r.min.y) / r.height()).clamp(0.0, 1.0)); - let text = super::colors_affect_text(app); - let q = |v: f32| (v * 255.0).round() / 255.0; - let _ = app.run("object.color", json!({"color": {"model": "rgb", "r": q(c[0]), "g": q(c[1]), "b": q(c[2])}, "target": target, "text": text})); - } + let p = resp.interact_pointer_pos().filter(|_| resp.clicked())?; + let c = at(((p.x - r.min.x) / r.width()).clamp(0.0, 1.0), ((p.y - r.min.y) / r.height()).clamp(0.0, 1.0)); + Some(c.map(|v| (v * 255.0).round() / 255.0)) } /// A swatch dragged out of the Swatches panel (the Gradient panel's ramp takes it as a stop). @@ -618,6 +928,105 @@ fn color_json(c: &designcraft_color::Color) -> serde_json::Value { } } +#[cfg(test)] +mod stop_color_tests { + use super::*; + + #[test] + fn a_new_stop_colour_drops_the_stops_swatch() { + let mut stops = vec![json!({"location": 0, "color": "#000000", "swatch": "Gold"})]; + set_stop_color(&mut stops, 0, json!("#ff0000")); + assert_eq!(stops[0]["color"], "#ff0000"); + assert!(stops[0].get("swatch").is_none()); + } +} + +#[cfg(test)] +mod color_panel_defaults_tests { + use super::*; + use egui_kittest::kittest::Queryable; + + #[test] + fn color_panel_with_no_selection_edits_future_shapes_without_recoloring_existing_ones() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let old = + designcraft_doc::ItemId(app.run("frame.create", json!({"rect":[72,72,200,200],"content":"unassigned"})).unwrap()["id"].as_u64().unwrap()); + let original = app.session.active().unwrap().doc.item(old).unwrap().stroke.clone(); + app.run("selection.set", json!({"ids":[]})).unwrap(); + app.ui.stroke_front = true; + app.run("window.panel", json!({"panel":"color"})).unwrap(); + let mut h = crate::test_window::open(app, vec2(1440.0, 900.0)); + assert!(h.query_by_label("Select an object or text to color its fill or stroke.").is_none()); + let ramp = h.get_by_label("Color spectrum").rect(); + crate::test_window::click_at(&mut h, egui::pos2(ramp.left() + ramp.width() / 3.0, ramp.center().y)); + let defaults = h.state().app.session.active().unwrap().doc.settings.new_object.clone(); + assert_ne!(defaults.stroke, original.swatch); + assert_eq!(h.state().app.session.active().unwrap().doc.item(old).unwrap().stroke, original); + h.state_mut().app.run("selection.set", json!({"ids":[old.0]})).unwrap(); + assert_eq!(crate::panels::color_target(&mut h.state_mut().app).unwrap().2, original.swatch); + h.state_mut().app.run("selection.set", json!({"ids":[]})).unwrap(); + let new = designcraft_doc::ItemId( + h.state_mut().app.run("frame.create", json!({"rect":[240,72,368,200],"content":"unassigned"})).unwrap()["id"].as_u64().unwrap(), + ); + assert_eq!(h.state().app.session.active().unwrap().doc.item(new).unwrap().stroke.swatch, defaults.stroke); + // Two undo steps: the new frame, then the spectrum click's default stroke. + h.state_mut().app.run("edit.undo", json!({})).unwrap(); + h.state_mut().app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.state().app.session.active().unwrap().doc.settings.new_object.stroke, original.swatch); + assert_eq!(h.state().app.session.active().unwrap().doc.item(old).unwrap().stroke, original); + // With the Fill box in front, the ramp sets the default fill instead. + let original_fill = h.state().app.session.active().unwrap().doc.item(old).unwrap().fill.clone(); + h.state_mut().app.ui.stroke_front = false; + h.run_steps(2); + let ramp = h.get_by_label("Color spectrum").rect(); + crate::test_window::click_at(&mut h, egui::pos2(ramp.left() + ramp.width() * 2.0 / 3.0, ramp.center().y)); + let defaults = h.state().app.session.active().unwrap().doc.settings.new_object.clone(); + assert_ne!(defaults.fill, original_fill.swatch); + assert_eq!(defaults.stroke, original.swatch, "the stroke default is left alone"); + assert_eq!(h.state().app.session.active().unwrap().doc.item(old).unwrap().fill, original_fill); + } + + /// The Swatches panel shows the same defaults: its Tint field reads the default fill's tint, + /// and a click on a swatch row sets the default fill, not an existing frame's. + #[test] + fn swatches_panel_with_no_selection_shows_and_edits_future_shape_colors() { + use egui::accesskit::Role; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let old = + designcraft_doc::ItemId(app.run("frame.create", json!({"rect":[72,72,200,200],"content":"unassigned"})).unwrap()["id"].as_u64().unwrap()); + let original = app.session.active().unwrap().doc.item(old).unwrap().fill.clone(); + app.run("selection.set", json!({"ids":[]})).unwrap(); + app.run("object.fill", json!({"swatch": "[Black]", "tint": 0.4})).unwrap(); + app.ui.stroke_front = false; + let mut h = egui_kittest::Harness::builder().with_size(vec2(320.0, 480.0)).build_ui_state( + |ui, app: &mut DesignApp| { + // The panel's fonts are installed on the first frame and used from the next. + let id = egui::Id::unique("test_fonts"); + if ui.data(|d| d.get_temp::<bool>(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + let fields = h.query_all_by(|n| matches!(n.role(), Role::SpinButton | Role::TextInput)).filter_map(|n| n.value()).collect::<Vec<_>>(); + assert!(fields.iter().any(|v| v.starts_with("40")), "the Tint field shows the default fill's 40 %: {fields:?}"); + let row = h.get_by_label("[Paper]").rect().center(); + h.hover_at(row); + h.drag_at(row); + h.drop_at(row); + h.run_steps(3); + let defaults = h.state().session.active().unwrap().doc.settings.new_object.clone(); + assert_eq!((defaults.fill.as_str(), defaults.fill_tint), ("[Paper]", 1.0)); + assert_eq!(h.state().session.active().unwrap().doc.item(old).unwrap().fill, original); + } +} + /// Stops in `object.gradient` form: a stop on a swatch also names it, so it keeps its inks. fn stops_json(g: &designcraft_color::Gradient) -> Vec<serde_json::Value> { g.stops @@ -659,6 +1068,10 @@ fn thumbnail(p: &egui::Painter, r: egui::Rect, g: &designcraft_color::Gradient, p.rect_stroke(r, 0.0, egui::Stroke::new(1.0, border), egui::StrokeKind::Outside); } +/// Temp-data key of what's selected on the Gradient panel's ramp ([`RampSel`]): the Color panel +/// edits that stop. +pub(super) const GRAD_SEL: &str = "grad_sel"; + /// The gradient across `ramp`, sampled in bands. fn paint_ramp(painter: &egui::Painter, ramp: egui::Rect, g: &designcraft_color::Gradient) { let n = 96; @@ -717,7 +1130,7 @@ fn gradient_type_combo(ui: &mut egui::Ui, lang: &str, g: &designcraft_color::Gra /// What's selected on the ramp: a stop, or the midpoint after a stop. #[derive(Clone, Copy, PartialEq, Debug)] -enum RampSel { +pub(super) enum RampSel { Stop(usize), Mid(usize), } @@ -735,6 +1148,14 @@ fn live_drag(d: Option<RampDrag>, n: usize) -> Option<RampDrag> { } /// Sets `key` of stop `i` in `object.gradient` stops; nothing if there's no such stop. +/// A new colour replaces the stop's swatch (`object.gradient` gives `swatch` precedence). +fn set_stop_color(stops: &mut [serde_json::Value], i: usize, color: serde_json::Value) { + if let Some(o) = stops.get_mut(i).and_then(serde_json::Value::as_object_mut) { + o.remove("swatch"); + } + set_stop(stops, i, "color", color); +} + fn set_stop(stops: &mut [serde_json::Value], i: usize, key: &str, v: serde_json::Value) { if let Some(o) = stops.get_mut(i).and_then(serde_json::Value::as_object_mut) { o.insert(key.to_string(), v); @@ -760,7 +1181,7 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let is_gradient = cur.is_some(); let g = cur.unwrap_or_default(); let radial = g.kind == designcraft_color::GradientKind::Radial; - let sel_id = egui::Id::unique("grad_sel"); + let sel_id = egui::Id::unique(GRAD_SEL); let drag_id = egui::Id::unique("grad_drag"); let mut sel: RampSel = ui.data(|d| d.get_temp(sel_id)).unwrap_or(RampSel::Stop(0)); let last = g.stops.len().saturating_sub(1); @@ -924,11 +1345,7 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let mut rgb = [(r * 255.0) as u8, (gg * 255.0) as u8, (bb * 255.0) as u8]; if egui::color_picker::color_edit_button_srgb(ui, &mut rgb).changed() { let mut stops = stops_json(&g); - // A picked color replaces the stop's swatch. - if let Some(o) = stops.get_mut(i).and_then(serde_json::Value::as_object_mut) { - o.remove("swatch"); - } - set_stop(&mut stops, i, "color", json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]))); + set_stop_color(&mut stops, i, json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]))); send = Some(json!({"stops": stops})); } }); @@ -1138,7 +1555,7 @@ mod gradient_panel_tests { // The thumbnail sits left of the Type row. let ty = h.get_by_label("Type:").rect(); let from = egui::pos2(ty.min.x - 21.0, ty.min.y + 12.0); - let to = h.get_by_label(label).rect().center(); + let to = h.get_by(|n| n.label().is_some_and(|l| crate::panels::swatches::names_swatch(&l, label))).rect().center(); h.event(Event::PointerMoved(from)); h.step(); h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); @@ -1250,14 +1667,14 @@ mod swatches_header_tests { let mut h = harness(app); h.run_steps(3); // Fill in front: a swatch is the fill. - h.get_by_label("C=0 M=100 Y=0 K=0").click(); + h.get_by(|n| n.label().is_some_and(|l| crate::panels::swatches::names_swatch(&l, "C=0 M=100 Y=0 K=0"))).click(); h.run_steps(2); assert_eq!(items(h.state())[0].fill.swatch, "C=0 M=100 Y=0 K=0"); // The proxy brings the stroke to the front; the next swatch is the stroke. h.get_by_label("Fill").click(); h.run_steps(2); assert!(h.state().ui.stroke_front); - h.get_by_label("C=100 M=0 Y=0 K=0").click(); + h.get_by(|n| n.label().is_some_and(|l| crate::panels::swatches::names_swatch(&l, "C=100 M=0 Y=0 K=0"))).click(); h.run_steps(2); let it = &items(h.state())[0]; assert_eq!((it.fill.swatch.as_str(), it.stroke.swatch.as_str()), ("C=0 M=100 Y=0 K=0", "C=100 M=0 Y=0 K=0")); @@ -1269,13 +1686,157 @@ mod swatches_header_tests { h.run_steps(2); assert!(!h.state().ui.formatting_affects_text); // Delete Swatch removes the swatch last clicked. - h.get_by_label("Brand").click(); + h.get_by(|n| n.label().is_some_and(|l| crate::panels::swatches::names_swatch(&l, "Brand"))).click(); h.run_steps(2); h.get_by_label("Delete Swatch").click(); h.run_steps(2); assert!(h.state().session.doc().unwrap().doc.swatch("Brand").is_none()); } + /// A rename in place belongs to its document: with another one active, a swatch of the same + /// name there gets no field, and nothing is renamed. + #[test] + fn a_rename_in_place_ends_with_its_document() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let color = json!({"color": {"c": 14, "m": 8, "y": 95, "k": 0}}); + let name = app.run("swatch.create", color.clone()).unwrap()["name"].as_str().unwrap().to_string(); + let mut h = Harness::builder().with_size(vec2(300.0, 520.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::unique("rename_test_fonts"); + if ui.data(|d| d.get_temp::<bool>(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + super::show(app, ui); + }, + app, + ); + h.run_steps(3); + let fields = h.query_all_by_role(egui::accesskit::Role::TextInput).count(); + let renaming = egui::Id::unique(super::RENAMING); + h.ctx.data_mut(|d| d.insert_temp(renaming, (name.clone(), "Typed".to_string()))); + h.run_steps(2); + assert!(h.query_all_by_role(egui::accesskit::Role::TextInput).count() > fields, "the name is a field"); + // Another document with a swatch of the same name. + h.state_mut().run("file.new", json!({})).unwrap(); + h.state_mut().run("swatch.create", color).unwrap(); + h.run_steps(3); + assert!(h.ctx.data(|d| d.get_temp::<(String, String)>(renaming)).is_none(), "the rename is dropped"); + let doc = &h.state().session.active().unwrap().doc; + assert!(doc.swatch(&name).is_some() && doc.swatch("Typed").is_none()); + } + + /// The course's chapter 23: a swatch added as "C=14 M=8 Y=95 K=0" is renamed in the panel by + /// clicking it, then clicking its name again: the name becomes a field, and Enter renames the + /// swatch (and what uses it). A double-click still opens Swatch Options, Escape keeps the name, + /// and [Black] can't be renamed. + #[test] + fn a_second_click_on_the_picked_swatch_renames_it_in_place() { + use egui::accesskit::Role; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "unassigned"})).unwrap(); + let added = app.run("swatch.create", json!({"color": {"c": 14, "m": 8, "y": 95, "k": 0}})).unwrap()["name"].as_str().unwrap().to_string(); + assert_eq!(added, "C=14 M=8 Y=95 K=0"); + // Frames 50 ms apart: the double-click time is six of them. + let mut h = Harness::builder().with_size(vec2(300.0, 520.0)).with_step_dt(0.05).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::unique("test_fonts"); + if ui.data(|d| d.get_temp::<bool>(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + // The panel's own fields (Tint); renaming adds one. + let fields = h.query_all_by_role(Role::TextInput).count(); + let field = |h: &Harness<'_, DesignApp>| h.query_all_by_role(Role::TextInput).count() > fields; + let has = |h: &Harness<'_, DesignApp>, n: &str| h.state().session.doc().unwrap().doc.swatch(n).is_some(); + let name_at = |h: &Harness<'_, DesignApp>, name: &str| { + let row = h.query_all_by(|n| n.label().is_some_and(|l| names_swatch(&l, name))).next().expect("the swatch's row"); + row.rect().left_center() + vec2(40.0, 0.0) + }; + let click = |h: &mut Harness<'_, DesignApp>, at: egui::Pos2| { + h.hover_at(at); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.step(); + }; + // A click on the name: applied, and picked. + let at = name_at(&h, &added); + click(&mut h, at); + h.run_steps(10); + assert_eq!(items(h.state())[0].fill.swatch, added); + assert!(!field(&h)); + // Again, after the double-click time: the name is a field, with the name selected. + click(&mut h, at); + h.run_steps(10); + assert!(field(&h), "the name is a field"); + h.event(egui::Event::Text("Lemon Yellow Sun".into())); + h.step(); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert!(has(&h, "Lemon Yellow Sun") && !has(&h, &added)); + assert_eq!(items(h.state())[0].fill.swatch, "Lemon Yellow Sun", "what uses it follows"); + // Escape keeps the name. + let at = name_at(&h, "Lemon Yellow Sun"); + h.run_steps(10); + click(&mut h, at); + h.run_steps(10); + assert!(field(&h)); + h.event(egui::Event::Text("Sour".into())); + h.step(); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert!(has(&h, "Lemon Yellow Sun") && !field(&h)); + // A double-click on it opens Swatch Options, and doesn't rename. + h.run_steps(20); + click(&mut h, at); + click(&mut h, at); + h.run_steps(10); + assert_eq!(h.state().ui.dialog.as_ref().map(|d| d.id.as_str()), Some("swatchOptions")); + assert!(!field(&h), "no rename"); + // [Black] keeps its name. + h.state_mut().ui.dialog = None; + h.run_steps(20); + let at = name_at(&h, "[Black]"); + click(&mut h, at); + h.run_steps(10); + click(&mut h, at); + h.run_steps(10); + assert!(!field(&h)); + // A click on another swatch while the rename waits: that one is picked, and nothing + // renames. + let picked = |h: &Harness<'_, DesignApp>| h.ctx.data(|d| d.get_temp::<String>(egui::Id::unique(super::PICKED))); + let (lemon, paper) = (name_at(&h, "Lemon Yellow Sun"), name_at(&h, "[Paper]")); + h.run_steps(20); + click(&mut h, lemon); + h.run_steps(10); + click(&mut h, lemon); + click(&mut h, paper); + h.run_steps(10); + assert!(!field(&h), "the waiting rename is cancelled"); + assert_eq!(picked(&h).as_deref(), Some("[Paper]")); + // Renaming, a click on another swatch renames, and picks that one (for Delete Swatch). + click(&mut h, lemon); + h.run_steps(10); + click(&mut h, lemon); + h.run_steps(10); + assert!(field(&h)); + h.event(egui::Event::Text("Lemon".into())); + h.step(); + click(&mut h, paper); + h.run_steps(5); + assert!(has(&h, "Lemon") && !field(&h)); + assert_eq!(picked(&h).as_deref(), Some("[Paper]"), "the swatch clicked stays picked"); + } + #[test] fn the_tint_field_keeps_each_objects_own_swatch() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); @@ -1302,3 +1863,54 @@ mod swatches_header_tests { assert_eq!((it[1].fill.swatch.as_str(), it[1].fill.tint), ("C=0 M=100 Y=0 K=0", 0.4)); } } + +#[cfg(test)] +mod row_mark_tests { + use designcraft_color::swatch::{Swatch, default_swatches}; + use designcraft_color::{Color, ColorType, SwatchValue}; + + use super::{RowMarks, RowMode, row_label, row_marks}; + + /// A row's accessible name says what its marks show. + #[test] + fn swatch_rows_say_their_marks() { + let all = default_swatches(); + let label = |name: &str| row_label("", name, row_marks(all.iter().find(|s| s.name == name).unwrap())); + assert_eq!(label("[Black]"), "[Black], Process, CMYK, Cannot be edited"); + assert_eq!(label("[None]"), "[None], Cannot be edited"); + assert_eq!(label("[Paper]"), "[Paper]"); + let cyan = all.iter().find(|s| matches!(&s.value, SwatchValue::Color { .. }) && !s.locked).unwrap(); + let spot = Swatch { + name: "Pantone".into(), + value: SwatchValue::Color { color: Color::Rgb { r: 1.0, g: 0.0, b: 0.0 }, color_type: ColorType::Spot }, + locked: false, + ..cyan.clone() + }; + assert_eq!(row_label("it", &spot.name, row_marks(&spot)), "Pantone, Tinta piatta, RGB"); + } + + /// The Swatches panel's right-hand columns, as InDesign's: [None], [Registration] and [Black] + /// can't be edited, [Paper] can; colours show their type and mode. + #[test] + fn swatch_rows_mark_type_mode_and_whether_they_can_be_edited() { + let all = default_swatches(); + let marks = |name: &str| row_marks(all.iter().find(|s| s.name == name).unwrap()); + assert_eq!(marks("[None]"), RowMarks { not_editable: true, kind: None, mode: Some(RowMode::None) }); + assert_eq!(marks("[Registration]"), RowMarks { not_editable: true, kind: None, mode: Some(RowMode::Registration) }); + assert_eq!(marks("[Paper]"), RowMarks { not_editable: false, kind: None, mode: None }); + assert_eq!(marks("[Black]"), RowMarks { not_editable: true, kind: Some(ColorType::Process), mode: Some(RowMode::Cmyk) }); + let cyan = all.iter().find(|s| matches!(&s.value, SwatchValue::Color { .. }) && !s.locked).unwrap(); + assert_eq!(row_marks(cyan), RowMarks { not_editable: false, kind: Some(ColorType::Process), mode: Some(RowMode::Cmyk) }); + let spot = Swatch { + name: "PANTONE".into(), + value: SwatchValue::Color { color: Color::Rgb { r: 1.0, g: 0.0, b: 0.0 }, color_type: ColorType::Spot }, + locked: false, + ..cyan.clone() + }; + assert_eq!(row_marks(&spot), RowMarks { not_editable: false, kind: Some(ColorType::Spot), mode: Some(RowMode::Rgb) }); + // A locked Paper swatch under another name (a document from elsewhere) is still editable. + let paper = all.iter().find(|s| s.name == designcraft_color::swatch::PAPER).unwrap(); + let renamed = designcraft_color::swatch::Swatch { name: "Papier".into(), ..paper.clone() }; + assert!(!row_marks(&renamed).not_editable); + } +} diff --git a/crates/ui-egui/src/section_options.rs b/crates/ui-egui/src/section_options.rs index ea96f9f60..bb9be5ab4 100644 --- a/crates/ui-egui/src/section_options.rs +++ b/crates/ui-egui/src/section_options.rs @@ -36,13 +36,17 @@ pub(crate) fn open(app: &mut DesignApp) { let doc = &st.doc; let page = current.min(doc.page_count().saturating_sub(1)); let own = doc.sections.iter().find(|s| s.start == page); - // A new section starts from the one the page is in. + // A new section starts from the one the page is in. As in InDesign, the window opened on a + // page that doesn't start a section is New Section: Start Section checked, Start Page + // Numbering at offering 1, Automatic Page Numbering chosen. let base = own.or_else(|| doc.section_of(page)); + let new = page != 0 && own.is_none(); let fields = json!({ "page": page + 1, - "startSection": page == 0 || own.is_some(), + "newSection": new, + "startSection": true, "automatic": own.is_none_or(|s| s.start_number.is_none()), - "startNumber": doc.page_number(page).to_string(), + "startNumber": if new { "1".to_string() } else { doc.page_number(page).to_string() }, "prefix": base.map(|s| s.prefix.clone()).unwrap_or_default(), "style": base.map(|s| s.style).unwrap_or_default(), "marker": base.map(|s| s.marker.clone()).unwrap_or_default(), @@ -160,7 +164,12 @@ pub(crate) fn body(app: &DesignApp, ui: &mut Ui, d: &mut Dialog) { if ui.radio(!automatic, crate::rtl::widget(ui, tr("Start Page Numbering at:"))).clicked() { d.fields.insert("automatic".into(), json!(false)); } - ui.add_enabled_ui(!automatic, |ui| text(ui, d, "startNumber", FIELD_W)); + // As in InDesign, the number stays editable; typing one chooses Start Page Numbering at. + let mut n = d.s("startNumber"); + if ui.add(egui::TextEdit::singleline(&mut n).desired_width(FIELD_W)).changed() { + d.fields.insert("startNumber".into(), Value::String(n)); + d.fields.insert("automatic".into(), json!(false)); + } }); ui.add_space(4.0); group(ui, rtl, tr("Page Numbering"), |ui| { @@ -200,7 +209,12 @@ pub(crate) fn body(app: &DesignApp, ui: &mut Ui, d: &mut Dialog) { } }); if value == ChapterSource::UserDefined { - ui.add_enabled_ui(source == value, |ui| text(ui, d, "chapterStart", FIELD_W)); + // Editable whichever is chosen, as in InDesign; typing a number chooses it. + let mut n = d.s("chapterStart"); + if ui.add(egui::TextEdit::singleline(&mut n).desired_width(FIELD_W)).changed() { + d.fields.insert("chapterStart".into(), Value::String(n)); + d.fields.insert("chapterSource".into(), json!(value)); + } } }); } diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index b413e90c4..47459cddb 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -11,6 +11,9 @@ pub type DownloadFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>; pub type ClipboardFn = Box<dyn FnMut() -> Option<String>>; +/// Keeps the window's frame as the app needs it; called once per frame, before it is laid out. +pub type WindowFrameFn = Box<dyn FnMut()>; + /// Files delivered asynchronously with their original operation and document identity. pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<ImportedFile>>>; @@ -32,4 +35,8 @@ pub struct Services { /// The system clipboard's text, for Paste chosen from a menu (egui only delivers it with the /// paste keys, which a native menu bar takes first). pub clipboard_text: Option<ClipboardFn>, + /// Keeps the undecorated window borderless, so the content and the pointer stay aligned + /// (Windows custom title bar, #1295). `None` when the window is decorated or the platform + /// needs no fixing. + pub window_frame: Option<WindowFrameFn>, } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index bd038090a..63100f614 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -226,6 +226,9 @@ pub struct UiState { pub story_editor_size: f32, /// View › Story Editor › Show/Hide Depth Ruler. pub story_editor_depth_ruler: bool, + /// The Color Picker's component (its radio button, as `PickComp::key`: `h`, `s`, `b`, `l`, + /// `la`, `lb`, `r`, `g`, `bl`), kept between openings and sessions, as Photoshop's picker is. + pub color_picker_component: String, /// Preferences › General › Interface Language: supported codes are listed in `i18n::LANGUAGES`. pub language: String, /// Edit › Transparency Flattener Presets: "" (none), "high", "medium" or "low" for PDF export. @@ -399,6 +402,7 @@ impl Default for UiState { show_full_menus: false, story_editor_size: 14.0, story_editor_depth_ruler: true, + color_picker_component: "r".into(), scripts: vec![ ("Number the pages".into(), "# A page-number frame at the bottom of the current page.\nframe.create {\"rect\": [288, 740, 324, 760], \"content\": \"text\", \"text\": \"\"}\ntext.select {\"story\": \"$0.story\", \"anchor\": 0, \"focus\": 0}\ntext.insert {\"text\": \"\\ue000\", \"raw\": true}".into()), ("Two-column grid".into(), "# Four text frames in a 2 × 2 grid.\nframe.grid {\"rect\": [36, 36, 576, 756], \"cols\": 2, \"rows\": 2, \"gutter\": 12}".into()), diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 846cf2e89..8efa2e423 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -83,6 +83,10 @@ pub struct Tokens { /// Measurement labels on the canvas (smart dimensions, gaps). pub measure_bg: Color32, pub measure_text: Color32, + /// The readout beside the cursor while drawing, moving or measuring ("W: 3.13 cm"): InDesign's + /// light grey box, with dark text in every brightness but High Contrast. + pub readout_bg: Color32, + pub readout_text: Color32, /// Live-corner widget and corner diamonds. pub live_corner: Color32, /// The Windows caption Close button when hovered, and its glyph. @@ -130,6 +134,8 @@ impl Tokens { smart_guide: hex(0x00c853), measure_bg: Color32::from_rgba_unmultiplied(70, 70, 70, 230), measure_text: Color32::WHITE, + readout_bg: hex(0xa8a8a8), + readout_text: hex(0x1e1e1e), live_corner: hex(0xffe500), caption_close: hex(0xc42b1c), caption_close_text: Color32::WHITE, @@ -255,6 +261,9 @@ impl Tokens { ruler_text: hex(0xffffff), section_divider: hex(0xffffff), tab_strip: hex(0x000000), + // White on InDesign's grey is too faint here: black, as the rest of the chrome. + readout_bg: hex(0x000000), + readout_text: Color32::WHITE, ..dark }, } @@ -853,3 +862,36 @@ mod russian_font_tests { } } } + +#[cfg(test)] +mod readout_tests { + use super::{Brightness, Tokens}; + + fn luminance(c: egui::Color32) -> f32 { + let lin = |v: u8| { + let v = v as f32 / 255.0; + if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } + }; + 0.2126 * lin(c.r()) + 0.7152 * lin(c.g()) + 0.0722 * lin(c.b()) + } + + fn contrast(a: egui::Color32, b: egui::Color32) -> f32 { + let (x, y) = (luminance(a), luminance(b)); + (x.max(y) + 0.05) / (x.min(y) + 0.05) + } + + /// The readout beside the cursor ("W: 3.13 cm" while drawing a frame) is InDesign's light + /// grey box in every brightness, with text that stays readable on it; High Contrast keeps + /// it black. + #[test] + fn the_cursor_readout_is_indesigns_light_grey_with_readable_text() { + for b in [Brightness::Dark, Brightness::MediumDark, Brightness::MediumLight, Brightness::Light] { + let t = Tokens::for_brightness(b); + assert_eq!(t.readout_bg, egui::Color32::from_rgb(0xa8, 0xa8, 0xa8), "{b:?}"); + assert!(contrast(t.readout_text, t.readout_bg) >= 4.5, "{b:?}"); + } + let hc = Tokens::for_brightness(Brightness::HighContrast); + assert_eq!(hc.readout_bg, egui::Color32::BLACK); + assert!(contrast(hc.readout_text, hc.readout_bg) >= 4.5); + } +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 4c6965ffc..d909d6a04 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -10,6 +10,8 @@ use crate::{DesignApp, icons}; const BTN: f32 = 24.0; +/// Draws the adaptive Tools panel with localized tool flyouts, fill/stroke controls and +/// screen-mode buttons; flyout rows accommodate translated labels and their shortcuts. pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let frame = egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.0, t.divider)); @@ -103,23 +105,61 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { // on the tool (egui reports releases within 0.8 s as clicks). let hold_id = egui::Id::unique(("flyout_hold", gi)); let ended_hold = resp.clicked() && ui.data_mut(|d| d.remove_temp::<bool>(hold_id)).unwrap_or(false); - if resp.clicked() && !ended_hold { - chosen = Some(shown.id); + // Plain click selects the shown tool; Alt/Option-click cycles the flyout + // group, as the letter-key cycle does. Read the modifiers from the + // primary-release event (Alt may already be up by the time this frame + // reads i.modifiers), preferring a release on this slot; egui still + // counts a click when the pointer drifts a little outside, so a release + // whose press started here counts too, and one pressed elsewhere doesn't. + let click_mods = ui.input(|i| { + let press_here = i.pointer.press_origin().is_some_and(|o| resp.rect.contains(o)); + i.events + .iter() + .rev() + .find_map(|e| match e { + egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: false, + modifiers, + .. + } if resp.rect.contains(*pos) || press_here => Some(*modifiers), + _ => None, + }) + .unwrap_or(i.modifiers) + }); + let alt = click_mods.alt; + let modified = click_mods.alt || click_mods.shift || click_mods.ctrl || click_mods.command; + // egui's double-click ignores modifiers: remember whether the previous + // click was modified so dialogs only open for two plain clicks. + let prev_mod_id = egui::Id::unique(("tool-click-mod", gi)); + let prev_modified = ui.data(|d| d.get_temp::<bool>(prev_mod_id)).unwrap_or(false); + let slot_click = resp.clicked() && !ended_hold; + if slot_click { + chosen = Some(if g.len() > 1 && alt { + let from = g.iter().position(|tl| tl.id == current).unwrap_or(0); + g[(from + 1) % g.len()].id + } else { + shown.id + }); } if !resp.is_pointer_button_down_on() { ui.data_mut(|d| d.remove::<bool>(hold_id)); } // Double-click a polygon tool: Polygon Settings. - if resp.double_clicked() && matches!(shown.id, "polygon" | "polygonFrame") { + // Modified clicks only cycle / select: exclude them from dialogs. + let plain_double = resp.double_clicked() && !modified && !prev_modified; + if plain_double && matches!(shown.id, "polygon" | "polygonFrame") { let cur = app.session.execute("tool.polygonSettings", &json!({})).unwrap_or_default(); app.ui.dialog = Some(crate::dialogs::Dialog::new("polygonSettings", cur)); } // Double-click a transform tool: its Object › Transform dialog. - if resp.double_clicked() - && let Some(cmd) = transform_dialog_of(shown.id) - { + if plain_double && let Some(cmd) = transform_dialog_of(shown.id) { crate::menus::activate(app, cmd, &Value::Null); } + if slot_click { + ui.data_mut(|d| d.insert_temp(prev_mod_id, modified)); + } let held = resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.press_start_time().is_some_and(|s| i.time - s > 0.35)); if g.len() > 1 && held { @@ -135,43 +175,77 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { - for tl in g { - ui.horizontal(|ui| { - let (r, row) = ui.allocate_exact_size(vec2(210.0, 24.0), Sense::click()); - if row.hovered() { - ui.painter().rect_filled(r, 2.0, t.hover); - } - icons::paint( - ui.painter(), - egui::Rect::from_min_size(r.min + vec2(4.0, 3.0), vec2(18.0, 18.0)), - tl.icon, - t.icon, - ); - if tl.id == current { - ui.painter().circle_filled(r.min + vec2(-2.0, 12.0), 2.0, t.accent); - } - ui.painter().text( - r.min + vec2(28.0, 12.0), - egui::Align2::LEFT_CENTER, - crate::i18n::tr(&app.ui.language, tl.label), - egui::FontId::proportional(12.5), - t.text, - ); - if let Some(sc) = crate::menus::tool_shortcut_label(app, tl.id) { - ui.painter().text( - r.right_center() - vec2(6.0, 0.0), - egui::Align2::RIGHT_CENTER, - sc, - egui::FontId::proportional(11.5), - t.text_dim, - ); - } - row.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl.label)); - if row.clicked() { - chosen = Some(tl.id); - } - }); + let row_width = g.iter().fold(210.0_f32, |width, tl| { + let label = crate::i18n::tr(&app.ui.language, tl.label); + let label_width = ui + .painter() + .layout_no_wrap(label.to_owned(), egui::FontId::proportional(12.5), t.text) + .size() + .x; + let shortcut_width = ui + .painter() + .layout_no_wrap( + crate::menus::tool_shortcut_label(app, tl.id).unwrap_or_default(), + egui::FontId::proportional(11.5), + t.text_dim, + ) + .size() + .x; + width.max(label_width + shortcut_width + 46.0) + }); + let max_width = (ui.ctx().content_rect().width() + - egui::Frame::popup(ui.style()).total_margin().sum().x) + .max(0.0); + let mut scroll = + egui::ScrollArea::horizontal().id_salt(("tool-flyout-scroll", gi)).max_width(max_width); + if opened_now { + scroll = scroll.horizontal_scroll_offset(0.0); } + scroll.show(ui, |ui| { + for tl in g { + ui.horizontal(|ui| { + let (r, row) = ui.allocate_exact_size(vec2(row_width, 24.0), Sense::click()); + if row.hovered() { + ui.painter().rect_filled(r, 2.0, t.hover); + } + icons::paint( + ui.painter(), + egui::Rect::from_min_size(r.min + vec2(4.0, 3.0), vec2(18.0, 18.0)), + tl.icon, + t.icon, + ); + if tl.id == current { + ui.painter().circle_filled(r.min + vec2(-2.0, 12.0), 2.0, t.accent); + } + ui.painter().text( + r.min + vec2(28.0, 12.0), + egui::Align2::LEFT_CENTER, + crate::i18n::tr(&app.ui.language, tl.label), + egui::FontId::proportional(12.5), + t.text, + ); + if let Some(sc) = crate::menus::tool_shortcut_label(app, tl.id) { + ui.painter().text( + r.right_center() - vec2(6.0, 0.0), + egui::Align2::RIGHT_CENTER, + sc, + egui::FontId::proportional(11.5), + t.text_dim, + ); + } + row.widget_info(|| { + egui::WidgetInfo::labeled( + egui::Role::Button, + true, + crate::i18n::tr(&app.ui.language, tl.label), + ) + }); + if row.clicked() { + chosen = Some(tl.id); + } + }); + } + }); }); }, ); diff --git a/crates/ui-egui/src/toolbar/tests.rs b/crates/ui-egui/src/toolbar/tests.rs index 6a8ea752c..df06bd020 100644 --- a/crates/ui-egui/src/toolbar/tests.rs +++ b/crates/ui-egui/src/toolbar/tests.rs @@ -19,10 +19,203 @@ fn column_width(cols: f32) -> f32 { 8.0 + super::BTN * cols + 4.0 * (cols - 1.0) + 8.0 } +/// Returns the bounds of the labeled test widget, failing if the label cannot be found. fn rect(h: &egui_kittest::Harness<'static, test_window::Window>, label: &str) -> egui::Rect { h.get_by_label(label).rect() } +/// Collects painted text bounds and their clip rectangles, including nested shapes. +fn collect_text(shape: &egui::Shape, clip: egui::Rect, texts: &mut Vec<(String, egui::Rect, egui::Rect)>) { + match shape { + egui::Shape::Text(text) => texts.push((text.galley.text().to_owned(), text.visual_bounding_rect(), clip)), + egui::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect_text(shape, clip, texts)), + _ => {} + } +} + +/// Checks translated tooltips and right-click flyout rows in both Tools-panel layouts, +/// including painted labels, unchanged shortcuts and unclipped, nonoverlapping text. +#[test] +fn hungarian_tool_buttons_translate_every_tool_and_keep_shortcuts() { + use egui::{Event, Modifiers, PointerButton}; + + for double in [false, true] { + let mut app = app(double); + app.ui.language = "hu".into(); + let mut h = test_window::open(app, vec2(1400.0, 1000.0)); + for tool in designcraft_tools::TOOL_GROUPS.iter().flat_map(|group| group.iter()) { + h.state_mut().app.run("tool.select", serde_json::json!({"tool": tool.id})).unwrap(); + h.run_steps(3); + let label = crate::i18n::tr("hu", tool.label); + assert_ne!(label, tool.label, "untranslated tool: {}", tool.id); + let shortcut = tool.shortcut.map(crate::menus::shortcut_text); + assert_eq!(crate::menus::tool_shortcut_label(&h.state().app, tool.id), shortcut, "default binding for {}", tool.id); + let tip = shortcut.map_or_else(|| label.to_owned(), |shortcut| format!("{label} ({shortcut})")); + assert!(rect(&h, &tip).is_finite(), "{tip}"); + } + // A user-assigned chord also has to fit beside a translated flyout label. + let custom_shortcut = "Ctrl+Alt+Shift+F12"; + h.state_mut() + .app + .run( + "window.setShortcut", + serde_json::json!({"id": "tool.select", "params": {"tool": "ellipseFrame"}, "shortcut": custom_shortcut, "steal": true}), + ) + .unwrap(); + for (gi, group) in designcraft_tools::TOOL_GROUPS.iter().enumerate() { + // Single tools have no flyout; the collector group is split into separate + // buttons in two-column mode and is covered by the tooltip checks above. + if group.len() < 2 || (double && designcraft_tools::SPLIT_IN_TWO_COLUMNS.contains(&group[0].id)) { + continue; + } + let first = group[0]; + h.state_mut().app.run("tool.select", serde_json::json!({"tool": first.id})).unwrap(); + h.run_steps(3); + let label = crate::i18n::tr("hu", first.label); + let tip = first.shortcut.map(crate::menus::shortcut_text).map_or_else(|| label.to_owned(), |shortcut| format!("{label} ({shortcut})")); + let at = rect(&h, &tip).center(); + h.input_mut().events.push(Event::PointerMoved(at)); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + } + h.run_steps(3); + assert_eq!(h.state().app.ui.flyout, Some(gi), "right-click opens {} (double={double})", first.id); + let mut texts = Vec::new(); + for shape in &h.output().shapes { + collect_text(&shape.shape, shape.clip_rect, &mut texts); + } + for tool in *group { + let label = crate::i18n::tr("hu", tool.label); + // Flyout accessibility labels omit the shortcut; inspect the painted + // text inside this row so another button cannot satisfy the assertion. + let row = rect(&h, label); + assert!(row.is_finite() && row.is_positive(), "{label}"); + let painted = |expected: &str| { + let (_, bounds, clip) = texts + .iter() + .find(|(text, bounds, _)| text == expected && row.contains(bounds.center())) + .unwrap_or_else(|| panic!("missing flyout text {expected:?} for {} (double={double})", tool.id)); + assert!(row.contains_rect(*bounds) && clip.contains_rect(*bounds), "clipped flyout text {expected:?}: {bounds:?}"); + *bounds + }; + let label_bounds = painted(label); + let binding = if tool.id == "ellipseFrame" { Some(custom_shortcut) } else { tool.shortcut }; + if let Some(shortcut) = binding.map(crate::menus::shortcut_text) { + let shortcut_bounds = painted(&shortcut); + assert!(label_bounds.right() < shortcut_bounds.left(), "label overlaps shortcut for {} (double={double})", tool.id); + } + } + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.state().app.ui.flyout.is_none()); + } + assert_eq!(crate::i18n::tr("hu", "Direct Selection Tool"), "Közvetlen kijelölő eszköz"); + } +} + +/// Keeps long translated flyout rows reachable by scrolling in a narrow viewport, +/// resetting on reopening while preserving the open scroll position, shortcut and selection. +#[test] +fn hungarian_flyout_scrolls_horizontally_in_narrow_windows() { + use egui::{Event, Modifiers, PointerButton}; + + for double in [false, true] { + for width in [180.0, 240.0] { + let mut app = app(double); + app.ui.language = "hu".into(); + let chord = "Ctrl+Alt+Shift+F12"; + app.run( + "window.setShortcut", + serde_json::json!({"id": "tool.select", "params": {"tool": "ellipseFrame"}, "shortcut": chord, "steal": true}), + ) + .unwrap(); + app.select_tool("rectangleFrame"); + let mut h = test_window::open(app, vec2(width, 1000.0)); + let at = rect(&h, "Téglalapkeret eszköz (F)").center(); + h.input_mut().events.push(Event::PointerMoved(at)); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + } + h.run_steps(3); + let gi = h.state().app.ui.flyout.unwrap(); + let area = h.ctx.memory(|m| m.area_rect(egui::Id::unique(("flyout", gi)))).unwrap(); + let viewport = h.ctx.content_rect(); + assert!(viewport.expand(0.5).contains_rect(area), "flyout {area:?} exceeds viewport {viewport:?}"); + let label = "Ellipsziskeret eszköz"; + let shortcut = crate::menus::shortcut_text(chord); + let painted = |h: &egui_kittest::Harness<'static, test_window::Window>, text: &str| { + let row = rect(h, label); + let mut texts = Vec::new(); + for shape in &h.output().shapes { + collect_text(&shape.shape, shape.clip_rect, &mut texts); + } + let (_, bounds, clip) = texts.into_iter().find(|(t, b, _)| t == text && row.contains(b.center())).unwrap(); + (bounds, clip) + }; + let (label_bounds, label_clip) = painted(&h, label); + assert!(label_clip.contains_rect(label_bounds), "label must be readable at the start"); + let (before, clip) = painted(&h, &shortcut); + let oversized = rect(&h, label).width() > clip.width(); + if width == 180.0 { + assert!(oversized, "the tighter case must exercise horizontal overflow on every platform"); + } + review_shot(&mut h, &format!("flyout-{width}-double-{double}-left.png")); + if oversized { + assert!(!clip.contains_rect(before), "the shortcut starts beyond the narrow viewport"); + wheel(&mut h, area.center(), vec2(-1000.0, 0.0)); + let (after, clip) = painted(&h, &shortcut); + assert!(clip.contains_rect(after), "scrolling must make the complete shortcut readable"); + assert!(after.left() < before.left(), "horizontal input must move the row"); + h.run_steps(3); + let (settled, clip) = painted(&h, &shortcut); + assert!(clip.contains_rect(settled), "an open flyout must retain its horizontal scroll position"); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.state().app.ui.flyout.is_none()); + // Reopen through the toolbar input path, without changing the stored scroll state. + let at = rect(&h, "Téglalapkeret eszköz (F)").center(); + h.input_mut().events.push(Event::PointerMoved(at)); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { + pos: at, + button: PointerButton::Secondary, + pressed, + modifiers: Modifiers::NONE, + }); + } + h.run_steps(3); + assert_eq!(h.state().app.ui.flyout, Some(gi)); + let (reopened, clip) = painted(&h, label); + assert!(clip.contains_rect(reopened), "a reopened flyout must show the complete translated label"); + assert!((reopened.left() - label_bounds.left()).abs() < 0.5, "reopening must return to the initial left edge"); + review_shot(&mut h, &format!("flyout-{width}-double-{double}-reopened.png")); + wheel(&mut h, area.center(), vec2(-1000.0, 0.0)); + let (rescrolled, clip) = painted(&h, &shortcut); + assert!(clip.contains_rect(rescrolled), "a reopened flyout must still allow scrolling to the shortcut"); + } else { + assert!(clip.contains_rect(before)); + } + review_shot(&mut h, &format!("flyout-{width}-double-{double}-right.png")); + assert_eq!(crate::menus::tool_shortcut_label(&h.state().app, "ellipseFrame"), Some(shortcut)); + let at = rect(&h, label).intersect(area).center(); + h.input_mut().events.push(Event::PointerMoved(at)); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + } + h.run_steps(3); + assert_eq!(h.state().app.session.tool_id(), "ellipseFrame"); + assert!(h.state().app.ui.flyout.is_none()); + } + } +} + +/// Saves synthetic UI evidence only when the local review explicitly requests it. +fn review_shot(h: &mut egui_kittest::Harness<'static, test_window::Window>, name: &str) { + if let Some(dir) = std::env::var_os("DESIGNCRAFT_HU_REVIEW_SHOTS") { + h.render().unwrap().save(std::path::Path::new(&dir).join(name)).unwrap(); + } +} + /// As InDesign's: a tool group's flyout closes on Esc and on a click outside it (the fill /// swatch, a panel), not only on the canvas. #[test] @@ -197,7 +390,8 @@ fn the_tools_panel_lays_out_like_indesigns() { for double in [false, true] { let h = test_window::open(app(double), vec2(1400.0, 900.0)); let _ = rect(&h, "Gradient Swatch Tool (G)"); - let _ = rect(&h, "Gradient Feather Tool (Shift+G)"); + let feather = format!("Gradient Feather Tool ({})", crate::menus::shortcut_text("Shift+G")); + let _ = rect(&h, &feather); let _ = rect(&h, "View Options"); let split = ["Content Placer Tool (B)", "Apply Color (,)", "Apply Gradient (.)", "Normal"]; for label in split { @@ -378,3 +572,54 @@ fn fill_stroke_proxy_keys() { press(&mut app, &ctx, Key::X, Modifiers::NONE); assert_eq!(app.ui.stroke_front, front, "X while typing leaves the proxy alone"); } + +/// Alt/Option-click cycles a tool group's flyout without opening the menu. +#[test] +fn alt_click_cycles_the_tool_flyout_group() { + use egui::{Event, Modifiers, PointerButton}; + use egui_kittest::kittest::Queryable; + let app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + // 1 ms frames so an Alt-click and a following plain click can form a real double-click + // (default open() spaces frames by 0.25 s, outside egui's double-click window). + let mut h = crate::test_window::open_stepped(app, vec2(1400.0, 900.0), 1.0, 0.001); + h.state_mut().app.select_tool("rectangleFrame"); + h.run_steps(2); + let at = h.get_by_label("Rectangle Frame Tool (F)").rect().center(); + let alt = Modifiers { alt: true, ..Default::default() }; + // Clear i.modifiers before the release while keeping Alt on the release event, so a + // regression that only reads i.modifiers (and ignores the pointer-release modifiers) fails. + let click = |h: &mut egui_kittest::Harness<'static, crate::test_window::Window>, press_modifiers: Modifiers, current_modifiers: Modifiers| { + h.input_mut().events.push(Event::ModifiersChanged(press_modifiers)); + h.input_mut().events.push(Event::PointerMoved(at)); + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: press_modifiers }); + h.input_mut().events.push(Event::ModifiersChanged(current_modifiers)); + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: press_modifiers }); + h.run_steps(3); + }; + // Break the double-click window between independent clicks (~0.4 s at 1 ms/step). + let settle = |h: &mut egui_kittest::Harness<'static, crate::test_window::Window>| h.run_steps(400); + click(&mut h, Modifiers::NONE, Modifiers::NONE); + assert_eq!(h.state().app.session.tool_id(), "rectangleFrame"); + assert!(h.state().app.ui.flyout.is_none()); + settle(&mut h); + click(&mut h, alt, Modifiers::NONE); + assert_eq!(h.state().app.session.tool_id(), "ellipseFrame", "Alt-click advances the frame group"); + assert!(h.state().app.ui.flyout.is_none(), "Alt-click must not open the flyout"); + settle(&mut h); + click(&mut h, alt, Modifiers::NONE); + assert_eq!(h.state().app.session.tool_id(), "polygonFrame"); + settle(&mut h); + click(&mut h, alt, Modifiers::NONE); + assert_eq!(h.state().app.session.tool_id(), "rectangleFrame", "Alt-click after the last group tool wraps to the first"); + assert!(h.state().app.ui.flyout.is_none(), "wrap-around Alt-click must not open the flyout"); + settle(&mut h); + // Alt-click onto polygonFrame, then a plain click inside the double-click window: egui + // reports double_clicked, but prev_modified must keep Polygon Settings closed. + h.state_mut().app.select_tool("ellipseFrame"); + h.run_steps(2); + click(&mut h, alt, Modifiers::NONE); + assert_eq!(h.state().app.session.tool_id(), "polygonFrame"); + click(&mut h, Modifiers::NONE, Modifiers::NONE); + assert!(h.state().app.ui.dialog.is_none(), "plain click after Alt-click must not open Polygon Settings"); + assert_eq!(h.state().app.session.tool_id(), "polygonFrame"); +} diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs index 43803c9a5..24a056247 100644 --- a/crates/ui-egui/src/updates_ui.rs +++ b/crates/ui-egui/src/updates_ui.rs @@ -4,8 +4,12 @@ use serde_json::Value; use crate::DesignApp; +#[cfg(all(not(target_arch = "wasm32"), test))] +use craft_update::updater::Status; #[cfg(not(target_arch = "wasm32"))] -use craft_update::updater::{Action, Status, Updater}; +use craft_update::updater::{Action, Updater}; +#[cfg(not(target_arch = "wasm32"))] +use craft_update::{CheckResultDialog, CheckResultResponse}; pub(crate) struct Runtime { #[cfg(not(target_arch = "wasm32"))] @@ -18,23 +22,24 @@ pub(crate) struct Runtime { /// The context of the last frame, to wake the UI when a check is queued from a menu callback. #[cfg(not(target_arch = "wasm32"))] repaint: Option<egui::Context>, + /// The web build has no updater; its Help ▸ Check for Updates opens a window with a fixed message. + #[cfg(target_arch = "wasm32")] pub(crate) manual_window_open: bool, - pub(crate) status_message: Option<String>, } impl Default for Runtime { fn default() -> Self { Self { #[cfg(not(target_arch = "wasm32"))] - updater: Updater::new(&craft_update::suite::DESIGNCRAFT, env!("CARGO_PKG_VERSION")), + updater: Updater::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")), #[cfg(not(target_arch = "wasm32"))] startup_check: false, #[cfg(not(target_arch = "wasm32"))] check_requested: false, #[cfg(not(target_arch = "wasm32"))] repaint: None, + #[cfg(target_arch = "wasm32")] manual_window_open: false, - status_message: None, } } } @@ -59,7 +64,6 @@ pub(crate) fn check(app: &mut DesignApp) -> Result<Value, String> { #[cfg(target_arch = "wasm32")] { app.updates_rt.manual_window_open = true; - app.updates_rt.status_message = Some("Update check is not supported in the web browser.".into()); Ok(Value::Null) } } @@ -90,22 +94,16 @@ pub(crate) fn show(app: &mut DesignApp, ctx: &egui::Context) { Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close), Action::None => {} } - // The window answers Help ▸ Check for Updates, so it follows the updater's status. - let rt = &mut app.updates_rt; - (rt.manual_window_open, rt.status_message) = match rt.updater.status() { - Status::Idle => (false, None), - Status::Checking => (true, Some(crate::i18n::tr(&app.ui.language, "Checking for updates…").to_string())), - Status::UpToDate => ( - true, - Some( - crate::i18n::tr(&app.ui.language, "DesignCraft {version} is currently the newest version available.") - .replace("{version}", env!("CARGO_PKG_VERSION")), - ), - ), - Status::Failed(e) => (true, Some(crate::i18n::tr(&app.ui.language, "Could not check for updates:\n{error}").replace("{error}", e))), - }; + // Help ▸ Check for Updates answers in the shared dialog, which follows the updater's status. + let dialog = CheckResultDialog::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")); + match dialog.show(ctx, app.updates_rt.updater.status()) { + CheckResultResponse::None => {} + CheckResultResponse::Close => app.updates_rt.updater.dismiss_status(), + CheckResultResponse::CheckAgain => app.updates_rt.check_requested = true, + } } + #[cfg(target_arch = "wasm32")] if app.updates_rt.manual_window_open { let mut open = true; let title = crate::i18n::tr(&app.ui.language, "DesignCraft Updates"); @@ -117,9 +115,7 @@ pub(crate) fn show(app: &mut DesignApp, ctx: &egui::Context) { .pivot(egui::Align2::CENTER_CENTER) .default_pos(ctx.content_rect().center()) .show(ctx, |ui| { - if let Some(msg) = &app.updates_rt.status_message { - ui.label(msg); - } + ui.label(crate::i18n::tr(&app.ui.language, "Update check is not supported in the web browser.")); ui.add_space(8.0); if ui.button(ok_label).clicked() { app.updates_rt.manual_window_open = false; @@ -129,12 +125,6 @@ pub(crate) fn show(app: &mut DesignApp, ctx: &egui::Context) { app.updates_rt.manual_window_open = false; } } - - // Closing the window dismisses the outcome it showed. - #[cfg(not(target_arch = "wasm32"))] - if !app.updates_rt.manual_window_open { - app.updates_rt.updater.dismiss_status(); - } } #[cfg(test)] @@ -145,6 +135,7 @@ mod tests { #[test] fn default_runtime_is_idle() { let rt = Runtime::default(); + #[cfg(target_arch = "wasm32")] assert!(!rt.manual_window_open); #[cfg(not(target_arch = "wasm32"))] { diff --git a/crates/ui-egui/src/window_frame_tests.rs b/crates/ui-egui/src/window_frame_tests.rs new file mode 100644 index 000000000..18be1ae5b --- /dev/null +++ b/crates/ui-egui/src/window_frame_tests.rs @@ -0,0 +1,39 @@ +//! The window-frame service keeps the custom title bar's undecorated window borderless (#1295). +//! +//! winit 0.30 gives an undecorated window a caption and border (`WS_CAPTION | WS_BORDER`), which +//! on some Windows systems offset the drawn content and the pointer. The app shell strips them +//! and re-strips them after every toolkit flag change (maximize, restore, …), driven from +//! `raw_input_hook`. These tests pin the shell side: the hook must run the service once per frame, +//! before the frame is laid out. + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use super::*; + +#[test] +fn the_window_frame_service_runs_every_raw_input_hook() { + let calls = Arc::new(AtomicUsize::new(0)); + let n = calls.clone(); + let services = Services { + window_frame: Some(Box::new(move || { + n.fetch_add(1, Ordering::SeqCst); + })), + ..Default::default() + }; + let mut app = DesignApp::new(designcraft_engine::Session::new(), services); + let ctx = egui::Context::default(); + for _ in 0..3 { + app.raw_input_hook(&ctx, &mut egui::RawInput::default()); + } + assert_eq!(calls.load(Ordering::SeqCst), 3); +} + +#[test] +fn no_window_frame_service_is_fine() { + // Decorated windows and non-Windows platforms leave the service unset; the hook must simply + // do nothing. + let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default()); + let ctx = egui::Context::default(); + app.raw_input_hook(&ctx, &mut egui::RawInput::default()); +} diff --git a/docs/agents.md b/docs/agents.md index 566357fbe..621173a85 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -54,6 +54,17 @@ same references, and MCP's `batch` tool takes `commands` or `script` text with t `.epub`, `.html`, and `.rtf` or `.txt` (the story being edited or of the selected frame). A name with no extension is a PNG of the page; another extension is an error, and nothing runs. +## Table of contents bookmarks + +`toc.generate` accepts `createBookmarks: true` to generate page-level PDF bookmarks from the +included paragraph styles and their levels. `toc.update` refreshes their titles, hierarchy and +destinations. Refresh keeps the generated outline at its previous position, preserving manual +bookmarks before and after it; disabling the option removes only the generated +bookmarks. Without `createBookmarks`, `toc.generate` keeps the document's existing TOC setting, +which is off when there is no TOC yet, so a script that makes a new TOC gets no bookmarks unless +it passes `createBookmarks: true`. The Table of Contents dialog differs: its Create PDF Bookmarks +option is on by default for a new TOC, and for an existing one it shows the saved setting. + ## Color themes `color.extractTheme {id?}` previews up to five RGB colours from a placed raster image (or the selected image). diff --git a/docs/contributors.md b/docs/contributors.md index 410a94091..1728c4727 100644 --- a/docs/contributors.md +++ b/docs/contributors.md @@ -1,16 +1,14 @@ # Contributors in the About window **About ▸ Contributors** credits everyone who contributed to DesignCraft, and **About ▸ Models** credits -the AI models named in `Co-Authored-By` trailers. This follows the shared craftrules standard -[`standards/contributors.md`](https://github.com/storytold/craftrules/blob/main/standards/contributors.md); -this page is the local copy of the decision. +the AI models named in `Co-Authored-By` trailers. ## Decision - Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from disk or the network at run time; the web build has them too): - [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub - username. **Generated** by `craftrules/scripts/contributors.py`; never hand-edit. + username. - [`contributors/people.toml`](../contributors/people.toml): the names people chose for themselves. **Each contributor adds only their own entry**, in their own PR. - Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced @@ -36,20 +34,5 @@ display_name = "Nickname" sync_github_name = true # use the name on your public GitHub profile as display name ``` -or, with craftrules checked out next to `craft-apps/`: - -```sh -python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "Your Name" --sync-github-name -``` - -Names show after the next refresh: the script checks that each entry was committed by its own -user (or by a maintainer with a `consent_source`). Delete your entry to remove your names. - -## Refreshing (maintainers) - -```sh -python3 ../../craftrules/scripts/contributors.py . # regenerate + verify; needs git, Python 3.11+, gh -python3 ../../craftrules/scripts/contributors.py --check . # verify people.toml only -``` - -Commit the updated `contributors/contributors.json`. Git author names and emails are never recorded. +Names show for entries committed by their own user (or by a maintainer with a `consent_source`). +Delete your entry to remove your names. Git author names and emails are never recorded. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 92e7f108e..e886c07ab 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -49,7 +49,7 @@ so only enable it while you use it. Transport: `apps/designcraft/src/control_ser | `ui.dialog.set` / `ui.dialog.confirm` / `ui.dialog.cancel` | `{field, value}` | Fill and confirm the open dialog | | `ui.resize`, `ui.focus` | | Window control | | `ui.screenshot` | `{path?}` | PNG of the whole window | -| `ui.render` | `{path?, page?, scale?, bleed?}` | Render a page headlessly (PNG; base64 if no path) | +| `ui.render` | `{path?, page?, scale?, bleed?}` | Render a document page headlessly (PNG; base64 if no path); without a canvas, document mode defaults to page 0; parent editing mode requires an explicit 0-based `page` | | `app.open` / `app.save` / `app.export` | | Files | | `app.quit` | | Quit without asking about unsaved changes (the `app.quit` command, like ⌘Q, asks) | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 7b70a9a43..fcab4b9f9 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -44,7 +44,7 @@ layer survive interchange; literal XML and round-trip tests are in | PDF (print) | `.pdf` | ✓ all presets, PDF/X-1a/3/4, PDF/A, PDF/UA | ✓ krilla: real text with subset fonts, DeviceCMYK/RGB, spot Separations, bleed/slug, marks, spreads, compression options, remembered settings (#196); **PDF/X-4** with output intent and a validator (#223, #200); **PDF/A-2b**; tagged PDF; **PDF/X-1a:2003** flattened in its inks by craft-prepress | 53 tests in `crates/pdf`; no PDF/X-3; no presets / `.joboptions`; no external validator (veraPDF / callas) in CI | 18% | | PDF (interactive) | `.pdf` | ✓ | ✓ links, bookmarks, buttons, AcroForm fields, page transitions, object states | | 2% | | JPEG / PNG | `.jpg`, `.png` | ✓ | PNG from the UI; JPEG from the CLI only | no resolution / colour space options dialog for JPEG | 3% | -| EPUB reflowable | `.epub` | ✓ | ✓ stories in article order, tables, footnotes and endnotes, CSS from styles, images, nav | | 2% | +| EPUB reflowable | `.epub` | ✓ | ✓ stories in article order, tables, footnotes and endnotes, CSS from styles, images, nav | the cover is the first page, or a PNG or JPEG file named in EPUB Export Options (#1934) | 2% | | EPUB fixed layout | `.epub` | ✓ (live HTML/CSS) | ~ page images with text kept for search | not live text | 1% | | HTML | `.html` | ✓ | ~ one self-contained page (#314) | | 1% | | HTML5 ZIP / Publish Online | | ✓ | ✗ (Publish Online out of scope) | | <1% | diff --git a/docs/gaps.md b/docs/gaps.md index ea9cafd40..c2ca44ad2 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -156,7 +156,7 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · - **Estimate:** 25–40 h. ### 13. Hyphenation and spelling languages (T, L) -- **Missing:** hyphenation patterns for every language but English, Spanish, Portuguese, Norwegian and Greek; spelling for +- **Missing:** hyphenation patterns for every language but English, Spanish, Portuguese, Norwegian, Greek, German (2006 reform), French, Italian and Dutch; spelling for every language but English. InDesign ships dictionaries for ~40 languages. - **Estimate:** 30–60 h (permissively licensed patterns exist for most; spelling word lists need licence review). **Doc:** [typography-parity.md](typography-parity.md) diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 404cf13a7..677c4291a 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (missing translations are reported, never a failure; catalog counts remain the 2026-10-10 measurement) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-01 · **Change:** minor (Hungarian interface catalog, bundled-font coverage and flyout regression checks; Swedish interface) · **Target:** Adobe InDesign 2026 (21.6) Per-language status of the DesignCraft interface and of document typesetting in each script. Script-specific typesetting detail: [cjk.md](cjk.md), [cjk-typography.md](cjk-typography.md), @@ -11,7 +11,7 @@ Script-specific typesetting detail: [cjk.md](cjk.md), [cjk-typography.md](cjk-ty ## How it was measured Interface strings live in `crates/ui-egui/src/i18n/table.rs` (a shared table with German, French, -Spanish, Japanese and Simplified Chinese columns) and `crates/ui-egui/src/i18n/{ar,cs,it,ja,ka,pt_br,uk}.rs`. +Spanish, Japanese and Simplified Chinese columns) and `crates/ui-egui/src/i18n/{ar,cs,de,fr,hu,it,ja,ka,pl,pt_br,ru,sv,uk}.rs`. Untranslated strings fall back to English. A script (2026-10-10) built the set of English interface strings as the union of every catalog's keys, every engine command label and every literal passed to `tr`/`t` in the UI crate: **1,724 strings**. A further ~145 capitalized literals @@ -19,9 +19,11 @@ in the UI crate appear in no catalog (some are test data or proper names), so th somewhat higher and every percentage below is a slight overestimate. Help: DesignCraft has no help content in any language (Help ▸ opens the project's pages). -A missing translation never fails a test, a build or a release. `cargo xtask i18n-report` (also -a step of `cargo xtask ci`, so the list is in every gate log) prints how many interface strings -each language lacks, then the strings. +Missing translations normally fall back to English and are reported without blocking a build. +Targeted regression tests can require specific captions, such as the Data Merge arrangement +label, in every registered interface language. `cargo xtask i18n-report` (also a step of +`cargo xtask ci`, so the list is in every gate log) prints how many interface strings each +language lacks, then the strings. InDesign 2026 ships 25 interface localizations (`.lproj` folders: cs, da, de, el, en, en_GB, es, fi, fr, hu, it, ja, ko, nb, nl, pl, pt, ro, ru, sq, sv, tr, uk, zh_CN, zh_TW); Arabic and Hebrew @@ -36,11 +38,11 @@ come as Middle East editions with an English or French interface and right-to-le | Spanish | es | ✓ | 1,438 (83%) | menus, panels, most dialogs; no help | Latin ✓; Spanish hyphenation ✓ (#222) | no | partial | 5–8 | | Hindi | hi | ✗ | 0 (0%) | none | Devanagari shaped by harfrust in documents but untested; no Hindi hyphenation or spelling | no | none | 20–30 | | Arabic | ar | Middle East edition (English/French UI) | 1,459 (85%) | menus, panels, most dialogs; no help | RTL UI text (`rtl.rs`: bidi + shaping); Arabic composition, kashida, digits, RTL stories and tables ✓ (arabic-typography.md); list bullets and multi-column RTL open (#100) | no | partial | 15–25 | -| French | fr | ✓ | 1,438 (83%) | menus, panels, most dialogs; no help | Latin ✓; no French hyphenation | no | partial | 6–10 | +| French | fr | ✓ | 1,438 (83%) | menus, panels, most dialogs; no help | Latin ✓; French hyphenation ✓ (hyph-fr) | no | partial | 6–10 | | Portuguese (Brazil) | pt-br | ✓ (pt) | 1,057 (61%) | menus, panels, some dialogs; no help | Latin ✓; Portuguese hyphenation ✓ | no | partial | 8–12 | | Indonesian | id | ✗ | 0 (0%) | none | Latin ✓ | no | none | 12–18 | | Japanese | ja | ✓ | 1,644 (95%) | whole interface (#272), vertical-type commands; no help | vertical text, kinsoku, aki, ruby, warichu ✓ (partial depth, cjk.md); canvas IME ✓, not checked by hand (#322); Japanese UI and Mincho fonts from craft-fonts | no | partial | 6–10 (+ IME checks, shared with zh) | -| German | de | ✓ | 1,438 (83%) | menus, panels, most dialogs; no help | Latin ✓; no German hyphenation (InDesign ships 2006 reform patterns) | no | partial | 6–10 | +| German | de | ✓ | 1,438 (83%) | menus, panels, most dialogs; no help | Latin ✓; German 2006 hyphenation ✓ (hyph-de-1996, MIT) | no | partial | 6–10 | | Korean | ko | ✓ | 0 (0%) | none | Korean line breaking at spaces ✓; canvas IME ✓, not checked by hand (#322) | no | none | 15–25 | | Vietnamese | vi | ✗ | 0 (0%) | none | Latin with stacked diacritics via harfrust, untested | no | none | 12–18 | @@ -52,25 +54,51 @@ caption, table row/column movement and row-height distribution now use the exist catalog. Print, import options and an active table were rendered in Japanese and English with craft-fonts. The language totals above retain their dated measurement; this is a targeted fix. +Hungarian interface (2026-11-01): `hu.rs` contains **1,906 entries and four context-specific +captions**, including all 37 tool names. The initial catalog was prepared with AI assistance +and personally reviewed in the app by **Veress Róbert**, a native Hungarian speaker with over +30 years of DTP experience. Further terminology and in-context feedback is welcome. This is a +partial catalog, not a new coverage measurement against current main: uncatalogued strings +fall back to English. Preferences lists `Magyar`; interface selection does not enable the +separate spelling and hyphenation work in #1484. + +The interface uses bundled **Source Sans 3 Regular** and **Source Sans 3 Semibold**; monospace +labels use **JetBrains Mono Regular** (`assets/fonts/`, registered by `theme::font_definitions`). +`hungarian_catalog_glyphs_exist_in_the_bundled_ui_fonts` checks the actual configured font +bytes, every Latin letter used by the catalog and all Hungarian accented vowels in both cases, +including **ŐőŰű**, with system and optional craft-fonts fallbacks disabled. No new font asset +or font dependency is needed. + +Hungarian regressions cover placeholder/context integrity, formal address and count/column +terminology, language persistence, matching menu paths, translated tool labels and real +right-click flyouts in both Tools-panel layouts. Narrow-window coverage exercises horizontal +scrolling, complete label/shortcut accessibility and selecting a scrolled row at 180 and +240 points; the existing wide-window checks keep their assertions. + ## Other shipped languages | Language | Code | UI strings translated | Status | |---|---|---|---| +| Hungarian | hu | 1,906 entries + 4 context captions (2026-11-01); initial catalog native-reviewed | partial | | Czech | cs | 1,722 (99%), plural categories handled | partial | | Italian | it | 1,464 (85%) | partial | | Ukrainian | uk | 1,432 (83%), plural categories handled | partial | | Polish | pl | 1,717 (99%), count captions category-first; AI-drafted, awaiting native review | partial | | Georgian | ka | 1,724 (~100%: every catalog key plus the command labels the others lack), count captions as `noun: n` | partial (reviewed by a native speaker) | +| Swedish | sv | 2,857 of 2,891 (99%, 2026-11-01 missing-translations report): menus, panels, dialogs and messages; singular for a count of one; AI-drafted with Claude Code from the English source, spot-checked by a native speaker (not every string) | partial | -DesignCraft ships **5** interface languages beyond the twelve; InDesign ships 17 beyond its +DesignCraft ships **6** interface languages beyond the twelve; InDesign ships 17 beyond its eight of the twelve (cs, da, el, en_GB, fi, hu, it, nb, nl, pl, ro, ru, sq, sv, tr, uk, zh_TW). ## Document languages InDesign 2026 hyphenates and spell-checks ~40 languages (Hunspell and Duden dictionaries). -DesignCraft hyphenates **English, Spanish, Portuguese and Norwegian** and spell-checks **English** only. Text language +DesignCraft hyphenates **English, Dutch, French, German (2006 reform), Greek, Italian, Norwegian, Portuguese and Spanish** and spell-checks **English** only. Text language can be set per paragraph and character (Properties, Character panel) and drives quotes and locale shaping. See [typography-parity.md](typography-parity.md). +German hyphenation accepts `German: 2006 Reform`, `German: Swiss 2006 Reform` and `de` locale +codes without a spelling year or with `2006`. Traditional names, `de_DE_1901` and unknown +German variants have no automatic hyphenation; their German shaping and quotes are unchanged. ## Remaining effort @@ -89,8 +117,11 @@ locale shaping. See [typography-parity.md](typography-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-11-01 | minor | Initial Hungarian interface: 1,906 entries, four context captions, native review, bundled-font glyph coverage and wide/narrow flyout regressions | | 2026-10-11 | minor | Localized Japanese print, import and table controls; checked Japanese and English renders | +| 2026-10-15 | minor | Swedish (`sv`) interface added | | 2026-10-14 | minor | Georgian (`ka`) interface added | | 2026-10-10 | major | Created: catalogs measured (1,724 strings), the twelve key languages plus Italian and Ukrainian | | 2026-10-11 | minor | Czech (cs) added: 1,722 strings, plural categories handled | | 2026-10-13 | minor | Polish interface added (1,717 strings) | +| 2026-10-22 | minor | French and Italian hyphenation (hyph-fr, hyph-it): the French row and Document languages updated | diff --git a/docs/roadmap.md b/docs/roadmap.md index bc2962cb9..ae162eedc 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # DesignCraft roadmap (detail) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (.indd and .indt open through indd-utils; text threads by hand; the gate waits on the Primary Text Frame page fixes) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (independent parent page resizing, #1620; .indd and .indt open through indd-utils; text threads by hand; the gate waits on the Primary Text Frame page fixes) · **Target:** Adobe InDesign 2026 (21.6) Forward-looking plan. The one-page summary is [ROADMAP.md](../ROADMAP.md); numbers are in [target-app-parity.md](target-app-parity.md); the work list is [gaps.md](gaps.md). Hours are @@ -17,7 +17,7 @@ but are next in line. | Core workflow | Works end to end? | Evidence | Hours to pass | |---|---|---|---| -| Set up a document (size, margins, columns, parent pages, page numbers, sections); save and reopen | yes | New Document, Document Setup, parent pages and overrides, Numbering & Section Options; `.designcraft` round-trip tests; recovery | 0 | +| Set up a document (size, margins, columns, parent pages, page numbers, sections); save and reopen | yes | New Document, Document Setup, independently sized parent pages (Properties and Page Tool, #1620), parent overrides, Numbering & Section Options; `.designcraft` round-trip tests; recovery | 0 | | Flow a long story across frames and pages: thread, unthread, autoflow | **no** | threading by hand works (ports, loaded text cursor, `text.thread`/`text.unthread`/`text.removeFromThread`); Primary Text Frame autoflow adds pages users can't delete (#165) | 5–7 | | Format text with paragraph and character styles | partial (not blocking) | Paragraph/Character Style Options, Quick Apply, based-on, nested/GREP styles work; style edit/delete from the panel reported (#169); formatting set at a caret is discarded (#289) | 6–12 | | Place, crop and fit images; wrap text around them | yes | place gun, frame fitting and Fitting Options, content grabber, text wrap, Links panel | 0 | @@ -113,7 +113,7 @@ output intent · EPS place") is done except Books depth and EPS (preview only). |---|---|---| | 2026-10-13 | minor | INDD and INDT open through storytold/indd-utils; focus item 3 shrinks to a real-file corpus (10–30 h) | | 2026-10-12 | minor | Threading row: threading by hand works; the gate waits on the Primary Text Frame page fixes (#165), 5–7 h | -| 2026-10-10 | trivial | Ready figure 42% → 47% (method aligned with the standard); beta distance 28 points | +| 2026-10-10 | trivial | Ready figure 42% → 47%; beta distance 28 points | | 2026-10-10 | minor | Alpha gate table added (6 core workflows); gate fails on manual threading, stage pre-alpha; A1 milestone and focus item 0 | | 2026-10-10 | major | Created: milestones moved from ROADMAP.md and re-assessed; Current focus and the beta plan from the full re-measure | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3443ca8e6..4f2dc5ec2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DesignCraft parity with Adobe InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Keyboard Shortcuts on craft-keymap's shared dialog, with shortcut sets; Convert Variable to Text; list buttons with the default hanging indent; custom anchored objects honour Relative to Spine; PDF/X-4 visible unembedded font detection, #909; autoflow fills the following pages first; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Text Frame Options layout, Preview and Paragraph Spacing Limit; Swatches rows show InDesign's type columns; Keyboard Shortcuts on craft-keymap's shared dialog, with shortcut sets; Convert Variable to Text; list buttons with the default hanging indent; custom anchored objects honour Relative to Spine; PDF/X-4 visible unembedded font detection, #909; autoflow fills the following pages first; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature @@ -59,7 +59,7 @@ they receive, setting type exactly, and basic editing that never fails. | Dimension | Weight | Ready | Remaining (Opus 5.5 h) | Evidence | Doc | |---|---|---|---|---|---| | Features (depth of each area) | 25% | 55% | 250–400 | 275 catalogue rows exist; many lack options (e.g. Books is 258 lines of engine code, Liquid Layout 329); see the area table | this file | -| Typography and composition fidelity | 15% | 45% | 120–200 | Knuth–Plass composer, keeps, grids, CJK, Arabic all exist; no comparison with InDesign's line breaks; optical kerning falls back to metrics (#181); overset rule differs (#180); hyphenation patterns for English, Spanish, Portuguese, Norwegian and Greek only | [typography-parity.md](typography-parity.md) | +| Typography and composition fidelity | 15% | 45% | 120–200 | Knuth–Plass composer, keeps, grids, CJK, Arabic all exist; no comparison with InDesign's line breaks; optical kerning falls back to metrics (#181); overset rule differs (#180); hyphenation patterns for English, Spanish, Portuguese, Norwegian, Greek, German 2006, French, Italian and Dutch only | [typography-parity.md](typography-parity.md) | | File formats | 20% | 40% | 150–250 (+10–30 for an INDD corpus) | INDD and INDT open through indd-utils, measured on synthetic bytes only (#21); IDML both ways and verified in InDesign 2026, but real-file bugs open (#193, #338, #339, #340); PDF export strong; PDF/X-4 only | [file-format-parity.md](file-format-parity.md) | | UI/UX fidelity | 15% | 45% | 120–180 | Measured InDesign 2026 look; but users report object copy/paste (#351, #348, #185, #164), right-click menus (#238, #187, #160), out-port threading (#352, #194, #165) failing | [ui-parity.md](ui-parity.md) | | Stability | 10% | 45% | 60–100 | Never-crash rules and last-resort guards; startup failures on some GPUs and machines (#334, #273, #220, #192, #167, #166); no CI runs the tests on pull requests | [gaps.md](gaps.md) | @@ -71,17 +71,16 @@ they receive, setting type exactly, and basic editing that never fails. | AI features | 0% (not weighted) | 15% | 60–120 | Agent control through MCP goes beyond InDesign; no generative features (InDesign 2026: Generative Expand, Text to Image, Auto Style) | [gaps.md](gaps.md) | | **Weighted sum** | 100% | **47.2% → ~47%** (range 42–52%) | **1,150–1,950** | | | -**How the number is built:** the plain weighted sum of the table above, per the craftrules -standard: 25×55 + 15×45 + 20×40 + 15×45 + 10×45 + 5×65 + 3×45 + 3×55 + 2×45 + 2×15 = 47.2% → +**How the number is built:** the plain weighted sum of the table above: 25×55 + 15×45 + 20×40 + 15×45 + 10×45 + 5×65 + 3×45 + 3×55 + 2×45 + 2×15 = 47.2% → **~47%** (range 42–52%, ±5 points of judgement in the dimension values). An earlier version today applied a further ×0.9 for cross-cutting core-path failures and reported 42%. That -multiplicative step was dropped to match the standard (discounts belong only to the mainstream +multiplicative step was dropped (discounts belong only to the mainstream and essentials numbers). Those failures are already in the UI/UX (45%) and Stability (45%) rows, and the Primary Text Frame page fixes (#165) are what hold the stage at pre-alpha through the gate. ## Mainstream practitioner and essentials user -Two narrower questions, computed with the craftrules method so every app's numbers compare. The +Two narrower questions. The stage still follows the full number (~47%) and the core-workflow gate. All three are estimates. | Audience | Ready | Opus 5.5 agent-hours to ~95% | Parallelizes | Work that dominates | @@ -275,12 +274,12 @@ 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), 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). +- 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, which reads 100 with nothing selected; a swatch goes to the fill or stroke in front; rows show InDesign's type columns: the crossed pencil on swatches that can't be edited, process or spot, and the colour mode; 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, with Find Format: text that has a character or paragraph style or character attributes; Change and Change/Find on the text Find Next found), 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. - IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories; attributes a file leaves unset take InDesign's defaults (Keep Lines Together at start/end of paragraph, 12 pt corners, paragraph rules in Text Color, auto-sizing from the centre, numbered lists restarting in each story, alternating table fills Black 20 % / None; a tint of −1 is the colour's own; frames take the fill, stroke, corners and Text Frame Options they don't write from their object style chain, then [None]) — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`). -- Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu, Table panel, Create Table dialog, caret/typing/Tab navigation and cell selection in cells; Type tool drags of row and column borders (Shift keeps the table size, or scales every row/column on the outer edge; `table.resizeBorder`); `table.*` commands. +- Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu (in InDesign's order), Table panel, Insert Table dialog (with Table Style), Convert Text to Table and Convert Table to Text dialogs (any separators, Number of Columns, Table Style), caret/typing/Tab navigation and cell selection in cells; Type tool drags of row and column borders (Shift keeps the table size, or scales every row/column on the outer edge; `table.resizeBorder`); `table.*` commands. - Hyperlinks (text or frames → URL / e-mail / page) and bookmarks, exported as PDF link annotations and outline. - EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`). - Data Merge (one linked CSV, TSV, text, or Excel file; text, image, QR, and hyperlink placeholders; preview; a new document with one record per page or records tiled on a single page), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid, with a previewing dialog), snippets, object styles, Numbering & Section Options. @@ -294,7 +293,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - 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. +- 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` (with each face's OS/2 `fsType` embedding bits) / `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. - 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. - Damage-region repaint: after an edit the canvas re-renders only the frames whose composed text (or the items that) changed and patches its texture — pixel-identical to a full render; typing in a frame repaints in under a millisecond instead of a full-viewport render (22 ms measured on the sample). - Native engine saves, Save a Copy, snippets, books, libraries, swatches and engine/MCP exports use same-folder atomic replacement through craft-libs/craft-atomic, as do desktop Services writes and CLI raster exports. Failed writes before replacement retain the original, and already-open readers retain the old bytes. On Unix a new file gets the permissions ordinary file creation gives it (the umask applies) and a replaced file keeps its permissions. Parent-folder flushing is best effort, so this is not a power-loss durability guarantee. @@ -337,7 +336,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Drop caps (`type.para {dropCapLines, dropCapChars, dropCapStyle, dropCapAlignLeft, dropCapScaleDescenders}`, Paragraph panel, Paragraph Style Options › Drop Caps and Nested Styles; IDML DropCapLines/DropCapCharacters/DropcapDetail, the drop cap style as the leading "through 1 Dropcap" nested style): the first characters (code points, as InDesign counts them, never splitting a letter from its combining marks; up to 150) are enlarged from the first line's cap height down N − 1 leadings of the text after them (up to 25 lines; grid steps on a baseline grid), the lines beside them are indented by their advance (on the right in right-to-left paragraphs), spaces right after them are set at their size without widening the indent, baselines don't move; a one-line drop cap keeps a smaller size of its own and sets its tabs at the paragraph's tab stops; Align Left Edge (on by default) moves the drop cap left by its first letter's side bearing so its ink starts at the indent; Scale for Descenders is kept but doesn't change the layout yet; a drop cap character style, and nested styles through the drop cap (Dropcap delimiter). Drawn on screen and in PDF (text in order), with caret, hit testing and selection over the enlarged letters. Vertical text sets no drop caps. - User dictionary hyphenation exceptions (`hyphenation.addException/removeException/list`, Edit ▸ Spelling ▸ User Dictionary): `ex~am~ple` breaks only at `~`, a plain word never hyphenates. - Frame Fitting Options (`object.fittingOptions`, Object ▸ Fitting ▸ Frame Fitting Options…): Auto-Fit refits on resize, fitting, Align From reference point, crop amounts; IDML FrameFittingOption. -- Paragraph borders (per-side weights, colour, tint, offsets) and shading offsets, drawn per column part of split paragraphs (also when overset); Paragraph panel controls; IDML ParagraphBorder*/ParagraphShading*Offset. +- Paragraph borders (per-side weights, colour, tint, offsets) and shading offsets, drawn per column part of split paragraphs (also when overset); Paragraph panel controls; IDML ParagraphBorder*/ParagraphShading*Offset. Overprint for the shading, the border and its gap (`shadingOverprint`, `borderOverprint`, `borderGapOverprint`; IDML ParagraphShadingOverprint, ParagraphBorderOverprint, ParagraphBorderGapOverprint) is kept; PDF export warns that it does not overprint yet, as for objects. Paragraph Style Options › Paragraph Border and Paragraph Shading are laid out as InDesign's: the page's heading, tint spinners with Overprint, corners in a two-by-two grid with shape glyph menus and the link between, spinner offsets. - Font menu laid out like InDesign's: search, a Filters row (classification: serif, slab serif, sans serif, script, monospaced, decorative, symbol, read from PANOSE, the OS/2 family class, the name, else the shape of the "l"; Show Favorites Only; Show Recently Added, fonts added in the last 30 days), the sample text (the selected text, Sample, pangrams) in three sizes, Recently Used fonts at the top (`recentFonts` preference), favourites (stars, `favoriteFonts`), and each family previewed in its own face with its format (TrueType, OpenType, variable, colour). A family of several styles shows how many and opens to list them, each previewed in its own face, and a style picked is applied as it is; Show Similar Fonts (≈) lists the families of the same class nearest by PANOSE. Western (and other-script) families come first, then Japanese, Simplified Chinese, Traditional Chinese and Korean ones, each group alphabetical and set off by a separator; the group comes from the font's own data (`meta` languages, family names in a CJK language, OS/2 code pages, else what it covers), read once (installed fonts by the font scan, without loading them). CJK families show their native names, the English name on hover; Preferences › Type › Show Font Names in English (`showFontNamesInEnglish`) shows English names throughout. Search matches either name; documents store the English name. Every family list (Character panel, Control bar, Properties, style dialogs, Find Font, Glyphs) follows this order. - Application preferences (engine `Prefs`) now persist across launches (desktop: prefs.json beside ui.json). - View ▸ Fit Selection in Window (⌥⌘=) and Find/Change ▸ Object (`find.objects`, `find.changeObjects`: by fill, stroke, weight, opacity, kind, layer, label). @@ -351,6 +350,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Column rules (Text Frame Options › Column Rules, `object.textFrameOptions {columnRule, columnRuleWeight, columnRuleColor, columnRuleTint, columnRuleOffset, columnRuleTopInset, columnRuleBottomInset}`, reported by `document.inspect`): a bar centred in each gutter of a multi-column frame from the top line's ascent to the bottom line's descent, between the columns of right-to-left and vertical frames too, broken around paragraphs that span columns, with weight, colour, tint, horizontal offset and top/bottom insets; on screen, in PDF (tagged as an artifact) and in IDML (`ColumnRule…` attributes of `TextFramePreference`, also in object styles). The dialog shows the frame's options and changes only the fields edited. Rule stroke types and overprint aren't there yet. - Auto-size (Text Frame Options › Auto-Size, `object.textFrameOptions {autoSize: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference, useMinimumHeight, minimumHeight, useMinimumWidth, minimumWidth, noLineBreaks}`): the frame fits its text exactly after every edit (typing, pasting, style changes, option changes) in the same undo step (opening a file keeps the frame sizes it gives); it grows and shrinks around the reference point, which stays put in spread space for rotated frames too, never below the minimum height/width and never past 10,000 pt. Width Only and No Line Breaks set each paragraph on one line; Height and Width without No Line Breaks keeps the width; Keep Proportions scales both sides until the text fits. IDML keeps all the options (`AutoSizing…`, `…ForAutoSizing` attributes). Not covered: frames of a thread other than the last, vertical frames, type on a path, and multi-column balancing in Keep Proportions. - Balance Columns (Text Frame Options › Balance Columns, `object.textFrameOptions {balanceColumns}`, IDML `VerticalBalanceColumns`): in the frame the story ends in, the columns after the last explicit column break fill to about the same depth (the last may be shorter by a line per column); keep options hold, spanning and split paragraphs keep their own balancing, vertical justification then applies to each column as usual, and vertical frames balance their bands. A frame the text runs on from, or that is overset, fills normally. +- Text Frame Options dialog, laid out as InDesign's (General, Column Rules, Baseline Options, Auto-Size; framed Columns, Inset Spacing and Vertical Justification groups with spinner fields; Fixed Number shows each column's width as the fields change). Preview shows the edits on the page until OK (one undo step) or Cancel. An invalid value (such as a gutter that isn't a measure) takes the preview off the page, so the frame shows as it was, and the dialog's status line gives the reason; OK refuses the value too. Preview stays checked and shows the edits again once the value is valid. Vertical Justification › Paragraph Spacing Limit (`object.textFrameOptions {paragraphSpacingLimit}`, IDML `VerticalThreshold`): with Justify, the extra space goes between paragraphs up to the limit, the rest between lines. - Place a page of an IDML or DesignCraft document (`file.place {layoutPage}`): its objects, stories, styles and swatches as one group (INDD files are not readable). - Articles panel (`article.new/add/remove/options/delete/list`): reading order and content of EPUB and HTML exports. - Object ▸ Clipping Path (`object.clippingPath {type: alpha|edges, threshold, tolerance}`): traces the placed image and makes the outline the frame. Clipping paths saved in IDML (Detect Edges, Alpha Channel, Photoshop Path, User-Modified Path; Invert) hide the image outside the path on screen and in PDF and survive IDML export; `object.clippingPathOptions` reports, inverts or removes one (Object ▸ Clipping Path ▸ None). @@ -362,7 +362,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Linked stories (Edit ▸ Place and Link, `story.placeAndLink/links/updateLink/unlink`): child stories copy a parent, show out of date in the Links panel and update. - Edit ▸ Menus (`window.hideMenuItem`): hide menu items; menus with hidden items end in Show All Menu Items. - View ▸ Separations Preview (`view.separations`, Window ▸ Output ▸ Separations Preview, `ink.separations`): the plates the print PDF puts on film, spot plates included. Each visible spread is exported as Export PDF writes it and separated with craft-prepress, so the Ink Manager's aliases and spots to process, overprint and the flattener show as printed. All inks as printed, one plate as film, or an ink limit in red over gray; the panel lists the plates with each ink's percentage under the pointer. -- Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Spanish is hyphenated with Javier Bezos's Liang patterns (`hyph-es.tex` 5.0, MIT), Portuguese with hyph-utf8's `hyph-pt.tex` 1.4 (BSD-3-Clause) and Norwegian with hyph-utf8's `hyph-no.tex` (permissive), all through the `hypher` crate; hyphenation for other languages isn't bundled yet. +- Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Spanish is hyphenated with Javier Bezos's Liang patterns (`hyph-es.tex` 5.0, MIT), Portuguese with hyph-utf8's `hyph-pt.tex` 1.4 (BSD-3-Clause) and Norwegian with hyph-utf8's `hyph-no.tex` (permissive) and Dutch with Piet Tutelaers's `hyph-nl.tex` 1.1 (MIT), all through the `hypher` crate; Greek uses `hyph-el-monoton.tex` (MIT). German uses hyph-utf8's `hyph-de-1996.tex` 2024-02-28 (MIT), which contains the reformed 2006 patterns despite its filename. Only the explicit 2006 reform names and plain or 2006 German locale codes use this set; `German: Traditional`, `de_DE_1901` and unknown German variants have no automatic hyphenation. No separate old-spelling or Swiss set is bundled. Hyphenation for other languages isn't bundled yet. - Books (`book.new/open/add/remove/list/styleSource/paginate/syncStyles/exportPdf`, Book panel): `.dcbook` files, continuous page numbering, chapter numbers resolved in book order (Automatic, Start Chapter Numbering at, Same as Previous Document; `layout.chapterNumbering`, with a style for Chapter Number variables and cross-references), style sync from a style source, one merged PDF. Section prefixes follow InDesign's rule (up to 8 characters, no + or comma). - Numbering & Section Options window as in InDesign (Start Section, automatic or explicit page numbering up to 99999, prefix, style, marker, Include Prefix; the document's chapter numbering and its book); OK applies both in one undo step (`layout.numberingOptions`). - Book numbering/style updates validate and serialize all targets before changing the first chapter. They write only closed native chapters, checking canonical paths for open aliases; IDML remains a read-only style source and PDF input. Snapshots are staged on disk with at most 4,096 chapter records in memory. Each replacement is atomic; a later write failure explicitly lists completed chapters rather than claiming whole-book rollback. Import/serialization memory limits and advanced book TOC/index work remain separate. @@ -445,7 +445,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Place SVG (vector in PDF export via krilla-svg, resvg on screen, text in the bundled fonts), Photoshop (.psd/.psb composite in its colour mode — Bitmap, Grayscale, Duotone, Indexed, RGB, CMYK, Lab, 8/16-bit — with merged transparency; extra alpha and spot channels don't change the image), BMP, and Illustrator files saved with PDF compatibility (.ai). A swatch on a placed greyscale or bitmap image (IDML `<Image FillColor>`) colours it on screen and in PDF. New `designcraft-images` crate (L2) for sniffing, sizes and decoding. EPS is still missing. - View › Display Performance: Fast (grey boxes, no effects, ⌥⇧⌘Z), Typical (72 ppi proxies, ⌥⌘Z), High Quality (⌥⌘H, our default — the renderer is fast enough). - OpenType menu (Character panel): discretionary ligatures, fractions, ordinal, swash, titling, contextual alternates, slashed zero, the four figure styles and stylistic sets 1–20; `type.openType`; mapped to IDML's OTF attributes. -- Add Anchor Point (=), Delete Anchor Point (-) and Convert Direction Point (⇧C) tools (click toggles smooth/corner, drag pulls out handles), with `path.addAnchor`, `path.deleteAnchor`, `path.convertAnchor`. +- Add Anchor Point (=), Delete Anchor Point (-) and Convert Direction Point (⇧C) tools (click toggles smooth/corner, drag pulls out handles), with `path.addAnchor`, `path.deleteAnchor`, `path.convertAnchor`. Points selected with Direct Selection title Properties "Anchor Point(s)", with Convert Points and the Delete Point(s) quick action (`path.deletePoints`). - Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel (InDesign's layout: thumbnail, Type, Location, Angle, Reverse) sets type, angle, reverse and edits stops and midpoints on a ramp (drag, add, drag off to remove, location; drop a swatch from the Swatches panel on a stop or the ramp; stops keep their CMYK, and a stop on a swatch keeps it, so spot stops shade as Separation or DeviceN in PDF), and follows the Fill/Stroke proxy. Edited gradients are unnamed swatches until the Swatches panel's New Swatch names one "New Gradient Swatch" (`swatch.new`, which also duplicates a named swatch); `swatch.create {gradient}` builds one from color swatches; `object.gradient` (`target: stroke` for a stroke's gradient, drawn across the path's box on screen and in PDF); the vector round-trips through IDML (`GradientFill…`/`GradientStroke…`). A radial gradient stretches with the object's box as InDesign's does (an ellipse reaching the sides of a non-square frame, on screen and in PDF). Text takes a gradient swatch as its fill or stroke, laid across its frame's box as in InDesign (screen and PDF). - New Document (⌘N) as in InDesign: Recent, Saved, Print, Web and Mobile tabs of page cards (double-click creates) beside the preset's details: document name, size with arrows, units, orientation, pages, start page #, facing pages, primary text frame, columns, margins, bleed and slug per edge (linked or not); Save Document Preset (`file.savePreset`, `file.deletePreset`; saved presets are kept with the preferences, listed by `file.presets` and made by `file.new {preset: name}`); `file.new` takes `intent`, `units`, `startPage` and per-edge `bleed`/`slug`. Preview shows a document with the window's settings in the workspace while it is open; Create keeps it, Close removes it. - Document Setup (⌥⌘P), laid out like InDesign's: intent, number of pages, start page # (an even start begins with a left page), facing pages, binding, Page Size (presets keeping the orientation, width and height with arrows, orientation), margins for every page and parent page, bleed and slug per edge (each group linked or not), and Adjust Layout (objects follow the new page size and margins), all in one undo step; measures are read in the document's units, and a value that can't be read keeps the dialog open; `layout.documentSetup {}` reports the setup, margins included. @@ -476,7 +476,7 @@ Task notes that headed ROADMAP.md: | 2026-10-13 | minor | `.indd` and `.indt` open through storytold/indd-utils; INDD remaining work 10–30 h (real-file corpus); readiness unchanged until it is measured | | 2026-10-12 | minor | Text threads by hand; stage note and distance to alpha follow the gate (5–7 h, #165); percentages unchanged | | 2026-10-11 | minor | Spring-loaded Zoom navigation added to the working feature inventory (DC-1); readiness estimates unchanged | -| 2026-10-10 | minor | Full number 42% → 47%: method aligned with the standard (plain weighted sum, the ×0.9 discount dropped), no new evidence; readiness table with hours for full, mainstream and essentials; beta distance 33 → 28 points | +| 2026-10-10 | minor | Full number 42% → 47%: plain weighted sum, the ×0.9 discount dropped, no new evidence; readiness table with hours for full, mainstream and essentials; beta distance 33 → 28 points | | 2026-10-10 | minor | Added mainstream practitioner (~42%) and essentials user (~54%) numbers with written weights and discounts and a user-evidence count; full number (42%) now written as 47.2% × 0.9, value unchanged | | 2026-10-10 | minor | Stage alpha → pre-alpha under the core-workflow gate (manual threading missing); Type & text frames row updated | | 2026-10-10 | major | Created from ROADMAP.md's "How far from full parity" (2026-10-04) and "Working today"; full re-measure against InDesign 2026 21.6: menu coverage measured against InDesign 21.6's menu dump (69%) and the documented tree (75%), breadth set at ~80%, ready-for-real-work re-estimated 83% → 42% on user-reported evidence and the INDD gap; hours recalibrated from git history | diff --git a/docs/typography-parity.md b/docs/typography-parity.md index 8c92e8dfa..3e06b2007 100644 --- a/docs/typography-parity.md +++ b/docs/typography-parity.md @@ -1,6 +1,6 @@ # Typography and composition parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Optical Margin Alignment per story with a size in the Story panel, and Ignore Optical Margin in the Paragraph panel menu and paragraph styles, kept through IDML (#947); Type › Reload Fonts reads fonts installed, edited or removed since start-up (#328, #547); Optical kerning from outlines (#181); macOS lists fonts that font services and managers activate; Justification dialog for the selected paragraphs; anchored objects wrap text; Tabs panel listed as present (#343); character stroke behind fill) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (German 2006 hyphenation; Optical Margin Alignment per story with a size in the Story panel, and Ignore Optical Margin in the Paragraph panel menu and paragraph styles, kept through IDML (#947); Type › Reload Fonts reads fonts installed, edited or removed since start-up (#328, #547); Optical kerning from outlines (#181); macOS lists fonts that font services and managers activate; Justification dialog for the selected paragraphs; anchored objects wrap text; Tabs panel listed as present (#343); character stroke behind fill) · **Target:** Adobe InDesign 2026 (21.6) Typesetting is what InDesign is bought for. A feature can exist and still set text differently from InDesign: different line breaks, different page count, overset where InDesign fits. This @@ -36,7 +36,7 @@ Fidelity: **unmeasured** unless stated. | Single-line Composer | greedy | greedy with the same ranges | ✓ | | | World-Ready composers | Paragraph / Single-line World-Ready | RTL, Arabic and bidi in the main composer | ~ | Indic scripts (Devanagari etc.) shape through harfrust but are untested | | Justification | word/letter spacing, glyph scaling min/desired/max, single-word, last line | all of these | ✓ | a justified line with a tab justifies after the tab (#284); set per paragraph in Type › Justification… and the Paragraph panel menu (#531) | -| Hyphenation | per-language dictionaries (~40), zone, limits, capitalized, last word, across columns | Liang patterns: English (trained from the public-domain Moby list), Spanish (Bezos), Portuguese (hyph-pt), Norwegian (hyph-no) and Greek (hyph-el-monoton); zone, limits, exceptions in the user dictionary; Hyphenate Across Column off keeps automatic hyphens off the last line of a column or frame | ~ | other languages don't hyphenate (#87 reported "doesn't work") | +| Hyphenation | per-language dictionaries (~40), zone, limits, capitalized, last word, across columns | Liang patterns: English (trained from the public-domain Moby list), Spanish (Bezos), Portuguese (hyph-pt), Norwegian (hyph-no), Greek (hyph-el-monoton), German 2006 (hyph-de-1996), French (hyph-fr), Italian (hyph-it) and Dutch (Tutelaers); zone, limits, exceptions in the user dictionary; Hyphenate Across Column off keeps automatic hyphens off the last line of a column or frame; discretionary U+00AD breaks with both composers regardless of automatic hyphenation and replaces pattern points within its word (#1955) | ~ | other languages don't hyphenate (#87 reported "doesn't work") | | Spelling | per-language dictionaries | English (Moby) + document/user dictionary | ~ | | | Kerning | Metrics, Optical, manual, none | Metrics, Optical (outline-based: pairs pulled towards the font's stem gap, `crates/compose/src/optical.rs`), manual, none | ~ | Optical is an approximation of Adobe's algorithm (#181), not a match: imported display text can still break differently | | Tracking, scale, skew, baseline shift | ✓ | ✓ | ✓ | | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 4bc40c627..7adf8fead 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Story panel; text Control panel Character/Paragraph modes; Properties ••• expands Character and Paragraph; live corners work; document views row: scrollbars, Alt pans while editing text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; paragraph style override feedback and clearing) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** patch (the Pen previews the curve while a smooth point is dragged and curves the rubber band after one, #1438; a Custom text variable defined with a number shows its digits, #2093; Table of Contents dialog: title field keeps its width, More Options toggles without losing hidden settings; Story panel; text Control panel Character/Paragraph modes; Properties ••• expands Character and Paragraph; live corners work; document views row: scrollbars, Alt pans while editing text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; paragraph style override feedback and clearing) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS) Tools, handles, snapping, nudging, modifiers, shortcuts, panels, menus, context menus and feel, against InDesign 2026. The look was measured from the running app @@ -29,7 +29,7 @@ basics of editing fail for too many users on Windows and Linux. | Chrome: app bar, Control panel, Tools panel, dock, doc tabs, status bar | Medium Dark, measured sizes | measured look; the text Control panel switches between Character (A) and Paragraph (¶) Formatting Controls (¶ with the Bulleted and Numbered List buttons, Shading and Border), and Escape in a field reverts what was typed | ✓ | 11-observed-ui.md; Learn Skills Daily course, chapter 16 | | Properties panel and Contextual Task Bar | per selection | ✓, task bar movable (#143); ••• expands Character (scales, baseline shift, skew, language, case and position buttons) and Paragraph (indents, spacing, drop cap, baseline grid, hyphenation) in place | ✓ | Learn Skills Daily course, chapter 16; `properties.rs` `more_options_expand_character_and_paragraph_in_place` | | Selection tool: click, marquee, move, Alt-duplicate, resize handles, rotate from corners | ✓ | ✓ | ✓ | Cmd/Ctrl held with another tool borrows the selection tool used last (Selection or Direct Selection) for one gesture, #919 | -| Direct Selection, Pen family, Pencil, Scissors | ✓ | ✓ | ~ | moving reference point "0" fails (#337) | +| Direct Selection, Pen family, Pencil, Scissors | ✓ | ✓ | ~ | the Pen previews the segment while a smooth point is dragged out and curves the rubber band out of a smooth point's direction line (`pen_previews_the_curve_while_dragging_and_after_a_smooth_point`, #1438); moving reference point "0" fails (#337) | | Live corners (yellow handle) | ✓ | ✓ widget on a selected rectangle frame; diamonds drag the corner size (Shift: one corner), Alt-click steps the shape (Shift+Alt: all) | ✓ | #186 | | Content grabber, frame fitting by double-click | ✓ | ✓ | ✓ | | | Overset mark (red + at the out port) | selected frame only | every overset frame, groups included, while frame edges show | ✓ | a deliberate step past InDesign: grouped labels that overflow were invisible until Preflight | @@ -48,7 +48,7 @@ basics of editing fail for too many users on Windows and Linux. | 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) | | Panels: Pages, Layers, Swatches, Color, Stroke, Character, Paragraph, Styles, Links, Effects, Text Wrap, Align, Pathfinder, Table, Index, TOC, Hyperlinks, Bookmarks, Articles, Tags, Notes, Track Changes, Object States, Buttons and Forms, Preflight, Data Merge, Library, Scripts, Glyphs, Info, Tabs, Story | ✓ | all present | ~ | Navigator, Trap Presets, Animation, Timing missing; pull-down panels can't scroll on Linux (#267) | -| Dialogs: New Document, Document Setup, Preferences (all pages), Paragraph/Character/Object/Table/Cell Style Options, Export PDF, Print, Package, Find/Change | ✓ | present | ~ | generated command dialogs fill the long tail; depth varies | +| Dialogs: New Document, Document Setup, Preferences (all pages), Paragraph/Character/Object/Table/Cell Style Options, Export PDF, Print, Package, Find/Change | ✓ | present | ~ | a Custom text variable whose text is typed as a number shows its digits (`variables.define` takes a string, a number or a boolean for `text` and refuses anything else; #2093); generated command dialogs fill the long tail; depth varies | | Workspaces | Essentials, Essentials Classic, Advanced, Book, Digital Publishing, Interactive for PDF, Printing and Proofing, Review, Typography, Touch | ✓ named workspaces (Essentials Classic: Control panel and iconic panel groups) + custom; Window › Workspace; no Review or Touch | ~ | tab bar overlaps menus in Book/Advanced (#182) | | Story Editor | ✓ | ✓ | ~ | depth ruler and View › Story Editor › Show/Hide Depth Ruler (`story_editor.rs` `depth_ruler_shows_and_hides_from_the_view_menu`); hiding or resizing the style-name column missing | | Undo granularity (a drag = one step) | ✓ | ✓ (Begin/Preview/Commit) | ✓ | | @@ -68,8 +68,20 @@ focused text field, and does not interrupt an existing tool drag. Synthetic canv cover the cursors, click anchors, scrub/release ownership, restoration, typing guards and undo history (DC-1). +The Table of Contents title field reserves its edit width inside the title/style row. The +default Contents title is fully visible instead of being clipped to its first few characters. + +The Table of Contents dialog can collapse to its entry-style controls or expand to show page +number, separator, sorting and level controls. Toggling visibility preserves their settings. + ## Remaining effort +The Table of Contents separator field accepts `^y` (Right Indent Tab), with a special-character +picker alongside it. Literal carets use `^^`; ordinary tabs, em/en spaces and surrounding spaces +survive reopening. IDML preserves right-indent separators, including `$ID/^y`, so updating an +imported TOC keeps page numbers at the right edge and inherits paragraph tab leaders. This covers +10:54–16:08 of [Michael Bullo's TOC tutorial](https://www.youtube.com/watch?v=m4y2tm6LH_c). + Applying a paragraph or character style now removes local values equal to the applied style, so text used to capture a style follows later edits to that style. Different local formatting and named character styles stay intact. Paragraph-style application treats selection endpoints @@ -122,9 +134,15 @@ covers checking the sampled and redefined settings in the styles tutorial (2:45 | Date | Change | Summary | |---|---|---| +| 2026-11-02 | patch | Pen: while a smooth point's direction line is dragged out, the segment it shapes (from the previous anchor's out handle to the new anchor's in handle) shows live under the pointer; after a smooth point the rubber band to the pointer curves out of its direction line, a corner's stays straight (#1438, first item; the Option/Alt handle split is the other). Test: `pen_previews_the_curve_while_dragging_and_after_a_smooth_point` | +| 2026-11-02 | patch | Text variables: a Custom variable defined with a number (a typed `2026` reaches `variables.define` as one) shows its digits instead of nothing (#2093, first item); `text` takes a string, a number or a boolean. Test: `variables::tests::a_custom_variable_defined_with_a_number_shows_its_digits` | +| 2026-10-30 | minor | Dialogs: Table of Contents title field keeps its edit width; More Options toggles without losing hidden settings | | 2026-10-24 | minor | Panels row: Story panel (Optical Margin Alignment and its size) | | 2026-10-21 | minor | Chrome row: the text Control panel's A/¶ modes; Properties row: ••• expands Character and Paragraph in place | | 2026-10-11 | minor | Live corners row to ✓: widget, diamonds, drag, Shift-drag, Alt-click shape cycling (#186) | | 2026-10-11 | minor | Added held Cmd+Space / Cmd+Alt+Space Zoom navigation and canvas input regression tests | | 2026-10-10 | minor | Threading row corrected to ✗ after checking the code (no out-port interaction, no thread command) | | 2026-10-10 | major | Created: measured menu coverage, 27-row interaction checklist from the code and issues | + +Double-clicking a section triangle in the Pages panel opens Numbering & Section Options for +that section’s first page. It leaves parent-page editing and does not edit section data until confirmed. diff --git a/packaging/linux/ai.storyteller.designcraft.thumbnailer b/packaging/linux/ai.storyteller.designcraft.thumbnailer new file mode 100644 index 000000000..4642996b3 --- /dev/null +++ b/packaging/linux/ai.storyteller.designcraft.thumbnailer @@ -0,0 +1,8 @@ +# Installed as share/thumbnailers/ai.storyteller.designcraft.thumbnailer (package.sh). File managers +# that read freedesktop thumbnailers (Nautilus, Thunar, Dolphin through KIO) run it for .designcraft +# files: %i is the file, %o the PNG to write, %s the size in pixels. It writes the first-page preview +# saved in the file; a file without one keeps the file type's icon. +[Thumbnailer Entry] +TryExec=designcraft-cli +Exec=designcraft-cli thumbnail %i %o %s +MimeType=application/x-designcraft; diff --git a/packaging/linux/flatpak-bundle.sh b/packaging/linux/flatpak-bundle.sh index dbb9b699e..40301a1fb 100755 --- a/packaging/linux/flatpak-bundle.sh +++ b/packaging/linux/flatpak-bundle.sh @@ -20,10 +20,12 @@ APP_ID=ai.storyteller.designcraft FLATHUB=https://dl.flathub.org/repo/flathub.flatpakrepo TEST=1 +OCI=0 TARBALL="" while [[ $# -gt 0 ]]; do case "$1" in --no-test) TEST=0; shift ;; + --oci) OCI=1; shift ;; -h | --help) sed -n '2,14p' "$0"; exit 0 ;; -*) echo "unknown argument: $1" >&2; exit 2 ;; *) TARBALL="$1"; shift ;; @@ -61,6 +63,12 @@ OUT="$DIST/$BASENAME.flatpak" # --runtime-repo lets `flatpak install` fetch the freedesktop runtime from Flathub if missing. flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable echo "wrote $OUT" +if [[ "$OCI" = 1 ]]; then + OUT_OCI="$DIST/$BASENAME-oci" + rm -rf "$OUT_OCI" + flatpak build-bundle --oci "$WORK/repo" "$OUT_OCI" "$APP_ID" stable + echo "wrote OCI layout to $OUT_OCI" +fi if [[ "$TEST" = 1 ]]; then flatpak install --user -y --noninteractive --reinstall "$OUT" diff --git a/packaging/linux/flatpak-oci.sh b/packaging/linux/flatpak-oci.sh new file mode 100755 index 000000000..4a500a2b8 --- /dev/null +++ b/packaging/linux/flatpak-oci.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Build designcraft as an OCI container layout and push it to GitHub Container Registry (GHCR) +# for consumption by Flatpak OCI clients (flatpak-oci-specs). +# +# Usage: +# packaging/linux/flatpak-oci.sh [--push] [TARBALL] +# +# Environment variables: +# REGISTRY Target registry image (default: ghcr.io/storytold/designcraft) +# TAG Target image tag (default: $VERSION-$ARCH, also tags stable-$ARCH) +# GITHUB_TOKEN Token for GHCR authentication when --push is used +set -euo pipefail +# shellcheck source=../env.sh +. "$(dirname "${BASH_SOURCE[0]}")/../env.sh" +HERE="$ROOT/packaging/linux" + +PUSH=0 +TARBALL="" +while [[ $# -gt 0 ]]; do + case "$1" in + --push) PUSH=1; shift ;; + -h | --help) sed -n '2,12p' "$0"; exit 0 ;; + -*) echo "unknown argument: $1" >&2; exit 2 ;; + *) TARBALL="$1"; shift ;; + esac +done + +ARCH="$(uname -m)" +case "$ARCH" in + x86_64) ;; + aarch64 | arm64) ARCH=aarch64 ;; + *) echo "unsupported architecture $ARCH" >&2; exit 2 ;; +esac + +BASENAME="designcraft-$VERSION-linux-$ARCH" +OUT_OCI="$DIST/$BASENAME-oci" + +echo "==> Building Flatpak OCI layout for $BASENAME" +"$HERE/flatpak-bundle.sh" --oci --no-test "${TARBALL:-}" + +[[ -d "$OUT_OCI" ]] || { echo "error: $OUT_OCI layout not generated" >&2; exit 1; } + +REGISTRY="${REGISTRY:-ghcr.io/storytold/designcraft}" +TAG="${TAG:-$VERSION-$ARCH}" + +echo "==> OCI layout ready at $OUT_OCI (Target: $REGISTRY:$TAG)" + +if [[ "$PUSH" = 1 ]]; then + command -v skopeo >/dev/null || { echo "error: skopeo is required to push OCI images (apt install skopeo)" >&2; exit 1; } + + AUTH_ARGS=() + if [[ -n "${GITHUB_TOKEN:-}" ]]; then + AUTH_ARGS=(--dest-creds "${GITHUB_ACTOR:-storytold}:$GITHUB_TOKEN") + fi + + echo "==> Pushing to $REGISTRY:$TAG" + skopeo copy --format oci "${AUTH_ARGS[@]}" "oci:$OUT_OCI" "docker://$REGISTRY:$TAG" + + # Also update architecture-specific stable tag + echo "==> Pushing to $REGISTRY:stable-$ARCH" + skopeo copy --format oci "${AUTH_ARGS[@]}" "oci:$OUT_OCI" "docker://$REGISTRY:stable-$ARCH" + + echo "==> Push complete" +fi diff --git a/packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml index 755d2e2a3..750400657 100644 --- a/packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml @@ -17,6 +17,7 @@ runtime-version: '25.08' sdk: org.freedesktop.Sdk command: designcraft finish-args: + - --filesystem=xdg-data/ArtCraft/versions:ro # Display: Wayland first; X11 only when no Wayland compositor is running. - --socket=wayland - --socket=fallback-x11 @@ -27,6 +28,14 @@ finish-args: - --share=network # GPU access for egui's renderer (Vulkan, or GL as a fallback). - --device=dri + # Printing: lpstat/lp (the cups-client module) reach the host's CUPS through its socket and + # find driverless network printers through Avahi (`lpstat -e`). Without these the Print + # dialog lists no printers. + - --socket=cups + - --system-talk-name=org.freedesktop.Avahi + # The user's CUPS settings, read-only: their default printer and saved options (lpoptions) + # and a print server named in their client.conf, which lp reaches over the network. + - --filesystem=~/.cups:ro # Open/Save, Place and Export go through the file-chooser portal (rfd's xdg-portal backend) # and drag-and-drop through the document portal, so picked files work anywhere without broad # filesystem access. Documents and Pictures are granted so files passed by path (the command @@ -34,7 +43,56 @@ finish-args: - --filesystem=xdg-documents - --filesystem=xdg-pictures # Home/host filesystem access is deliberately not granted. +# The release runner's flatpak-builder has no eu-strip (elfutils), which its debuginfo pass runs +# on cups-client's unstripped tools, in whichever module comes first after they're installed. The +# app's own binaries come from the release tarball, already stripped. +build-options: + no-debuginfo: true modules: + # CUPS client tools for craft-print: the runtime has libcups but not lpstat/lp/lpoptions. + # Linked statically against this release's libcups, so a runtime update can't break them; + # the socket path matches the host's, which --socket=cups mounts. + - name: cups-client + buildsystem: simple + build-commands: + - ./configure --prefix=/app --sysconfdir=/etc --localstatedir=/var --with-rundir=/run/cups + --with-domainsocket=/run/cups/cups.sock --disable-shared --with-components=libcups + --with-dnssd=avahi --with-tls=gnutls --disable-pam --disable-dbus + - make -C cups libs + - make -C systemv lp lpoptions lpstat + - install -Dm755 -t /app/libexec/cups systemv/lp systemv/lpoptions systemv/lpstat + - for tool in lp lpoptions lpstat; do install -Dm755 cups-socket.sh "/app/bin/$tool"; done + - install -Dm644 -t /app/share/licenses/cups LICENSE NOTICE + sources: + - type: archive + url: https://github.com/OpenPrinting/cups/releases/download/v2.4.20/cups-2.4.20-source.tar.gz + sha256: ab4d9cd7f3e58060091d2b24972223d6401675f11c49b65abf4f6ef31dea22ff + # Runs each tool. Flatpak mounts the host's CUPS socket at /var/run/cups/cups.sock, choosing + # it as flatpak-run-cups.c does from the host's CUPS_SERVER, else the first line spelled + # exactly `ServerName /path` in ~/.cups/client.conf, else the default; it mounts nothing + # when that socket is missing. CUPS reads the keyword in any case and takes the last + # ServerName. When CUPS' socket from client.conf is the one Flatpak chose, it is pointed at + # the mount; when it isn't, the tool fails rather than reach a different server. A + # CUPS_SERVER is left alone: one set by `flatpak run --env` may not be what was mounted. + - type: script + dest-filename: cups-socket.sh + commands: + - 'conf="$HOME/.cups/client.conf"' + - 'if [ -z "${CUPS_SERVER:-}" ] && [ -r "$conf" ]; then' + - ' server=$(sed -n ''s/^[[:space:]]*[Ss][Ee][Rr][Vv][Ee][Rr][Nn][Aa][Mm][Ee][[:space:]]\{1,\}\([^[:space:]]*\).*/\1/p'' "$conf" | tail -n 1)' + - ' case "$server" in' + - ' /*)' + - ' mounted=$(sed -n ''s/^[[:space:]]*ServerName \(.*\)$/\1/p'' "$conf" | sed ''s/^[[:space:]]*//'' | grep -m 1 ''^/[^:]*$'')' + - ' if [ "$mounted" = "$server" ]; then' + - ' export CUPS_SERVER=/var/run/cups/cups.sock' + - ' else' + - ' echo "${0##*/}: ~/.cups/client.conf names the CUPS socket $server, but Flatpak gave this app ${mounted:-a different one}; give each socket one ServerName line, spelled ServerName" >&2' + - ' exit 1' + - ' fi' + - ' ;;' + - ' esac' + - 'fi' + - 'exec "/app/libexec/cups/${0##*/}" "$@"' - name: designcraft buildsystem: simple build-commands: diff --git a/packaging/linux/flatpak/ai.storyteller.designcraft.yml b/packaging/linux/flatpak/ai.storyteller.designcraft.yml index 1dc2ee4ed..fd56910ce 100644 --- a/packaging/linux/flatpak/ai.storyteller.designcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.designcraft.yml @@ -18,6 +18,7 @@ sdk-extensions: - org.freedesktop.Sdk.Extension.rust-stable command: designcraft finish-args: + - --filesystem=xdg-data/ArtCraft/versions:ro # Display: Wayland first; X11 only when no Wayland compositor is running. - --socket=wayland - --socket=fallback-x11 @@ -28,6 +29,14 @@ finish-args: - --share=network # GPU access for egui's renderer (Vulkan, or GL as a fallback). - --device=dri + # Printing: lpstat/lp (the cups-client module) reach the host's CUPS through its socket and + # find driverless network printers through Avahi (`lpstat -e`). Without these the Print + # dialog lists no printers. + - --socket=cups + - --system-talk-name=org.freedesktop.Avahi + # The user's CUPS settings, read-only: their default printer and saved options (lpoptions) + # and a print server named in their client.conf, which lp reaches over the network. + - --filesystem=~/.cups:ro # Open/Save, Place and Export go through the file-chooser portal (rfd's xdg-portal backend) # and drag-and-drop through the document portal, so picked files work anywhere without broad # filesystem access. Documents and Pictures are granted so files passed by path (the command @@ -38,6 +47,50 @@ finish-args: build-options: append-path: /usr/lib/sdk/rust-stable/bin modules: + # CUPS client tools for craft-print: the runtime has libcups but not lpstat/lp/lpoptions. + # Linked statically against this release's libcups, so a runtime update can't break them; + # the socket path matches the host's, which --socket=cups mounts. + - name: cups-client + buildsystem: simple + build-commands: + - ./configure --prefix=/app --sysconfdir=/etc --localstatedir=/var --with-rundir=/run/cups + --with-domainsocket=/run/cups/cups.sock --disable-shared --with-components=libcups + --with-dnssd=avahi --with-tls=gnutls --disable-pam --disable-dbus + - make -C cups libs + - make -C systemv lp lpoptions lpstat + - install -Dm755 -t /app/libexec/cups systemv/lp systemv/lpoptions systemv/lpstat + - for tool in lp lpoptions lpstat; do install -Dm755 cups-socket.sh "/app/bin/$tool"; done + - install -Dm644 -t /app/share/licenses/cups LICENSE NOTICE + sources: + - type: archive + url: https://github.com/OpenPrinting/cups/releases/download/v2.4.20/cups-2.4.20-source.tar.gz + sha256: ab4d9cd7f3e58060091d2b24972223d6401675f11c49b65abf4f6ef31dea22ff + # Runs each tool. Flatpak mounts the host's CUPS socket at /var/run/cups/cups.sock, choosing + # it as flatpak-run-cups.c does from the host's CUPS_SERVER, else the first line spelled + # exactly `ServerName /path` in ~/.cups/client.conf, else the default; it mounts nothing + # when that socket is missing. CUPS reads the keyword in any case and takes the last + # ServerName. When CUPS' socket from client.conf is the one Flatpak chose, it is pointed at + # the mount; when it isn't, the tool fails rather than reach a different server. A + # CUPS_SERVER is left alone: one set by `flatpak run --env` may not be what was mounted. + - type: script + dest-filename: cups-socket.sh + commands: + - 'conf="$HOME/.cups/client.conf"' + - 'if [ -z "${CUPS_SERVER:-}" ] && [ -r "$conf" ]; then' + - ' server=$(sed -n ''s/^[[:space:]]*[Ss][Ee][Rr][Vv][Ee][Rr][Nn][Aa][Mm][Ee][[:space:]]\{1,\}\([^[:space:]]*\).*/\1/p'' "$conf" | tail -n 1)' + - ' case "$server" in' + - ' /*)' + - ' mounted=$(sed -n ''s/^[[:space:]]*ServerName \(.*\)$/\1/p'' "$conf" | sed ''s/^[[:space:]]*//'' | grep -m 1 ''^/[^:]*$'')' + - ' if [ "$mounted" = "$server" ]; then' + - ' export CUPS_SERVER=/var/run/cups/cups.sock' + - ' else' + - ' echo "${0##*/}: ~/.cups/client.conf names the CUPS socket $server, but Flatpak gave this app ${mounted:-a different one}; give each socket one ServerName line, spelled ServerName" >&2' + - ' exit 1' + - ' fi' + - ' ;;' + - ' esac' + - 'fi' + - 'exec "/app/libexec/cups/${0##*/}" "$@"' - name: designcraft buildsystem: simple build-options: diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index fa24385d5..5bbbb01b0 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -61,6 +61,7 @@ install -Dm755 "$BIN/designcraft-cli" "$STAGE/usr/bin/designcraft-cli" "${CROSS_COMPILE:-}strip" "$STAGE/usr/bin/designcraft" "$STAGE/usr/bin/designcraft-cli" 2>/dev/null || true install -Dm644 "$HERE/$APP_ID.desktop" "$STAGE/usr/share/applications/$APP_ID.desktop" install -Dm644 "$HERE/$APP_ID.mime.xml" "$STAGE/usr/share/mime/packages/$APP_ID.xml" +install -Dm644 "$HERE/$APP_ID.thumbnailer" "$STAGE/usr/share/thumbnailers/$APP_ID.thumbnailer" mkdir -p "$STAGE/usr/share/metainfo" sed -e "s/@VERSION@/$VERSION/g" -e "s/@DATE@/$DESIGNCRAFT_BUILD_DATE/g" \ "$HERE/$APP_ID.metainfo.xml.in" >"$STAGE/usr/share/metainfo/$APP_ID.metainfo.xml" @@ -142,6 +143,8 @@ if has appimage; then esac APPDIR="$WORK/DesignCraft.AppDir" cp -R "$STAGE" "$APPDIR" + # Keep sctk-adwaita's MIT notice in the AppImage. + install -Dm644 "$ROOT/NOTICE" "$APPDIR/usr/share/doc/designcraft/NOTICE" ln -s usr/bin/designcraft "$APPDIR/AppRun" cp "$HERE/$APP_ID.desktop" "$APPDIR/$APP_ID.desktop" cp "$ROOT/assets/app-icon/hicolor/256x256/apps/$APP_ID.png" "$APPDIR/$APP_ID.png" diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index ba4503678..01e03a682 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -6,6 +6,27 @@ <dict> <key>CFBundleDevelopmentRegion</key> <string>en</string> + <!-- UI translations are embedded in Rust; declare them for AppKit's system dialogs (open and + save panels, overwrite confirmation). Keep in sync with the interface languages. --> + <key>CFBundleLocalizations</key> + <array> + <string>en</string> + <string>ar</string> + <string>cs</string> + <string>de</string> + <string>es</string> + <string>fr</string> + <string>hu</string> + <string>it</string> + <string>ja</string> + <string>ka</string> + <string>pl</string> + <string>pt-BR</string> + <string>ru</string> + <string>sv</string> + <string>uk</string> + <string>zh-Hans</string> + </array> <key>CFBundleDisplayName</key> <string>DesignCraft</string> <key>CFBundleName</key> @@ -72,6 +93,33 @@ <key>LSItemContentTypes</key> <array><string>ai.storyteller.designcraft.idml</string></array> </dict> + <!-- The same, by extension. When another installed app (InDesign) exports its own type for + .idml, files get that type instead of the one imported below, so the entry above no + longer matches them: Finder then won't drop them on the app's icon, and Open With leaves + DesignCraft out. An extension entry matches whatever type the file has. It must be a + dict of its own: where LSItemContentTypes is present, the extension keys are ignored. --> + <dict> + <key>CFBundleTypeName</key> + <string>IDML Document (by extension)</string> + <key>CFBundleTypeRole</key> + <string>Viewer</string> + <key>LSHandlerRank</key> + <string>Alternate</string> + <key>CFBundleTypeExtensions</key> + <array><string>idml</string></array> + </dict> + <!-- InDesign documents and templates, converted to IDML on open (file.open); by extension + for the same reason as above. --> + <dict> + <key>CFBundleTypeName</key> + <string>InDesign Document</string> + <key>CFBundleTypeRole</key> + <string>Viewer</string> + <key>LSHandlerRank</key> + <string>Alternate</string> + <key>CFBundleTypeExtensions</key> + <array><string>indd</string><string>indt</string></array> + </dict> </array> <!-- DesignCraft's own format: a zip package, declared as a document rather than an archive so