Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2e42ce0ff | |||
| a9a1f2cbcb | |||
| c583d6e6ed | |||
| 73137d1598 | |||
| fd85683f3d | |||
| f524267e0c | |||
| d1b5cfbf1e | |||
| 91263c912f | |||
| cc5b4a1f0f | |||
| 64f0ca8fa6 | |||
| 17cf5b4e91 | |||
| c3859c828b | |||
| 8e151a2fd4 | |||
| 9509b2bcea | |||
| e198eb5d1d | |||
| b9048a5b08 | |||
| d020b4e85d | |||
| 328b4d5a52 | |||
| 5cdcac4ace | |||
| 3624f48819 | |||
| ebd1d78194 | |||
| 3384224419 | |||
| 835edae659 | |||
| 5789caaa60 | |||
| fae9bc84ce | |||
| a21e8db14f | |||
| 80c2904681 | |||
| f70f93de44 | |||
| 95c8c9cc5b | |||
| 223dedfe5a | |||
| ca1a8a12bc | |||
| 597f0e649f | |||
| 65ef027f9f | |||
| 66860bd1de | |||
| 2b46ca664c | |||
| 3dc0a1563a | |||
| 957890223a | |||
| 3ec5cef6a2 | |||
| 9f7da3f6ab |
@@ -0,0 +1,18 @@
|
||||
# Strip upstream (ArtCraft / Discord) branding. Run: perl tools/craft-skills/skills/debrand/debrand.pl . (--check in CI)
|
||||
delete docs/brand
|
||||
# MIT/Apache texts stay verbatim. The copyright holder line stays everywhere (legal requirement).
|
||||
keep ^(LICENSE-(MIT|APACHE)|\.debrand)$
|
||||
dropline ^ASSETS\.md$ :: \|\s*`docs/brand/
|
||||
dropline ^crates/ui-egui/src/i18n(/.*)?\.rs$ :: \s*\("[^"]*(?:ArtCraft|Discord|discord)
|
||||
sub (?s)\nThe ArtCraft name, wordmark and logos in docs/brand/.*?LICENSE-brand\.txt\.\n =>
|
||||
sub \s*\(except the ArtCraft trademarks in `docs/brand/`, under `docs/brand/LICENSE-brand\.txt`\) =>
|
||||
sub drawn in ArtCraft\) => drawn by hand)
|
||||
sub owner's ArtCraft drawing => owner's drawing
|
||||
sub by the project owner in ArtCraft, then => by the project owner, then
|
||||
sub The owner drew the cat in ArtCraft\. => The owner drew the cat by hand.
|
||||
sub the MIT OR Apache-2\.0 ArtCraft sibling catalogs => the MIT OR Apache-2.0 sibling catalogs
|
||||
forbid (?i)artcraft(?! Team and the DesignCraft contributors)
|
||||
forbid (?i)discord
|
||||
sub \s*The one exception is the ArtCraft brand files in `docs/brand/`: ArtCraft trademarks, not open source, used under `docs/brand/LICENSE-brand\.txt`\. =>
|
||||
sub https://getartcraft\.com/apps/designcraft => https://github.com/storytold/designcraft
|
||||
sub https://discord\.gg/artcraft => https://github.com/storytold/designcraft/issues
|
||||
@@ -35,3 +35,9 @@ jobs:
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "tools/craft-skills"]
|
||||
path = tools/craft-skills
|
||||
url = https://git.marcospaulo.dev.br/storytold/craft-skills.git
|
||||
@@ -20,7 +20,7 @@ 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.
|
||||
- **Assets:** no Adobe iconography or images — ever. Every asset is original / OSI / CC0 / redistributable CC 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`.
|
||||
- **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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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 and craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)).
|
||||
|
||||
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`).
|
||||
|
||||
@@ -45,25 +45,20 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
||||
| `crates/render/tests/golden/inner-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
|
||||
| `crates/render/tests/golden/outer-glow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
|
||||
| `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.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/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`) |
|
||||
| `assets/app-icon/hicolor/256x256/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/32x32/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/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`) |
|
||||
| `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) |
|
||||
| `docs/brand/artcraft-mark-black.svg`, `docs/brand/artcraft-mark-black.png` | ArtCraft / Storyteller | ArtCraft brand kit (project owner) | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Mark for light backgrounds |
|
||||
| `docs/brand/LICENSE-brand.txt` | (licence text) | ArtCraft Team | — | Terms for the ArtCraft Marks in `docs/brand/` |
|
||||
| `assets/app-icon/designcraft-1024.png` | DesignCraft project owner (drawn by hand) | 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 by hand) | 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 by hand) | owner's 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.icns` | DesignCraft project owner (drawn by hand) | 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 by hand) | 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 by hand) | owner's 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/128x128/apps/ai.storyteller.designcraft.png` | DesignCraft project owner (drawn by hand) | 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 by hand) | 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 by hand) | 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/256x256/apps/ai.storyteller.designcraft.png` | DesignCraft project owner (drawn by hand) | 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/32x32/apps/ai.storyteller.designcraft.png` | DesignCraft project owner (drawn by hand) | 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/48x48/apps/ai.storyteller.designcraft.png` | DesignCraft project owner (drawn by hand) | 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 by hand) | 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 by hand) | 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 by hand) | owner's 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`) |
|
||||
| `packaging/macos/dmg/background.svg` | @XusBadia | original work for this repository, derived from the DesignCraft app icon (`assets/app-icon/designcraft.svg`, referenced via `<image>`, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | DesignCraft macOS DMG window background for the Finder window; `packaging/macos/dmg/generate.py` renders it to `background.tiff` (2026-10-08) |
|
||||
| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") |
|
||||
|
||||
@@ -20,7 +20,7 @@ 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.
|
||||
- **Assets:** no Adobe iconography or images — ever. Every asset is original / OSI / CC0 / redistributable CC 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`.
|
||||
- **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.
|
||||
|
||||
@@ -4,13 +4,14 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.6.0"
|
||||
version = "0.7.2"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
repository = "https://github.com/storytold/designcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
craft-print = { git = "https://git.marcospaulo.dev.br/masi/craft-libs", version = "0.1.0", rev = "7fcee66b8eef8eac3c7644fd024e1f1b051f5a3e" }
|
||||
# internal
|
||||
designcraft-geom = { path = "crates/geom" }
|
||||
designcraft-color = { path = "crates/color" }
|
||||
@@ -68,7 +69,7 @@ zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
|
||||
# ui
|
||||
# Shared frontend components; keep this dependency above the engine boundary.
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
craft-ui = { version = "0.1.0", git = "https://git.marcospaulo.dev.br/masi/craft-libs", rev = "7fcee66b8eef8eac3c7644fd024e1f1b051f5a3e", features = ["serde"] }
|
||||
egui = "0.36"
|
||||
egui_extras = { version = "0.36", default-features = false, features = ["svg"] }
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
@@ -96,3 +97,7 @@ opt-level = 3
|
||||
lto = "thin"
|
||||
codegen-units = 16
|
||||
debug = 0
|
||||
|
||||
# Opt-in cancelable macOS termination on the pinned winit 0.30 line.
|
||||
[patch.crates-io]
|
||||
winit = { git = "https://github.com/skylartaylor/winit.git", rev = "3d51077fd1a966f84df9ef287839dc84fd5b4ca7" }
|
||||
|
||||
@@ -3,10 +3,6 @@ Copyright (c) 2026 ArtCraft Team and the DesignCraft contributors.
|
||||
Licensed under the MIT License or the Apache License, Version 2.0, at your option
|
||||
(LICENSE-MIT, LICENSE-APACHE).
|
||||
|
||||
The ArtCraft name, wordmark and logos in docs/brand/ are trademarks of the ArtCraft Team. They are
|
||||
not open source and are not covered by the license above; their terms are in
|
||||
docs/brand/LICENSE-brand.txt.
|
||||
|
||||
Third-party material
|
||||
|
||||
This product bundles third-party material under its own licenses, listed below. The per-file
|
||||
@@ -32,6 +28,26 @@ 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.
|
||||
- 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,
|
||||
2024 Leonardo Araujo and Aline Benevides. BSD 3-clause licence:
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted
|
||||
provided that the following conditions are met: * Redistributions of source code must retain the
|
||||
above copyright notice, this list of conditions and the following disclaimer. * Redistributions in
|
||||
binary form must reproduce the above copyright notice, this list of conditions and the following
|
||||
disclaimer in the documentation and/or other materials provided with the distribution. * Neither
|
||||
the name of the University of Campinas, of the University of Minho nor the names of its
|
||||
contributors may be used to endorse or promote products derived from this software without
|
||||
specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
|
||||
CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
|
||||
EVENT SHALL PEDRO J. DE REZENDE OR J.JOAO DIAS ALMEIDA BE LIABLE FOR ANY DIRECT, INDIRECT,
|
||||
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
||||
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
|
||||
LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
- 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,
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/brand/artcraft-logo-white.svg">
|
||||
<img alt="ArtCraft" src="docs/brand/artcraft-logo.svg" width="200">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
<h1 align="center">DesignCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
@@ -16,8 +6,7 @@
|
||||
|
||||
<p align="center">
|
||||
A fast, open-source, clean-room take on the Adobe InDesign workflow. It runs natively on macOS,
|
||||
Windows and Linux, and in the browser via WebAssembly.<br>
|
||||
<i>By the ArtCraft team.</i>
|
||||
Windows and Linux, and in the browser via WebAssembly.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -27,16 +16,6 @@
|
||||
<img alt="Agent-drivable over MCP" src="https://img.shields.io/badge/agents-MCP-7bb51c?style=flat-square">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/apps/designcraft"><b>DesignCraft on getartcraft.com</b></a> ·
|
||||
<a href="https://getartcraft.com/">ArtCraft</a> ·
|
||||
<a href="https://getartcraft.com/apps">All Crafting Apps</a>
|
||||
</p>
|
||||
|
||||
<br>
|
||||
|
||||
<p align="center">
|
||||
@@ -44,10 +23,6 @@
|
||||
<br><sub><b>Quarterly, Spring Issue</b>: threaded three-column body text, a wrapped pull quote and parent-page folios, all set by DesignCraft's own paragraph composer.</sub>
|
||||
</p>
|
||||
|
||||
> [!NOTE]
|
||||
> **ArtCraft is a community of artists from all walks of life.** Digital, generative, music,
|
||||
> games — if you make things, you're one of us. **[Come say hi on Discord](https://discord.gg/artcraft).**
|
||||
|
||||
<p align="center">
|
||||
<a href="#the-sample-magazine">The sample magazine</a> ·
|
||||
<a href="#why-designcraft">Why DesignCraft</a> ·
|
||||
@@ -192,9 +167,7 @@ egui frontend is a separate crate, so the UI can be swapped without touching the
|
||||
|
||||
## Downloads
|
||||
|
||||
**New to DesignCraft?** Download it from the [DesignCraft page on getartcraft.com](https://getartcraft.com/apps/designcraft). That's the easiest way to install it.
|
||||
|
||||
**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/designcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/designcraft/releases) has earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
|
||||
The [latest release](https://github.com/storytold/designcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/designcraft/releases) has earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
|
||||
|
||||
### Windows
|
||||
|
||||
@@ -240,42 +213,20 @@ RISC-V (riscv64): `designcraft-<ver>-linux-riscv64.tar.gz` only, cross-compiled;
|
||||
## The Crafting Apps
|
||||
|
||||
DesignCraft is one of the **Crafting Apps**: free, open-source creative tools from the
|
||||
[ArtCraft](https://getartcraft.com/) team, each written from scratch in Rust and each able to
|
||||
[storytold](https://github.com/storytold) team, each written from scratch in Rust and each able to
|
||||
stand on its own.
|
||||
|
||||
| | App | What it's for | Code | Learn more |
|
||||
|:-:|---|---|---|---|
|
||||
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | **Page layout and publishing · you are here** | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
|
||||
| | App | What it's for | Code |
|
||||
|:-:|---|---|---|
|
||||
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | **Page layout and publishing · you are here** | [GitHub](https://github.com/storytold/designcraft) |
|
||||
|
||||
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
|
||||
|
||||
<br>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
|
||||
</p>
|
||||
|
||||
<h3 align="center">Come make things with us</h3>
|
||||
|
||||
<p align="center">
|
||||
Our Discord is where artists of every kind hang out: people who paint, shoot, draw, cut film,
|
||||
set type, and people still figuring out what they like to make. Share what you're working on,
|
||||
ask for help, tell us what's broken, or tell us what you wish these tools could do.
|
||||
Whatever your medium and however long you've been at it, you're welcome here.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/artcraft"><b>discord.gg/artcraft</b></a> ·
|
||||
<a href="https://getartcraft.com/">getartcraft.com</a> ·
|
||||
<a href="https://getartcraft.com/apps">The Crafting Apps</a> ·
|
||||
<a href="https://getartcraft.com/apps/designcraft">DesignCraft</a>
|
||||
</p>
|
||||
The Crafting Apps share the same conventions: clean-room and pure Rust, native on macOS, Windows and Linux, in the browser via WebAssembly, and fully drivable by agents.
|
||||
|
||||
## License and credits
|
||||
|
||||
@@ -288,16 +239,4 @@ with its author, source and license in [ASSETS.md](ASSETS.md).
|
||||
The bundled fonts, and the craft-fonts fonts embedded by release builds, are under the SIL Open
|
||||
Font License; all UI icons are drawn in code and are original.
|
||||
|
||||
The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the
|
||||
ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as
|
||||
part of this repository and DesignCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
Forks and modified versions must remove them.
|
||||
|
||||
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. DesignCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
|
||||
|
||||
<br>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
</p>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: pre-alpha** · next: alpha, ~0% and ~15–25 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (stage re-normalized to pre-alpha by the core-workflow gate; earlier today: major, full re-measure against InDesign 2026 21.6; restructured to the craftrules progress-docs standard) · **Target:** Adobe InDesign 2026
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (cancelable macOS Dock Quit) · **Target:** Adobe InDesign 2026
|
||||
|
||||
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.
|
||||
@@ -129,6 +129,20 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
|
||||
|
||||
Newest first.
|
||||
|
||||
- **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change.
|
||||
- **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change.
|
||||
- **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change.
|
||||
- **2026-10-12** · Book numbering and style updates prepare every chapter before saving. Only closed native chapters are changed; imported IDML remains a read-only style/PDF source. If a later disk write fails, the error names the completed chapters; the batch is not a whole-book transaction. No readiness percentage change.
|
||||
- **2026-10-12** · Desktop write callbacks and CLI raster exports use the atomic writer too; open readers retain the original bytes, while private Unix modes and symlink targets survive replacement. Permanent tests cover both actual callers. No readiness percentage change.
|
||||
- **2026-10-12** · Save As, library edits and book collection edits persist prospective values before changing live state; failed writes preserve the document and existing collections. Save a Copy rebases data-source paths for its destination without changing the open document. No readiness percentage change.
|
||||
- **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change.
|
||||
- **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change.
|
||||
- **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change.
|
||||
- **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change.
|
||||
- **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change.
|
||||
- **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change.
|
||||
- **2026-10-11 (printing):** `file.printers` and `file.print` use craft-libs/craft-print for CUPS and Windows. DesignCraft retains PDF export, page selection, spreads, marks and bleed; dry runs create no temporary PDF or print job. Windows selects matching or default driver paper and shrinks oversized sheets without enlarging smaller ones; queue duplex defaults are preserved. Headless tests exercise exported bytes, job settings, dry runs and refusals; shared native Windows CI passes, including paused-queue submission and cleanup. PostScript, production print controls and physical output validation remain open. No readiness percentage change.
|
||||
|
||||
- **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).
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
//! designcraft-cli perf [--pages N] [--frames N] [--chars N] [--images N] [--runs N] [--strict] # budgets on a synthetic stress document
|
||||
//! designcraft-cli bench FILE [--runs N] # the same measurements on one document
|
||||
//! designcraft-cli validate-pdf FILE.pdf # run the built-in PDF/X-4 conformance checks
|
||||
//! designcraft-cli links # Discord, website, app page and GitHub links
|
||||
//! designcraft-cli links # GitHub and issue-tracker links
|
||||
//! designcraft-cli --version # print the version
|
||||
//! ```
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
@@ -40,12 +40,7 @@ const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID
|
||||
|
||||
/// The links line under the usage block.
|
||||
fn usage_footer() -> String {
|
||||
format!(
|
||||
"\nCommunity: {} · {} · {}",
|
||||
designcraft_engine::links::DISCORD,
|
||||
designcraft_engine::links::APP_PAGE,
|
||||
designcraft_engine::links::GITHUB
|
||||
)
|
||||
format!("\nSource: {} · Issues: {}", designcraft_engine::links::GITHUB, designcraft_engine::links::ISSUES)
|
||||
}
|
||||
|
||||
/// stdout went away. A reader that stopped early (a closed pipe) ends the program quietly, as
|
||||
@@ -88,7 +83,7 @@ fn main() -> ExitCode {
|
||||
Some("validate-pdf") => report(validate_pdf(args.get(1).map(String::as_str))),
|
||||
Some("links") => {
|
||||
use designcraft_engine::links::*;
|
||||
outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}\nIssues {ISSUES}");
|
||||
outln!("GitHub {GITHUB}\nIssues {ISSUES}");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
// Asking for help is not an error: it goes to stdout and succeeds. An unknown command
|
||||
@@ -281,7 +276,7 @@ fn export(s: &mut Session, out: &str, page: usize, scale: f64) -> Result<(), Str
|
||||
.render_page(&st.doc, &s.cache, page, scale, true, &designcraft_render::RenderOptions { printing_only: true, ..Default::default() })
|
||||
.ok_or("no such page")?;
|
||||
let bytes = if out.ends_with(".jpg") || out.ends_with(".jpeg") { img.to_jpeg(90) } else { img.to_png() };
|
||||
std::fs::write(out, bytes).map_err(|e| format!("{out}: {e}"))?;
|
||||
designcraft_engine::atomic::write_atomic(std::path::Path::new(out), &bytes).map_err(|e| format!("{out}: {e}"))?;
|
||||
eprintln!("wrote {out} ({}×{}, {:.1} ms, {} glyphs)", img.width, img.height, t.elapsed().as_secs_f64() * 1000.0, r.stats.glyphs);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -122,3 +122,35 @@ fn run_export_options_come_before_their_export() {
|
||||
assert!(err.contains("wrote") && std::path::Path::new(a).exists() && std::path::Path::new(b).exists(), "{err}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn raster_export_preserves_an_existing_reader_and_private_symlink_target() {
|
||||
use std::io::Read;
|
||||
use std::os::unix::fs::{PermissionsExt, symlink};
|
||||
struct Fixture(std::path::PathBuf);
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("designcraft-cli-atomic-{}-{stamp}", std::process::id()));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
let fixture = Fixture(dir);
|
||||
let target = fixture.0.join(format!("{}.png", "a".repeat(243)));
|
||||
let old = b"previous private export";
|
||||
std::fs::write(&target, old).unwrap();
|
||||
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
let alias = fixture.0.join("linked.png");
|
||||
symlink(target.file_name().unwrap(), &alias).unwrap();
|
||||
let mut reader = std::fs::File::open(&target).unwrap();
|
||||
let out = cli().args(["run", "--sample", "--scale", "0.1", "--export", alias.to_str().unwrap()]).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let mut observed = Vec::new();
|
||||
reader.read_to_end(&mut observed).unwrap();
|
||||
assert!(observed == old, "CLI export changed bytes under an existing reader");
|
||||
assert!(std::fs::read(&target).unwrap().starts_with(b"\x89PNG\r\n\x1a\n"));
|
||||
assert_eq!(std::fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
assert!(std::fs::symlink_metadata(alias).unwrap().file_type().is_symlink());
|
||||
}
|
||||
|
||||
@@ -31,4 +31,6 @@ rfd = { workspace = true }
|
||||
winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# The same winit as eframe; only the native termination opt-in is used.
|
||||
winit = { version = "0.30", default-features = false }
|
||||
muda = { version = "0.21", default-features = false }
|
||||
|
||||
@@ -64,10 +64,33 @@ impl eframe::App for App {
|
||||
if let Some(s) = self.startup.as_mut() {
|
||||
s.presented();
|
||||
}
|
||||
if let Err(e) = preserve_documents_on_exit(&mut self.app) {
|
||||
log::error!("Couldn't preserve documents on exit: {e}");
|
||||
eprintln!("designcraft: couldn't preserve documents on exit: {e}");
|
||||
}
|
||||
save_prefs(&self.app);
|
||||
}
|
||||
}
|
||||
|
||||
/// Orderly OS termination can bypass egui's cancelable close event. Finish only a complete,
|
||||
/// current quit plan; otherwise preserve every live document, including edits after the timer.
|
||||
fn preserve_documents_on_exit(app: &mut DesignApp) -> Result<(), String> {
|
||||
app.finish_confirmed_quit()?;
|
||||
if app.session.documents().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if app.session.recovery_dir.is_none() {
|
||||
return if app.session.documents().iter().any(|d| d.is_dirty()) {
|
||||
Err("Recovery is unavailable; unsaved documents could not be preserved on exit".into())
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
}
|
||||
// Also discard stale recovery for clean documents (for example, Undo back to the saved
|
||||
// state after the last timer tick). Never choose a fallback recovery path here.
|
||||
app.run("file.recovery.save", serde_json::json!({})).map(|_| ())
|
||||
}
|
||||
|
||||
/// `DESIGNCRAFT_NO_PREFS`: read and write nothing in the settings directory (tests, demos).
|
||||
fn no_prefs() -> bool {
|
||||
std::env::var_os("DESIGNCRAFT_NO_PREFS").is_some()
|
||||
@@ -186,7 +209,7 @@ fn services() -> Services {
|
||||
})),
|
||||
pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))),
|
||||
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
|
||||
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
|
||||
write: Some(Box::new(|p: &str, b: &[u8]| designcraft_engine::atomic::write_atomic(std::path::Path::new(p), b).map_err(|e| e.to_string()))),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -276,6 +299,15 @@ fn main() -> eframe::Result {
|
||||
b.with_x11();
|
||||
}));
|
||||
}
|
||||
// AppKit Dock Quit otherwise terminates before egui can cancel the close. Route it through
|
||||
// ordinary CloseRequested events, preserving the same transactional Save/Don't Save flow.
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
options.event_loop_builder = Some(Box::new(|builder| {
|
||||
use winit::platform::macos::EventLoopBuilderExtMacOS;
|
||||
builder.with_close_requested_on_terminate(true);
|
||||
}));
|
||||
}
|
||||
eframe::run_native(
|
||||
"DesignCraft",
|
||||
options,
|
||||
@@ -353,3 +385,168 @@ mod tests {
|
||||
assert!(documents.iter().any(|filter| filter.extensions.contains(&"designcraft")));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod atomic_writer_tests {
|
||||
#[test]
|
||||
fn desktop_write_preserves_an_existing_reader_and_private_symlink_target() {
|
||||
use std::io::Read;
|
||||
use std::os::unix::fs::{PermissionsExt, symlink};
|
||||
struct Fixture(std::path::PathBuf);
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("designcraft-desktop-atomic-{}-{stamp}", std::process::id()));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
let fixture = Fixture(dir);
|
||||
let target = fixture.0.join(format!("{}.png", "a".repeat(243)));
|
||||
let old = b"previous private export";
|
||||
std::fs::write(&target, old).unwrap();
|
||||
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
let alias = fixture.0.join("linked.png");
|
||||
symlink(target.file_name().unwrap(), &alias).unwrap();
|
||||
let mut reader = std::fs::File::open(&target).unwrap();
|
||||
let mut write = super::services().write.unwrap();
|
||||
write(alias.to_str().unwrap(), b"complete new export").unwrap();
|
||||
let mut observed = Vec::new();
|
||||
reader.read_to_end(&mut observed).unwrap();
|
||||
assert!(observed == old, "desktop export changed bytes under an existing reader");
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"complete new export");
|
||||
assert_eq!(std::fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
assert!(std::fs::symlink_metadata(alias).unwrap().file_type().is_symlink());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod exit_recovery_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn dirty_app(label: &str) -> (DesignApp, std::path::PathBuf) {
|
||||
let mut app = DesignApp::new(Session::new(), Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
app.run("frame.create", json!({"rect": [20, 20, 120, 120]})).unwrap();
|
||||
let uid = app.session.active().unwrap().uid;
|
||||
let dir = std::env::temp_dir().join(format!("designcraft-exit-{label}-{}-{uid}", std::process::id()));
|
||||
assert!(!dir.exists());
|
||||
app.session.recovery_dir = Some(dir.clone());
|
||||
(app, dir)
|
||||
}
|
||||
|
||||
fn close_frame(app: &mut DesignApp, ctx: &egui::Context, close: bool) {
|
||||
let mut raw = egui::RawInput::default();
|
||||
if close {
|
||||
raw.viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close);
|
||||
}
|
||||
let mut out = ctx.run_ui(raw, |ui| app.logic(ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_recovery_preserves_latest_dirty_document_without_mutating_it() {
|
||||
let (mut app, dir) = dirty_app("latest");
|
||||
app.run("file.recovery.save", json!({})).unwrap();
|
||||
app.run("frame.create", json!({"rect": [200, 200, 300, 300]})).unwrap();
|
||||
let d = app.session.active().unwrap();
|
||||
let (uid, revision, saved_revision, doc, selection, undo, redo) =
|
||||
(d.uid, d.revision, d.saved_revision, d.doc.clone(), d.selection.items.clone(), d.history.undo.len(), d.history.redo.len());
|
||||
preserve_documents_on_exit(&mut app).unwrap();
|
||||
let current = app.session.active().unwrap();
|
||||
assert_eq!(
|
||||
(current.uid, current.revision, current.saved_revision, ¤t.selection.items, current.history.undo.len(), current.history.redo.len()),
|
||||
(uid, revision, saved_revision, &selection, undo, redo)
|
||||
);
|
||||
assert!(std::sync::Arc::ptr_eq(¤t.doc, &doc));
|
||||
assert!(current.is_dirty());
|
||||
let recovered = designcraft_engine::cmd::from_bytes(&std::fs::read(dir.join(format!("{uid}.designcraft"))).unwrap()).unwrap();
|
||||
assert_eq!(serde_json::to_value(&recovered).unwrap(), serde_json::to_value(doc.as_ref()).unwrap());
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_recovery_removes_stale_data_after_undo_to_saved_state() {
|
||||
let (mut app, dir) = dirty_app("clean");
|
||||
let uid = app.session.active().unwrap().uid;
|
||||
app.run("file.recovery.save", json!({})).unwrap();
|
||||
let entry = dir.join(format!("{uid}.designcraft"));
|
||||
assert!(entry.exists());
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert!(!app.session.active().unwrap().is_dirty());
|
||||
let doc = app.session.active().unwrap().doc.clone();
|
||||
preserve_documents_on_exit(&mut app).unwrap();
|
||||
assert!(!entry.exists(), "clean exit must not resurrect the pre-Undo edits");
|
||||
assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &doc));
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_recovery_reports_unavailable_or_failed_storage_without_changing_documents() {
|
||||
let (mut app, dir) = dirty_app("failure");
|
||||
let doc = app.session.active().unwrap().doc.clone();
|
||||
app.session.recovery_dir = None;
|
||||
assert!(preserve_documents_on_exit(&mut app).unwrap_err().contains("unavailable"));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
let blocked = dir.join("not-a-directory");
|
||||
std::fs::write(&blocked, b"preserve this file").unwrap();
|
||||
app.session.recovery_dir = Some(blocked.clone());
|
||||
assert!(preserve_documents_on_exit(&mut app).is_err());
|
||||
assert_eq!(std::fs::read(&blocked).unwrap(), b"preserve this file");
|
||||
assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &doc));
|
||||
assert!(app.session.active().unwrap().is_dirty());
|
||||
assert_eq!(app.session.documents().len(), 1);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_recovery_respects_complete_incomplete_stale_and_new_document_quit_plans() {
|
||||
for case in ["complete", "incomplete", "stale", "new"] {
|
||||
let (mut app, dir) = dirty_app(case);
|
||||
if case == "incomplete" {
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
app.run("frame.create", json!({"rect": [20, 20, 120, 120]})).unwrap();
|
||||
}
|
||||
app.run("file.recovery.save", json!({})).unwrap();
|
||||
let first_uid = app.session.documents()[0].uid;
|
||||
let ctx = egui::Context::default();
|
||||
close_frame(&mut app, &ctx, true);
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true));
|
||||
designcraft_ui_egui::dialogs::confirm(&mut app).unwrap();
|
||||
close_frame(&mut app, &ctx, false);
|
||||
if case == "stale" {
|
||||
app.run("frame.create", json!({"rect": [200, 200, 300, 300]})).unwrap();
|
||||
} else if case == "new" {
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
app.run("frame.create", json!({"rect": [20, 20, 120, 120]})).unwrap();
|
||||
}
|
||||
let active = app.session.active().unwrap().uid;
|
||||
let before: Vec<_> = app
|
||||
.session
|
||||
.documents()
|
||||
.iter()
|
||||
.map(|d| (d.uid, d.revision, d.doc.clone(), d.selection.items.clone(), d.history.undo.len(), d.history.redo.len()))
|
||||
.collect();
|
||||
preserve_documents_on_exit(&mut app).unwrap();
|
||||
if case == "complete" {
|
||||
assert!(app.session.documents().is_empty());
|
||||
assert!(!dir.join(format!("{first_uid}.designcraft")).exists(), "accepted Don't Save must not be recovered");
|
||||
} else {
|
||||
assert_eq!(app.session.documents().len(), before.len(), "{case}");
|
||||
assert_eq!(app.session.active().unwrap().uid, active, "{case}");
|
||||
for (d, (uid, revision, doc, selection, undo, redo)) in app.session.documents().iter().zip(before) {
|
||||
assert_eq!(
|
||||
(d.uid, d.revision, &d.selection.items, d.history.undo.len(), d.history.redo.len()),
|
||||
(uid, revision, &selection, undo, redo),
|
||||
"{case}"
|
||||
);
|
||||
assert!(std::sync::Arc::ptr_eq(&d.doc, &doc), "{case}");
|
||||
let recovered = designcraft_engine::cmd::from_bytes(&std::fs::read(dir.join(format!("{uid}.designcraft"))).unwrap()).unwrap();
|
||||
assert_eq!(serde_json::to_value(&recovered).unwrap(), serde_json::to_value(doc.as_ref()).unwrap(), "{case}");
|
||||
}
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,12 +37,12 @@ impl NativeMenu {
|
||||
let mut counter = 0usize;
|
||||
// Application menu.
|
||||
let app_menu = Submenu::new("DesignCraft", true);
|
||||
// Our own About splash (with the community links) instead of the stock panel.
|
||||
// Our own About splash instead of the stock panel.
|
||||
let about = MenuItem::with_id("dc-about", "About DesignCraft", true, None);
|
||||
let discord = MenuItem::with_id("dc-discord", "Join the ArtCraft Discord…", true, None);
|
||||
// Not the predefined Quit: it terminates the app at once, past the unsaved-changes prompt.
|
||||
let quit = MenuItem::with_id("dc-quit", designcraft_ui_egui::i18n::tr(&app.ui.language, "Quit DesignCraft"), true, accel("Cmd+Q"));
|
||||
let _ = app_menu.append_items(&[
|
||||
&about,
|
||||
&discord,
|
||||
&PredefinedMenuItem::separator(),
|
||||
&PredefinedMenuItem::services(None),
|
||||
&PredefinedMenuItem::separator(),
|
||||
@@ -50,10 +50,10 @@ impl NativeMenu {
|
||||
&PredefinedMenuItem::hide_others(None),
|
||||
&PredefinedMenuItem::show_all(None),
|
||||
&PredefinedMenuItem::separator(),
|
||||
&PredefinedMenuItem::quit(None),
|
||||
&quit,
|
||||
]);
|
||||
items.insert("dc-about".into(), ("help.about".into(), serde_json::Value::Null, Handle::Plain(about)));
|
||||
items.insert("dc-discord".into(), ("help.discord".into(), serde_json::Value::Null, Handle::Plain(discord)));
|
||||
items.insert("dc-quit".into(), ("app.quit".into(), serde_json::Value::Null, Handle::Plain(quit)));
|
||||
let _ = menu.append(&app_menu);
|
||||
for (title, entries) in menus::menu_tree() {
|
||||
let sub = Submenu::new(designcraft_ui_egui::i18n::tr(&app.ui.language, title), true);
|
||||
|
||||
@@ -22,7 +22,19 @@ fn config_dir_with(os: &str, env: impl Fn(&str) -> Option<OsString>) -> Option<P
|
||||
}
|
||||
|
||||
pub fn recovery_dir() -> Option<PathBuf> {
|
||||
recovery_dir_with(std::env::var_os("DESIGNCRAFT_CONFIG_DIR"), designcraft_engine::recovery::default_dir)
|
||||
recovery_dir_with(std::env::var_os("DESIGNCRAFT_CONFIG_DIR"), designcraft_engine::recovery::default_dir).and_then(own_run)
|
||||
}
|
||||
|
||||
/// This run's folder in the recovery folder `root`. A second running DesignCraft has its own, so
|
||||
/// neither writes over the other's documents nor takes them for a crash's.
|
||||
fn own_run(root: PathBuf) -> Option<PathBuf> {
|
||||
match designcraft_engine::recovery::run_dir(&root) {
|
||||
Ok(dir) => Some(dir),
|
||||
Err(e) => {
|
||||
log::warn!("recovery is unavailable because this run cannot be isolated: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn recovery_dir_with(root: Option<OsString>, default: impl FnOnce() -> Option<PathBuf>) -> Option<PathBuf> {
|
||||
@@ -68,4 +80,29 @@ mod tests {
|
||||
assert_eq!(config_dir_with("linux", |key| if key == "HOME" { environment(key) } else { None }), Some("/user/.config/designcraft".into()));
|
||||
assert_eq!(recovery_dir_with(None, || Some("/existing/recovery".into())), Some("/existing/recovery".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_has_its_own_recovery_folder_when_one_can_be_made() {
|
||||
let dir = std::env::temp_dir().join(format!("designcraft-profile-run-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let root = dir.join("Recovery");
|
||||
assert_eq!(own_run(root.clone()).as_deref().and_then(|dir| dir.parent()), Some(root.as_path()));
|
||||
// Failure must not fall back to the shared root.
|
||||
let blocked = dir.join("blocked");
|
||||
std::fs::write(&blocked, "a file where the folder should be").unwrap();
|
||||
assert_eq!(own_run(blocked.join("Recovery")), None, "isolation failure must not select the shared root");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_exhausted_run_namespace_never_falls_back_to_shared_recovery() {
|
||||
let root = std::env::temp_dir().join(format!("designcraft-profile-exhausted-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
for n in 0..64 {
|
||||
std::fs::create_dir(root.join(format!("run-{}-{n}", std::process::id()))).unwrap();
|
||||
}
|
||||
assert_eq!(own_run(root.clone()), None, "a shared root would restore inter-process UID collisions");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ DesignCraft app icon (designcraft.svg, designcraft-small.svg and every file deri
|
||||
this directory: designcraft-1024.png, designcraft-macos-512.png, designcraft.icns, designcraft.ico,
|
||||
hicolor/).
|
||||
|
||||
Original artwork made for this project by the project owner in ArtCraft, then vectorised. No
|
||||
Original artwork made for this project by the project owner, then vectorised. No
|
||||
third-party material. Licensed like the rest of DesignCraft, under either of
|
||||
|
||||
* Apache License, Version 2.0 (https://www.apache.org/licenses/LICENSE-2.0)
|
||||
|
||||
@@ -22,7 +22,7 @@ transparent margin.
|
||||
|
||||
## Provenance
|
||||
|
||||
The owner drew the cat in ArtCraft. The 2880 px drawing, keyed to the three-colour palette, lives
|
||||
The owner drew the cat by hand. 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`.
|
||||
|
||||
@@ -104,9 +104,12 @@ impl Gradient {
|
||||
out
|
||||
}
|
||||
pub fn reverse(&mut self) {
|
||||
// A midpoint belongs to the segment after its stop, so it moves with that segment and mirrors.
|
||||
let mids: Vec<_> = self.stops.iter().rev().skip(1).map(|s| 1.0 - s.midpoint).collect();
|
||||
self.stops.reverse();
|
||||
for s in &mut self.stops {
|
||||
for (s, m) in self.stops.iter_mut().zip(mids.into_iter().chain([0.5])) {
|
||||
s.offset = 1.0 - s.offset;
|
||||
s.midpoint = m;
|
||||
}
|
||||
}
|
||||
pub fn sort(&mut self) {
|
||||
@@ -219,8 +222,24 @@ mod tests {
|
||||
#[test]
|
||||
fn reverse_stops() {
|
||||
let mut g = Gradient::default();
|
||||
g.stops[0].midpoint = 0.25;
|
||||
g.reverse();
|
||||
assert_eq!(g.stops[0].color.to_hex(), "#000000");
|
||||
assert_eq!(g.stops[0].offset, 0.0);
|
||||
// The midpoint moves to its reversed segment and mirrors.
|
||||
assert!((g.sample(0.75).0.to_rgb()[0] - 0.5).abs() < 1e-5);
|
||||
assert_eq!(g.stops.iter().map(|s| s.midpoint).collect::<Vec<_>>(), [0.75, 0.5]);
|
||||
|
||||
// With several stops, the reversed gradient is the original mirrored.
|
||||
let mut g = Gradient::default();
|
||||
g.stops[0].midpoint = 0.3;
|
||||
g.stops.insert(1, GradientStop { offset: 0.4, color: Color::rgb(1.0, 0.0, 0.0), opacity: 0.5, midpoint: 0.8 });
|
||||
let original = g.clone();
|
||||
g.reverse();
|
||||
for t in [0.1, 0.3, 0.5, 0.7, 0.9] {
|
||||
let ((c, o), (want_c, want_o)) = (g.sample(t), original.sample(1.0 - t));
|
||||
assert!(c.to_rgb().iter().zip(want_c.to_rgb()).all(|(a, b)| (a - b).abs() < 1e-5), "colour at {t}");
|
||||
assert!((o - want_o).abs() < 1e-5, "opacity at {t}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ designcraft-fonts = { workspace = true }
|
||||
log = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
miniz_oxide = "0.8"
|
||||
hypher = { version = "0.1.8", default-features = false, features = ["alloc", "spanish"] }
|
||||
hypher = { version = "0.1.8", default-features = false, features = ["alloc", "portuguese", "spanish"] }
|
||||
|
||||
[dev-dependencies]
|
||||
designcraft-fonts = { workspace = true, features = ["testing"] }
|
||||
|
||||
@@ -13,7 +13,8 @@ use designcraft_doc::{Document, Item, Story, StoryId, Styles, WrapMode};
|
||||
use crate::vars::RunningIndex;
|
||||
use crate::{ComposeOptions, ComposedStory};
|
||||
|
||||
type Key = (StoryId, Option<String>);
|
||||
/// A story, composed for one absolute page (parent-page items) or for its own frames (`None`).
|
||||
type Key = (StoryId, Option<usize>);
|
||||
|
||||
struct Entry {
|
||||
sig: Vec<usize>,
|
||||
@@ -55,11 +56,23 @@ impl Cache {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// The composed story, from cache or freshly composed.
|
||||
pub fn get(&self, doc: &Document, sid: StoryId, page_name: Option<&str>) -> Arc<ComposedStory> {
|
||||
/// The composed story, from cache or freshly composed. `page` is the absolute page a
|
||||
/// parent-page item is shown on (its page number markers and variables are that page's), or
|
||||
/// `None` for a story's own frames. Pages are identified by index, not name: names repeat when
|
||||
/// sections restart their numbering.
|
||||
pub fn get(&self, doc: &Document, sid: StoryId, page: Option<usize>) -> Arc<ComposedStory> {
|
||||
let Some(story) = doc.stories.get(&sid) else { return Arc::new(ComposedStory { story: sid, ..Default::default() }) };
|
||||
let (mut sig, keep_items) = signature(doc, story);
|
||||
let key = (sid, page_name.map(str::to_string));
|
||||
let key = (sid, page);
|
||||
// The key holds the page's index, which a renumbering doesn't change: its name (section
|
||||
// start number, style, prefix) is part of the signature.
|
||||
let page_name = page.map(|p| doc.page_name(p));
|
||||
if let Some(name) = &page_name {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
name.hash(&mut h);
|
||||
sig.push(h.finish() as usize);
|
||||
}
|
||||
let with_vars = crate::vars::has_vars(story);
|
||||
let running = with_vars && crate::vars::has_running(doc, story);
|
||||
if with_vars {
|
||||
@@ -125,23 +138,20 @@ impl Cache {
|
||||
return e.out.clone();
|
||||
}
|
||||
}
|
||||
// Parent-page items are composed per page; variables need that page's index.
|
||||
let page = match page_name {
|
||||
Some(n) if with_vars => (0..doc.page_count()).find(|&i| doc.page_name(i) == n),
|
||||
_ => None,
|
||||
};
|
||||
// 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 out = Arc::new(crate::compose_story(
|
||||
doc,
|
||||
sid,
|
||||
&ComposeOptions { page_name: page_name.map(str::to_string), page, running, label: None, xrefs: with_xrefs.then(|| self.xref_index(doc)) },
|
||||
&ComposeOptions { page_name, page, running, label: None, xrefs: with_xrefs.then(|| self.xref_index(doc)) },
|
||||
));
|
||||
let mut g = self.map.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let stamp = g.1;
|
||||
if g.0.len() > 4096 {
|
||||
g.0.retain(|_, e| stamp - e.stamp < 64);
|
||||
}
|
||||
let replaced = g.0.insert(key.clone(), Entry { sig, _keep: (story.clone(), doc.styles.clone(), keep_items), out: out.clone(), stamp });
|
||||
let replaced = g.0.insert(key, Entry { sig, _keep: (story.clone(), doc.styles.clone(), keep_items), out: out.clone(), stamp });
|
||||
drop(g);
|
||||
if key.1.is_none()
|
||||
&& let Some(old) = replaced
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Hyphenation. English uses the bundled dictionary and patterns below. Other languages use the
|
||||
//! Liang patterns compiled into `hypher`, one cargo feature per language: Spanish is Javier Bezos's
|
||||
//! `hyph-es.tex` 5.0 from hyph-utf8, MIT (see NOTICE).
|
||||
//! `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 (see NOTICE).
|
||||
//!
|
||||
//! English lookup order for each word:
|
||||
//! 1. the **dictionary** ([`Dictionary::en_us`]) — the public-domain Moby Hyphenator II word list
|
||||
@@ -232,9 +233,8 @@ pub fn hyphen_points_in(word: &str, lim: &Limits, lang: Lang) -> Vec<usize> {
|
||||
if letters < lim.min_word.max(2) {
|
||||
return false;
|
||||
}
|
||||
if letters > 1 && run.iter().filter(|c| c.is_alphabetic()).all(|c| c.is_uppercase()) {
|
||||
return false;
|
||||
}
|
||||
// Words in capitals (FERRUM COMMODI) hyphenate like capitalized words, as InDesign's: Hyphenate
|
||||
// Capitalized Words off keeps both, acronyms included, whole.
|
||||
if !lim.capitalized && run[0].is_uppercase() {
|
||||
return false;
|
||||
}
|
||||
@@ -332,7 +332,10 @@ mod tests {
|
||||
#[test]
|
||||
fn case_apostrophes_and_punctuation() {
|
||||
assert_eq!(h("Typography"), "Ty-pog-ra-phy");
|
||||
assert_eq!(h("TYPOGRAPHY"), "TYPOGRAPHY");
|
||||
assert_eq!(h("TYPOGRAPHY"), "TY-POG-RA-PHY");
|
||||
assert_eq!(h("COMMODI"), "COM-MO-DI");
|
||||
let whole = Limits { capitalized: false, ..Limits::default() };
|
||||
assert_eq!(hyphenate_word("TYPOGRAPHY", &whole), "TYPOGRAPHY", "Hyphenate Capitalized Words off");
|
||||
assert_eq!(h("typography,"), "ty-pog-ra-phy,");
|
||||
assert_eq!(h("“typography”"), "“ty-pog-ra-phy”");
|
||||
assert_eq!(h("typography's"), "ty-pog-ra-phy's");
|
||||
@@ -389,6 +392,25 @@ mod tests {
|
||||
assert!(Lang::for_language("French").is_none(), "only languages built in");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portuguese_uses_the_hyph_pt_patterns() {
|
||||
let pt = Lang::for_language("Portuguese: Brazilian").unwrap();
|
||||
assert_eq!(pt, Lang::for_language("Portuguese").unwrap());
|
||||
assert_eq!(pt, Lang::for_language("pt_BR").unwrap());
|
||||
assert_ne!(pt, Lang::for_language("Spanish").unwrap());
|
||||
// hyph-pt's own minimum: three letters after the last break.
|
||||
let lim = Limits { before_last: 3, ..Limits::default() };
|
||||
let h = |w: &str| {
|
||||
let pts = hyphen_points_in(w, &lim, pt);
|
||||
w.chars().enumerate().map(|(i, c)| if pts.contains(&i) { format!("-{c}") } else { c.to_string() }).collect::<String>()
|
||||
};
|
||||
assert_eq!(h("matemática"), "ma-te-má-tica");
|
||||
assert_eq!(h("orientação"), "ori-en-ta-ção");
|
||||
assert_eq!(h("Universidade"), "Uni-ver-si-dade");
|
||||
assert_eq!(h("experiência"), "ex-pe-ri-ên-cia");
|
||||
assert_eq!(h("pós-graduação"), "pós-gra-du-a-ção", "each part of a compound");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heuristic_fallback() {
|
||||
let c: Vec<char> = "happen".chars().collect();
|
||||
|
||||
@@ -1371,6 +1371,23 @@ fn spanish_text_gets_spanish_hyphenation() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portuguese_text_gets_portuguese_hyphenation() {
|
||||
let text = "matemática matemática matemática matemática matemática matemática";
|
||||
let (mut d, sid, _) = doc_with(text, Rect::new(36.0, 36.0, 76.0, 700.0), ParaAttrs::default());
|
||||
let st = d.story_mut(sid).unwrap();
|
||||
let n = st.len();
|
||||
st.format_chars(0..n, |f| f.over.language = Some("Portuguese: Brazilian".into()));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let breaks: Vec<&str> = all_lines(&cs).into_iter().filter(|l| l.hyphenated).map(|l| text[l.range.clone()].trim_end()).collect();
|
||||
assert!(!breaks.is_empty(), "Portuguese text is hyphenated");
|
||||
// Portuguese syllables: ma-te-má-tica.
|
||||
for b in breaks {
|
||||
let tail = b.rsplit(' ').next().unwrap_or(b);
|
||||
assert!(["ma", "mate", "matemá"].contains(&tail), "{b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cjk_aki_adds_space_without_scaling_outlines() {
|
||||
let (mut d, sid, _) = doc_with("AB", Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default());
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
//! Placed files (assets): one asset per distinct file, the assets a document uses, and the
|
||||
//! compaction a save applies (duplicates merged, unused assets left out).
|
||||
//!
|
||||
//! An asset holds what is shared by every placement of a file (bytes, link, type, the PDF page
|
||||
//! shown); each placement's frame data (transform, crop, fitting) lives on its [`Graphic`].
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::story::Story;
|
||||
use crate::{Asset, AssetId, Content, Document, Item};
|
||||
|
||||
/// Nesting limit for groups, anchored objects, table cells and notes. A document nested deeper
|
||||
/// is treated as using every asset, so nothing it references is merged away or pruned.
|
||||
const MAX_DEPTH: usize = 64;
|
||||
|
||||
impl Asset {
|
||||
/// Whether `other` holds the same file: the same type, page, link and bytes. Files at
|
||||
/// different paths stay apart even with identical bytes, since each can be updated or
|
||||
/// relinked on its own. Assets without bytes (a link that couldn't be read) are the same only
|
||||
/// when they link to the same path.
|
||||
pub fn same_file(&self, other: &Asset) -> bool {
|
||||
if self.mime != other.mime || self.page != other.page {
|
||||
return false;
|
||||
}
|
||||
if self.link != other.link {
|
||||
return false;
|
||||
}
|
||||
match (self.data.is_empty(), other.data.is_empty()) {
|
||||
(false, false) => self.same_bytes(other),
|
||||
(true, true) => self.link.is_some(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn same_bytes(&self, other: &Asset) -> bool {
|
||||
Arc::ptr_eq(&self.data, &other.data) || (self.data.len() == other.data.len() && *self.data == *other.data)
|
||||
}
|
||||
}
|
||||
|
||||
/// The asset in `assets` holding the same file as `asset`.
|
||||
pub fn find_same_asset(assets: &BTreeMap<AssetId, Arc<Asset>>, asset: &Asset) -> Option<AssetId> {
|
||||
assets.values().find(|a| a.same_file(asset)).map(|a| a.id)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn insert_asset(assets: &mut BTreeMap<AssetId, Arc<Asset>>, mut asset: Asset) -> AssetId {
|
||||
if let Some(id) = find_same_asset(assets, &asset) {
|
||||
return id;
|
||||
}
|
||||
if !asset.data.is_empty()
|
||||
&& let Some(a) = assets.values().find(|a| a.same_bytes(&asset))
|
||||
{
|
||||
asset.data = Arc::clone(&a.data);
|
||||
}
|
||||
let id = asset.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 {
|
||||
if depth > MAX_DEPTH {
|
||||
return false;
|
||||
}
|
||||
if let Content::Graphic(g) = &it.content {
|
||||
f(g.asset);
|
||||
}
|
||||
if let Some(p) = it.media.as_ref().and_then(|m| m.poster) {
|
||||
f(p);
|
||||
}
|
||||
it.children().iter().all(|c| item_refs(c, f, depth + 1))
|
||||
}
|
||||
|
||||
/// Asset references in a story's anchored objects, graphic cells and notes (recursively).
|
||||
fn story_refs(st: &Story, f: &mut impl FnMut(AssetId), depth: usize) -> bool {
|
||||
if depth > MAX_DEPTH {
|
||||
return false;
|
||||
}
|
||||
let mut complete = st.objects.iter().all(|o| item_refs(&o.item, f, depth + 1));
|
||||
for t in st.tables.values() {
|
||||
for c in &t.cells {
|
||||
if let Some(g) = &c.graphic {
|
||||
f(g.asset);
|
||||
}
|
||||
complete &= story_refs(&c.text, f, depth + 1);
|
||||
}
|
||||
}
|
||||
for n in st.notes.iter().chain(&st.endnotes) {
|
||||
complete &= story_refs(&n.text, f, depth + 1);
|
||||
}
|
||||
complete
|
||||
}
|
||||
|
||||
/// Every asset reference in a story (its anchored objects, graphic cells and notes).
|
||||
pub fn story_asset_refs(st: &Story) -> Vec<AssetId> {
|
||||
let mut out = Vec::new();
|
||||
story_refs(st, &mut |a| out.push(a), 0);
|
||||
out
|
||||
}
|
||||
|
||||
/// Point a story's references to a key of `map` at its value.
|
||||
pub fn remap_story_assets(st: &mut Story, map: &HashMap<AssetId, AssetId>) {
|
||||
if !map.is_empty() {
|
||||
remap_story(st, map, 0);
|
||||
}
|
||||
}
|
||||
|
||||
fn item_uses(it: &Item, map: &HashMap<AssetId, AssetId>) -> bool {
|
||||
let mut hit = false;
|
||||
item_refs(it, &mut |a| hit |= map.contains_key(&a), 0);
|
||||
hit
|
||||
}
|
||||
|
||||
fn story_uses(st: &Story, map: &HashMap<AssetId, AssetId>) -> bool {
|
||||
let mut hit = false;
|
||||
story_refs(st, &mut |a| hit |= map.contains_key(&a), 0);
|
||||
hit
|
||||
}
|
||||
|
||||
fn remap_id(id: &mut AssetId, map: &HashMap<AssetId, AssetId>) {
|
||||
if let Some(n) = map.get(id) {
|
||||
*id = *n;
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrites the references in `it`, copying only the shared items that change.
|
||||
fn remap_item(it: &mut Arc<Item>, map: &HashMap<AssetId, AssetId>, depth: usize) {
|
||||
if depth > MAX_DEPTH || !item_uses(it, map) {
|
||||
return;
|
||||
}
|
||||
remap_item_in(Arc::make_mut(it), map, depth);
|
||||
}
|
||||
|
||||
fn remap_item_in(it: &mut Item, map: &HashMap<AssetId, AssetId>, depth: usize) {
|
||||
if let Content::Graphic(g) = &mut it.content {
|
||||
remap_id(&mut g.asset, map);
|
||||
}
|
||||
if let Some(p) = it.media.as_mut().and_then(|m| m.poster.as_mut()) {
|
||||
remap_id(p, map);
|
||||
}
|
||||
if let Some(kids) = it.children_mut() {
|
||||
for k in kids {
|
||||
remap_item(k, map, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remap_story(st: &mut Story, map: &HashMap<AssetId, AssetId>, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for o in &mut st.objects {
|
||||
if item_uses(&o.item, map) {
|
||||
remap_item_in(&mut Arc::make_mut(o).item, map, depth + 1);
|
||||
}
|
||||
}
|
||||
for t in st.tables.values_mut() {
|
||||
if !t.cells.iter().any(|c| c.graphic.as_ref().is_some_and(|g| map.contains_key(&g.asset)) || story_uses(&c.text, map)) {
|
||||
continue;
|
||||
}
|
||||
for c in &mut Arc::make_mut(t).cells {
|
||||
if let Some(g) = &mut c.graphic {
|
||||
remap_id(&mut g.asset, map);
|
||||
}
|
||||
remap_story(&mut c.text, map, depth + 1);
|
||||
}
|
||||
}
|
||||
for n in st.notes.iter_mut().chain(st.endnotes.iter_mut()) {
|
||||
if story_uses(&n.text, map) {
|
||||
remap_story(&mut Arc::make_mut(n).text, map, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Add a placed file, reusing the asset of an identical file already in the document;
|
||||
/// returns the id to reference.
|
||||
pub fn add_asset(&mut self, asset: Asset) -> AssetId {
|
||||
insert_asset(&mut self.assets, asset)
|
||||
}
|
||||
|
||||
/// Copy asset `id` of `src` into this document (or reuse the identical asset it already
|
||||
/// has); returns the id to reference here, `None` when `src` has no such asset.
|
||||
pub fn adopt_asset(&mut self, src: &Document, id: AssetId) -> Option<AssetId> {
|
||||
let a = src.assets.get(&id)?;
|
||||
if let Some(same) = find_same_asset(&self.assets, a) {
|
||||
return Some(same);
|
||||
}
|
||||
let fresh = AssetId(self.alloc());
|
||||
Some(self.add_asset(Asset { id: fresh, ..(**a).clone() }))
|
||||
}
|
||||
|
||||
/// Calls `f` with every asset reference: graphics on document and parent spreads (in
|
||||
/// groups and multi-state objects too), media posters, anchored and inline objects, graphic
|
||||
/// table cells and notes. False when nesting exceeded the depth limit.
|
||||
fn asset_refs(&self, f: &mut impl FnMut(AssetId)) -> bool {
|
||||
let mut complete = true;
|
||||
for sp in self.spreads.iter().chain(&self.parents) {
|
||||
for it in &sp.items {
|
||||
complete &= item_refs(it, f, 0);
|
||||
}
|
||||
}
|
||||
for st in self.stories.values() {
|
||||
complete &= story_refs(st, f, 0);
|
||||
}
|
||||
complete
|
||||
}
|
||||
|
||||
/// The assets something in the document references. When the document nests deeper than
|
||||
/// the traversal limit, every asset counts as used.
|
||||
pub fn used_assets(&self) -> BTreeSet<AssetId> {
|
||||
let mut used = BTreeSet::new();
|
||||
if !self.asset_refs(&mut |a| {
|
||||
used.insert(a);
|
||||
}) {
|
||||
return self.assets.keys().copied().collect();
|
||||
}
|
||||
used
|
||||
}
|
||||
|
||||
/// Point every reference to a key of `map` at its value.
|
||||
pub fn remap_assets(&mut self, map: &HashMap<AssetId, AssetId>) {
|
||||
if map.is_empty() {
|
||||
return;
|
||||
}
|
||||
for sp in self.spreads.iter_mut().chain(self.parents.iter_mut()) {
|
||||
if sp.items.iter().any(|it| item_uses(it, map)) {
|
||||
for it in &mut Arc::make_mut(sp).items {
|
||||
remap_item(it, map, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
for st in self.stories.values_mut() {
|
||||
if story_uses(st, map) {
|
||||
remap_story(Arc::make_mut(st), map, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge assets holding the same file into the first of them (lowest id), rewriting the
|
||||
/// references; returns how many assets were removed.
|
||||
pub fn merge_duplicate_assets(&mut self) -> usize {
|
||||
if !self.asset_refs(&mut |_| {}) {
|
||||
return 0;
|
||||
}
|
||||
let mut reps: HashMap<(usize, &str, u32), Vec<&Arc<Asset>>> = HashMap::new();
|
||||
let mut map = HashMap::new();
|
||||
for a in self.assets.values() {
|
||||
let bucket = reps.entry((a.data.len(), a.mime.as_str(), a.page)).or_default();
|
||||
match bucket.iter().find(|r| r.same_file(a)) {
|
||||
Some(r) => {
|
||||
map.insert(a.id, r.id);
|
||||
}
|
||||
None => bucket.push(a),
|
||||
}
|
||||
}
|
||||
self.remap_assets(&map);
|
||||
self.assets.retain(|id, _| !map.contains_key(id));
|
||||
map.len()
|
||||
}
|
||||
|
||||
/// Merge duplicate assets and drop the ones nothing references (what a saved file holds).
|
||||
pub fn compact_assets(&mut self) {
|
||||
self.merge_duplicate_assets();
|
||||
let used = self.used_assets();
|
||||
self.assets.retain(|id, _| used.contains(id));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Graphic, ItemId, LayerId, Shape, StoryId};
|
||||
use designcraft_geom::{Affine, Rect, shapes};
|
||||
|
||||
fn asset(id: u64, data: &[u8]) -> Asset {
|
||||
Asset { id: AssetId(id), name: "p.png".into(), mime: "image/png".into(), link: None, data: Arc::new(data.to_vec()), pixels: None, page: 0 }
|
||||
}
|
||||
|
||||
fn graphic_item(id: u64, asset: u64, crop: f64) -> Item {
|
||||
let mut it = Item::new(ItemId(id), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)));
|
||||
it.content = Content::Graphic(Graphic {
|
||||
asset: AssetId(asset),
|
||||
size: (10.0, 10.0),
|
||||
xf: Affine::translate((crop, 0.0)),
|
||||
auto_fit: Default::default(),
|
||||
fit_align: 4,
|
||||
crop: [crop; 4],
|
||||
});
|
||||
it
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_files_share_one_asset() {
|
||||
let mut d = Document::new(&Default::default());
|
||||
let a = d.add_asset(asset(1, b"same"));
|
||||
let b = d.add_asset(asset(2, b"same"));
|
||||
let c = d.add_asset(asset(3, b"diff"));
|
||||
let p = d.add_asset(Asset { page: 1, ..asset(4, b"same") });
|
||||
assert_eq!(a, b);
|
||||
assert_ne!(a, c);
|
||||
assert_ne!(a, p);
|
||||
assert_eq!(d.assets.len(), 3);
|
||||
// Unreadable links match by path only.
|
||||
let m1 = d.add_asset(Asset { link: Some("/m/1.png".into()), ..asset(5, b"") });
|
||||
let m2 = d.add_asset(Asset { link: Some("/m/2.png".into()), ..asset(6, b"") });
|
||||
let m3 = d.add_asset(asset(7, b""));
|
||||
let m4 = d.add_asset(asset(8, b""));
|
||||
assert_ne!(m1, m2);
|
||||
assert_ne!(m3, m4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_at_different_paths_stay_separate_assets() {
|
||||
let mut d = Document::new(&Default::default());
|
||||
let linked = |id, path: &str| Asset { link: Some(path.into()), ..asset(id, b"same") };
|
||||
let embedded = d.add_asset(asset(1, b"same"));
|
||||
let a = d.add_asset(linked(2, "/p/a.png"));
|
||||
let b = d.add_asset(linked(3, "/q/b.png"));
|
||||
assert_eq!(d.add_asset(linked(4, "/p/a.png")), a, "the same path placed again");
|
||||
assert_eq!(d.assets.len(), 3);
|
||||
assert!(embedded != a && a != b && embedded != b);
|
||||
assert_eq!(d.assets[&embedded].link, None);
|
||||
assert_eq!(d.assets[&b].link.as_deref(), Some("/q/b.png"));
|
||||
assert!(Arc::ptr_eq(&d.assets[&a].data, &d.assets[&b].data), "identical bytes share one buffer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compaction_merges_duplicates_and_drops_unused_assets() {
|
||||
let mut d = Document::new(&Default::default());
|
||||
for (id, data) in [(1, b"one"), (2, b"one"), (3, b"two"), (4, b"old")] {
|
||||
d.assets.insert(AssetId(id), Arc::new(asset(id, data)));
|
||||
}
|
||||
let sp = Arc::make_mut(d.spreads.first_mut().unwrap());
|
||||
sp.items.push(Arc::new(graphic_item(10, 1, 1.0)));
|
||||
sp.items.push(Arc::new(graphic_item(11, 2, 2.0)));
|
||||
// A graphic cell and an anchored object in a story.
|
||||
let mut st = Story::new(StoryId(20));
|
||||
st.objects.push(Arc::new(crate::AnchoredObject::new(graphic_item(12, 3, 3.0), Default::default())));
|
||||
d.stories.insert(StoryId(20), Arc::new(st));
|
||||
assert_eq!(d.used_assets(), [1, 2, 3].map(AssetId).into_iter().collect());
|
||||
|
||||
d.compact_assets();
|
||||
assert_eq!(d.assets.keys().copied().collect::<Vec<_>>(), vec![AssetId(1), AssetId(3)]);
|
||||
let crops: Vec<(AssetId, f64)> = d.spreads[0].items.iter().filter_map(|i| i.graphic().map(|g| (g.asset, g.crop[0]))).collect();
|
||||
assert_eq!(crops, vec![(AssetId(1), 1.0), (AssetId(1), 2.0)]);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
pub mod anchored;
|
||||
pub mod arabic;
|
||||
pub mod arrow;
|
||||
pub mod assets;
|
||||
pub mod attrs;
|
||||
pub mod build;
|
||||
pub mod cjk;
|
||||
@@ -36,6 +37,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 attrs::*;
|
||||
pub use datamerge::{
|
||||
DataField, DataFieldKind, DataMerge, DataSource, Delimiter, Fingerprint, MergeOptions, Placeholder, PlaceholderAnchor, PlaceholderRole,
|
||||
|
||||
@@ -9,6 +9,7 @@ description = "DesignCraft engine: session, documents, history, selection, comma
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-print = { workspace = true }
|
||||
designcraft-images = { workspace = true }
|
||||
designcraft-geom = { workspace = true }
|
||||
designcraft-color = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
//! Crash-safe file replacement: how documents, exports and other user files reach the disk.
|
||||
//!
|
||||
//! [`write_atomic`] never writes the destination in place. It writes a uniquely named temporary
|
||||
//! file in the same folder, flushes it (`sync_all`), renames it over the destination and then
|
||||
//! flushes the folder (Unix) so the rename survives a power loss. When any step before the rename
|
||||
//! fails, the temporary file is removed and the destination keeps its old bytes.
|
||||
//!
|
||||
//! - A replaced file keeps its permissions (Unix mode).
|
||||
//! - A symbolic link keeps pointing at its file: the file it names is replaced.
|
||||
//! - Windows: antivirus scanners and the search indexer briefly hold files open, which makes the
|
||||
//! replacing rename fail with `PermissionDenied`; the rename is retried with a short backoff.
|
||||
//! - A failed rename (cross-device, unwritable folder) is returned as an error; there is no
|
||||
//! fallback to writing in place.
|
||||
//! - Flushing the parent folder after replacement is best effort. A successful save confirms
|
||||
//! that the new file is in place; it cannot guarantee survival of a subsequent power loss if
|
||||
//! the file system rejects that folder flush.
|
||||
//!
|
||||
//! The web has no file system, so this module is not built for `wasm32`.
|
||||
|
||||
use std::fs::File;
|
||||
#[cfg(unix)]
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::{self, Write as _};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// How many times a replacing rename is tried before its error is returned.
|
||||
const RENAME_TRIES: u32 = if cfg!(windows) { 5 } else { 1 };
|
||||
/// The pause before the first rename retry; it doubles up to [`RENAME_MAX_DELAY_MS`].
|
||||
const RENAME_FIRST_DELAY_MS: u64 = 10;
|
||||
const RENAME_MAX_DELAY_MS: u64 = 200;
|
||||
/// Symbolic links followed before giving up (a loop, or an absurd chain).
|
||||
const MAX_LINKS: usize = 40;
|
||||
|
||||
/// Atomically replace (or create) `path` with `bytes`. See the module docs.
|
||||
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
write_atomic_with(path, |f| f.write_all(bytes))
|
||||
}
|
||||
|
||||
/// [`write_atomic`] for a command's `path` parameter: the error names the path.
|
||||
pub(crate) fn write_file(path: &str, bytes: &[u8]) -> crate::Result<()> {
|
||||
write_atomic(Path::new(path), bytes).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
/// Atomically replace (or create) `path` with what `fill` writes into the temporary file. When
|
||||
/// `fill`, the flush or the rename fails, the temporary file is removed, `path` is untouched and
|
||||
/// the error is returned.
|
||||
pub fn write_atomic_with(path: &Path, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
|
||||
let target = resolve_links(path)?;
|
||||
target.file_name().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, format!("{}: not a file path", path.display())))?;
|
||||
let dir = match target.parent() {
|
||||
Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(),
|
||||
_ => PathBuf::from("."),
|
||||
};
|
||||
let existing = std::fs::metadata(&target).ok();
|
||||
if existing.is_some() {
|
||||
check_writable(&target)?;
|
||||
}
|
||||
let mut tmp = create_temp(&dir, existing.as_ref())?;
|
||||
if let Some(existing) = existing {
|
||||
// Restore the exact mode before any document bytes are written, and flush it along with
|
||||
// the content. The caller's umask may have restricted the initial temporary-file mode.
|
||||
keep_permissions(tmp.as_file(), &existing)?;
|
||||
}
|
||||
fill(tmp.as_file_mut())?;
|
||||
tmp.as_file().sync_all()?;
|
||||
// Closing the file before renaming also allows replacement on Windows. TempPath removes the
|
||||
// staging file on every error; NamedTempFile does the same if the writer unwinds.
|
||||
let tmp = tmp.into_temp_path();
|
||||
retry_rename(RENAME_TRIES, || std::fs::rename(&tmp, &target))?;
|
||||
// The new bytes are in place. A failed folder flush can't be undone and must not report the
|
||||
// save as failed.
|
||||
let _ = sync_dir(&dir);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a fresh temporary file beside the target (the same folder, so the rename stays on one
|
||||
/// volume). A short randomized name supports long destination names and skips existing entries.
|
||||
fn create_temp(dir: &Path, existing: Option<&std::fs::Metadata>) -> io::Result<tempfile::NamedTempFile> {
|
||||
let mut builder = tempfile::Builder::new();
|
||||
builder.prefix(".designcraft-save-").suffix(".tmp");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
// Private replacements are private from creation. New files retain fs::write's normal
|
||||
// 0666 mode restricted by the caller's umask.
|
||||
let mode = existing.map_or(0o666, |metadata| metadata.permissions().mode());
|
||||
builder.permissions(std::fs::Permissions::from_mode(mode));
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = existing;
|
||||
builder.tempfile_in(dir)
|
||||
}
|
||||
|
||||
/// The file a chain of symbolic links ends at (`path` itself when it is not a link). A dangling
|
||||
/// link resolves to the missing file it names, which is then created.
|
||||
fn resolve_links(path: &Path) -> io::Result<PathBuf> {
|
||||
let mut p = path.to_path_buf();
|
||||
for _ in 0..MAX_LINKS {
|
||||
match std::fs::symlink_metadata(&p) {
|
||||
Ok(m) if m.file_type().is_symlink() => {
|
||||
let to = std::fs::read_link(&p)?;
|
||||
p = match p.parent() {
|
||||
Some(dir) if to.is_relative() => dir.join(to),
|
||||
_ => to,
|
||||
};
|
||||
}
|
||||
_ => return Ok(p),
|
||||
}
|
||||
}
|
||||
Err(io::Error::new(io::ErrorKind::InvalidInput, format!("{}: too many levels of symbolic links", path.display())))
|
||||
}
|
||||
|
||||
/// A rename replaces a file the user can't write (Unix only needs the folder to be writable);
|
||||
/// refuse, as writing it in place would. Opening for writing without truncating changes nothing.
|
||||
#[cfg(unix)]
|
||||
fn check_writable(target: &Path) -> io::Result<()> {
|
||||
OpenOptions::new().write(true).open(target).map(drop)
|
||||
}
|
||||
|
||||
/// Windows refuses to rename over a read-only file by itself.
|
||||
#[cfg(not(unix))]
|
||||
fn check_writable(_: &Path) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The replacement keeps the replaced file's mode (Unix; Windows files keep only a read-only flag,
|
||||
/// and a read-only file can't be replaced).
|
||||
#[cfg(unix)]
|
||||
fn keep_permissions(tmp: &File, replaced: &std::fs::Metadata) -> io::Result<()> {
|
||||
tmp.set_permissions(replaced.permissions())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn keep_permissions(_: &File, _: &std::fs::Metadata) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run `rename` up to `tries` times, pausing with a doubling delay while it fails with
|
||||
/// `PermissionDenied` (a scanner holding the file on Windows). Other errors return at once.
|
||||
fn retry_rename(tries: u32, mut rename: impl FnMut() -> io::Result<()>) -> io::Result<()> {
|
||||
let mut delay = RENAME_FIRST_DELAY_MS;
|
||||
let mut left = tries.max(1);
|
||||
loop {
|
||||
left = left.saturating_sub(1);
|
||||
match rename() {
|
||||
Err(e) if e.kind() == io::ErrorKind::PermissionDenied && left > 0 => {
|
||||
std::thread::sleep(std::time::Duration::from_millis(delay));
|
||||
delay = delay.saturating_mul(2).min(RENAME_MAX_DELAY_MS);
|
||||
}
|
||||
r => return r,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush a folder's entries (Unix). Rust's portable file API cannot open a directory on Windows;
|
||||
/// this does not establish power-loss durability for its directory entries.
|
||||
#[cfg(unix)]
|
||||
fn sync_dir(dir: &Path) -> io::Result<()> {
|
||||
File::open(dir)?.sync_all()
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn sync_dir(_: &Path) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn dir(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("dc-atomic-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// The names in `d`, sorted.
|
||||
fn names(d: &Path) -> Vec<String> {
|
||||
let mut v: Vec<String> = std::fs::read_dir(d).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaces_and_creates_without_leftovers() {
|
||||
let d = dir("replace");
|
||||
let path = d.join("doc.designcraft");
|
||||
write_atomic(&path, b"first").unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"first");
|
||||
write_atomic(&path, b"second, longer").unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"second, longer");
|
||||
write_atomic(&path, b"3").unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"3");
|
||||
assert_eq!(names(&d), ["doc.designcraft"], "no temporary file is left");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_write_leaves_the_original_and_no_temporary_file() {
|
||||
let d = dir("fail");
|
||||
let path = d.join("doc.designcraft");
|
||||
std::fs::write(&path, b"original").unwrap();
|
||||
let r = write_atomic_with(&path, |f| {
|
||||
f.write_all(b"half a docu")?;
|
||||
Err(io::Error::other("disk full"))
|
||||
});
|
||||
assert_eq!(r.unwrap_err().to_string(), "disk full");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"original");
|
||||
assert_eq!(names(&d), ["doc.designcraft"], "the temporary file is removed");
|
||||
// A folder that doesn't exist: an error, nothing written.
|
||||
assert!(write_atomic(&d.join("missing").join("x.designcraft"), b"x").is_err());
|
||||
assert_eq!(names(&d), ["doc.designcraft"]);
|
||||
// A folder in the way: an error, the folder stays and nothing is left beside it.
|
||||
std::fs::create_dir(d.join("folder")).unwrap();
|
||||
assert!(write_atomic(&d.join("folder"), b"x").is_err());
|
||||
assert_eq!(names(&d), ["doc.designcraft", "folder"]);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_destination_name_does_not_lengthen_the_temporary_name() {
|
||||
let d = dir("long-name");
|
||||
let name = format!("{}.designcraft", "a".repeat(235));
|
||||
let path = d.join(&name);
|
||||
std::fs::write(&path, b"old").unwrap();
|
||||
write_atomic(&path, b"new").unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"new");
|
||||
assert_eq!(names(&d), [name]);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn private_permissions_are_preserved_while_the_file_is_written() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let d = dir("private-staging");
|
||||
let path = d.join("private.designcraft");
|
||||
std::fs::write(&path, b"old").unwrap();
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
write_atomic_with(&path, |file| {
|
||||
assert_eq!(file.metadata()?.permissions().mode() & 0o777, 0o600);
|
||||
file.write_all(b"private document")?;
|
||||
assert_eq!(file.metadata()?.permissions().mode() & 0o777, 0o600);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"private document");
|
||||
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
assert_eq!(names(&d), ["private.designcraft"]);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unwinding_writer_removes_its_partial_temporary_file() {
|
||||
let d = dir("unwinding-writer");
|
||||
let path = d.join("private.designcraft");
|
||||
std::fs::write(&path, b"old").unwrap();
|
||||
let result = std::panic::catch_unwind(|| {
|
||||
write_atomic_with(&path, |file| {
|
||||
file.write_all(b"partial private document")?;
|
||||
panic!("synthetic writer panic");
|
||||
})
|
||||
});
|
||||
assert!(result.is_err());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"old");
|
||||
assert_eq!(names(&d), ["private.designcraft"]);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rename_is_retried_only_while_access_is_denied() {
|
||||
let mut calls = 0;
|
||||
let r = retry_rename(3, || {
|
||||
calls += 1;
|
||||
if calls < 3 { Err(io::Error::from(io::ErrorKind::PermissionDenied)) } else { Ok(()) }
|
||||
});
|
||||
assert!(r.is_ok() && calls == 3);
|
||||
calls = 0;
|
||||
let r = retry_rename(3, || {
|
||||
calls += 1;
|
||||
Err(io::Error::from(io::ErrorKind::PermissionDenied))
|
||||
});
|
||||
assert_eq!(r.map_err(|e| e.kind()), Err(io::ErrorKind::PermissionDenied));
|
||||
assert_eq!(calls, 3, "gives up after the last try");
|
||||
calls = 0;
|
||||
let r = retry_rename(3, || {
|
||||
calls += 1;
|
||||
Err(io::Error::from(io::ErrorKind::CrossesDevices))
|
||||
});
|
||||
assert!(r.is_err() && calls == 1, "other errors are not retried");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn permissions_are_kept_and_unwritable_files_are_not_replaced() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let d = dir("perms");
|
||||
let path = d.join("private.designcraft");
|
||||
std::fs::write(&path, b"old").unwrap();
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
write_atomic(&path, b"new").unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"new");
|
||||
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
// A read-only file stays as it is (root may write it anyway).
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o400)).unwrap();
|
||||
if OpenOptions::new().write(true).open(&path).is_err() {
|
||||
assert!(write_atomic(&path, b"newer").is_err());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"new");
|
||||
assert_eq!(names(&d), ["private.designcraft"]);
|
||||
}
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// A folder the user can't write: the save fails and the original stays.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn an_unwritable_folder_fails_without_touching_the_file() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let d = dir("ro-dir");
|
||||
let path = d.join("doc.designcraft");
|
||||
std::fs::write(&path, b"original").unwrap();
|
||||
std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o555)).unwrap();
|
||||
// Root writes anyway; the check only means something for an ordinary user.
|
||||
let probe = d.join("probe");
|
||||
let unwritable = std::fs::write(&probe, b"").is_err();
|
||||
if unwritable {
|
||||
assert!(write_atomic(&path, b"new").is_err());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"original");
|
||||
}
|
||||
std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
assert_eq!(names(&d), ["doc.designcraft"]);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_symbolic_link_keeps_pointing_at_the_replaced_file() {
|
||||
let d = dir("link");
|
||||
std::fs::create_dir(d.join("real")).unwrap();
|
||||
let target = d.join("real").join("doc.designcraft");
|
||||
std::fs::write(&target, b"old").unwrap();
|
||||
let link = d.join("link.designcraft");
|
||||
std::os::unix::fs::symlink("real/doc.designcraft", &link).unwrap();
|
||||
write_atomic(&link, b"new").unwrap();
|
||||
assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink());
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"new");
|
||||
assert_eq!(names(&d), ["link.designcraft", "real"]);
|
||||
assert_eq!(names(&d.join("real")), ["doc.designcraft"]);
|
||||
// A loop of links is an error, not a hang.
|
||||
std::os::unix::fs::symlink("b", d.join("a")).unwrap();
|
||||
std::os::unix::fs::symlink("a", d.join("b")).unwrap();
|
||||
assert!(write_atomic(&d.join("a"), b"x").is_err());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ fn no_book() -> EngineError {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn save(b: &Book) -> Result<()> {
|
||||
let bytes = serde_json::to_vec_pretty(b).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
std::fs::write(&b.path, bytes).map_err(|e| EngineError::Other(format!("{}: {e}", b.path)))
|
||||
crate::atomic::write_file(&b.path, &bytes)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -47,20 +47,176 @@ fn load(path: &str) -> Result<designcraft_doc::Document> {
|
||||
if path.to_lowercase().ends_with(".idml") {
|
||||
return designcraft_idml::import_idml(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")));
|
||||
}
|
||||
super::file::from_bytes(&bytes)
|
||||
super::file::from_bytes(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
fn store(path: &str, d: &designcraft_doc::Document) -> Result<()> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
return std::fs::write(path, super::file::to_bytes(d)).map_err(|e| EngineError::Other(format!("{path}: {e}")));
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = (path, d);
|
||||
Err(EngineError::Other("books need a file system".into()))
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_BATCH_CHAPTERS: usize = 4096;
|
||||
|
||||
/// A batch keeps only bounded path records in memory. Chapter snapshots live in a private
|
||||
/// temporary directory; only one document and serialized blob are live while staging.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
struct ChapterBatch {
|
||||
directory: tempfile::TempDir,
|
||||
chapters: Vec<(String, tempfile::TempPath)>,
|
||||
command: &'static str,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl ChapterBatch {
|
||||
fn new(chapters: usize, command: &'static str) -> Result<Self> {
|
||||
if chapters > MAX_BATCH_CHAPTERS {
|
||||
return Err(bad(command, format!("a book update supports at most {MAX_BATCH_CHAPTERS} chapters")));
|
||||
}
|
||||
let directory = tempfile::Builder::new()
|
||||
.prefix(".designcraft-book-")
|
||||
.tempdir()
|
||||
.map_err(|e| EngineError::Other(format!("{command}: cannot stage chapter changes: {e}")))?;
|
||||
Ok(Self { directory, chapters: Vec::with_capacity(chapters), command })
|
||||
}
|
||||
|
||||
fn stage(&mut self, path: &str, doc: &designcraft_doc::Document) -> Result<()> {
|
||||
use std::io::Write;
|
||||
|
||||
// Propagate serialization errors; an empty fallback must never replace a chapter.
|
||||
let bytes = designcraft_format::save(doc).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let mut file = tempfile::Builder::new()
|
||||
.prefix("chapter-")
|
||||
.tempfile_in(self.directory.path())
|
||||
.map_err(|e| EngineError::Other(format!("{path}: cannot stage changes: {e}")))?;
|
||||
file.write_all(&bytes).map_err(|e| EngineError::Other(format!("{path}: cannot stage changes: {e}")))?;
|
||||
self.chapters.push((path.to_string(), file.into_temp_path()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn commit(self) -> Result<()> {
|
||||
self.commit_with(write_staged_chapter)
|
||||
}
|
||||
|
||||
fn commit_with(self, mut write: impl FnMut(&std::path::Path, &std::path::Path) -> std::io::Result<()>) -> Result<()> {
|
||||
let mut completed = Vec::with_capacity(self.chapters.len());
|
||||
for (path, staged) in self.chapters {
|
||||
if let Err(e) = write(std::path::Path::new(&path), &staged) {
|
||||
// Each file is atomic; the whole book is not. Keep the successful prefix visible
|
||||
// rather than pretending that an error undid earlier chapter replacements.
|
||||
return Err(EngineError::Other(format!(
|
||||
"{}: failed to save chapter {path}: {e}; completed chapter paths: {completed:?}",
|
||||
self.command
|
||||
)));
|
||||
}
|
||||
completed.push(path);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn write_staged_chapter(path: &std::path::Path, staged: &std::path::Path) -> std::io::Result<()> {
|
||||
let mut input = std::fs::File::open(staged)?;
|
||||
crate::atomic::write_atomic_with(path, |file| std::io::copy(&mut input, file).map(|_| ()))
|
||||
}
|
||||
|
||||
/// Batch commands only write closed native chapters. IDML is an imported source, and an open
|
||||
/// chapter's model (including unsaved edits) cannot be reconciled by a disk-only book update.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn writable_chapter(s: &Session, path: &str, command: &'static str) -> Result<()> {
|
||||
use std::path::Path;
|
||||
|
||||
if Path::new(path).extension().and_then(|ext| ext.to_str()).is_some_and(|ext| ext.eq_ignore_ascii_case("idml")) {
|
||||
return Err(bad(command, format!("cannot change imported IDML chapter {path}; convert it to a native DesignCraft document first")));
|
||||
}
|
||||
let target = std::fs::canonicalize(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
if s.docs.iter().any(|doc| doc.path.as_ref().is_some_and(|open| std::fs::canonicalize(open).is_ok_and(|open| open == target))) {
|
||||
return Err(bad(command, format!("chapter {path} is open; close it before updating the book")));
|
||||
}
|
||||
if !std::fs::metadata(&target).is_ok_and(|metadata| metadata.is_file()) {
|
||||
return Err(bad(command, format!("chapter {path} is not a regular file")));
|
||||
}
|
||||
// Check both the file and its resolved parent without truncating it. Replacement needs a
|
||||
// writable directory even when an in-place write would be permitted.
|
||||
std::fs::OpenOptions::new().write(true).open(&target).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let parent = target.parent().ok_or_else(|| bad(command, format!("chapter {path} has no parent folder")))?;
|
||||
tempfile::Builder::new().prefix(".designcraft-book-check-").tempfile_in(parent).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn paginate(s: &Session) -> Result<Value> {
|
||||
let b = s.book.as_ref().ok_or_else(no_book)?;
|
||||
if b.documents.is_empty() {
|
||||
return Ok(json!({"pages": 0}));
|
||||
}
|
||||
let mut batch = ChapterBatch::new(b.documents.len(), "book.paginate")?;
|
||||
let mut next = 1u32;
|
||||
for path in &b.documents {
|
||||
writable_chapter(s, path, "book.paginate")?;
|
||||
let mut d = load(path)?;
|
||||
start_at(&mut d, next);
|
||||
let pages = u32::try_from(d.page_count()).map_err(|_| bad("book.paginate", "too many pages for continuous numbering"))?;
|
||||
next = next.checked_add(pages).ok_or_else(|| bad("book.paginate", "too many pages for continuous numbering"))?;
|
||||
batch.stage(path, &d)?;
|
||||
}
|
||||
batch.commit()?;
|
||||
Ok(json!({"pages": next - 1}))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn sync_styles(s: &Session) -> Result<Value> {
|
||||
let b = s.book.as_ref().ok_or_else(no_book)?;
|
||||
let mut batch = ChapterBatch::new(b.documents.len(), "book.syncStyles")?;
|
||||
let src_path = b.documents.get(b.style_source).ok_or_else(|| bad("book.syncStyles", "the book is empty"))?;
|
||||
let src = load(src_path)?;
|
||||
let styles = src.styles.clone();
|
||||
let swatches = src.swatches.clone();
|
||||
drop(src);
|
||||
let mut n = 0;
|
||||
for (i, path) in b.documents.iter().enumerate() {
|
||||
if i == b.style_source {
|
||||
continue;
|
||||
}
|
||||
writable_chapter(s, path, "book.syncStyles")?;
|
||||
let mut d = load(path)?;
|
||||
{
|
||||
let st = d.styles_mut();
|
||||
for ps in &styles.paragraph {
|
||||
st.paragraph.retain(|x| x.name != ps.name);
|
||||
st.paragraph.push(ps.clone());
|
||||
}
|
||||
for cs in &styles.character {
|
||||
st.character.retain(|x| x.name != cs.name);
|
||||
st.character.push(cs.clone());
|
||||
}
|
||||
}
|
||||
for sw in &swatches {
|
||||
match d.swatches.iter_mut().find(|w| w.name == sw.name) {
|
||||
Some(w) if !w.locked => *w = sw.clone(),
|
||||
Some(_) => {}
|
||||
None => d.swatches.push(sw.clone()),
|
||||
}
|
||||
}
|
||||
batch.stage(path, &d)?;
|
||||
n += 1;
|
||||
}
|
||||
batch.commit()?;
|
||||
Ok(json!({"synced": n}))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn paginate(_: &Session) -> Result<Value> {
|
||||
Err(EngineError::Other("books need a file system".into()))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn sync_styles(_: &Session) -> Result<Value> {
|
||||
Err(EngineError::Other("books need a file system".into()))
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
#[path = "book/safety_tests.rs"]
|
||||
mod safety_tests;
|
||||
|
||||
/// Make page numbering of `d` start at `n` (the first section).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn start_at(d: &mut designcraft_doc::Document, n: u32) {
|
||||
if !d.sections.iter().any(|x| x.start == 0) {
|
||||
d.sections.insert(
|
||||
@@ -108,15 +264,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "book.add", "Add Document", [], None, "{path, at?: index}", has_book, |s, p| {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("book.add", "`path` required"))?.to_string();
|
||||
load(&path)?;
|
||||
let b = s.book.as_mut().ok_or_else(no_book)?;
|
||||
let mut b = s.book.as_ref().ok_or_else(no_book)?.clone();
|
||||
let at = p.get("at").and_then(Value::as_u64).map_or(b.documents.len(), |i| (i as usize).min(b.documents.len()));
|
||||
b.documents.insert(at, path);
|
||||
save(b)?;
|
||||
save(&b)?;
|
||||
s.book = Some(b);
|
||||
ok()
|
||||
}),
|
||||
cmd!(noundo "book.remove", "Remove Document", [], None, "{index}", has_book, |s, p| {
|
||||
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("book.remove", "`index` required"))? as usize;
|
||||
let b = s.book.as_mut().ok_or_else(no_book)?;
|
||||
let mut b = s.book.as_ref().ok_or_else(no_book)?.clone();
|
||||
if i >= b.documents.len() {
|
||||
return Err(bad("book.remove", format!("no document {i}")));
|
||||
}
|
||||
@@ -124,17 +281,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
if b.style_source >= b.documents.len() {
|
||||
b.style_source = 0;
|
||||
}
|
||||
save(b)?;
|
||||
save(&b)?;
|
||||
s.book = Some(b);
|
||||
ok()
|
||||
}),
|
||||
cmd!(noundo "book.styleSource", "Style Source", [], None, "{index}", has_book, |s, p| {
|
||||
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let b = s.book.as_mut().ok_or_else(no_book)?;
|
||||
let mut b = s.book.as_ref().ok_or_else(no_book)?.clone();
|
||||
if i >= b.documents.len() {
|
||||
return Err(bad("book.styleSource", format!("no document {i}")));
|
||||
}
|
||||
b.style_source = i;
|
||||
save(b)?;
|
||||
save(&b)?;
|
||||
s.book = Some(b);
|
||||
ok()
|
||||
}),
|
||||
cmd!(query "book.list", "Book", [], None, "{} → {path, styleSource, documents: [{path, pages, firstPage}]}", has_book, |s, _| {
|
||||
@@ -149,48 +308,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"path": b.path, "styleSource": b.style_source, "documents": docs}))
|
||||
}),
|
||||
cmd!(noundo "book.paginate", "Update Numbering", [], None, "{} — each document starts numbering where the previous one ended (files are saved)", has_book, |s, _| {
|
||||
let b = s.book.clone().ok_or_else(no_book)?;
|
||||
let mut next = 1u32;
|
||||
for p in &b.documents {
|
||||
let mut d = load(p)?;
|
||||
start_at(&mut d, next);
|
||||
next += d.page_count() as u32;
|
||||
store(p, &d)?;
|
||||
}
|
||||
Ok(json!({"pages": next - 1}))
|
||||
paginate(s)
|
||||
}),
|
||||
cmd!(noundo "book.syncStyles", "Synchronize Book", [], None, "{} — paragraph and character styles and swatches of the style source go into every document (by name)", has_book, |s, _| {
|
||||
let b = s.book.clone().ok_or_else(no_book)?;
|
||||
let src_path = b.documents.get(b.style_source).ok_or_else(|| bad("book.syncStyles", "the book is empty"))?;
|
||||
let src = load(src_path)?;
|
||||
let mut n = 0;
|
||||
for (i, p) in b.documents.iter().enumerate() {
|
||||
if i == b.style_source {
|
||||
continue;
|
||||
}
|
||||
let mut d = load(p)?;
|
||||
{
|
||||
let st = d.styles_mut();
|
||||
for ps in &src.styles.paragraph {
|
||||
st.paragraph.retain(|x| x.name != ps.name);
|
||||
st.paragraph.push(ps.clone());
|
||||
}
|
||||
for cs in &src.styles.character {
|
||||
st.character.retain(|x| x.name != cs.name);
|
||||
st.character.push(cs.clone());
|
||||
}
|
||||
}
|
||||
for sw in &src.swatches {
|
||||
match d.swatches.iter_mut().find(|w| w.name == sw.name) {
|
||||
Some(w) if !w.locked => *w = sw.clone(),
|
||||
Some(_) => {}
|
||||
None => d.swatches.push(sw.clone()),
|
||||
}
|
||||
}
|
||||
store(p, &d)?;
|
||||
n += 1;
|
||||
}
|
||||
Ok(json!({"synced": n}))
|
||||
sync_styles(s)
|
||||
}),
|
||||
cmd!(noundo "book.exportPdf", "Export Book to PDF…", [], None, "{path?} — every document in order as one PDF → {path, bytes, pages} (no path: {base64, …})", has_book, |s, p| {
|
||||
let b = s.book.clone().ok_or_else(no_book)?;
|
||||
@@ -210,7 +331,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
crate::atomic::write_file(path, &bytes)?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len(), "pages": pages}))
|
||||
}
|
||||
None => Ok(json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len(), "pages": pages})),
|
||||
@@ -235,6 +356,7 @@ mod tests {
|
||||
s.execute("file.new", &json!({"pages": pages})).unwrap();
|
||||
s.execute("style.paragraph.create", &json!({"name": style, "chars": {"size": 20}})).unwrap();
|
||||
s.execute("file.saveAs", &json!({"path": p(name)})).unwrap();
|
||||
s.execute("file.close", &json!({})).unwrap();
|
||||
}
|
||||
s.execute("book.new", &json!({"path": p("book.dcbook")})).unwrap();
|
||||
s.execute("book.add", &json!({"path": p("a.designcraft")})).unwrap();
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
use super::{ChapterBatch, MAX_BATCH_CHAPTERS, write_staged_chapter};
|
||||
use crate::Session;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn a_commit_failure_reports_the_completed_prefix_and_cleans_staging() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let first = dir.path().join("first.designcraft");
|
||||
let second = dir.path().join("second.designcraft");
|
||||
let third = dir.path().join("third.designcraft");
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let old = (*s.doc().unwrap().doc).clone();
|
||||
let mut changed = old.clone();
|
||||
changed.sections.first_mut().unwrap().start_number = Some(9);
|
||||
let old_bytes = designcraft_format::save(&old).unwrap();
|
||||
for path in [&first, &second, &third] {
|
||||
std::fs::write(path, &old_bytes).unwrap();
|
||||
}
|
||||
let mut batch = ChapterBatch::new(3, "book.paginate").unwrap();
|
||||
for path in [&first, &second, &third] {
|
||||
batch.stage(path.to_str().unwrap(), &changed).unwrap();
|
||||
}
|
||||
let staging = batch.directory.path().to_path_buf();
|
||||
let mut calls = 0;
|
||||
let error = batch
|
||||
.commit_with(|path, staged| {
|
||||
calls += 1;
|
||||
if path == second { Err(std::io::Error::other("synthetic disk full")) } else { write_staged_chapter(path, staged) }
|
||||
})
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert_eq!(calls, 2);
|
||||
assert!(error.contains("synthetic disk full"), "{error}");
|
||||
assert!(error.contains(second.to_str().unwrap()), "{error}");
|
||||
let completed = error.split("completed chapter paths:").nth(1).unwrap();
|
||||
assert!(completed.contains(first.to_str().unwrap()), "{error}");
|
||||
assert!(!completed.contains(second.to_str().unwrap()) && !completed.contains(third.to_str().unwrap()), "{error}");
|
||||
assert_eq!(designcraft_format::load(&std::fs::read(first).unwrap()).unwrap().sections.first().unwrap().start_number, Some(9));
|
||||
assert_eq!(std::fs::read(second).unwrap(), old_bytes);
|
||||
assert_eq!(std::fs::read(third).unwrap(), old_bytes);
|
||||
assert!(!staging.exists(), "temporary chapter snapshots must be removed on a partial failure");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chapter_record_limit_is_checked_before_staging_allocation() {
|
||||
let error = match ChapterBatch::new(MAX_BATCH_CHAPTERS + 1, "book.paginate") {
|
||||
Err(e) => e,
|
||||
Ok(_) => panic!("oversized book batch accepted"),
|
||||
};
|
||||
assert!(error.to_string().contains("4096"));
|
||||
}
|
||||
@@ -75,6 +75,11 @@ fn renumber(dst: &mut designcraft_doc::Document, src: &designcraft_doc::Document
|
||||
let new_id = ItemId(dst.alloc());
|
||||
maps.items.insert(old, new_id);
|
||||
it.id = new_id;
|
||||
if let Some(m) = it.media.as_mut()
|
||||
&& let Some(p) = m.poster
|
||||
{
|
||||
m.poster = dst.adopt_asset(src, p).or(Some(p));
|
||||
}
|
||||
match &mut it.content {
|
||||
Content::Text(tf) => {
|
||||
let new_sid = if let Some(n) = maps.stories.get(&tf.story) {
|
||||
@@ -83,6 +88,15 @@ fn renumber(dst: &mut designcraft_doc::Document, src: &designcraft_doc::Document
|
||||
let n = StoryId(dst.alloc());
|
||||
let mut st = src.story(tf.story).cloned().unwrap_or_else(|| Story::new(n));
|
||||
st.id = n;
|
||||
let mut assets = HashMap::new();
|
||||
for a in designcraft_doc::story_asset_refs(&st) {
|
||||
if let Some(na) = dst.adopt_asset(src, a)
|
||||
&& na != a
|
||||
{
|
||||
assets.insert(a, na);
|
||||
}
|
||||
}
|
||||
designcraft_doc::remap_story_assets(&mut st, &assets);
|
||||
st.frames.clear();
|
||||
dst.stories.insert(n, Arc::new(st));
|
||||
maps.stories.insert(tf.story, n);
|
||||
@@ -94,8 +108,8 @@ fn renumber(dst: &mut designcraft_doc::Document, src: &designcraft_doc::Document
|
||||
}
|
||||
}
|
||||
Content::Graphic(g) => {
|
||||
if let Some(a) = src.assets.get(&g.asset) {
|
||||
dst.assets.entry(g.asset).or_insert_with(|| a.clone());
|
||||
if let Some(a) = dst.adopt_asset(src, g.asset) {
|
||||
g.asset = a;
|
||||
}
|
||||
}
|
||||
Content::Group { items } => {
|
||||
@@ -355,21 +369,18 @@ fn place_image(
|
||||
};
|
||||
let (nw, nh) = (f64::from(pw), f64::from(ph));
|
||||
let mode = fitting_of(&options.fitting);
|
||||
let aid = AssetId(doc.alloc());
|
||||
let fresh = AssetId(doc.alloc());
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| field.to_string());
|
||||
let link = options.link_images.then(|| path.to_string_lossy().into_owned());
|
||||
doc.assets.insert(
|
||||
aid,
|
||||
Arc::new(Asset {
|
||||
id: aid,
|
||||
name,
|
||||
mime: designcraft_render::image_mime(&bytes).to_string(),
|
||||
link,
|
||||
data: Arc::new(bytes),
|
||||
pixels: Some((pw, ph)),
|
||||
page: 0,
|
||||
}),
|
||||
);
|
||||
let aid = doc.add_asset(Asset {
|
||||
id: fresh,
|
||||
name,
|
||||
mime: designcraft_render::image_mime(&bytes).to_string(),
|
||||
link,
|
||||
data: Arc::new(bytes),
|
||||
pixels: Some((pw, ph)),
|
||||
page: 0,
|
||||
});
|
||||
let mut g = Graphic { asset: aid, size: (nw, nh), xf: Affine::translate((inner.x0, inner.y0)), auto_fit: mode, fit_align: 0, crop: [0.0; 4] };
|
||||
if let Some(xf) = g.fitted(inner, mode) {
|
||||
g.xf = xf;
|
||||
|
||||
@@ -44,7 +44,7 @@ fn print_booklet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::fs::write(path, &r.bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
crate::atomic::write_file(path, &r.bytes)?;
|
||||
Ok(json!({"path": path, "bytes": r.bytes.len(), "sheets": r.pages, "warnings": r.warnings}))
|
||||
}
|
||||
None => Ok(json!({"base64": super::file::base64_encode(&r.bytes), "bytes": r.bytes.len(), "sheets": r.pages, "warnings": r.warnings})),
|
||||
@@ -59,7 +59,7 @@ fn export_html(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::fs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
crate::atomic::write_file(path, text.as_bytes())?;
|
||||
Ok(json!({"path": path, "bytes": text.len()}))
|
||||
}
|
||||
None => Ok(json!({"bytes": text.len(), "text": text})),
|
||||
@@ -91,7 +91,7 @@ fn export_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match path {
|
||||
Some(path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::fs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
crate::atomic::write_file(path, text.as_bytes())?;
|
||||
Ok(json!({"path": path, "bytes": text.len()}))
|
||||
}
|
||||
None => Ok(json!({"bytes": text.len(), "text": text})),
|
||||
@@ -160,7 +160,7 @@ fn export_epub(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match p.get("path").and_then(Value::as_str) {
|
||||
Some(path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::fs::write(path, &bytes).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?;
|
||||
crate::atomic::write_file(path, &bytes)?;
|
||||
Ok(serde_json::json!({"path": path, "bytes": bytes.len()}))
|
||||
}
|
||||
None => Ok(serde_json::json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len()})),
|
||||
@@ -598,7 +598,7 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
std::fs::write(path, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
crate::atomic::write_file(path, bytes)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn write_file(path: &str, _bytes: &[u8]) -> Result<()> {
|
||||
|
||||
@@ -18,16 +18,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "file.newSample", "Sample Document", ["Help"], None, "{} — a multi-page magazine sample", always, file_sample),
|
||||
cmd!(query "file.presets", "Document Presets", [], None, "{}", always, |_, _| Ok(serde_json::to_value(PRESETS).unwrap_or_default())),
|
||||
cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"),
|
||||
"{path} — .designcraft or .idml; the fonts in a `Document Fonts` folder beside it load first → {index, documentFonts: faces loaded, warnings: font files skipped}",
|
||||
"{path} — .designcraft or .idml; the fonts in a `Document Fonts` folder beside it load first → {index, documentFonts: faces loaded, warnings: font files skipped (and, for .idml, package parts missing)}",
|
||||
always, file_open),
|
||||
cmd!(noundo "file.openBytes", "Open Bytes", [], None, "{name, base64} — DesignCraft JSON or an IDML package", always, file_open_bytes),
|
||||
cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?}", has_doc, file_save),
|
||||
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path}", has_doc, file_save),
|
||||
cmd!(noundo "file.saveACopy", "Save a Copy…", ["File"], None, "{path} — writes the document without changing which file it is or its unsaved state", has_doc, |s, p| {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("file.saveACopy", "missing `path`"))?.to_string();
|
||||
let bytes = to_bytes(&s.doc()?.doc);
|
||||
let mut copy = (*s.doc()?.doc).clone();
|
||||
super::datamerge::refresh_relative_paths(&mut copy, std::path::Path::new(&path));
|
||||
let bytes = to_bytes(©);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::fs::write(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
crate::atomic::write_file(&path, &bytes)?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}),
|
||||
cmd!(noundo "file.revert", "Revert", ["File"], None, "{} — back to the last saved version (not undoable)", can_revert, file_revert),
|
||||
@@ -37,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
cmd!(noundo "file.recovery.save", "Save Recovery Data", [], None, "{dir?} — write every unsaved document to the recovery folder (the app does this on a timer)", always, |s, p| {
|
||||
let dir = recovery_dir(s, p)?;
|
||||
Ok(json!({"saved": crate::recovery::save(s, &dir)?}))
|
||||
Ok(json!({"saved": crate::recovery::save_tracked(s, &dir)?}))
|
||||
}),
|
||||
cmd!(noundo "file.recovery.open", "Recover Documents", [], None, "{dir?} — reopen documents left in the recovery folder (unsaved)", always, |s, p| {
|
||||
let dir = recovery_dir(s, p)?;
|
||||
@@ -237,24 +239,26 @@ fn file_save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.map(str::to_string)
|
||||
.or_else(|| st.path.clone())
|
||||
.ok_or_else(|| bad("file.save", "missing `path` (document has never been saved)"))?;
|
||||
{
|
||||
let mut d = (*st.doc).clone();
|
||||
super::datamerge::refresh_relative_paths(&mut d, std::path::Path::new(&path));
|
||||
if d.data_merge != st.doc.data_merge {
|
||||
st.doc = Arc::new(d);
|
||||
st.revision = st.revision.saturating_add(1);
|
||||
}
|
||||
}
|
||||
let bytes = to_bytes(&st.doc);
|
||||
// Prepare paths for the destination without changing the live document until persistence succeeds.
|
||||
let mut saved = (*st.doc).clone();
|
||||
super::datamerge::refresh_relative_paths(&mut saved, std::path::Path::new(&path));
|
||||
let paths_changed = saved.data_merge != st.doc.data_merge;
|
||||
let saved = if paths_changed { Arc::new(saved) } else { st.doc.clone() };
|
||||
let bytes = to_bytes(&saved);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::fs::write(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
crate::atomic::write_file(&path, &bytes)?;
|
||||
if paths_changed {
|
||||
st.doc = saved;
|
||||
st.revision = st.revision.saturating_add(1);
|
||||
}
|
||||
st.path = Some(path.clone());
|
||||
st.saved_revision = st.revision;
|
||||
st.saved_doc = st.doc.clone();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(dir) = &s.recovery_dir {
|
||||
let uid = s.doc()?.uid;
|
||||
crate::recovery::discard(dir, uid);
|
||||
{
|
||||
let recovery_dir = s.recovery_dir.clone();
|
||||
let index = s.active_index().ok_or(EngineError::NoDocument)?;
|
||||
crate::recovery::discard_document(s, index, recovery_dir.as_deref());
|
||||
}
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}
|
||||
@@ -313,8 +317,9 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.map(|(w, h)| (w.round().max(1.0) as u32, h.round().max(1.0) as u32))
|
||||
.ok_or_else(|| bad("file.place", "can't read that PDF page"))?,
|
||||
};
|
||||
// 72 ppi by default unless the file says otherwise; scale so it fits the page when huge.
|
||||
let (nw, nh) = eps_size.unwrap_or((pw as f64, ph as f64));
|
||||
// 72 ppi by default unless the file says otherwise; scale so it fits the page when huge. PDF pages
|
||||
// and SVGs keep their exact size: rounding it to whole points would stretch them unevenly.
|
||||
let (nw, nh) = eps_size.or_else(|| designcraft_render::natural_size(&bytes, pdf_page as usize)).unwrap_or((pw as f64, ph as f64));
|
||||
// Image Import Options › Crop to: the frame shows that box of the PDF page.
|
||||
let crop_box = match str_param(p, "pdfCrop") {
|
||||
Some(k) if designcraft_render::is_pdf(&bytes) => {
|
||||
@@ -336,9 +341,9 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let py = p.get("y").and_then(Value::as_f64);
|
||||
let want_w = p.get("width").and_then(Value::as_f64);
|
||||
s.edit(|d, sel| {
|
||||
let aid = AssetId(d.alloc());
|
||||
let fresh = AssetId(d.alloc());
|
||||
let mime = designcraft_render::image_mime(&bytes).to_string();
|
||||
d.assets.insert(aid, Arc::new(Asset { page: pdf_page, id: aid, name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)) }));
|
||||
let aid = d.add_asset(Asset { page: pdf_page, id: fresh, name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)) });
|
||||
// The width the whole page (or image) gets; the height follows the proportions.
|
||||
let w = match want_w {
|
||||
Some(w) => w,
|
||||
@@ -350,14 +355,14 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = if let Some(fid) = target_frame {
|
||||
let it = d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?;
|
||||
let r = it.inner_bounds();
|
||||
// Fill frame proportionally.
|
||||
// Fill the frame proportionally once. Auto-Fit stays off, so resizing the frame crops.
|
||||
let k = (r.width() / nw).max(r.height() / nh);
|
||||
let (gw, gh) = (nw * k, nh * k);
|
||||
it.content = Content::Graphic(Graphic {
|
||||
asset: aid,
|
||||
size: (nw, nh),
|
||||
xf: Affine::translate((r.x0 + (r.width() - gw) / 2.0, r.y0 + (r.height() - gh) / 2.0)) * Affine::scale(k),
|
||||
auto_fit: designcraft_doc::Fitting::FillProportionally,
|
||||
auto_fit: Default::default(),
|
||||
fit_align: 4,
|
||||
crop: [0.0; 4],
|
||||
});
|
||||
@@ -440,16 +445,13 @@ pub(crate) fn read_source(p: &Value) -> Result<(Vec<u8>, String, Option<String>)
|
||||
fn place_load(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (bytes, name, link) = read_source(p)?;
|
||||
let (pw, ph) = designcraft_render::image_size(&bytes).ok_or_else(|| bad("place.load", "unsupported or corrupt image"))?;
|
||||
let natural = designcraft_render::natural_size(&bytes, 0).unwrap_or((pw as f64, ph as f64));
|
||||
let aid = s.edit(|d, _| {
|
||||
let aid = AssetId(d.alloc());
|
||||
let fresh = AssetId(d.alloc());
|
||||
let mime = designcraft_render::image_mime(&bytes).to_string();
|
||||
d.assets.insert(
|
||||
aid,
|
||||
Arc::new(Asset { page: 0, id: aid, name: name.clone(), mime, link: link.clone(), data: Arc::new(bytes), pixels: Some((pw, ph)) }),
|
||||
);
|
||||
Ok(aid)
|
||||
Ok(d.add_asset(Asset { page: 0, id: fresh, name: name.clone(), mime, link: link.clone(), data: Arc::new(bytes), pixels: Some((pw, ph)) }))
|
||||
})?;
|
||||
s.loaded = Some((aid, (pw as f64, ph as f64)));
|
||||
s.loaded = Some((aid, natural));
|
||||
s.set_tool("placeGun");
|
||||
Ok(json!({"asset": aid.0, "name": name, "width": pw, "height": ph}))
|
||||
}
|
||||
@@ -471,7 +473,7 @@ fn place_drop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
asset: aid,
|
||||
size: (nw, nh),
|
||||
xf: Affine::translate((r.x0 + (r.width() - nw * k) / 2.0, r.y0 + (r.height() - nh * k) / 2.0)) * Affine::scale(k),
|
||||
auto_fit: designcraft_doc::Fitting::FillProportionally,
|
||||
auto_fit: Default::default(),
|
||||
fit_align: 4,
|
||||
crop: [0.0; 4],
|
||||
});
|
||||
@@ -520,7 +522,7 @@ fn snippet_export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
crate::atomic::write_file(path, &bytes)?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len(), "items": d.spreads.first().map(|s| s.items.len()).unwrap_or(0)}))
|
||||
}
|
||||
None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})),
|
||||
@@ -645,15 +647,14 @@ fn file_revert(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
// As opened again: the fonts beside it now (unchanged files are shared, not read again).
|
||||
let (fonts, _, _) = load_document_fonts(&mut d, &path);
|
||||
let uid = s.doc()?.uid;
|
||||
let st = s.doc_mut()?;
|
||||
let recovery_dir = s.recovery_dir.clone();
|
||||
let index = s.active_index().ok_or(EngineError::NoDocument)?;
|
||||
let mut fresh = DocState::new(d, Some(path.clone()));
|
||||
fresh.fonts = fonts;
|
||||
fresh.uid = uid;
|
||||
fresh.revision = st.revision + 1;
|
||||
*st = fresh;
|
||||
if let Some(dir) = &s.recovery_dir {
|
||||
crate::recovery::discard(dir, uid);
|
||||
}
|
||||
fresh.revision = s.doc()?.revision + 1;
|
||||
crate::recovery::discard_document(s, index, recovery_dir.as_deref());
|
||||
*s.doc_mut()? = fresh;
|
||||
Ok(json!({"path": path}))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -689,6 +690,24 @@ mod pdf_crop_tests {
|
||||
assert!((trim.width() - 612.0).abs() < 1e-3 && (trim.height() - 792.0).abs() < 1e-3, "{trim:?}");
|
||||
assert!(t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "pdfCrop": "nope"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placed_pdf_keeps_its_exact_page_size() {
|
||||
// A 100.4 x 50.4 pt page placed 100.4 pt wide is 50.4 pt tall, not stretched to whole points.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100.4, "height": 50.4, "pages": 1})).unwrap();
|
||||
let b64 = s.execute("file.exportPdf", &json!({})).unwrap()["base64"].as_str().unwrap().to_string();
|
||||
let mut t = Session::new();
|
||||
t.execute("file.new", &json!({})).unwrap();
|
||||
let r = t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "width": 100.4, "x": 0, "y": 0})).unwrap();
|
||||
let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap());
|
||||
let d = &t.doc().unwrap().doc;
|
||||
let it = d.item(id).unwrap();
|
||||
let b = it.bounds();
|
||||
assert!((b.width() - 100.4).abs() < 1e-3 && (b.height() - 50.4).abs() < 1e-3, "{b:?}");
|
||||
let designcraft_doc::Content::Graphic(g) = &it.content else { panic!("not a graphic") };
|
||||
assert!((g.size.0 - 100.4).abs() < 1e-3 && (g.size.1 - 50.4).abs() < 1e-3, "{:?}", g.size);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -719,6 +738,37 @@ mod layout_place_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod save_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Saving over an existing document replaces it whole and leaves nothing beside it.
|
||||
#[test]
|
||||
fn save_replaces_the_existing_file_without_leftovers() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-save-atomic-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("doc.designcraft");
|
||||
std::fs::write(&path, vec![b'x'; 1 << 20]).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Saved whole", "caret": false})).unwrap();
|
||||
s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
s.execute("file.saveACopy", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let saved = super::from_bytes(&std::fs::read(&path).unwrap()).unwrap();
|
||||
assert!(saved.stories.values().any(|st| st.text == "Saved whole"));
|
||||
let names: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect();
|
||||
assert_eq!(names, ["doc.designcraft"], "no temporary file is left");
|
||||
// A folder that doesn't exist: an error naming the path.
|
||||
let missing = dir.join("missing").join("doc.designcraft");
|
||||
let e = s.execute("file.save", &json!({"path": missing.to_string_lossy()})).unwrap_err();
|
||||
assert!(e.to_string().contains("missing"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod document_fonts_tests {
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -181,6 +181,7 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
over.set_json(k, v).map_err(|e| bad("find.change", e))?;
|
||||
}
|
||||
}
|
||||
super::text::validate_char_fill(&s.doc()?.doc, &over, "find.change")?;
|
||||
let targets = scope(s, p)?;
|
||||
s.edit(|d, sel| {
|
||||
let mut count = 0usize;
|
||||
|
||||
@@ -10,7 +10,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "document.inspect", "Inspect Document", [], None, "{} → pages, spreads, items, stories (with overset), styles, swatches, selection", has_doc, inspect),
|
||||
cmd!(query "app.links", "Community & Project Links", [], None,
|
||||
"{} → {discord, website, appPage, github, issues} (ArtCraft Discord, website, DesignCraft page and repository)", always, |_, _| Ok(crate::links::all())),
|
||||
"{} → {github, issues} (DesignCraft repository and issue tracker)", always, |_, _| Ok(crate::links::all())),
|
||||
cmd!(query "document.list", "List Documents", [], None, "{}", always, |s, _| {
|
||||
Ok(json!(s.documents().iter().enumerate().map(|(i, d)| json!({"index": i, "title": d.title(), "dirty": d.is_dirty(), "active": Some(i) == s.active_index()})).collect::<Vec<_>>()))
|
||||
}),
|
||||
|
||||
@@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{path?, embedImages?: true} — writes an IDML package to `path`, or returns {base64} without a path",
|
||||
has_doc, export_idml),
|
||||
cmd!(noundo "file.openIdml", "Open IDML", [], None,
|
||||
"{path | base64, name?} — opens an IDML package as a new document (linked images are read next to the file or from its Links/ folder; the fonts in a `Document Fonts` folder beside it load first) → {index, documentFonts, warnings}",
|
||||
"{path | base64, name?} — opens an IDML package as a new document (linked images are read next to the file or from its Links/ folder; the fonts in a `Document Fonts` folder beside it load first) → {index, documentFonts, warnings: package parts missing, font files skipped}",
|
||||
always, open_idml),
|
||||
]
|
||||
}
|
||||
@@ -25,7 +25,7 @@ fn export_idml(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
crate::atomic::write_file(path, &bytes)?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}
|
||||
None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})),
|
||||
@@ -34,6 +34,11 @@ fn export_idml(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// Import IDML bytes; `dir` is the folder the package came from (for relative link lookup).
|
||||
pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result<Document> {
|
||||
import_report(bytes, dir).map(|(document, _)| document)
|
||||
}
|
||||
|
||||
/// [`import`], with the problems that did not stop it (parts missing from the package, …).
|
||||
pub fn import_report(bytes: &[u8], dir: Option<&std::path::Path>) -> Result<(Document, Vec<String>)> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let resolved = std::cell::RefCell::new(std::collections::HashMap::new());
|
||||
let read = |link: &str| -> Option<Vec<u8>> {
|
||||
@@ -49,7 +54,8 @@ pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result<Document> {
|
||||
None
|
||||
}
|
||||
};
|
||||
let document = designcraft_idml::import_idml_with(bytes, &read).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let designcraft_idml::Imported { document, warnings } =
|
||||
designcraft_idml::import_idml_report(bytes, &read).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let document = {
|
||||
let mut document = document;
|
||||
@@ -62,7 +68,7 @@ pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result<Document> {
|
||||
resolve_packaged_links(&mut document, dir);
|
||||
document
|
||||
};
|
||||
Ok(document)
|
||||
Ok((document, warnings))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -142,14 +148,15 @@ pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
return Err(bad("file.openIdml", "missing `path` or `base64`"));
|
||||
};
|
||||
let mut d = import(&bytes, dir.as_deref())?;
|
||||
let (mut d, mut warnings) = import_report(&bytes, dir.as_deref())?;
|
||||
if let Some(n) = name {
|
||||
d.title = n;
|
||||
}
|
||||
let (fonts, faces, warnings) = match str_param(p, "path").filter(|_| str_param(p, "base64").is_none()) {
|
||||
let (fonts, faces, font_warnings) = match str_param(p, "path").filter(|_| str_param(p, "base64").is_none()) {
|
||||
Some(path) => super::file::load_document_fonts(&mut d, path),
|
||||
None => (None, 0, Vec::new()),
|
||||
};
|
||||
warnings.extend(font_warnings);
|
||||
// Never save over the .idml with the native format: the document starts unsaved.
|
||||
let mut st = DocState::new(d, None);
|
||||
st.fonts = fonts;
|
||||
|
||||
@@ -29,7 +29,7 @@ impl Library {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(p) = &self.path {
|
||||
let bytes = serde_json::to_vec_pretty(self).map_err(|e| crate::EngineError::Other(e.to_string()))?;
|
||||
std::fs::write(p, bytes).map_err(|e| crate::EngineError::Other(format!("{p}: {e}")))?;
|
||||
crate::atomic::write_file(p, &bytes)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -86,10 +86,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let n = st.selection.items.len();
|
||||
(str_param(p, "name").map(str::to_string).unwrap_or_else(|| if n > 1 { format!("{n} objects") } else { kind.clone() }), str_param(p, "description").unwrap_or("").to_string())
|
||||
};
|
||||
let lib = s.library.as_mut().ok_or_else(no_library)?;
|
||||
let mut lib = s.library.as_ref().ok_or_else(no_library)?.clone();
|
||||
let index = lib.items.len();
|
||||
lib.items.push(LibraryItem { name, description, snippet: super::file::base64_encode(&snip) });
|
||||
lib.save()?;
|
||||
Ok(json!({"index": lib.items.len() - 1}))
|
||||
s.library = Some(lib);
|
||||
Ok(json!({"index": index}))
|
||||
}),
|
||||
cmd!(
|
||||
"library.place",
|
||||
@@ -107,13 +109,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
),
|
||||
cmd!(noundo "library.remove", "Delete Item", [], None, "{index}", has_library, |s, p| {
|
||||
let lib = s.library.as_mut().ok_or_else(no_library)?;
|
||||
let mut lib = s.library.as_ref().ok_or_else(no_library)?.clone();
|
||||
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("library.remove", "`index` required"))? as usize;
|
||||
if i >= lib.items.len() {
|
||||
return Err(bad("library.remove", format!("no item {i}")));
|
||||
}
|
||||
lib.items.remove(i);
|
||||
lib.save()?;
|
||||
s.library = Some(lib);
|
||||
ok()
|
||||
}),
|
||||
cmd!(noundo "conveyor.collect", "Collect", [], None, "{ids? (default: the selection)} — each object onto the Content Collector conveyor → {count}", has_selection, |s, p| {
|
||||
|
||||
@@ -112,8 +112,10 @@ pub fn status(a: &designcraft_doc::Asset) -> &'static str {
|
||||
}
|
||||
|
||||
fn list(d: &Document) -> Vec<Value> {
|
||||
let used = d.used_assets();
|
||||
d.assets
|
||||
.values()
|
||||
.filter(|a| used.contains(&a.id))
|
||||
.map(|a| {
|
||||
let uses: Vec<Value> = uses(d, a.id)
|
||||
.into_iter()
|
||||
@@ -311,3 +313,118 @@ mod relink_folder_tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod shared_asset_tests {
|
||||
use super::*;
|
||||
|
||||
fn png(w: u32, h: u32) -> Vec<u8> {
|
||||
designcraft_render::Rendered { width: w, height: h, pixels: vec![200u8; (w * h * 4) as usize] }.to_png()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placements_of_one_file_share_an_asset_and_saves_drop_replaced_ones() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let a = super::super::file::base64_encode(&png(10, 10));
|
||||
s.execute("file.place", &json!({"base64": a, "name": "a.png", "x": 72, "y": 72})).unwrap();
|
||||
s.execute("edit.deselectAll", &json!({})).unwrap();
|
||||
s.execute("file.place", &json!({"base64": a, "name": "a.png", "x": 200, "y": 72})).unwrap();
|
||||
let l = s.execute("links.list", &json!({})).unwrap();
|
||||
assert_eq!(l.as_array().unwrap().len(), 1);
|
||||
assert_eq!(l[0]["uses"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(s.doc().unwrap().doc.assets.len(), 1);
|
||||
|
||||
// A new image into a frame: the old asset stays in memory (undo) but not in the file.
|
||||
let f = s.execute("frame.create", &json!({"rect": [100, 300, 200, 400]})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = super::super::file::base64_encode(&png(20, 10));
|
||||
let c = super::super::file::base64_encode(&png(30, 10));
|
||||
s.execute("file.place", &json!({"base64": b, "name": "b.png", "frame": f})).unwrap();
|
||||
s.execute("file.place", &json!({"base64": c, "name": "c.png", "frame": f})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.assets.len(), 3);
|
||||
assert_eq!(s.execute("links.list", &json!({})).unwrap().as_array().unwrap().len(), 2);
|
||||
let back = designcraft_format::load(&designcraft_format::save(&s.doc().unwrap().doc).unwrap()).unwrap();
|
||||
let mut names: Vec<&str> = back.assets.values().map(|a| a.name.as_str()).collect();
|
||||
names.sort();
|
||||
assert_eq!(names, ["a.png", "c.png"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_with_identical_bytes_at_different_paths_keep_their_own_links() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-same-bytes-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (a, b) = (dir.join("a.png"), dir.join("b.png"));
|
||||
std::fs::write(&a, png(10, 10)).unwrap();
|
||||
std::fs::write(&b, png(10, 10)).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
for (path, x) in [(&a, 72), (&b, 200)] {
|
||||
s.execute("edit.deselectAll", &json!({})).unwrap();
|
||||
s.execute("file.place", &json!({"path": path.to_string_lossy(), "x": x, "y": 72})).unwrap();
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().doc.assets.len(), 2);
|
||||
let rows = |s: &mut Session| -> Vec<(String, Value)> {
|
||||
let l = s.execute("links.list", &json!({})).unwrap();
|
||||
l.as_array().unwrap().iter().map(|r| (r["path"].as_str().unwrap().to_string(), r["pixels"].clone())).collect()
|
||||
};
|
||||
let paths = |r: &[(String, Value)]| r.iter().map(|(p, _)| p.clone()).collect::<Vec<_>>();
|
||||
let before = rows(&mut s);
|
||||
assert_eq!(paths(&before), [a.to_string_lossy(), b.to_string_lossy()]);
|
||||
|
||||
// b.png changes on disk: updating it leaves a.png's placement alone.
|
||||
std::fs::write(&b, png(20, 10)).unwrap();
|
||||
assert_eq!(s.execute("links.update", &json!({})).unwrap()["updated"], 1);
|
||||
let after = rows(&mut s);
|
||||
assert_eq!(paths(&after), paths(&before));
|
||||
assert_eq!((after[0].1.clone(), after[1].1.clone()), (json!([10, 10]), json!([20, 10])));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_graphics_reuse_the_identical_asset_of_the_target() {
|
||||
use designcraft_doc::build::NewDocument;
|
||||
use designcraft_doc::{Asset, Graphic, Item, ItemId, Shape, SpreadRef};
|
||||
let asset = |id: u64, data: Vec<u8>| Asset {
|
||||
id: AssetId(id),
|
||||
name: "p.png".into(),
|
||||
mime: "image/png".into(),
|
||||
link: None,
|
||||
data: Arc::new(data),
|
||||
pixels: None,
|
||||
page: 0,
|
||||
};
|
||||
let mut src = Document::new(&NewDocument::default());
|
||||
let mut dst = Document::new(&NewDocument::default());
|
||||
let (pa, pb) = (png(4, 4), png(8, 4));
|
||||
// The source's asset id is taken in the target by a different file.
|
||||
let sid = AssetId(src.alloc());
|
||||
src.assets.insert(sid, Arc::new(asset(sid.0, pa.clone())));
|
||||
dst.assets.insert(sid, Arc::new(asset(sid.0, pb)));
|
||||
dst.alloc();
|
||||
let same = AssetId(dst.alloc());
|
||||
assert_ne!(same, sid);
|
||||
dst.assets.insert(same, Arc::new(asset(same.0, pa)));
|
||||
let iid = ItemId(src.alloc());
|
||||
let mut it = Item::new(
|
||||
iid,
|
||||
src.default_layer(),
|
||||
Shape::Rectangle,
|
||||
designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, 9.0, 9.0)),
|
||||
);
|
||||
it.content = Content::Graphic(Graphic {
|
||||
asset: sid,
|
||||
size: (4.0, 4.0),
|
||||
xf: designcraft_geom::Affine::IDENTITY,
|
||||
auto_fit: Default::default(),
|
||||
fit_align: 4,
|
||||
crop: [1.0; 4],
|
||||
});
|
||||
src.insert_item(SpreadRef::Doc(0), it, None).unwrap();
|
||||
let ids = super::super::object::duplicate_from(&mut dst, &src, &[iid], SpreadRef::Doc(0), designcraft_geom::Vec2::ZERO).unwrap();
|
||||
let g = dst.item(ids[0]).and_then(|i| i.graphic()).unwrap();
|
||||
assert_eq!((g.asset, g.crop), (same, [1.0; 4]));
|
||||
assert_eq!(dst.assets.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,16 @@ use crate::{Result, Session};
|
||||
|
||||
fn math_item(d: &mut designcraft_doc::Document, latex: &str, size: f64, lid: designcraft_doc::LayerId, at: (f64, f64)) -> Item {
|
||||
let (svg, (w, h)) = crate::math::to_svg(latex, size);
|
||||
let aid = AssetId(d.alloc());
|
||||
d.assets.insert(
|
||||
aid,
|
||||
Arc::new(Asset { page: 0, id: aid, name: "math.svg".into(), mime: "image/svg+xml".into(), link: None, data: Arc::new(svg), pixels: None }),
|
||||
);
|
||||
let fresh = AssetId(d.alloc());
|
||||
let aid = d.add_asset(Asset {
|
||||
page: 0,
|
||||
id: fresh,
|
||||
name: "math.svg".into(),
|
||||
mime: "image/svg+xml".into(),
|
||||
link: None,
|
||||
data: Arc::new(svg),
|
||||
pixels: None,
|
||||
});
|
||||
let id = ItemId(d.alloc());
|
||||
let r = Rect::new(at.0, at.1, at.0 + w, at.1 + h);
|
||||
let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r));
|
||||
|
||||
@@ -25,8 +25,8 @@ pub(crate) fn place_media(s: &mut Session, p: &Value, name: String, mime: &str,
|
||||
let (px, py) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64));
|
||||
let mime = mime.to_string();
|
||||
s.edit(|d, sel| {
|
||||
let aid = AssetId(d.alloc());
|
||||
d.assets.insert(aid, Arc::new(Asset { page: 0, id: aid, name, mime, link, data: Arc::new(bytes), pixels: None }));
|
||||
let fresh = AssetId(d.alloc());
|
||||
let aid = d.add_asset(Asset { page: 0, id: fresh, name, mime, link, data: Arc::new(bytes), pixels: None });
|
||||
let id = match target {
|
||||
Some(fid) => {
|
||||
let it = d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?;
|
||||
@@ -90,10 +90,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.edit(|d, _| {
|
||||
let poster_id = match poster {
|
||||
Some(Some((bytes, name, link, px))) => {
|
||||
let aid = AssetId(d.alloc());
|
||||
let fresh = AssetId(d.alloc());
|
||||
let mime = designcraft_render::image_mime(&bytes).to_string();
|
||||
d.assets.insert(aid, Arc::new(Asset { page: 0, id: aid, name, mime, link, data: Arc::new(bytes), pixels: Some(px) }));
|
||||
Some(Some(aid))
|
||||
Some(Some(d.add_asset(Asset { page: 0, id: fresh, name, mime, link, data: Arc::new(bytes), pixels: Some(px) })))
|
||||
}
|
||||
Some(None) => Some(None),
|
||||
None => None,
|
||||
|
||||
@@ -14,6 +14,17 @@ use serde_json::{Value, json};
|
||||
use super::{CommandSpec, bad, bool_or, cmd, f64_or, has_doc, has_selection, ok, point_param, rect_param, spread_param, str_param, targets};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// `name` must be a swatch of the document: an unknown name would paint nothing. The error names
|
||||
/// the swatch it was probably meant to be (`Paper` → `[Paper]`, a different case).
|
||||
fn known_swatch(doc: &designcraft_doc::Document, name: &str, cmd: &str, key: &str) -> Result<()> {
|
||||
if doc.swatch(name).is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let bare = |n: &str| n.trim().trim_start_matches('[').trim_end_matches(']').to_lowercase();
|
||||
let hint = doc.swatches.iter().find(|s| bare(&s.name) == bare(name)).map(|s| format!(" (did you mean `{}`?)", s.name)).unwrap_or_default();
|
||||
Err(bad(cmd, format!("{key}: no swatch named `{name}`{hint}; create one with `swatch.create` and use its returned name")))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
@@ -26,7 +37,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
frame_create
|
||||
),
|
||||
cmd!("line.create", "Create Line", [], None, "{spread?, a: [x,y], b: [x,y]}", has_doc, line_create),
|
||||
cmd!("transform.move", "Move", ["Object", "Transform"], None, "{dx, dy, copy?: bool, ids?, toSpread?}", has_selection, transform_move),
|
||||
cmd!(
|
||||
"transform.move",
|
||||
"Move",
|
||||
["Object", "Transform"],
|
||||
None,
|
||||
"{dx, dy, copy?: bool, ids?, toSpread?} → {moved, ids: the objects moved (with copy, the copies)}",
|
||||
has_selection,
|
||||
transform_move
|
||||
),
|
||||
cmd!(
|
||||
"transform.resize",
|
||||
"Resize",
|
||||
@@ -160,8 +179,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
)
|
||||
}
|
||||
),
|
||||
cmd!("object.fill", "Fill", [], None, "{swatch, tint?: 0..1, ids?}", has_selection, |s, p| {
|
||||
cmd!("object.fill", "Fill", [], None, "{swatch (a swatch's name, e.g. [Paper]), tint?: 0..1, ids?}", has_selection, |s, p| {
|
||||
let sw = str_param(p, "swatch").ok_or_else(|| bad("object.fill", "missing swatch"))?.to_string();
|
||||
known_swatch(&s.doc()?.doc, &sw, "object.fill", "swatch")?;
|
||||
let tint = f64_or(p, "tint", 1.0) as f32;
|
||||
set_flag(s, p, move |i| i.fill = Fill { swatch: sw.clone(), tint, ..i.fill.clone() }, false)
|
||||
}),
|
||||
@@ -173,6 +193,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{swatch?, tint?, weight?, align?: center|inside|outside, type?: {kind:…}, cap?: butt|round|projecting, join?: miter|round|bevel, miterLimit?, start?, end?: none|simple|simpleWide|triangle|triangleWide|barbed|curved|circle|circleSolid|square|squareSolid|bar, gapSwatch?, gapTint?, gapOverprint?: bool, ids?}",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
for key in ["swatch", "gapSwatch"] {
|
||||
if let Some(name) = p.get(key).and_then(Value::as_str) {
|
||||
known_swatch(&s.doc()?.doc, name, "object.stroke", key)?;
|
||||
}
|
||||
}
|
||||
let p2 = p.clone();
|
||||
set_flag(
|
||||
s,
|
||||
@@ -565,7 +590,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Step and Repeat…",
|
||||
["Edit"],
|
||||
Some("Cmd+Alt+U"),
|
||||
"{count?: 1..1000, dx?, dy?, rows?, columns?, ids?} — copies of the selection (or ids) offset by (dx, dy); with rows/columns, a grid of at most 1000 cells, and at most 1000 item copies including group contents",
|
||||
"{count?: 1..1000, dx?, dy?, rows?, columns?, ids?} — copies of the selection (or ids) offset by (dx, dy); with rows/columns, a grid of at most 1000 cells, and at most 1000 item copies including group contents → {created, ids: the copies}",
|
||||
has_selection,
|
||||
step_and_repeat
|
||||
),
|
||||
@@ -900,6 +925,11 @@ pub fn duplicate_from(dst: &mut Document, src: &Document, ids: &[ItemId], to: Sp
|
||||
fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut HashMap<StoryId, StoryId>) {
|
||||
it.id = ItemId(dst.alloc());
|
||||
let my_id = it.id;
|
||||
if let Some(m) = it.media.as_mut()
|
||||
&& let Some(p) = m.poster
|
||||
{
|
||||
m.poster = dst.adopt_asset(src, p).or(Some(p));
|
||||
}
|
||||
match &mut it.content {
|
||||
Content::Text(tf) => {
|
||||
let new_sid = match stories.get(&tf.story) {
|
||||
@@ -908,6 +938,15 @@ fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut Has
|
||||
let n = StoryId(dst.alloc());
|
||||
let mut st = src.story(tf.story).cloned().unwrap_or_else(|| Story::new(n));
|
||||
st.id = n;
|
||||
let mut assets = HashMap::new();
|
||||
for a in designcraft_doc::story_asset_refs(&st) {
|
||||
if let Some(na) = dst.adopt_asset(src, a)
|
||||
&& na != a
|
||||
{
|
||||
assets.insert(a, na);
|
||||
}
|
||||
}
|
||||
designcraft_doc::remap_story_assets(&mut st, &assets);
|
||||
st.frames.clear();
|
||||
dst.stories.insert(n, Arc::new(st));
|
||||
stories.insert(tf.story, n);
|
||||
@@ -920,10 +959,8 @@ fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut Has
|
||||
}
|
||||
}
|
||||
Content::Graphic(g) => {
|
||||
if let Some(a) = src.assets.get(&g.asset)
|
||||
&& !dst.assets.contains_key(&g.asset)
|
||||
{
|
||||
dst.assets.insert(g.asset, a.clone());
|
||||
if let Some(a) = dst.adopt_asset(src, g.asset) {
|
||||
g.asset = a;
|
||||
}
|
||||
}
|
||||
Content::Group { items } => {
|
||||
@@ -969,7 +1006,7 @@ fn transform_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
it.xf = Affine::translate((dx, dy)) * it.xf;
|
||||
}
|
||||
Ok(json!({"moved": ids.len()}))
|
||||
Ok(json!({"moved": ids.len(), "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2079,7 +2116,8 @@ fn step_and_repeat(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
*sel = Selection::items(all.clone());
|
||||
Ok(json!({"created": all.len() - ids.len()}))
|
||||
let copies: Vec<u64> = all.iter().skip(ids.len()).map(|i| i.0).collect();
|
||||
Ok(json!({"created": copies.len(), "ids": copies}))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2151,7 +2189,7 @@ mod scale_tests {
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [100, 100, 200, 150]})).unwrap();
|
||||
let id = ItemId(r["id"].as_u64().unwrap());
|
||||
s.execute("object.stroke", &json!({"weight": 4, "swatch": "Black"})).unwrap();
|
||||
s.execute("object.stroke", &json!({"weight": 4, "swatch": "[Black]"})).unwrap();
|
||||
let weight = |s: &Session| s.doc().unwrap().doc.item(id).unwrap().stroke.weight;
|
||||
let bounds = |s: &Session| s.doc().unwrap().doc.item(id).unwrap().bounds();
|
||||
s.execute("transform.scale", &json!({"sx": 2.0})).unwrap();
|
||||
@@ -2463,8 +2501,8 @@ mod fitting_tests {
|
||||
s.execute("object.fittingOptions", &json!({"ids": [f], "fitting": "fitProportionally", "align": 0})).unwrap();
|
||||
let c = content(&s);
|
||||
assert!((c.x0 - 100.0).abs() < 1e-6 && (c.y0 - 100.0).abs() < 1e-6 && (c.width() - 200.0).abs() < 1e-6, "{c:?}");
|
||||
// Crop 10 pt on every side: the content spreads past the frame.
|
||||
s.execute("object.fittingOptions", &json!({"ids": [f], "crop": 10, "align": 4})).unwrap();
|
||||
// Crop 10 pt on every side and fit again: the content spreads past the frame.
|
||||
s.execute("object.fittingOptions", &json!({"ids": [f], "fitting": "fitProportionally", "crop": 10, "align": 4})).unwrap();
|
||||
let c = content(&s);
|
||||
assert!((c.width() - 220.0).abs() < 1e-6, "{c:?}");
|
||||
// Auto-fit: resizing the frame refits the content.
|
||||
@@ -2714,3 +2752,64 @@ mod blend_mode_tests {
|
||||
assert_eq!(it.opacity, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod copy_ids_tests {
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn copies_report_their_ids() {
|
||||
let mut s = crate::Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let a = s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap()["id"].as_u64().unwrap();
|
||||
let ids = |r: &serde_json::Value| r["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect::<Vec<_>>();
|
||||
let selected = |s: &crate::Session| s.doc().unwrap().selection.items.iter().map(|i| i.0).collect::<Vec<_>>();
|
||||
// Moving reports what moved; copying reports the copies, which are selected.
|
||||
assert_eq!(ids(&s.execute("transform.move", &json!({"dx": 5, "dy": 0, "ids": [a]})).unwrap()), [a]);
|
||||
let r = s.execute("transform.move", &json!({"dx": 100, "dy": 0, "copy": true, "ids": [a]})).unwrap();
|
||||
let copy = ids(&r);
|
||||
assert!(copy.len() == 1 && copy[0] != a && s.doc().unwrap().doc.item(designcraft_doc::ItemId(copy[0])).is_some(), "{r}");
|
||||
assert_eq!(selected(&s), copy);
|
||||
// Edit › Duplicate.
|
||||
let r = s.execute("edit.duplicate", &json!({})).unwrap();
|
||||
let dup = ids(&r);
|
||||
assert!(dup.len() == 1 && dup[0] != copy[0], "{r}");
|
||||
assert_eq!(selected(&s), dup);
|
||||
// Step and Repeat: every copy.
|
||||
let r = s.execute("edit.stepAndRepeat", &json!({"count": 3, "dx": 0, "dy": 60, "ids": [a]})).unwrap();
|
||||
assert_eq!((r["created"].as_u64(), ids(&r).len()), (Some(3), 3), "{r}");
|
||||
assert!(ids(&r).iter().all(|i| s.doc().unwrap().doc.item(designcraft_doc::ItemId(*i)).is_some()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod swatch_name_tests {
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn fills_and_strokes_must_name_a_swatch_and_change_nothing_otherwise() {
|
||||
let mut s = crate::Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = s.execute("frame.create", &json!({"rect": [0, 0, 100, 100]})).unwrap()["id"].clone();
|
||||
for (cmd, params, hint) in [
|
||||
("object.fill", json!({"swatch": "Paper", "ids": [id]}), Some("[Paper]")),
|
||||
("object.fill", json!({"swatch": "[black]", "ids": [id]}), Some("[Black]")),
|
||||
("object.fill", json!({"swatch": "Missing Ink", "ids": [id]}), None),
|
||||
("object.stroke", json!({"swatch": "#ff0000", "weight": 4, "ids": [id]}), None),
|
||||
("object.stroke", json!({"gapSwatch": "paper", "ids": [id]}), Some("[Paper]")),
|
||||
] {
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let e = s.execute(cmd, ¶ms).unwrap_err().to_string();
|
||||
assert!(e.contains("no swatch named") && e.contains("swatch.create"), "{cmd}: {e}");
|
||||
if let Some(h) = hint {
|
||||
assert!(e.contains(&format!("did you mean `{h}`")), "{cmd}: {e}");
|
||||
}
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "{cmd}: nothing changed");
|
||||
}
|
||||
// Real names, [None] included, still work.
|
||||
for sw in ["[Paper]", "[None]", "C=100 M=0 Y=0 K=0"] {
|
||||
s.execute("object.fill", &json!({"swatch": sw, "ids": [id]})).unwrap();
|
||||
s.execute("object.stroke", &json!({"swatch": sw, "ids": [id]})).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,13 +285,13 @@ fn write_links(d: &Document, root: &OutputRoot, dir: &Path, only: Option<&[Asset
|
||||
let mut out = d.clone();
|
||||
let mut taken = Vec::new();
|
||||
let mut written = Vec::new();
|
||||
let mut ids: Vec<AssetId> = d.assets.keys().copied().collect();
|
||||
ids.sort_by_key(|i| i.0);
|
||||
// Only files something places; the saved document leaves the others out.
|
||||
let ids = d.used_assets();
|
||||
for id in ids {
|
||||
if only.is_some_and(|o| !o.contains(&id)) {
|
||||
continue;
|
||||
}
|
||||
let a = &d.assets[&id];
|
||||
let Some(a) = d.assets.get(&id) else { continue };
|
||||
let name = link_name(a.link.as_deref().unwrap_or(&a.name), id, &mut taken);
|
||||
let path = dir.join(&name);
|
||||
root.write(&path, a.data.as_slice())?;
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
//! File › Print: the pages go to a printer through the system print spooler (`lpr`), as PDF.
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::io::Write;
|
||||
//! File › Print: the pages go to a printer through the system print spooler (craft-print), as PDF.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -20,91 +17,60 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Print…",
|
||||
["File"],
|
||||
Some("Cmd+P"),
|
||||
"{printer?, copies?: 1, pages?: \"1-3,5\", spreads?, marks?, bleed?, dryRun?: bool (only return the spool command)} → {printer, copies, pages, command}",
|
||||
"{printer?, copies?: 1, pages?: \"1-3,5\", spreads?, marks?, bleed?, dryRun?: bool (export and preview submission without printing)} → {printer, copies, pages, command, bytes}",
|
||||
has_doc,
|
||||
print
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn printers() -> Value {
|
||||
let run = |args: &[&str]| {
|
||||
std::process::Command::new("lpstat").args(args).output().ok().map(|o| String::from_utf8_lossy(&o.stdout).to_string()).unwrap_or_default()
|
||||
};
|
||||
let names: Vec<String> = run(&["-e"]).lines().map(str::trim).filter(|l| !l.is_empty()).map(str::to_string).collect();
|
||||
// "system default destination: NAME"
|
||||
let default = run(&["-d"]).rsplit(':').next().map(str::trim).filter(|s| !s.is_empty() && !s.contains("no system default")).map(str::to_string);
|
||||
let printers = craft_print::printers();
|
||||
let default = printers.iter().find(|p| p.default).map(|p| p.name.clone());
|
||||
let names: Vec<_> = printers.into_iter().map(|p| p.name).collect();
|
||||
json!({"printers": names, "default": default})
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn printers() -> Value {
|
||||
json!({"printers": [], "default": null})
|
||||
}
|
||||
|
||||
fn print(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
let opts = super::export::options(p, d.page_count())?;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
// Even a dry run cannot construct a native spool command in the browser.
|
||||
let _ = opts;
|
||||
super::export::options(p, s.doc()?.doc.page_count())?;
|
||||
Err(bad("file.print", "printing isn't available on the web: export a PDF and print it"))
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let copies = p.get("copies").and_then(Value::as_u64).unwrap_or(1).clamp(1, 999);
|
||||
let printer = str_param(p, "printer").map(str::to_string);
|
||||
let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false);
|
||||
let pdf = designcraft_pdf::export_pdf(d, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let mut file = secure_print_file()?;
|
||||
let file_path = file.path().to_path_buf();
|
||||
let mut cmd: Vec<String> = vec!["lpr".into()];
|
||||
if let Some(pr) = &printer {
|
||||
cmd.extend(["-P".into(), pr.clone()]);
|
||||
}
|
||||
if copies > 1 {
|
||||
cmd.extend(["-#".into(), copies.to_string()]);
|
||||
}
|
||||
cmd.extend(["-T".into(), d.title.clone(), file_path.to_string_lossy().to_string()]);
|
||||
let pages = opts.pages.as_ref().map_or(d.page_count(), |v| v.len());
|
||||
let out = json!({"printer": printer, "copies": copies, "pages": pages, "command": cmd, "bytes": pdf.len()});
|
||||
if dry {
|
||||
return Ok(out);
|
||||
}
|
||||
file.as_file_mut().write_all(&pdf).map_err(|e| EngineError::Other(format!("{}: {e}", file.path().display())))?;
|
||||
file.as_file_mut().flush().map_err(|e| EngineError::Other(format!("{}: {e}", file.path().display())))?;
|
||||
let st = std::process::Command::new(&cmd[0]).args(&cmd[1..]).output().map_err(|e| bad("file.print", format!("lpr: {e}")))?;
|
||||
if !st.status.success() {
|
||||
return Err(bad("file.print", String::from_utf8_lossy(&st.stderr).trim().to_string()));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
print_with(s, p, craft_print::submit)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn secure_print_file() -> Result<tempfile::NamedTempFile> {
|
||||
let file = tempfile::Builder::new()
|
||||
.prefix("designcraft-print-")
|
||||
.suffix(".pdf")
|
||||
.tempfile()
|
||||
.map_err(|e| EngineError::Other(format!("temporary print file: {e}")))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
file.as_file()
|
||||
.set_permissions(std::fs::Permissions::from_mode(0o600))
|
||||
.map_err(|e| EngineError::Other(format!("{}: {e}", file.path().display())))?;
|
||||
fn print_with(
|
||||
s: &mut Session,
|
||||
p: &Value,
|
||||
submit: impl FnOnce(&[u8], &craft_print::Job) -> std::result::Result<String, craft_print::Error>,
|
||||
) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
let opts = super::export::options(p, d.page_count())?;
|
||||
let copies = p.get("copies").and_then(Value::as_u64).unwrap_or(1).clamp(1, 999) as u32;
|
||||
let job = craft_print::Job {
|
||||
printer: str_param(p, "printer").map(str::to_string),
|
||||
copies,
|
||||
scaling: craft_print::Scaling::ShrinkToFit,
|
||||
title: d.title.clone(),
|
||||
..craft_print::Job::default()
|
||||
};
|
||||
let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false);
|
||||
let pdf = designcraft_pdf::export_pdf(d, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let command = craft_print::command_preview(&job).map_err(|e| bad("file.print", e.to_string()))?;
|
||||
let pages = opts.pages.as_ref().map_or(d.page_count(), |v| v.len());
|
||||
let out = json!({"printer": job.printer, "copies": copies, "pages": pages, "command": command, "bytes": pdf.len()});
|
||||
if !dry {
|
||||
submit(&pdf, &job).map_err(|e| bad("file.print", e.to_string()))?;
|
||||
}
|
||||
Ok(file)
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn print_builds_the_spool_command() {
|
||||
@@ -112,37 +78,45 @@ mod tests {
|
||||
s.execute("file.new", &json!({"pages": 4})).unwrap();
|
||||
let r = s.execute("file.print", &json!({"printer": "Office", "copies": 2, "pages": "2-3", "dryRun": true})).unwrap();
|
||||
assert_eq!(r["pages"], 2);
|
||||
let cmd: Vec<&str> = r["command"].as_array().unwrap().iter().filter_map(|v| v.as_str()).collect();
|
||||
assert_eq!(&cmd[..5], ["lpr", "-P", "Office", "-#", "2"]);
|
||||
assert!(cmd.last().unwrap().ends_with(".pdf"));
|
||||
assert!(!std::path::Path::new(cmd.last().unwrap()).exists());
|
||||
let command: Vec<&str> = r["command"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
|
||||
if craft_print::backend() == Some(craft_print::Backend::Cups) {
|
||||
assert_eq!(&command[..5], ["lp", "-d", "Office", "-n", "2"]);
|
||||
assert!(command.contains(&"print-scaling=auto-fit"));
|
||||
assert!(!command.iter().any(|a| a.starts_with("sides=")));
|
||||
} else {
|
||||
assert_eq!(command[0], "powershell");
|
||||
}
|
||||
assert!(r["bytes"].as_u64().unwrap() > 500);
|
||||
assert!(s.execute("file.print", &json!({"pages": "9", "dryRun": true})).is_err());
|
||||
assert!(s.execute("file.printers", &json!({})).unwrap()["printers"].is_array());
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn print_uses_distinct_ephemeral_files() {
|
||||
let first = super::secure_print_file().unwrap();
|
||||
let second = super::secure_print_file().unwrap();
|
||||
let first_path = first.path().to_path_buf();
|
||||
let second_path = second.path().to_path_buf();
|
||||
assert_ne!(first_path, second_path);
|
||||
drop((first, second));
|
||||
assert!(!first_path.exists());
|
||||
assert!(!second_path.exists());
|
||||
}
|
||||
|
||||
#[cfg(all(not(target_arch = "wasm32"), unix))]
|
||||
#[test]
|
||||
fn print_file_is_private_and_removed_on_drop() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let file = super::secure_print_file().unwrap();
|
||||
let path = file.path().to_path_buf();
|
||||
assert_eq!(file.as_file().metadata().unwrap().permissions().mode() & 0o777, 0o600);
|
||||
drop(file);
|
||||
assert!(!path.exists());
|
||||
fn print_submits_the_selected_export_and_reports_refusal() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 4})).unwrap();
|
||||
let params = json!({"printer": "Office (east)", "copies": 2, "pages": "2-3", "marks": true, "bleed": true});
|
||||
let expected = {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let options = super::super::export::options(¶ms, d.page_count()).unwrap();
|
||||
designcraft_pdf::export_pdf(d, &s.cache, &options).unwrap()
|
||||
};
|
||||
let r = print_with(&mut s, ¶ms, |pdf, job| {
|
||||
// The production export options and full bytes reach the shared spooler boundary.
|
||||
assert_eq!(pdf, expected);
|
||||
assert!(pdf.starts_with(b"%PDF-"));
|
||||
assert_eq!(job.printer.as_deref(), Some("Office (east)"));
|
||||
assert_eq!(job.copies, 2);
|
||||
assert_eq!(job.duplex, None);
|
||||
assert_eq!(job.scaling, craft_print::Scaling::ShrinkToFit);
|
||||
assert!(!job.title.is_empty());
|
||||
Ok("request id is Office-1".into())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(r["pages"], 2);
|
||||
let refusal = print_with(&mut s, ¶ms, |_, _| Err(craft_print::Error::Spool("printer offline".into()))).unwrap_err();
|
||||
assert!(refusal.to_string().contains("printer offline"));
|
||||
print_with(&mut s, &json!({"dryRun": true}), |_, _| panic!("dry runs must not spool")).unwrap();
|
||||
assert!(print_with(&mut s, &json!({"pages": "9"}), |_, _| panic!("invalid ranges must not spool")).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,6 +444,7 @@ fn create_para(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
chars = full;
|
||||
}
|
||||
}
|
||||
super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.create")?;
|
||||
let based_on = str_param(p, "basedOn").map(str::to_string);
|
||||
let next = str_param(p, "nextStyle").map(str::to_string);
|
||||
s.edit(|d, _| {
|
||||
@@ -470,6 +471,7 @@ fn CharProps_to_attrs(p: &designcraft_doc::CharProps) -> CharAttrs {
|
||||
fn edit_para(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.edit", "missing name"))?.to_string();
|
||||
let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
|
||||
super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.edit")?;
|
||||
let rename = str_param(p, "rename").map(str::to_string);
|
||||
let based = p.get("basedOn").cloned();
|
||||
s.edit(|d, _| {
|
||||
@@ -621,6 +623,7 @@ fn delete_para(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn create_char(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let base = str_param(p, "name").unwrap_or("Character Style 1").to_string();
|
||||
let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
|
||||
super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.character.create")?;
|
||||
// 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);
|
||||
s.edit(|d, _| {
|
||||
@@ -641,6 +644,7 @@ fn edit_char(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad("style.character.edit", "[None] can't be edited"));
|
||||
}
|
||||
let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
|
||||
super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.character.edit")?;
|
||||
// `null` unsets an attribute (the style no longer sets it).
|
||||
let unset: Vec<String> = p
|
||||
.get("chars")
|
||||
@@ -716,13 +720,18 @@ pub fn parse_color(v: &Value) -> Option<designcraft_color::Color> {
|
||||
use designcraft_color::Color;
|
||||
match v {
|
||||
Value::String(s) => Color::from_hex(s),
|
||||
// One scale per colour, not per component: {c: 0.7, y: 12.2} is C 0.7 %, Y 12.2 %. Only a colour whose
|
||||
// components are all within 0..1 is read as fractions (the older form).
|
||||
Value::Array(a) if a.len() >= 3 => {
|
||||
let f = |i: usize| a[i].as_f64().map(|x| if x > 1.0 { x / 255.0 } else { x } as f32);
|
||||
Some(Color::rgb(f(0)?, f(1)?, f(2)?))
|
||||
let v = [a[0].as_f64()?, a[1].as_f64()?, a[2].as_f64()?];
|
||||
let scale = if v.iter().any(|&x| x > 1.0) { 255.0 } else { 1.0 };
|
||||
Some(Color::rgb((v[0] / scale) as f32, (v[1] / scale) as f32, (v[2] / scale) as f32))
|
||||
}
|
||||
Value::Object(o) => {
|
||||
let g = |k: &str| o.get(k).and_then(Value::as_f64).map(|x| if x > 1.0 { x / 100.0 } else { x } as f32);
|
||||
Some(Color::cmyk(g("c")?, g("m")?, g("y")?, g("k")?))
|
||||
let g = |k: &str| o.get(k).and_then(Value::as_f64);
|
||||
let v = [g("c")?, g("m")?, g("y")?, g("k")?];
|
||||
let scale = if v.iter().any(|&x| x > 1.0) { 100.0 } else { 1.0 };
|
||||
Some(Color::cmyk((v[0] / scale) as f32, (v[1] / scale) as f32, (v[2] / scale) as f32, (v[3] / scale) as f32))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
@@ -835,7 +844,11 @@ fn save_swatches(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let bytes = designcraft_color::ase::write(&chosen);
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
std::fs::write(path, &bytes).map_err(|e| bad("swatch.save", format!("{path}: {e}")))?;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let written = crate::atomic::write_atomic(std::path::Path::new(path), &bytes);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let written = std::fs::write(path, &bytes);
|
||||
written.map_err(|e| bad("swatch.save", format!("{path}: {e}")))?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}
|
||||
None => Ok(json!({"base64": super::file::base64_encode(&bytes)})),
|
||||
@@ -1040,6 +1053,168 @@ fn rename_in(it: &mut designcraft_doc::Item, from: &str, to: &str) {
|
||||
mod color_tests {
|
||||
use super::*;
|
||||
|
||||
fn fill_session() -> Session {
|
||||
let mut session = Session::new();
|
||||
session.execute("file.new", &json!({})).unwrap();
|
||||
let frame = session.execute("frame.create", &json!({"rect": [0, 0, 200, 100], "content": "text", "text": "Hi"})).unwrap();
|
||||
session.execute("text.select", &json!({"story": frame["story"], "anchor": 0, "focus": 2})).unwrap();
|
||||
session.execute("style.paragraph.create", &json!({"name": "Existing"})).unwrap();
|
||||
session.execute("style.character.create", &json!({"name": "Existing"})).unwrap();
|
||||
session.execute("type.char", &json!({"size": 18})).unwrap();
|
||||
session.execute("edit.undo", &json!({})).unwrap();
|
||||
session
|
||||
}
|
||||
|
||||
fn assert_fill_rejected(session: &mut Session, command: &str, params: Value) {
|
||||
let before = session.doc().unwrap().clone();
|
||||
let journal = session.journal.clone();
|
||||
let fill = params
|
||||
.pointer("/chars/fill")
|
||||
.or_else(|| params.pointer("/attrs/fill"))
|
||||
.or_else(|| params.get("fill"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("Missing Ink");
|
||||
|
||||
let result = session.execute(command, ¶ms);
|
||||
|
||||
match result {
|
||||
Err(crate::EngineError::BadParams { cmd, msg }) => {
|
||||
assert_eq!(cmd, command);
|
||||
assert!(msg.contains(fill), "{msg}");
|
||||
assert!(msg.contains("swatch.create") && msg.contains("returned name"), "{msg}");
|
||||
}
|
||||
other => panic!("expected BadParams for {command}, got {other:?}"),
|
||||
}
|
||||
let after = session.doc().unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&before.doc, &after.doc));
|
||||
assert_eq!(before.selection, after.selection);
|
||||
assert_eq!(before.revision, after.revision);
|
||||
assert_eq!(format!("{:?}", before.history), format!("{:?}", after.history));
|
||||
assert_eq!(journal, session.journal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_fill_rejects_unknown_flat_and_nested_names_when_formatting() {
|
||||
for fill in ["Missing Ink", "#ff0000", "[black]", " [Black]"] {
|
||||
for params in [json!({"fill": fill, "size": 24}), json!({"attrs": {"fill": fill, "size": 24}})] {
|
||||
let mut session = fill_session();
|
||||
assert_fill_rejected(&mut session, "type.char", params);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_fill_rejects_unknown_names_when_creating_or_editing_styles() {
|
||||
for command in ["style.paragraph.create", "style.paragraph.edit", "style.character.create", "style.character.edit"] {
|
||||
for fill in ["Missing Ink", "#ff0000"] {
|
||||
let mut session = fill_session();
|
||||
session.execute("style.paragraph.apply", &json!({"name": "Existing"})).unwrap();
|
||||
session.execute("style.character.apply", &json!({"name": "Existing"})).unwrap();
|
||||
assert_fill_rejected(&mut session, command, json!({"name": "Existing", "rename": "Renamed", "chars": {"fill": fill, "size": 24}}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_fill_rejects_inherited_unknown_name_when_creating_from_selection() {
|
||||
let mut session = fill_session();
|
||||
session
|
||||
.edit(|document, selection| {
|
||||
let selected = selection.text.unwrap();
|
||||
document.story_mut(selected.story).unwrap().format_chars(selected.range(), |format| format.over.fill = Some("Missing Ink".into()));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_fill_rejected(&mut session, "style.paragraph.create", json!({"name": "Inherited", "fromSelection": true}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_fill_accepts_valid_override_when_selection_has_unknown_name() {
|
||||
let mut session = fill_session();
|
||||
session
|
||||
.edit(|document, selection| {
|
||||
let selected = selection.text.unwrap();
|
||||
document.story_mut(selected.story).unwrap().format_chars(selected.range(), |format| format.over.fill = Some("Missing Ink".into()));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
session.execute("style.paragraph.create", &json!({"name": "Override", "fromSelection": true, "chars": {"fill": "[Paper]"}})).unwrap();
|
||||
|
||||
assert_eq!(session.doc().unwrap().doc.styles.para("Override").unwrap().chars.fill.as_deref(), Some("[Paper]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_fill_rejects_unknown_name_when_changing_text_even_without_output() {
|
||||
for (find, change) in [("Hi", "Bye"), ("Hi", ""), ("Absent", "Bye")] {
|
||||
for fill in ["Missing Ink", "#ff0000"] {
|
||||
let mut session = fill_session();
|
||||
assert_fill_rejected(&mut session, "find.change", json!({"find": find, "change": change, "attrs": {"fill": fill}}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_fill_preserves_exact_registered_names_when_used_at_all_boundaries() {
|
||||
for fill in ["Brand Ink", "#ff0000", "[None]", "[Black]"] {
|
||||
let mut session = fill_session();
|
||||
if !fill.starts_with('[') {
|
||||
let created = session.execute("swatch.create", &json!({"name": fill, "color": "#108080"})).unwrap();
|
||||
assert_eq!(created["name"], fill);
|
||||
}
|
||||
for params in [json!({"fill": fill}), json!({"attrs": {"fill": fill}})] {
|
||||
session.execute("type.char", ¶ms).unwrap();
|
||||
assert_eq!(session.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], fill);
|
||||
}
|
||||
for kind in ["paragraph", "character"] {
|
||||
session.execute(&format!("style.{kind}.create"), &json!({"name": "Colored", "chars": {"fill": fill}})).unwrap();
|
||||
session.execute(&format!("style.{kind}.edit"), &json!({"name": "Existing", "chars": {"fill": fill}})).unwrap();
|
||||
let document = &session.doc().unwrap().doc;
|
||||
for name in ["Colored", "Existing"] {
|
||||
let attrs = if kind == "paragraph" {
|
||||
&document.styles.para(name).unwrap().chars
|
||||
} else {
|
||||
&document.styles.char_style(name).unwrap().chars
|
||||
};
|
||||
assert_eq!(attrs.fill.as_deref(), Some(fill));
|
||||
}
|
||||
}
|
||||
assert_eq!(session.execute("find.change", &json!({"find": "Hi", "change": "Bye", "attrs": {"fill": fill}})).unwrap()["count"], 1);
|
||||
let selected = session.doc().unwrap().selection.text.unwrap();
|
||||
let story = session.doc().unwrap().doc.story(selected.story).unwrap();
|
||||
assert_eq!(story.text, "Bye");
|
||||
assert_eq!(story.format_after(0).over.fill.as_deref(), Some(fill));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_fill_accepts_omitted_and_null_attributes_when_used_at_all_boundaries() {
|
||||
for chars in [json!({"size": 18}), json!({"fill": null})] {
|
||||
let mut session = fill_session();
|
||||
session.execute("type.char", &json!({"attrs": chars})).unwrap();
|
||||
for kind in ["paragraph", "character"] {
|
||||
session.execute(&format!("style.{kind}.create"), &json!({"name": "Nullable", "chars": chars})).unwrap();
|
||||
session.execute(&format!("style.{kind}.edit"), &json!({"name": "Existing", "chars": chars})).unwrap();
|
||||
}
|
||||
assert_eq!(session.execute("find.change", &json!({"find": "Hi", "change": "Bye", "attrs": chars})).unwrap()["count"], 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percent_components_at_most_one_stay_percent() {
|
||||
use designcraft_color::Color;
|
||||
// {"c": 0.7, ..., "y": 12.2} is a pale cream (C 0.7 %, Y 12.2 %), not C 70 %.
|
||||
let Some(Color::Cmyk { c, m, y, k }) = parse_color(&json!({"c": 0.7, "m": 0, "y": 12.2, "k": 0})) else { panic!("not CMYK") };
|
||||
assert!((c - 0.007).abs() < 1e-6 && m == 0.0 && (y - 0.122).abs() < 1e-6 && k == 0.0, "got {c} {m} {y} {k}");
|
||||
// Fractions (every component within 0..1) keep working.
|
||||
let Some(Color::Cmyk { c, k, .. }) = parse_color(&json!({"c": 0.5, "m": 0, "y": 0, "k": 1})) else { panic!("not CMYK") };
|
||||
assert!((c - 0.5).abs() < 1e-6 && (k - 1.0).abs() < 1e-6);
|
||||
// RGB arrays follow the same rule: [1, 128, 255] is 0..255.
|
||||
let Some(Color::Rgb { r, g, b }) = parse_color(&json!([1, 128, 255])) else { panic!("not RGB") };
|
||||
assert!((r - 1.0 / 255.0).abs() < 1e-6 && (g - 128.0 / 255.0).abs() < 1e-6 && (b - 1.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn style_groups_rename_every_use() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -1402,20 +1402,10 @@ fn place_graphic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (pw, ph) = designcraft_render::image_size(&bytes).ok_or_else(|| bad(ID, "unsupported or corrupt image"))?;
|
||||
let fill = p.get("fit").and_then(Value::as_str) == Some("fill");
|
||||
s.edit(|d, sel| {
|
||||
let aid = designcraft_doc::AssetId(d.alloc());
|
||||
let fresh = designcraft_doc::AssetId(d.alloc());
|
||||
let mime = designcraft_render::image_mime(&bytes).to_string();
|
||||
d.assets.insert(
|
||||
aid,
|
||||
std::sync::Arc::new(designcraft_doc::Asset {
|
||||
page: 0,
|
||||
id: aid,
|
||||
name,
|
||||
mime,
|
||||
link,
|
||||
data: std::sync::Arc::new(bytes),
|
||||
pixels: Some((pw, ph)),
|
||||
}),
|
||||
);
|
||||
let aid =
|
||||
d.add_asset(designcraft_doc::Asset { page: 0, id: fresh, name, mime, link, data: std::sync::Arc::new(bytes), pixels: Some((pw, ph)) });
|
||||
let st = d.story_mut(g.story).ok_or_else(|| bad(ID, "no story"))?;
|
||||
let t = st.table_mut(g.table).ok_or_else(|| bad(ID, "no table"))?;
|
||||
let (r, c) = t.owner(g.range.r0, g.range.c0);
|
||||
|
||||
@@ -819,6 +819,18 @@ fn warichu_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
format_chars(s, &Value::Object(body))
|
||||
}
|
||||
|
||||
pub(crate) fn validate_char_fill(document: &designcraft_doc::Document, attrs: &CharAttrs, command_id: &str) -> Result<()> {
|
||||
if let Some(fill) = &attrs.fill
|
||||
&& document.swatch(fill).is_none()
|
||||
{
|
||||
return Err(bad(
|
||||
command_id,
|
||||
format!("unknown character fill `{fill}`; create a swatch with `swatch.create` and use its returned name as `fill`"),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result<Value> {
|
||||
let mut a = CharAttrs::default();
|
||||
if let Some(o) = attrs.as_object() {
|
||||
@@ -826,6 +838,7 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result<Value> {
|
||||
a.set_json(k, v).map_err(|e| bad("type.char", e))?;
|
||||
}
|
||||
}
|
||||
validate_char_fill(&s.doc()?.doc, &a, "type.char")?;
|
||||
let cleared: Vec<String> = 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);
|
||||
s.edit(|d, _| {
|
||||
|
||||
@@ -129,6 +129,63 @@ fn define(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 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.
|
||||
#[test]
|
||||
fn parent_page_number_follows_section_renumbering() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap();
|
||||
let marker = designcraft_doc::story::PAGE_NUMBER.to_string();
|
||||
let r = s
|
||||
.execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "content": "text", "text": marker}))
|
||||
.unwrap();
|
||||
let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
|
||||
assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, marker);
|
||||
let shown = |s: &Session, page: usize| -> usize {
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let cs = s.cache.get(&d, sid, Some(page));
|
||||
cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum()
|
||||
};
|
||||
assert_eq!(shown(&s, 2), 1, "page 3");
|
||||
s.execute("layout.section", &json!({"page": 1, "startNumber": 100})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.page_name(2), "102");
|
||||
assert_eq!(shown(&s, 2), 3, "page 102 after renumbering");
|
||||
}
|
||||
|
||||
/// Two sections that both number from 1 repeat page names ("1, 2, 1, 2"). A parent-page running
|
||||
/// header is composed for the page it is shown on, not the first page with that name.
|
||||
#[test]
|
||||
fn running_header_on_parent_page_with_repeated_page_numbers() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 4, "facingPages": false})).unwrap();
|
||||
s.execute("layout.section", &json!({"page": 3, "startNumber": 1})).unwrap();
|
||||
let names: Vec<String> = (0..4).map(|i| s.doc().unwrap().doc.page_name(i)).collect();
|
||||
assert_eq!(names, ["1", "2", "1", "2"]);
|
||||
s.execute("style.paragraph.create", &json!({"name": "Heading"})).ok();
|
||||
// The only heading is on the third page (the second section's page 1).
|
||||
let sp = s.doc().unwrap().doc.page_loc(2).unwrap().0;
|
||||
let r = s.execute("frame.create", &json!({"spread": sp, "rect": [72, 200, 400, 260], "content": "text", "text": "Epsilon"})).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": 1})).unwrap();
|
||||
s.execute("style.paragraph.apply", &json!({"name": "Heading"})).unwrap();
|
||||
s.execute("variables.define", &json!({"name": "Chapter Head", "type": "runningHeader", "style": "Heading"})).unwrap();
|
||||
let r = s
|
||||
.execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "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": "Chapter Head"})).unwrap();
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let shown = |page: usize| -> usize {
|
||||
let cs = s.cache.get(&d, sid, Some(page));
|
||||
cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum()
|
||||
};
|
||||
assert_eq!(shown(0), 0, "no heading on or before the first page");
|
||||
assert_eq!(shown(2), "Epsilon".len(), "the third page (named 1) shows its own heading");
|
||||
assert_eq!(shown(3), "Epsilon".len(), "the fourth page (named 2) carries it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_header_on_parent_page_follows_headings() {
|
||||
let mut s = Session::new();
|
||||
@@ -158,7 +215,7 @@ mod tests {
|
||||
assert!(matches!(d.find(fid).unwrap().spread, designcraft_doc::SpreadRef::Parent(0)));
|
||||
// Visible glyphs of the header composed for each page: "Alpha · 3" vs "Epsilon · 3".
|
||||
let shown = |page: usize| -> usize {
|
||||
let cs = s.cache.get(&d, sid, Some(&d.page_name(page)));
|
||||
let cs = s.cache.get(&d, sid, Some(page));
|
||||
cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum()
|
||||
};
|
||||
let (p1, p2, p3) = (shown(0), shown(1), shown(2));
|
||||
|
||||
@@ -305,7 +305,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::fs::write(path, text.as_bytes()).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?;
|
||||
crate::atomic::write_file(path, text.as_bytes())?;
|
||||
Ok(json!({"path": path, "bytes": text.len()}))
|
||||
}
|
||||
None => Ok(json!({"text": text, "bytes": text.len()})),
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod atomic;
|
||||
pub mod cmd;
|
||||
pub mod dtd;
|
||||
pub mod guard;
|
||||
@@ -91,6 +93,9 @@ pub struct DocState {
|
||||
/// Editing parent spreads (Pages panel double-click on a parent).
|
||||
pub editing_parents: bool,
|
||||
pub uid: u64,
|
||||
/// Recovery entries this state actually opened or published, for repeated-open and cleanup.
|
||||
/// Bounded, deduplicated checkpoint addresses; never inferred from uid alone.
|
||||
pub(crate) recovery_entries: Vec<(std::path::PathBuf, u64)>,
|
||||
/// Unfilled document while data-merge preview is showing a record. Not saved.
|
||||
pub preview_stash: Option<Arc<Document>>,
|
||||
/// 1-based record currently previewed.
|
||||
@@ -129,6 +134,7 @@ impl DocState {
|
||||
interaction: None,
|
||||
editing_parents: false,
|
||||
uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||
recovery_entries: Vec::new(),
|
||||
preview_stash: None,
|
||||
preview_record: None,
|
||||
fonts: None,
|
||||
@@ -374,8 +380,9 @@ impl Session {
|
||||
self.stop_data_preview();
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let (Some(dir), Some(d)) = (&self.recovery_dir, self.docs.get(i)) {
|
||||
recovery::discard(dir, d.uid);
|
||||
{
|
||||
let recovery_dir = self.recovery_dir.clone();
|
||||
recovery::discard_document(self, i, recovery_dir.as_deref());
|
||||
}
|
||||
if i < self.docs.len() {
|
||||
self.docs.remove(i);
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
//! ArtCraft community and project links (Help menu, About, start screen, CLI, MCP).
|
||||
//! Project links (Help menu, About, CLI, MCP).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// The app's slug on getartcraft.com and GitHub.
|
||||
/// The app's slug on GitHub.
|
||||
pub const APP: &str = "designcraft";
|
||||
/// The ArtCraft community Discord.
|
||||
pub const DISCORD: &str = "https://discord.gg/artcraft";
|
||||
/// The ArtCraft website.
|
||||
pub const WEBSITE: &str = "https://getartcraft.com";
|
||||
/// DesignCraft's page on the ArtCraft website.
|
||||
pub const APP_PAGE: &str = "https://getartcraft.com/apps/designcraft";
|
||||
/// DesignCraft's source repository.
|
||||
pub const GITHUB: &str = "https://github.com/storytold/designcraft";
|
||||
/// Where to report bugs and request features.
|
||||
@@ -18,20 +12,14 @@ pub const ISSUES: &str = "https://github.com/storytold/designcraft/issues";
|
||||
/// All links as JSON (the `app.links` command).
|
||||
pub fn all() -> Value {
|
||||
json!({
|
||||
"discord": DISCORD,
|
||||
"website": WEBSITE,
|
||||
"appPage": APP_PAGE,
|
||||
"github": GITHUB,
|
||||
"issues": ISSUES,
|
||||
})
|
||||
}
|
||||
|
||||
/// A link by key (`discord`, `website`, `appPage`, `github`, `issues`).
|
||||
/// A link by key (`github`, `issues`).
|
||||
pub fn get(key: &str) -> Option<&'static str> {
|
||||
Some(match key {
|
||||
"discord" => DISCORD,
|
||||
"website" => WEBSITE,
|
||||
"appPage" => APP_PAGE,
|
||||
"github" => GITHUB,
|
||||
"issues" => ISSUES,
|
||||
_ => return None,
|
||||
@@ -44,10 +32,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn links_follow_the_app_slug() {
|
||||
assert!(APP_PAGE.ends_with(&format!("/apps/{APP}")));
|
||||
assert!(GITHUB.ends_with(&format!("/storytold/{APP}")));
|
||||
assert!(ISSUES.starts_with(GITHUB));
|
||||
for k in ["discord", "website", "appPage", "github", "issues"] {
|
||||
for k in ["github", "issues"] {
|
||||
assert!(get(k).is_some_and(|u| u.starts_with("https://")), "{k}");
|
||||
assert_eq!(all()[k], get(k).unwrap());
|
||||
}
|
||||
|
||||
@@ -284,7 +284,15 @@ fn place_gun_click_drag_and_into_frame() {
|
||||
let f = s.execute("frame.create", &json!({"rect": [300, 300, 400, 400], "content": "graphic"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("place.load", &json!({"base64": b64})).unwrap();
|
||||
s.execute("place.drop", &json!({"frame": f})).unwrap();
|
||||
assert!(matches!(s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().content, designcraft_doc::Content::Graphic(_)));
|
||||
let graphic_xf = |s: &Session| match &s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().content {
|
||||
designcraft_doc::Content::Graphic(g) => g.xf,
|
||||
_ => panic!("not a graphic"),
|
||||
};
|
||||
let placed = graphic_xf(&s);
|
||||
// Dragging the top edge down crops the top only: the image stays where it is.
|
||||
s.execute("selection.set", &json!({"ids": [f]})).unwrap();
|
||||
s.execute("transform.resize", &json!({"from": [300, 300, 400, 400], "to": [300, 340, 400, 400]})).unwrap();
|
||||
assert_eq!(graphic_xf(&s), placed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use designcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
fn dir(name: &str) -> tempfile::TempDir {
|
||||
tempfile::Builder::new().prefix(&format!("dc-book-safety-{name}-")).tempdir().unwrap()
|
||||
}
|
||||
fn document(start: u32, custom: bool) -> designcraft_doc::Document {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages":2})).unwrap();
|
||||
let mut d = (*s.doc().unwrap().doc).clone();
|
||||
d.sections.first_mut().unwrap().start_number = Some(start);
|
||||
if custom {
|
||||
let mut style = d.styles.paragraph.get(1).unwrap().clone();
|
||||
style.name = "Synthetic Book Style".into();
|
||||
d.styles_mut().paragraph.push(style);
|
||||
}
|
||||
d
|
||||
}
|
||||
fn native(p: &Path, start: u32, custom: bool) {
|
||||
std::fs::write(p, designcraft_format::save(&document(start, custom)).unwrap()).unwrap();
|
||||
}
|
||||
fn idml(p: &Path, start: u32) {
|
||||
std::fs::write(p, designcraft_idml::export_idml(&document(start, false))).unwrap();
|
||||
assert!(designcraft_idml::import_idml(&std::fs::read(p).unwrap()).is_ok());
|
||||
}
|
||||
fn book(d: &Path, chapters: &[&Path]) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("book.new", &json!({"path":d.join("book.dcbook").to_string_lossy()})).unwrap();
|
||||
for p in chapters {
|
||||
s.execute("book.add", &json!({"path":p.to_string_lossy()})).unwrap();
|
||||
}
|
||||
s
|
||||
}
|
||||
#[test]
|
||||
fn late_missing_chapter_does_not_partially_paginate_earlier_chapters() {
|
||||
let d = dir("late-missing");
|
||||
let first = d.path().join("first.designcraft");
|
||||
let later = d.path().join("later.designcraft");
|
||||
native(&first, 9, false);
|
||||
native(&later, 1, false);
|
||||
let mut s = book(d.path(), &[&first, &later]);
|
||||
let before = std::fs::read(&first).unwrap();
|
||||
std::fs::remove_file(later).unwrap();
|
||||
let result = s.execute("book.paginate", &json!({}));
|
||||
assert!(result.is_err());
|
||||
let after = std::fs::read(&first).unwrap();
|
||||
assert!(
|
||||
before == after,
|
||||
"book.paginate changed the first chapter before error: {result:?}; new first number {:?}",
|
||||
designcraft_format::load(&after).unwrap().sections.first().unwrap().start_number
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn late_corrupt_chapter_does_not_partially_sync_earlier_chapters() {
|
||||
let d = dir("late-corrupt");
|
||||
let source = d.path().join("source.designcraft");
|
||||
let first = d.path().join("first.designcraft");
|
||||
let later = d.path().join("later.designcraft");
|
||||
native(&source, 1, true);
|
||||
native(&first, 1, false);
|
||||
native(&later, 1, false);
|
||||
let mut s = book(d.path(), &[&source, &first, &later]);
|
||||
let before = std::fs::read(&first).unwrap();
|
||||
std::fs::write(later, b"not a document").unwrap();
|
||||
let result = s.execute("book.syncStyles", &json!({}));
|
||||
assert!(result.is_err());
|
||||
let after = std::fs::read(&first).unwrap();
|
||||
assert!(
|
||||
before == after,
|
||||
"book.syncStyles changed the first target before error: {result:?}; copied custom style {}",
|
||||
designcraft_format::load(&after).unwrap().styles.paragraph.iter().any(|s| s.name == "Synthetic Book Style")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn paginate_does_not_replace_imported_idml_with_native_format() {
|
||||
let d = dir("idml-paginate");
|
||||
let chapter = d.path().join("chapter.idml");
|
||||
idml(&chapter, 9);
|
||||
let mut s = book(d.path(), &[&chapter]);
|
||||
let before = std::fs::read(&chapter).unwrap();
|
||||
let result = s.execute("book.paginate", &json!({}));
|
||||
let after = std::fs::read(&chapter).unwrap();
|
||||
assert!(
|
||||
before == after,
|
||||
"book.paginate rewrote imported IDML: {result:?}; IDML readable after {}, native readable after {}",
|
||||
designcraft_idml::import_idml(&after).is_ok(),
|
||||
designcraft_format::load(&after).is_ok()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn style_sync_does_not_replace_imported_idml_with_native_format() {
|
||||
let d = dir("idml-sync");
|
||||
let source = d.path().join("source.designcraft");
|
||||
let chapter = d.path().join("chapter.idml");
|
||||
native(&source, 1, true);
|
||||
idml(&chapter, 1);
|
||||
let mut s = book(d.path(), &[&source, &chapter]);
|
||||
let before = std::fs::read(&chapter).unwrap();
|
||||
let result = s.execute("book.syncStyles", &json!({}));
|
||||
let after = std::fs::read(&chapter).unwrap();
|
||||
assert!(
|
||||
before == after,
|
||||
"book.syncStyles rewrote imported IDML: {result:?}; IDML readable after {}, native readable after {}",
|
||||
designcraft_idml::import_idml(&after).is_ok(),
|
||||
designcraft_format::load(&after).is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_open_unsaved_chapter_blocks_batch_writes_before_any_files_change() {
|
||||
let d = dir("open-unsaved");
|
||||
let first = d.path().join("first.designcraft");
|
||||
let later = d.path().join("later.designcraft");
|
||||
native(&first, 9, false);
|
||||
native(&later, 5, false);
|
||||
let mut s = book(d.path(), &[&first, &later]);
|
||||
s.execute("file.open", &json!({"path":later.to_string_lossy()})).unwrap();
|
||||
s.execute("frame.create", &json!({"rect":[36,36,200,100],"content":"text","text":"Unsaved chapter edit"})).unwrap();
|
||||
let first_before = std::fs::read(&first).unwrap();
|
||||
let later_before = std::fs::read(&later).unwrap();
|
||||
let revision = s.doc().unwrap().revision;
|
||||
let result = s.execute("book.paginate", &json!({}));
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"book.paginate accepted an open dirty chapter: {result:?}; first changed {}, open backing file changed {}",
|
||||
std::fs::read(&first).unwrap() != first_before,
|
||||
std::fs::read(&later).unwrap() != later_before
|
||||
);
|
||||
assert_eq!(std::fs::read(&first).unwrap(), first_before);
|
||||
assert_eq!(std::fs::read(&later).unwrap(), later_before);
|
||||
assert_eq!(s.doc().unwrap().revision, revision);
|
||||
assert!(s.doc().unwrap().is_dirty());
|
||||
let e = result.unwrap_err().to_string();
|
||||
assert!(e.contains("later.designcraft") && e.contains("close"), "{e}");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn an_alias_of_an_open_chapter_blocks_all_batch_writes() {
|
||||
let d = dir("open-alias");
|
||||
let first = d.path().join("first.designcraft");
|
||||
let real = d.path().join("real.designcraft");
|
||||
let alias = d.path().join("alias.designcraft");
|
||||
native(&first, 9, false);
|
||||
native(&real, 5, false);
|
||||
std::os::unix::fs::symlink("real.designcraft", &alias).unwrap();
|
||||
let mut s = book(d.path(), &[&first, &alias]);
|
||||
s.execute("file.open", &json!({"path":real.to_string_lossy()})).unwrap();
|
||||
let first_before = std::fs::read(&first).unwrap();
|
||||
let real_before = std::fs::read(&real).unwrap();
|
||||
let result = s.execute("book.paginate", &json!({}));
|
||||
assert!(result.is_err(), "book.paginate accepted alias of open chapter: {result:?}");
|
||||
assert_eq!(std::fs::read(&first).unwrap(), first_before);
|
||||
assert_eq!(std::fs::read(&real).unwrap(), real_before);
|
||||
let e = result.unwrap_err().to_string();
|
||||
assert!(e.contains("alias.designcraft") && e.contains("close"), "{e}");
|
||||
assert!(std::fs::symlink_metadata(alias).unwrap().file_type().is_symlink());
|
||||
}
|
||||
#[test]
|
||||
fn imported_idml_remains_a_read_only_style_source() {
|
||||
let d = dir("idml-style-source");
|
||||
let source = d.path().join("source.idml");
|
||||
let target = d.path().join("target.designcraft");
|
||||
std::fs::write(&source, designcraft_idml::export_idml(&document(1, true))).unwrap();
|
||||
native(&target, 1, false);
|
||||
let mut s = book(d.path(), &[&source, &target]);
|
||||
let before = std::fs::read(&source).unwrap();
|
||||
let result = s.execute("book.syncStyles", &json!({})).unwrap();
|
||||
assert_eq!(result["synced"], 1);
|
||||
assert_eq!(std::fs::read(&source).unwrap(), before);
|
||||
assert!(designcraft_idml::import_idml(&before).is_ok());
|
||||
let target = designcraft_format::load(&std::fs::read(target).unwrap()).unwrap();
|
||||
assert!(target.styles.paragraph.iter().any(|s| s.name == "Synthetic Book Style"));
|
||||
}
|
||||
#[test]
|
||||
fn imported_idml_still_exports_to_book_pdf_without_source_changes() {
|
||||
let d = dir("idml-pdf");
|
||||
let chapter = d.path().join("chapter.idml");
|
||||
idml(&chapter, 1);
|
||||
let mut s = book(d.path(), &[&chapter]);
|
||||
let before = std::fs::read(&chapter).unwrap();
|
||||
let result = s.execute("book.exportPdf", &json!({})).unwrap();
|
||||
assert_eq!(result["pages"], 2);
|
||||
assert!(result["base64"].as_str().is_some_and(|s| !s.is_empty()));
|
||||
assert_eq!(std::fs::read(chapter).unwrap(), before);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use designcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn library_failure(command: &str) {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let folder = temp.path().join("library");
|
||||
std::fs::create_dir(&folder).unwrap();
|
||||
let file = folder.join("items.dclib");
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap();
|
||||
s.execute("library.new", &json!({"path": file})).unwrap();
|
||||
s.execute("library.add", &json!({"name": "Keep"})).unwrap();
|
||||
let before = serde_json::to_value(&s.library).unwrap();
|
||||
let path = s.library.as_ref().unwrap().path.clone();
|
||||
let journal = s.journal.clone();
|
||||
let original_bytes = std::fs::read(&file).unwrap();
|
||||
let kept = temp.path().join("kept-library");
|
||||
std::fs::rename(&folder, &kept).unwrap();
|
||||
|
||||
assert!(s.execute(command, &json!({"name": "New", "index": 0})).is_err());
|
||||
|
||||
assert_eq!(before, serde_json::to_value(&s.library).unwrap(), "a failed library write must retain its original items");
|
||||
assert_eq!(path, s.library.as_ref().unwrap().path);
|
||||
assert_eq!(journal, s.journal);
|
||||
assert_eq!(original_bytes, std::fs::read(kept.join("items.dclib")).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_library_add_keeps_existing_items() {
|
||||
library_failure("library.add");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_library_remove_keeps_existing_items() {
|
||||
library_failure("library.remove");
|
||||
}
|
||||
|
||||
fn book_failure(command: &str) {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let mut s = Session::new();
|
||||
let mut chapters = Vec::new();
|
||||
for i in 0..3 {
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let path = temp.path().join(format!("chapter-{i}.designcraft"));
|
||||
s.execute("file.saveAs", &json!({"path": path})).unwrap();
|
||||
chapters.push(path);
|
||||
}
|
||||
let folder = temp.path().join("book");
|
||||
std::fs::create_dir(&folder).unwrap();
|
||||
let file = folder.join("chapters.dcbook");
|
||||
s.execute("book.new", &json!({"path": file})).unwrap();
|
||||
s.execute("book.add", &json!({"path": chapters[0]})).unwrap();
|
||||
s.execute("book.add", &json!({"path": chapters[1]})).unwrap();
|
||||
s.execute("book.styleSource", &json!({"index": 1})).unwrap();
|
||||
let before = serde_json::to_value(&s.book).unwrap();
|
||||
let path = s.book.as_ref().unwrap().path.clone();
|
||||
let journal = s.journal.clone();
|
||||
let original_bytes = std::fs::read(&file).unwrap();
|
||||
let kept = temp.path().join("kept-book");
|
||||
std::fs::rename(&folder, &kept).unwrap();
|
||||
let params = if command == "book.add" { json!({"path": chapters[2]}) } else { json!({"index": 0}) };
|
||||
|
||||
assert!(s.execute(command, ¶ms).is_err());
|
||||
|
||||
assert_eq!(before, serde_json::to_value(&s.book).unwrap(), "a failed book write must retain its original chapter list and style source");
|
||||
assert_eq!(path, s.book.as_ref().unwrap().path);
|
||||
assert_eq!(journal, s.journal);
|
||||
assert_eq!(original_bytes, std::fs::read(kept.join("chapters.dcbook")).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_book_add_keeps_existing_chapters() {
|
||||
book_failure("book.add");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_book_remove_keeps_existing_chapters() {
|
||||
book_failure("book.remove");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_book_style_source_keeps_existing_source() {
|
||||
book_failure("book.styleSource");
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use designcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn linked_document(root: &Path) -> (Session, std::path::PathBuf) {
|
||||
let source = root.join("source/contacts.csv");
|
||||
std::fs::create_dir_all(source.parent().unwrap()).unwrap();
|
||||
std::fs::write(&source, "Name\nAda\n").unwrap();
|
||||
let original = root.join("original/template.designcraft");
|
||||
std::fs::create_dir_all(original.parent().unwrap()).unwrap();
|
||||
let mut session = Session::new();
|
||||
session.execute("file.new", &json!({})).unwrap();
|
||||
session.execute("data.source.select", &json!({"path": source})).unwrap();
|
||||
session.execute("file.saveAs", &json!({"path": original})).unwrap();
|
||||
(session, original)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_save_as_preserves_document_saved_state_history_and_original_file() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (mut session, original) = linked_document(temp.path());
|
||||
session.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap();
|
||||
session.execute("edit.undo", &json!({})).unwrap();
|
||||
let before = session.doc().unwrap().clone();
|
||||
let journal = session.journal.clone();
|
||||
let original_bytes = std::fs::read(&original).unwrap();
|
||||
let destination = temp.path().join("missing/elsewhere/template.designcraft");
|
||||
|
||||
assert!(session.execute("file.saveAs", &json!({"path": destination})).is_err());
|
||||
|
||||
let after = session.doc().unwrap();
|
||||
assert!(Arc::ptr_eq(&before.doc, &after.doc), "failed Save As must not change data-source paths");
|
||||
assert!(Arc::ptr_eq(&before.saved_doc, &after.saved_doc));
|
||||
assert_eq!(before.path, after.path);
|
||||
assert_eq!((before.revision, before.saved_revision, before.is_dirty()), (after.revision, after.saved_revision, after.is_dirty()));
|
||||
assert_eq!(before.doc.data_merge, after.doc.data_merge);
|
||||
assert_eq!(before.history.undo.len(), after.history.undo.len());
|
||||
assert_eq!(before.history.redo.len(), after.history.redo.len());
|
||||
assert_eq!(serde_json::to_value(&before.selection).unwrap(), serde_json::to_value(&after.selection).unwrap());
|
||||
assert_eq!(journal, session.journal);
|
||||
assert_eq!(original_bytes, std::fs::read(original).unwrap());
|
||||
assert!(!destination.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_a_copy_rebases_its_data_source_without_changing_the_open_document() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (mut session, original) = linked_document(temp.path());
|
||||
let before = session.doc().unwrap().clone();
|
||||
let original_bytes = std::fs::read(&original).unwrap();
|
||||
let destination = temp.path().join("copies/nested/template.designcraft");
|
||||
std::fs::create_dir_all(destination.parent().unwrap()).unwrap();
|
||||
|
||||
session.execute("file.saveACopy", &json!({"path": destination})).unwrap();
|
||||
|
||||
let copy = designcraft_format::load(&std::fs::read(destination).unwrap()).unwrap();
|
||||
assert_eq!(copy.data_merge.sources[0].relative_path.as_deref(), Some("../../source/contacts.csv"));
|
||||
let after = session.doc().unwrap();
|
||||
assert!(Arc::ptr_eq(&before.doc, &after.doc));
|
||||
assert!(Arc::ptr_eq(&before.saved_doc, &after.saved_doc));
|
||||
assert_eq!(before.path, after.path);
|
||||
assert_eq!((before.revision, before.saved_revision, before.is_dirty()), (after.revision, after.saved_revision, after.is_dirty()));
|
||||
assert_eq!(original_bytes, std::fs::read(original).unwrap());
|
||||
}
|
||||
@@ -2,14 +2,15 @@
|
||||
//!
|
||||
//! - `mimetype` (stored first, uncompressed): `application/vnd.designcraft+zip`
|
||||
//! - `document.json`: the [`Document`] (pretty JSON, documented by the serde model in `designcraft-doc`)
|
||||
//! - `assets/<id>.<ext>`: embedded placed files, byte-for-byte
|
||||
//! - `assets/<id>.<ext>`: embedded placed files, byte-for-byte: one per distinct file, and only
|
||||
//! the ones the document references (see [`Document::compact_assets`])
|
||||
//! - `meta.json`: format version and generator
|
||||
//!
|
||||
//! Older single-file JSON documents (assets as base64 in `assetData`) still open.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -42,8 +43,12 @@ fn ext_for(mime: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize a document to `.designcraft` bytes.
|
||||
/// Serialize a document to `.designcraft` bytes. The file holds each distinct placed file once
|
||||
/// and leaves out assets nothing references; `doc` itself keeps them (undo can bring them back).
|
||||
pub fn save(doc: &Document) -> Result<Vec<u8>, FormatError> {
|
||||
let mut compact = doc.clone();
|
||||
compact.compact_assets();
|
||||
let doc = &compact;
|
||||
let io = |e: zip::result::ZipError| FormatError::Io(e.to_string());
|
||||
let mut buf = Cursor::new(Vec::new());
|
||||
{
|
||||
@@ -100,13 +105,26 @@ fn load_zip(bytes: &[u8]) -> Result<Document, FormatError> {
|
||||
upgrade(&mut v);
|
||||
let mut doc: Document = serde_json::from_value(v).map_err(|e| FormatError::NotOurs(e.to_string()))?;
|
||||
let names: Vec<String> = z.file_names().map(str::to_string).collect();
|
||||
// Files saved with one copy per placement: identical copies share one buffer as they are
|
||||
// read, so memory holds each distinct file once.
|
||||
let mut loaded: HashMap<usize, Vec<Arc<Vec<u8>>>> = HashMap::new();
|
||||
for name in names {
|
||||
let Some(stem) = name.strip_prefix("assets/") else { continue };
|
||||
let Some(id) = stem.split('.').next().and_then(|s| s.parse::<u64>().ok()) else { continue };
|
||||
if let (Some(data), Some(a)) = (read(&mut z, &name), doc.assets.get_mut(&AssetId(id))) {
|
||||
Arc::make_mut(a).data = Arc::new(data);
|
||||
let same = loaded.entry(data.len()).or_default();
|
||||
let data = match same.iter().find(|d| ***d == data) {
|
||||
Some(d) => d.clone(),
|
||||
None => {
|
||||
let d = Arc::new(data);
|
||||
same.push(d.clone());
|
||||
d
|
||||
}
|
||||
};
|
||||
Arc::make_mut(a).data = data;
|
||||
}
|
||||
}
|
||||
doc.merge_duplicate_assets();
|
||||
doc.check().map_err(|e| FormatError::NotOurs(e.to_string()))?;
|
||||
Ok(doc)
|
||||
}
|
||||
@@ -124,6 +142,7 @@ fn load_legacy_json(bytes: &[u8]) -> Result<Document, FormatError> {
|
||||
}
|
||||
}
|
||||
}
|
||||
doc.merge_duplicate_assets();
|
||||
doc.check().map_err(|e| FormatError::NotOurs(e.to_string()))?;
|
||||
Ok(doc)
|
||||
}
|
||||
@@ -205,24 +224,67 @@ mod tests {
|
||||
use designcraft_doc::geom::Rect;
|
||||
use designcraft_doc::{Asset, ParaFormat, SpreadRef};
|
||||
|
||||
fn png_asset(d: &mut Document, data: &[u8]) -> AssetId {
|
||||
let aid = AssetId(d.alloc());
|
||||
let a = Asset {
|
||||
page: 0,
|
||||
id: aid,
|
||||
name: "x.png".into(),
|
||||
mime: "image/png".into(),
|
||||
link: None,
|
||||
data: Arc::new(data.to_vec()),
|
||||
pixels: Some((1, 1)),
|
||||
};
|
||||
d.assets.insert(aid, Arc::new(a));
|
||||
aid
|
||||
}
|
||||
|
||||
fn place(d: &mut Document, asset: AssetId, crop: f64) -> designcraft_doc::ItemId {
|
||||
use designcraft_doc::geom::{Affine, shapes};
|
||||
use designcraft_doc::{Content, Graphic, Item, ItemId, Shape};
|
||||
let id = ItemId(d.alloc());
|
||||
let mut it = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)));
|
||||
it.content = Content::Graphic(Graphic {
|
||||
asset,
|
||||
size: (1.0, 1.0),
|
||||
xf: Affine::translate((crop, crop)),
|
||||
auto_fit: Default::default(),
|
||||
fit_align: 4,
|
||||
crop: [crop; 4],
|
||||
});
|
||||
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn asset_entries(bytes: &[u8]) -> usize {
|
||||
let z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||
z.file_names().filter(|n| n.starts_with("assets/")).count()
|
||||
}
|
||||
|
||||
/// Zip `d` the way files were saved with one asset per placement (nothing merged or pruned).
|
||||
fn save_uncompacted(d: &Document) -> Vec<u8> {
|
||||
let mut buf = Cursor::new(Vec::new());
|
||||
let mut z = zip::ZipWriter::new(&mut buf);
|
||||
let opts = zip::write::SimpleFileOptions::default();
|
||||
z.start_file("mimetype", opts).unwrap();
|
||||
z.write_all(MIME.as_bytes()).unwrap();
|
||||
z.start_file("document.json", opts).unwrap();
|
||||
z.write_all(&serde_json::to_vec(d).unwrap()).unwrap();
|
||||
for (id, a) in &d.assets {
|
||||
z.start_file(format!("assets/{}.png", id.0), opts).unwrap();
|
||||
z.write_all(&a.data).unwrap();
|
||||
}
|
||||
z.finish().unwrap();
|
||||
buf.into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_with_assets() {
|
||||
let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() });
|
||||
let lid = d.default_layer();
|
||||
d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 200.0, 200.0), lid, "héllo\nworld", ParaFormat::default()).unwrap();
|
||||
let aid = AssetId(d.alloc());
|
||||
d.assets.insert(
|
||||
aid,
|
||||
Arc::new(Asset {
|
||||
page: 0,
|
||||
id: aid,
|
||||
name: "x.png".into(),
|
||||
mime: "image/png".into(),
|
||||
link: None,
|
||||
data: Arc::new(vec![1, 2, 3, 4]),
|
||||
pixels: Some((1, 1)),
|
||||
}),
|
||||
);
|
||||
let aid = png_asset(&mut d, &[1, 2, 3, 4]);
|
||||
place(&mut d, aid, 0.0);
|
||||
let bytes = save(&d).unwrap();
|
||||
assert!(bytes.starts_with(b"PK"));
|
||||
let back = load(&bytes).unwrap();
|
||||
@@ -231,6 +293,63 @@ mod tests {
|
||||
assert_eq!(back.stories, d.stories);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saves_hold_each_used_file_once() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let first = png_asset(&mut d, &[7; 64]);
|
||||
let copy = png_asset(&mut d, &[7; 64]);
|
||||
let replaced = png_asset(&mut d, &[9; 64]);
|
||||
let a = place(&mut d, first, 1.0);
|
||||
let b = place(&mut d, copy, 2.0);
|
||||
assert_eq!(d.assets.len(), 3);
|
||||
|
||||
// Duplicates merge into the first asset, the unreferenced one is left out, and every
|
||||
// placement keeps its own crop and transform.
|
||||
let bytes = save(&d).unwrap();
|
||||
assert_eq!(asset_entries(&bytes), 1);
|
||||
assert_eq!(d.assets.len(), 3, "the open document keeps its assets");
|
||||
let back = load(&bytes).unwrap();
|
||||
assert_eq!(back.assets.keys().copied().collect::<Vec<_>>(), vec![first]);
|
||||
assert!(!back.assets.contains_key(&replaced));
|
||||
for (id, crop) in [(a, 1.0), (b, 2.0)] {
|
||||
let g = back.item(id).and_then(|i| i.graphic()).unwrap();
|
||||
assert_eq!((g.asset, g.crop, g.xf), (first, [crop; 4], d.item(id).unwrap().graphic().unwrap().xf));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_with_identical_bytes_at_different_paths_keep_their_paths() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let ids: Vec<AssetId> = (0..3).map(|_| png_asset(&mut d, &[3; 32])).collect();
|
||||
for (id, link) in ids.iter().zip(["/p/a.png", "/q/b.png"]) {
|
||||
if let Some(a) = d.assets.get_mut(id) {
|
||||
Arc::make_mut(a).link = Some(link.into());
|
||||
}
|
||||
}
|
||||
let items: Vec<_> = ids.iter().enumerate().map(|(k, id)| place(&mut d, *id, k as f64)).collect();
|
||||
for bytes in [save(&d).unwrap(), save_uncompacted(&d)] {
|
||||
let back = load(&bytes).unwrap();
|
||||
assert_eq!(back.assets.len(), 3);
|
||||
let links: Vec<Option<&str>> = items
|
||||
.iter()
|
||||
.map(|i| back.item(*i).and_then(|i| i.graphic()).and_then(|g| back.assets.get(&g.asset)).unwrap().link.as_deref())
|
||||
.collect();
|
||||
assert_eq!(links, [Some("/p/a.png"), Some("/q/b.png"), None]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_files_with_one_asset_per_placement_open_with_one() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let ids: Vec<AssetId> = (0..3).map(|_| png_asset(&mut d, &[5; 32])).collect();
|
||||
for (k, id) in ids.iter().enumerate() {
|
||||
place(&mut d, *id, k as f64);
|
||||
}
|
||||
let back = load(&save_uncompacted(&d)).unwrap();
|
||||
assert_eq!(back.assets.len(), 1);
|
||||
assert_eq!(asset_entries(&save(&back).unwrap()), 1);
|
||||
}
|
||||
|
||||
/// The object of the item `id` in a document's JSON.
|
||||
fn item_json(v: &mut Value, id: u64) -> Option<&mut Value> {
|
||||
if v.get("id").and_then(Value::as_u64) == Some(id) && v.get("content").is_some() {
|
||||
|
||||
@@ -48,8 +48,24 @@ fn default_reader(path: &str) -> Option<Vec<u8>> {
|
||||
|
||||
/// Import with a custom reader for linked files (`path` → bytes).
|
||||
pub fn import_idml_with(bytes: &[u8], read_link: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<Document> {
|
||||
import_idml_report(bytes, read_link).map(|r| r.document)
|
||||
}
|
||||
|
||||
/// An imported IDML document and what the import could not bring in.
|
||||
#[derive(Debug)]
|
||||
pub struct Imported {
|
||||
pub document: Document,
|
||||
/// One line per problem that did not stop the import (a part listed in `designmap.xml` but
|
||||
/// absent from the package, …).
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Import with a custom reader for linked files, reporting the problems that did not stop it.
|
||||
pub fn import_idml_report(bytes: &[u8], read_link: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<Imported> {
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| IdmlError::NotIdml(e.to_string()))?;
|
||||
let mut files: HashMap<String, Vec<u8>> = HashMap::new();
|
||||
// Normalised part name → entry name, first entry wins (zip order, so the choice is stable).
|
||||
let mut lenient: HashMap<String, String> = HashMap::new();
|
||||
for i in 0..zip.len() {
|
||||
let mut f = zip.by_index(i).map_err(|e| IdmlError::NotIdml(e.to_string()))?;
|
||||
if f.is_dir() {
|
||||
@@ -59,35 +75,81 @@ pub fn import_idml_with(bytes: &[u8], read_link: &dyn Fn(&str) -> Option<Vec<u8>
|
||||
// The size is what the archive claims: don't reserve more than a sane part up front.
|
||||
let mut buf = Vec::with_capacity((f.size() as usize).min(1 << 24));
|
||||
f.read_to_end(&mut buf).map_err(|e| IdmlError::Part { part: name.clone(), msg: e.to_string() })?;
|
||||
lenient.entry(normalize_part_name(&name)).or_insert_with(|| name.clone());
|
||||
files.insert(name, buf);
|
||||
}
|
||||
if let Some(m) = files.get("mimetype")
|
||||
let part = |name: &str| files.get(name).or_else(|| lenient.get(&normalize_part_name(name)).and_then(|n| files.get(n)));
|
||||
if let Some(m) = part("mimetype")
|
||||
&& String::from_utf8_lossy(m).trim() != MIMETYPE
|
||||
{
|
||||
return Err(IdmlError::NotIdml(format!("unexpected mimetype `{}`", String::from_utf8_lossy(m).trim())));
|
||||
}
|
||||
let dm = files.get("designmap.xml").ok_or_else(|| IdmlError::NotIdml("missing designmap.xml".into()))?;
|
||||
let dm = part("designmap.xml").ok_or_else(|| IdmlError::NotIdml("missing designmap.xml".into()))?;
|
||||
let root = parse(dm).map_err(|msg| IdmlError::Part { part: "designmap.xml".into(), msg })?;
|
||||
if root.local() != "Document" {
|
||||
return Err(IdmlError::NotIdml(format!("designmap root is <{}>", root.name)));
|
||||
}
|
||||
// Flatten includes: every `idPkg:*` child is replaced by the children of its part's root.
|
||||
let mut top: Vec<El> = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
for c in root.elements() {
|
||||
if c.name.starts_with("idPkg:") {
|
||||
let Some(src) = c.get("src") else { continue };
|
||||
let Some(data) = files.get(src) else { continue };
|
||||
let part = parse(data).map_err(|msg| IdmlError::Part { part: src.into(), msg })?;
|
||||
top.extend(part.elements().cloned());
|
||||
let Some(data) = part(src) else {
|
||||
warnings.push(format!("{src}: listed in designmap.xml but missing from the package; its contents were not imported"));
|
||||
continue;
|
||||
};
|
||||
let el = parse(data).map_err(|msg| IdmlError::Part { part: src.into(), msg })?;
|
||||
top.extend(el.elements().cloned());
|
||||
} else {
|
||||
top.push(c.clone());
|
||||
}
|
||||
}
|
||||
let mut im = Importer::new(read_link);
|
||||
im.run(&root, &top)?;
|
||||
let d = im.finish(&root)?;
|
||||
d.check().map_err(|e| IdmlError::Invalid(e.to_string()))?;
|
||||
Ok(d)
|
||||
let 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("; "))),
|
||||
e => e,
|
||||
})?;
|
||||
document.check().map_err(|e| IdmlError::Invalid(e.to_string()))?;
|
||||
Ok(Imported { document, warnings })
|
||||
}
|
||||
|
||||
/// A package part name in the form the lookup compares: InDesign and other IDML writers differ
|
||||
/// in percent-encoding, case, path separators and a leading `./` or `/`.
|
||||
fn normalize_part_name(name: &str) -> String {
|
||||
let decoded = percent_decode(&name.replace('\\', "/"));
|
||||
let mut rest = decoded.as_str();
|
||||
loop {
|
||||
if let Some(r) = rest.strip_prefix("./") {
|
||||
rest = r;
|
||||
} else if let Some(r) = rest.strip_prefix('/') {
|
||||
rest = r;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
rest.to_lowercase()
|
||||
}
|
||||
|
||||
/// Decode `%XX` escapes; anything else (including a malformed escape) is kept as written.
|
||||
pub(crate) fn percent_decode(s: &str) -> String {
|
||||
let b = s.as_bytes();
|
||||
let mut out = Vec::with_capacity(b.len());
|
||||
let mut i = 0;
|
||||
while let Some(&c) = b.get(i) {
|
||||
if c == b'%'
|
||||
&& let (Some(h), Some(l)) = (b.get(i + 1).and_then(|h| (*h as char).to_digit(16)), b.get(i + 2).and_then(|l| (*l as char).to_digit(16)))
|
||||
{
|
||||
out.push((h * 16 + l) as u8);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
out.push(c);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
struct ItemCtx {
|
||||
@@ -132,6 +194,8 @@ struct Importer<'r> {
|
||||
page_index: HashMap<String, usize>,
|
||||
item_ids: HashMap<String, ItemId>,
|
||||
assets: BTreeMap<AssetId, Arc<Asset>>,
|
||||
/// Assets read from a link path, so later placements of that path reuse them unread.
|
||||
linked_assets: HashMap<String, AssetId>,
|
||||
ctx: ItemCtx,
|
||||
sections: Vec<Section>,
|
||||
footnote_options: designcraft_doc::FootnoteOptions,
|
||||
@@ -217,6 +281,7 @@ impl<'r> Importer<'r> {
|
||||
page_index: HashMap::new(),
|
||||
item_ids: HashMap::new(),
|
||||
assets: BTreeMap::new(),
|
||||
linked_assets: HashMap::new(),
|
||||
ctx: ItemCtx { threads: Vec::new() },
|
||||
sections: Vec::new(),
|
||||
footnote_options: Default::default(),
|
||||
@@ -2205,12 +2270,29 @@ impl<'r> Importer<'r> {
|
||||
let size = ((r - l).abs(), (b - t).abs());
|
||||
let link = g.find("Link");
|
||||
let uri = link.and_then(|k| k.get("LinkResourceURI")).map(uri_to_path);
|
||||
let graphic = |asset| {
|
||||
Content::Graphic(designcraft_doc::Graphic {
|
||||
asset,
|
||||
size,
|
||||
xf: gxf * Affine::translate((l, t)),
|
||||
auto_fit: Default::default(),
|
||||
fit_align: 4,
|
||||
crop: [0.0; 4],
|
||||
})
|
||||
};
|
||||
let mut data = g.prop_el("Contents").map(|c| base64_decode(&c.text_content())).unwrap_or_default();
|
||||
let mut read_from = None;
|
||||
if data.is_empty()
|
||||
&& let Some(p) = &uri
|
||||
&& let Some(d) = (self.read_link)(p)
|
||||
{
|
||||
data = d;
|
||||
// Every placement of a linked file shares one asset; read the file once.
|
||||
if let Some(id) = self.linked_assets.get(p) {
|
||||
return graphic(*id);
|
||||
}
|
||||
if let Some(d) = (self.read_link)(p) {
|
||||
data = d;
|
||||
read_from = Some(p.clone());
|
||||
}
|
||||
}
|
||||
let (mime, px) = sniff_image(&data);
|
||||
let mime = mime.map(str::to_string).unwrap_or_else(|| match link.and_then(|k| k.get("LinkResourceFormat")).unwrap_or("") {
|
||||
@@ -2226,17 +2308,14 @@ impl<'r> Importer<'r> {
|
||||
});
|
||||
let name =
|
||||
uri.as_deref().and_then(|p| p.rsplit(['/', '\\']).next()).filter(|n| !n.is_empty()).map(str::to_string).unwrap_or_else(|| "image".into());
|
||||
let id = AssetId(self.alloc());
|
||||
let fresh = AssetId(self.alloc());
|
||||
let link_path = uri.filter(|p| p.contains('/') || p.contains('\\'));
|
||||
self.assets.insert(id, Arc::new(Asset { page: 0, id, name, mime, link: link_path, data: Arc::new(data), pixels }));
|
||||
Content::Graphic(designcraft_doc::Graphic {
|
||||
asset: id,
|
||||
size,
|
||||
xf: gxf * Affine::translate((l, t)),
|
||||
auto_fit: Default::default(),
|
||||
fit_align: 4,
|
||||
crop: [0.0; 4],
|
||||
})
|
||||
let id =
|
||||
designcraft_doc::insert_asset(&mut self.assets, Asset { page: 0, id: fresh, name, mime, link: link_path, data: Arc::new(data), pixels });
|
||||
if let Some(p) = read_from {
|
||||
self.linked_assets.insert(p, id);
|
||||
}
|
||||
graphic(id)
|
||||
}
|
||||
|
||||
fn link_threads(&mut self) {
|
||||
@@ -2543,21 +2622,9 @@ fn path_of(pg: &El) -> PathData {
|
||||
pub(crate) fn uri_to_path(uri: &str) -> String {
|
||||
let rest = uri.strip_prefix("file://").or_else(|| uri.strip_prefix("file:")).unwrap_or(uri);
|
||||
// `file:///C:/…` → `/C:/…` → `C:/…`
|
||||
let rest = if rest.len() > 3 && rest.starts_with('/') && rest.as_bytes()[2] == b':' { &rest[1..] } else { rest };
|
||||
let mut out = Vec::with_capacity(rest.len());
|
||||
let b = rest.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
if b[i] == b'%'
|
||||
&& i + 2 < b.len()
|
||||
&& let Ok(v) = u8::from_str_radix(&rest[i + 1..i + 3], 16)
|
||||
{
|
||||
out.push(v);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
out.push(b[i]);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).to_string()
|
||||
let rest = match rest.as_bytes() {
|
||||
[b'/', _, b':', _, ..] => rest.get(1..).unwrap_or(rest),
|
||||
_ => rest,
|
||||
};
|
||||
percent_decode(rest)
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ mod xml;
|
||||
mod tests;
|
||||
|
||||
pub use export::{ExportOptions, export_idml, export_idml_with};
|
||||
pub use import::{import_idml, import_idml_with};
|
||||
pub use import::{Imported, import_idml, import_idml_report, import_idml_with};
|
||||
|
||||
/// The IDML package mimetype (content of the first, stored `mimetype` entry).
|
||||
pub const MIMETYPE: &str = "application/vnd.adobe.indesign-idml-package";
|
||||
|
||||
@@ -7,6 +7,7 @@ use designcraft_geom::{Rect, shapes};
|
||||
|
||||
use super::*;
|
||||
|
||||
mod assets;
|
||||
mod decorations;
|
||||
|
||||
fn zip_files(files: &[(&str, &str)]) -> Vec<u8> {
|
||||
@@ -427,6 +428,60 @@ fn imports_scalar_insets_in_object_style_without_replacing_frame_override() {
|
||||
assert_eq!(frame_inset(&d), [3.0; 4]);
|
||||
}
|
||||
|
||||
/// A one-page spread part `Self` with one rectangle.
|
||||
fn one_frame_spread(id: &str) -> String {
|
||||
format!(
|
||||
r#"<idPkg:Spread xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging">
|
||||
<Spread Self="{id}">
|
||||
<Page Self="{id}p" GeometricBounds="0 0 100 100" ItemTransform="1 0 0 1 0 0"/>
|
||||
<Rectangle Self="{id}r" ItemTransform="1 0 0 1 0 0">
|
||||
<Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>
|
||||
<PathPointType Anchor="10 10"/><PathPointType Anchor="10 50"/>
|
||||
<PathPointType Anchor="50 50"/><PathPointType Anchor="50 10"/>
|
||||
</PathPointArray></GeometryPathType></PathGeometry></Properties>
|
||||
</Rectangle>
|
||||
</Spread>
|
||||
</idPkg:Spread>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Writers differ in how a designmap `src` spells its part: percent-encoded, another case,
|
||||
/// backslashes, a leading `./`. Those resolve; a part absent from the package is a warning that
|
||||
/// names it, not silently dropped objects.
|
||||
#[test]
|
||||
fn designmap_parts_match_leniently_and_missing_ones_are_reported() {
|
||||
let designmap = r#"<Document xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" Self="d">
|
||||
<idPkg:Spread src="Spreads/Spread_u1a%20b.xml"/>
|
||||
<idPkg:Spread src="./spreads\SPREAD_u2.xml"/>
|
||||
<idPkg:Spread src="Spreads/Spread_gone.xml"/>
|
||||
</Document>"#;
|
||||
let zip = zip_files(&[
|
||||
("designmap.xml", designmap),
|
||||
("Spreads/Spread_u1a b.xml", &one_frame_spread("u1")),
|
||||
("Spreads/Spread_u2.xml", &one_frame_spread("u2")),
|
||||
]);
|
||||
let r = import_idml_report(&zip, &|_| None).unwrap();
|
||||
assert_eq!(r.document.spreads.len(), 2, "both lenient spellings resolve");
|
||||
assert!(r.document.spreads.iter().all(|sp| sp.items.len() == 1), "the spreads keep their frames");
|
||||
assert_eq!(r.warnings.len(), 1, "{:?}", r.warnings);
|
||||
assert!(r.warnings[0].contains("Spreads/Spread_gone.xml"), "{:?}", r.warnings);
|
||||
|
||||
// With every spread part missing the import fails, and the error names the part.
|
||||
let only_missing = r#"<Document xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" Self="d">
|
||||
<idPkg:Spread src="Spreads/Spread_gone.xml"/>
|
||||
</Document>"#;
|
||||
let err = import_idml(&zip_files(&[("designmap.xml", only_missing)])).unwrap_err();
|
||||
assert!(err.to_string().contains("Spreads/Spread_gone.xml"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percent_decode_keeps_malformed_escapes() {
|
||||
assert_eq!(import::percent_decode("a%20b%2"), "a b%2");
|
||||
assert_eq!(import::percent_decode("%é%zz"), "%é%zz");
|
||||
assert_eq!(import::uri_to_path("file:///C:/a%20b"), "C:/a b");
|
||||
assert_eq!(import::uri_to_path("file:/a%bé"), "/a%bé");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_idml() {
|
||||
assert!(import_idml(b"not a zip").is_err());
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Placed files: every placement of one file shares an asset.
|
||||
|
||||
use std::cell::Cell;
|
||||
|
||||
use designcraft_doc::{AssetId, Content};
|
||||
|
||||
use super::*;
|
||||
|
||||
const PHOTO: &str = "file:/photos/wedding%20day/couple.jpg";
|
||||
|
||||
/// A graphic frame at `x` showing `image` (an `<Image>` element) with `crop` on every side.
|
||||
fn frame(n: usize, x: f64, crop: f64, image: &str) -> String {
|
||||
format!(
|
||||
r#"<Rectangle Self="r{n}" ItemLayer="L1" ItemTransform="1 0 0 1 {x} 0" ContentType="GraphicType">
|
||||
<Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>
|
||||
<PathPointType Anchor="0 0"/><PathPointType Anchor="0 30"/><PathPointType Anchor="40 30"/><PathPointType Anchor="40 0"/>
|
||||
</PathPointArray></GeometryPathType></PathGeometry></Properties>
|
||||
<FrameFittingOption TopCrop="{crop}" LeftCrop="{crop}" BottomCrop="{crop}" RightCrop="{crop}"/>
|
||||
{image}
|
||||
</Rectangle>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn image(n: usize, uri: &str, contents: Option<&[u8]>) -> String {
|
||||
let contents = contents.map(|c| format!("<Contents><![CDATA[{}]]></Contents>", base64_encode(c))).unwrap_or_default();
|
||||
format!(
|
||||
r#"<Image Self="im{n}" ItemTransform="1 0 0 1 {n} 0">
|
||||
<Properties>{contents}<GraphicBounds Left="0" Top="0" Right="40" Bottom="30"/></Properties>
|
||||
<Link Self="lk{n}" LinkResourceURI="{uri}" LinkResourceFormat="$ID/JPEG" StoredState="Normal"/>
|
||||
</Image>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn package(frames: &[String]) -> Vec<u8> {
|
||||
let spread = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<idPkg:Spread xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0">
|
||||
<Spread Self="sp1" PageCount="1" ItemTransform="1 0 0 1 0 0">
|
||||
<Page Self="p1" AppliedMaster="m1" GeometricBounds="0 0 700 500" ItemTransform="1 0 0 1 0 0"/>
|
||||
{}
|
||||
</Spread>
|
||||
</idPkg:Spread>"#,
|
||||
frames.join("\n")
|
||||
);
|
||||
zip_files(&[
|
||||
("designmap.xml", DESIGNMAP),
|
||||
("Resources/Graphic.xml", GRAPHIC),
|
||||
("Resources/Styles.xml", STYLES),
|
||||
("Resources/Preferences.xml", PREFS),
|
||||
("MasterSpreads/MasterSpread_m1.xml", MASTER),
|
||||
("Spreads/Spread_sp1.xml", &spread),
|
||||
("Stories/Story_s1.xml", STORY),
|
||||
])
|
||||
}
|
||||
|
||||
/// (asset, crop, transform) of each graphic, left to right.
|
||||
fn placements(d: &Document) -> Vec<(AssetId, [f64; 4], designcraft_geom::Affine)> {
|
||||
let mut out: Vec<(f64, AssetId, [f64; 4], designcraft_geom::Affine)> = d.spreads[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|i| match &i.content {
|
||||
Content::Graphic(g) => Some((i.xf.translation().x, g.asset, g.crop, g.xf)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
out.into_iter().map(|(_, a, c, x)| (a, c, x)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_file_placed_several_times_imports_as_one_asset() {
|
||||
let bytes = vec![42u8; 300];
|
||||
let reads = Cell::new(0);
|
||||
let reader = |p: &str| {
|
||||
reads.set(reads.get() + 1);
|
||||
p.ends_with("couple.jpg").then(|| bytes.clone())
|
||||
};
|
||||
// Three placements linking the file, and one embedding the same bytes under another path:
|
||||
// that one keeps its own asset and path.
|
||||
let frames = vec![
|
||||
frame(1, 0.0, 1.0, &image(1, PHOTO, None)),
|
||||
frame(2, 100.0, 2.0, &image(2, PHOTO, None)),
|
||||
frame(3, 200.0, 3.0, &image(3, PHOTO, None)),
|
||||
frame(4, 300.0, 4.0, &image(4, "file:/elsewhere/copy.jpg", Some(&bytes))),
|
||||
];
|
||||
let d = import_idml_with(&package(&frames), &reader).unwrap();
|
||||
assert_eq!(reads.get(), 1, "the linked file is read once");
|
||||
assert_eq!(d.assets.len(), 2);
|
||||
let p = placements(&d);
|
||||
assert_eq!(p.iter().map(|(_, c, _)| c[0]).collect::<Vec<_>>(), vec![1.0, 2.0, 3.0, 4.0]);
|
||||
let link = |id| d.assets.get(id).and_then(|a| a.link.as_deref());
|
||||
let links: Vec<_> = p.iter().map(|(id, _, _)| link(id)).collect();
|
||||
assert_eq!(links[..3], [Some("/photos/wedding day/couple.jpg"); 3]);
|
||||
assert_eq!(links[3], Some("/elsewhere/copy.jpg"));
|
||||
assert!(p[..3].iter().all(|(id, _, _)| *id == p[0].0));
|
||||
|
||||
// Exported linked or embedded, the round trip keeps the two assets and each placement's crop
|
||||
// and transform.
|
||||
for embed_images in [false, true] {
|
||||
let out = export_idml_with(&d, &ExportOptions { embed_images });
|
||||
let back = import_idml_with(&out, &reader).unwrap();
|
||||
assert_eq!(back.assets.len(), 2, "embed_images: {embed_images}");
|
||||
let q = placements(&back);
|
||||
assert_eq!(q.len(), 4);
|
||||
for ((_, c0, x0), (_, c1, x1)) in p.iter().zip(&q) {
|
||||
assert_eq!(c0, c1);
|
||||
assert!(x0.as_coeffs().iter().zip(x1.as_coeffs()).all(|(a, b)| (a - b).abs() < 1e-6), "{x0:?} {x1:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,15 +78,11 @@ impl Backend for Remote {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
let line = json!({"id": id, "method": method, "params": params}).to_string();
|
||||
// One retry with a fresh connection (the app may have restarted).
|
||||
let reply = match self.roundtrip(&line) {
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
Err(e) => {
|
||||
self.conn = None;
|
||||
self.roundtrip(&line).map_err(|e| {
|
||||
self.conn = None;
|
||||
format!("DesignCraft app at {} is not reachable: {e}", self.addr)
|
||||
})?
|
||||
return Err(format!("DesignCraft app at {}: {e}; operation may have completed; inspect state before retrying", self.addr));
|
||||
}
|
||||
};
|
||||
let v: Value = serde_json::from_str(reply.trim()).map_err(|e| format!("bad reply from app: {e}"))?;
|
||||
@@ -105,3 +101,55 @@ impl Backend for Remote {
|
||||
format!("connected to the DesignCraft app at {}", self.addr)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::TcpListener;
|
||||
|
||||
#[test]
|
||||
fn remote_does_not_replay_when_reply_is_lost() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
let server = std::thread::spawn(move || {
|
||||
let first = {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
let mut reader = BufReader::new(stream);
|
||||
let mut line = String::new();
|
||||
assert!(reader.read_line(&mut line).unwrap() > 0);
|
||||
let request: Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(request["method"], "engine.execute");
|
||||
assert_eq!(request["params"]["command"], "frame.create");
|
||||
request
|
||||
};
|
||||
let mut applied = 1;
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
stream.set_write_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
assert!(reader.read_line(&mut line).unwrap() > 0);
|
||||
let request: Value = serde_json::from_str(&line).unwrap();
|
||||
if request == first {
|
||||
applied += 1;
|
||||
writeln!(stream, "{}", json!({"id": request["id"], "ok": true, "result": {"applied": applied}})).unwrap();
|
||||
} else {
|
||||
assert_eq!(request, json!({"id": 2, "method": "document.inspect", "params": {}}));
|
||||
writeln!(stream, "{}", json!({"id": request["id"], "ok": true, "result": {"applied": applied}})).unwrap();
|
||||
return applied;
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut remote = Remote::connect(&addr).unwrap();
|
||||
|
||||
let result = remote.call("engine.execute", json!({"command": "frame.create", "params": {"rect": [0, 0, 100, 100]}}));
|
||||
let next = remote.call("document.inspect", json!({}));
|
||||
let applied = server.join().unwrap();
|
||||
|
||||
assert_eq!(applied, 1, "lost reply must not replay the applied edit; client returned {result:?}");
|
||||
assert!(result.is_err(), "lost reply must report an uncertain outcome: {result:?}");
|
||||
assert_eq!(next, Ok(json!({"applied": 1})), "the next explicit request must reconnect successfully");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,7 +201,7 @@ impl Headless {
|
||||
let png = img.to_png();
|
||||
match s(p, "path") {
|
||||
Some(path) => {
|
||||
std::fs::write(path, &png).map_err(|e| format!("write {path}: {e}"))?;
|
||||
designcraft_engine::atomic::write_atomic(std::path::Path::new(path), &png).map_err(|e| format!("write {path}: {e}"))?;
|
||||
Ok(json!({"path": path, "width": img.width, "height": img.height}))
|
||||
}
|
||||
None => Ok(json!({"width": img.width, "height": img.height, "pngBase64": designcraft_engine::cmd::base64_encode(&png)})),
|
||||
@@ -221,7 +221,7 @@ impl Headless {
|
||||
.ok_or_else(|| format!("no page {page}"))?;
|
||||
let lower = path.to_ascii_lowercase();
|
||||
let bytes = if lower.ends_with(".jpg") || lower.ends_with(".jpeg") { img.to_jpeg(90) } else { img.to_png() };
|
||||
std::fs::write(path, &bytes).map_err(|e| format!("write {path}: {e}"))?;
|
||||
designcraft_engine::atomic::write_atomic(std::path::Path::new(path), &bytes).map_err(|e| format!("write {path}: {e}"))?;
|
||||
Ok(json!({"path": path, "width": img.width, "height": img.height}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,13 +134,12 @@ impl Server {
|
||||
Ok(json!({
|
||||
"protocolVersion": version,
|
||||
"capabilities": {"tools": {}, "resources": {}},
|
||||
"serverInfo": {"name": "designcraft", "title": "DesignCraft", "version": env!("CARGO_PKG_VERSION"), "websiteUrl": designcraft_engine::links::APP_PAGE},
|
||||
"serverInfo": {"name": "designcraft", "title": "DesignCraft", "version": env!("CARGO_PKG_VERSION"), "websiteUrl": designcraft_engine::links::GITHUB},
|
||||
"instructions": format!(
|
||||
"{INSTRUCTIONS} Backend: {}. Community: {} (Discord), {} (app page), {} (source). The `app.links` command returns these links.",
|
||||
"{INSTRUCTIONS} Backend: {}. Source: {}, issues: {}. The `app.links` command returns these links.",
|
||||
self.backend.describe(),
|
||||
designcraft_engine::links::DISCORD,
|
||||
designcraft_engine::links::APP_PAGE,
|
||||
designcraft_engine::links::GITHUB
|
||||
designcraft_engine::links::GITHUB,
|
||||
designcraft_engine::links::ISSUES
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -479,7 +479,7 @@ fn render_page(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
|
||||
let path = a.get("path").and_then(Value::as_str);
|
||||
if let Some(path) = path {
|
||||
let png = designcraft_engine::cmd::base64_decode(&b64);
|
||||
std::fs::write(path, png).map_err(|e| format!("write {path}: {e}"))?;
|
||||
designcraft_engine::atomic::write_atomic(std::path::Path::new(path), &png).map_err(|e| format!("write {path}: {e}"))?;
|
||||
}
|
||||
Ok(ToolResult::image(b64, &json!({"width": r.get("width"), "height": r.get("height"), "path": path})))
|
||||
}
|
||||
|
||||
@@ -630,7 +630,6 @@ impl Exporter<'_> {
|
||||
let Some((ppi, ppage)) = doc.parent_page_for(first + pi) else { continue };
|
||||
let parent = &doc.parents[ppi];
|
||||
let dx = page.x - parent.pages[ppage].x;
|
||||
let page_name = doc.page_name(first + pi);
|
||||
for it in &parent.items {
|
||||
if it.layer != layer.id || page.overridden.contains(&it.id) {
|
||||
continue;
|
||||
@@ -639,7 +638,7 @@ impl Exporter<'_> {
|
||||
if parent.page_at_x(it.bounds().center().x) != Some(ppage) && parent.pages.len() > 1 {
|
||||
continue;
|
||||
}
|
||||
self.top_item(s, it, Affine::translate((dx, 0.0)), Some(&page_name), true);
|
||||
self.top_item(s, it, Affine::translate((dx, 0.0)), Some(first + pi), true);
|
||||
}
|
||||
}
|
||||
for it in &sp.items {
|
||||
@@ -679,9 +678,9 @@ impl Exporter<'_> {
|
||||
}
|
||||
|
||||
/// A spread or parent item, inside its structure tag when tagging.
|
||||
fn top_item(&mut self, s: &mut Surface, it: &Item, parent: Affine, page_name: Option<&str>, on_parent: bool) {
|
||||
fn top_item(&mut self, s: &mut Surface, it: &Item, parent: Affine, page: Option<usize>, on_parent: bool) {
|
||||
if !self.opts.tagged || it.hidden || it.nonprinting {
|
||||
return self.item(s, it, parent, page_name);
|
||||
return self.item(s, it, parent, page);
|
||||
}
|
||||
let story = it.text_frame().map(|t| t.story);
|
||||
let figure = !it.alt_text.is_empty() || matches!(it.content, Content::Graphic(_)) || it.has_nested_items();
|
||||
@@ -689,7 +688,7 @@ impl Exporter<'_> {
|
||||
// Page furniture and decoration.
|
||||
let kind = if on_parent { ArtifactType::Page } else { ArtifactType::Other };
|
||||
s.start_tagged(ContentTag::Artifact(Artifact::new(kind, None)));
|
||||
self.item(s, it, parent, page_name);
|
||||
self.item(s, it, parent, page);
|
||||
s.end_tagged();
|
||||
return;
|
||||
}
|
||||
@@ -700,12 +699,12 @@ impl Exporter<'_> {
|
||||
self.tags.push(TagEntry::Story(sid));
|
||||
}
|
||||
self.tag_story = Some(sid);
|
||||
self.item(s, it, parent, page_name);
|
||||
self.item(s, it, parent, page);
|
||||
self.tag_story = None;
|
||||
return;
|
||||
}
|
||||
let id = s.start_tagged(ContentTag::Other);
|
||||
self.item(s, it, parent, page_name);
|
||||
self.item(s, it, parent, page);
|
||||
s.end_tagged();
|
||||
let alt = (!it.alt_text.is_empty()).then(|| it.alt_text.clone());
|
||||
let mut g = TagGroup::new(Tag::Figure(alt));
|
||||
@@ -751,10 +750,10 @@ impl Exporter<'_> {
|
||||
tree
|
||||
}
|
||||
|
||||
fn item(&mut self, s: &mut Surface, it: &Item, parent: Affine, page_name: Option<&str>) {
|
||||
fn item(&mut self, s: &mut Surface, it: &Item, parent: Affine, page: Option<usize>) {
|
||||
let gf = &it.effects.gradient_feather;
|
||||
if !gf.on || it.hidden || it.nonprinting {
|
||||
return self.item_inner(s, it, parent, page_name);
|
||||
return self.item_inner(s, it, parent, page);
|
||||
}
|
||||
// Gradient feather: a luminosity mask (white = opaque) over the object.
|
||||
let xf = parent * it.xf;
|
||||
@@ -804,11 +803,11 @@ impl Exporter<'_> {
|
||||
ms.finish();
|
||||
let stream = sb.finish();
|
||||
s.push_mask(krilla::mask::Mask::new(stream, krilla::mask::MaskType::Luminosity));
|
||||
self.item_inner(s, it, parent, page_name);
|
||||
self.item_inner(s, it, parent, page);
|
||||
s.pop();
|
||||
}
|
||||
|
||||
fn item_inner(&mut self, s: &mut Surface, it: &Item, parent: Affine, page_name: Option<&str>) {
|
||||
fn item_inner(&mut self, s: &mut Surface, it: &Item, parent: Affine, page: Option<usize>) {
|
||||
if it.hidden || it.nonprinting {
|
||||
return;
|
||||
}
|
||||
@@ -843,7 +842,7 @@ impl Exporter<'_> {
|
||||
s.push_clip_path(c, &FillRule::NonZero);
|
||||
}
|
||||
for c in it.children() {
|
||||
self.item(s, c, xf, page_name);
|
||||
self.item(s, c, xf, page);
|
||||
}
|
||||
if clip.is_some() {
|
||||
s.pop();
|
||||
@@ -863,7 +862,7 @@ impl Exporter<'_> {
|
||||
if !it.children().is_empty() {
|
||||
let pushes = Self::push_group(s, it.opacity, it.blend) + Self::push_isolation(s, it);
|
||||
for c in it.shown_children() {
|
||||
self.item(s, c, xf, page_name);
|
||||
self.item(s, c, xf, page);
|
||||
}
|
||||
for _ in 0..pushes {
|
||||
s.pop();
|
||||
@@ -921,7 +920,7 @@ impl Exporter<'_> {
|
||||
}
|
||||
}
|
||||
Content::Text(tfr) => {
|
||||
let cs = self.cache.get(doc, tfr.story, page_name);
|
||||
let cs = self.cache.get(doc, tfr.story, page);
|
||||
if let (Some(ft), Some(pt)) = (cs.frame(it.id), &tfr.options.path) {
|
||||
// Type on a path: the glyphs as outlines along the path.
|
||||
for (si, gp) in designcraft_compose::path_glyphs(&cs, ft, &bp, pt) {
|
||||
@@ -966,11 +965,11 @@ impl Exporter<'_> {
|
||||
if let Content::Text(tfr) = &it.content
|
||||
&& let Some(st) = doc.story(tfr.story).filter(|st| !st.objects.is_empty())
|
||||
{
|
||||
let cs = self.cache.get(doc, tfr.story, page_name);
|
||||
let cs = self.cache.get(doc, tfr.story, page);
|
||||
if let Some(ft) = cs.frame(it.id) {
|
||||
for o in &ft.objects {
|
||||
if let Some(obj) = st.objects.get(o.index) {
|
||||
self.item(s, &obj.item, xf * Affine::translate(o.origin.to_vec2()), page_name);
|
||||
self.item(s, &obj.item, xf * Affine::translate(o.origin.to_vec2()), page);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ fn shadow(sigma: f64, color: peniko::Color) -> Filter {
|
||||
|
||||
impl Renderer {
|
||||
/// Draw an item with soft effects: below-effects, the (feathered) body, inner shadow.
|
||||
pub(crate) fn draw_item_fx(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page_name: Option<&str>) {
|
||||
pub(crate) fn draw_item_fx(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page: Option<usize>) {
|
||||
let doc = f.doc;
|
||||
let e = &it.effects;
|
||||
let sw = if it.stroke.is_none() { 0.0 } else { it.stroke.extent() };
|
||||
@@ -95,7 +95,7 @@ impl Renderer {
|
||||
let moved = Frame { view: fr.view * Affine::translate(off), ..*fr };
|
||||
c.set_transform(moved.view);
|
||||
c.push_layer(None, None, None, None, Some(filter));
|
||||
me.draw_body(c, &moved, it, bp, xf, page_name);
|
||||
me.draw_body(c, &moved, it, bp, xf, page);
|
||||
if grow > 0.0 && solid {
|
||||
c.set_transform(moved.view * xf);
|
||||
c.set_paint(peniko::Color::BLACK);
|
||||
@@ -119,7 +119,7 @@ impl Renderer {
|
||||
self.with_filters(ctx, f, reach, w / 4.0, |me, c, fr| {
|
||||
c.set_transform(fr.view * xf);
|
||||
c.push_clip_layer(bp);
|
||||
me.draw_body(c, fr, it, bp, xf, page_name);
|
||||
me.draw_body(c, fr, it, bp, xf, page);
|
||||
// Keep the body where the blurred inset silhouette is.
|
||||
c.set_transform(Affine::IDENTITY);
|
||||
c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None);
|
||||
@@ -139,7 +139,7 @@ impl Renderer {
|
||||
c.pop_layer();
|
||||
});
|
||||
} else {
|
||||
self.draw_body(ctx, f, it, bp, xf, page_name);
|
||||
self.draw_body(ctx, f, it, bp, xf, page);
|
||||
}
|
||||
// Directional feather: one fading ramp per side, multiplied in.
|
||||
let df = &e.directional_feather;
|
||||
|
||||
@@ -216,7 +216,7 @@ pub struct Renderer {
|
||||
resources: Resources,
|
||||
glyphs: text::GlyphCache,
|
||||
/// Composed stories looked up during the current render.
|
||||
stories: HashMap<(StoryId, Option<String>), Arc<ComposedStory>>,
|
||||
stories: HashMap<(StoryId, Option<usize>), Arc<ComposedStory>>,
|
||||
pub threads: u16,
|
||||
pub stats: FrameStats,
|
||||
}
|
||||
@@ -408,7 +408,6 @@ impl Renderer {
|
||||
let Some((ppi, ppage)) = doc.parent_page_for(first + pi) else { continue };
|
||||
let parent = &doc.parents[ppi];
|
||||
let dx = page.x - parent.pages[ppage].x;
|
||||
let page_name = doc.page_name(first + pi);
|
||||
let pr = parent.pages[ppage].bounds();
|
||||
for it in &parent.items {
|
||||
if it.layer != layer.id || page.overridden.contains(&it.id) {
|
||||
@@ -419,7 +418,7 @@ impl Renderer {
|
||||
continue;
|
||||
}
|
||||
let _ = pr;
|
||||
self.draw_item(ctx, f, it, Affine::translate((dx, 0.0)), Some(&page_name));
|
||||
self.draw_item(ctx, f, it, Affine::translate((dx, 0.0)), Some(first + pi));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -431,7 +430,7 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_item(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, parent: Affine, page_name: Option<&str>) {
|
||||
fn draw_item(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, parent: Affine, page: Option<usize>) {
|
||||
if it.hidden || f.opts.hidden.contains(&it.id) || f.opts.printing_only && it.nonprinting {
|
||||
return;
|
||||
}
|
||||
@@ -458,7 +457,7 @@ impl Renderer {
|
||||
ctx.set_transform(f.view * xf);
|
||||
ctx.push_clip_layer(&bp);
|
||||
for c in it.children() {
|
||||
self.draw_item(ctx, f, c, xf, page_name);
|
||||
self.draw_item(ctx, f, c, xf, page);
|
||||
}
|
||||
ctx.pop_layer();
|
||||
if !it.stroke.is_none() {
|
||||
@@ -489,11 +488,11 @@ impl Renderer {
|
||||
ctx.fill_path(&c.path.to_bezpath());
|
||||
ctx.pop_layer();
|
||||
}
|
||||
self.draw_item(ctx, f, c, xf, page_name);
|
||||
self.draw_item(ctx, f, c, xf, page);
|
||||
}
|
||||
} else {
|
||||
for c in it.shown_children() {
|
||||
self.draw_item(ctx, f, c, xf, page_name);
|
||||
self.draw_item(ctx, f, c, xf, page);
|
||||
}
|
||||
}
|
||||
if layered {
|
||||
@@ -520,9 +519,9 @@ impl Renderer {
|
||||
ctx.push_layer(None, Some(blend_mode(it.blend)), Some(it.opacity), None, None);
|
||||
}
|
||||
if effects {
|
||||
self.draw_item_fx(ctx, f, it, &bp, xf, page_name);
|
||||
self.draw_item_fx(ctx, f, it, &bp, xf, page);
|
||||
} else {
|
||||
self.draw_body(ctx, f, it, &bp, xf, page_name);
|
||||
self.draw_body(ctx, f, it, &bp, xf, page);
|
||||
}
|
||||
if layered {
|
||||
ctx.pop_layer();
|
||||
@@ -531,18 +530,18 @@ impl Renderer {
|
||||
|
||||
/// The composed story for a frame (memoised for the current render: the cache lookup
|
||||
/// validates the whole thread each time).
|
||||
fn story(&mut self, f: &Frame, sid: StoryId, page_name: Option<&str>) -> Arc<ComposedStory> {
|
||||
let key = (sid, page_name.map(str::to_string));
|
||||
fn story(&mut self, f: &Frame, sid: StoryId, page: Option<usize>) -> Arc<ComposedStory> {
|
||||
let key = (sid, page);
|
||||
if let Some(cs) = self.stories.get(&key) {
|
||||
return cs.clone();
|
||||
}
|
||||
let cs = f.cache.get(f.doc, sid, page_name);
|
||||
let cs = f.cache.get(f.doc, sid, page);
|
||||
self.stories.insert(key, cs.clone());
|
||||
cs
|
||||
}
|
||||
|
||||
/// Fill, content and stroke of a leaf item (no opacity, blend or effects).
|
||||
pub(crate) fn draw_body(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page_name: Option<&str>) {
|
||||
pub(crate) fn draw_body(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page: Option<usize>) {
|
||||
let doc = f.doc;
|
||||
// Fill.
|
||||
if !it.fill.is_none() {
|
||||
@@ -575,7 +574,7 @@ impl Renderer {
|
||||
ctx.pop_layer();
|
||||
}
|
||||
Content::Text(tf) => {
|
||||
let cs = self.story(f, tf.story, page_name);
|
||||
let cs = self.story(f, tf.story, page);
|
||||
if let Some(ft) = cs.frame(it.id) {
|
||||
match &tf.options.path {
|
||||
Some(pt) => self.draw_path_text(ctx, f, &cs, ft, xf, bp, pt),
|
||||
@@ -1022,6 +1021,15 @@ pub fn image_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
designcraft_images::pixel_size(bytes)
|
||||
}
|
||||
|
||||
/// Natural size of a placed graphic, unrounded: a PDF page (crop box) and an SVG in points, a raster
|
||||
/// in pixels. Placed graphics are drawn at this size; `image_size` rounds it to whole pixels for display.
|
||||
pub fn natural_size(bytes: &[u8], page: usize) -> Option<(f64, f64)> {
|
||||
if is_pdf(bytes) {
|
||||
return pdf_page_size(bytes, page).map(|(w, h)| (w.max(1e-3), h.max(1e-3)));
|
||||
}
|
||||
designcraft_images::natural_size(bytes)
|
||||
}
|
||||
|
||||
/// MIME type guess for encoded image bytes.
|
||||
/// Is this a PDF file (placed PDFs are graphics)?
|
||||
pub fn is_pdf(bytes: &[u8]) -> bool {
|
||||
|
||||
@@ -99,6 +99,7 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
// Paragraphs: (style, runs of (text, format)).
|
||||
let mut paras: Vec<(String, Vec<(String, CharFormat)>)> = vec![(String::new(), Vec::new())];
|
||||
let mut started = false;
|
||||
let mut fmt = CharFormat::default();
|
||||
let mut para_styles: Vec<String> = Vec::new();
|
||||
let mut char_styles: Vec<String> = Vec::new();
|
||||
@@ -151,6 +152,7 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
|
||||
}
|
||||
}
|
||||
"ParaStyle" => {
|
||||
started = true;
|
||||
let v = value.trim().to_string();
|
||||
if !v.is_empty() && !para_styles.contains(&v) {
|
||||
para_styles.push(v.clone());
|
||||
@@ -187,7 +189,7 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
|
||||
}
|
||||
let style = paras.last().map(|p| p.0.clone()).unwrap_or_default();
|
||||
// Header lines before any text don't start paragraphs.
|
||||
if paras.len() == 1 && paras[0].1.is_empty() {
|
||||
if !started && paras.len() == 1 && paras.first().is_some_and(|p| p.1.is_empty()) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -243,6 +245,18 @@ mod tests {
|
||||
assert_eq!(import("<ASCII-WIN>\r\n<ParaStyle:Body>A<0xD83D>B<0xDE00>C".as_bytes()).unwrap().story.text, "ABC");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tagged_text_keeps_a_leading_empty_paragraph() {
|
||||
let doc = Document::new(&designcraft_doc::build::NewDocument::default());
|
||||
let mut story = Story::new(StoryId(0));
|
||||
story.insert(0, "\nText");
|
||||
story.paras[0].style = "Head".into();
|
||||
story.paras[1].style = "Body".into();
|
||||
let back = import(tagged_text(&doc, &story).as_bytes()).unwrap();
|
||||
assert_eq!(back.story.text, story.text);
|
||||
assert_eq!(back.story.paras, story.paras);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tagged_text_round_trips() {
|
||||
let src = "<ASCII-WIN>\r\n<Version:7>\r\n<ParaStyle:Head>Caf<0x00E9> \\<1\\>\r\n<ParaStyle:Body>A <CharStyle:Em>word<CharStyle:> and <cSize:18.000000>big<cSize:> text";
|
||||
|
||||
@@ -29,7 +29,9 @@ serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
designcraft-idml = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
# Headless (offscreen wgpu) rendering of the whole window for UI screenshots: `examples/ui_shot.rs`.
|
||||
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
|
||||
designcraft-fonts = { workspace = true, features = ["testing"] }
|
||||
|
||||
@@ -1,98 +1,16 @@
|
||||
//! Help › About DesignCraft: tabs About · Contributors · Models. About is the splash with the
|
||||
//! ArtCraft mark, version, and community links (Discord first and largest), plus the other ArtCraft
|
||||
//! apps; Contributors and Models are the compiled-in credits (`crate::credits`).
|
||||
//! version and project links; Contributors and Models are the compiled-in credits (`crate::credits`).
|
||||
|
||||
use designcraft_engine::links;
|
||||
use egui::{Color32, Rect, Sense, Stroke, pos2, vec2};
|
||||
use egui::{Color32, Sense, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::DesignApp;
|
||||
use crate::theme::{Tokens, semibold};
|
||||
|
||||
/// ArtCraft brand blue (from `docs/brand/artcraft-mark.svg`).
|
||||
pub const BRAND: Color32 = Color32::from_rgb(0x4e, 0x7b, 0xfb);
|
||||
/// Discord's brand colour, used for the "Join our Discord" buttons (colour only; no Discord artwork).
|
||||
pub const DISCORD: Color32 = Color32::from_rgb(0x58, 0x65, 0xf2);
|
||||
|
||||
/// The ArtCraft mark outline (viewBox -10 -19.42 136.34 136.34), from `docs/brand/artcraft-mark.svg`.
|
||||
const MARK_PATH: &str = "M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,75.85l21.55,21.55,63.79-36.94,16.99,37.04,14.01-21.74-12.06-26.27ZM32.89,65.66l32.42-48.87,10.91,23.77-43.32,25.09Z";
|
||||
|
||||
/// The other ArtCraft apps (app-page slug, name, what it is) — same list as the README.
|
||||
pub const SIBLINGS: &[(&str, &str, &str)] = &[
|
||||
("photocraft", "PhotoCraft", "image editing"),
|
||||
("vectorcraft", "VectorCraft", "vector illustration"),
|
||||
("filmcraft", "FilmCraft", "video editing, color and sound"),
|
||||
("lightcraft", "LightCraft", "photo library and raw development"),
|
||||
("pdfcraft", "PdfCraft", "reading, organizing and protecting PDFs"),
|
||||
("effectcraft", "EffectCraft", "motion graphics and visual effects"),
|
||||
];
|
||||
|
||||
/// The About window's tabs, in `UiState::about_tab` order (`help.about {tab}` names them in lowercase).
|
||||
pub const ABOUT_TABS: [&str; 3] = ["About", "Contributors", "Models"];
|
||||
|
||||
/// Paint the ArtCraft mark into `rect` (square), rasterised once per size and cached.
|
||||
pub fn paint_mark(ui: &egui::Ui, rect: Rect, color: Color32) {
|
||||
use designcraft_render::vello_cpu::{self, kurbo, peniko};
|
||||
let ctx = ui.ctx();
|
||||
let ppp = ctx.pixels_per_point();
|
||||
let px = ((rect.width().min(rect.height()) * ppp).round() as u32).clamp(8, 1024);
|
||||
let key = egui::Id::new(("artcraft_mark", px, color.to_array()));
|
||||
let tex: Option<egui::TextureHandle> = ctx.data(|d| d.get_temp(key));
|
||||
let tex = tex.unwrap_or_else(|| {
|
||||
let mut rc = vello_cpu::RenderContext::new_with(px as u16, px as u16, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
|
||||
let k = px as f64 / 136.34;
|
||||
rc.set_transform(kurbo::Affine::scale(k) * kurbo::Affine::translate((10.0, 19.42)));
|
||||
let [r, g, b, a] = color.to_srgba_unmultiplied();
|
||||
rc.set_paint(peniko::Color::from_rgba8(r, g, b, a));
|
||||
if let Ok(p) = kurbo::BezPath::from_svg(MARK_PATH) {
|
||||
rc.fill_path(&p);
|
||||
}
|
||||
rc.flush();
|
||||
let mut pm = vello_cpu::Pixmap::new(px as u16, px as u16);
|
||||
let mut res = vello_cpu::Resources::new();
|
||||
rc.render(&mut pm, &mut res);
|
||||
let ci = egui::ColorImage::from_rgba_premultiplied([px as usize, px as usize], pm.data_as_u8_slice());
|
||||
let t = ctx.load_texture("artcraft_mark", ci, egui::TextureOptions::LINEAR);
|
||||
ctx.data_mut(|d| d.insert_temp(key, t.clone()));
|
||||
t
|
||||
});
|
||||
let side = rect.width().min(rect.height());
|
||||
let r = Rect::from_center_size(rect.center(), vec2(side, side));
|
||||
ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
|
||||
/// A speech-bubble glyph (drawn in code) for Discord buttons.
|
||||
pub fn paint_chat_icon(p: &egui::Painter, c: egui::Pos2, s: f32, color: Color32) {
|
||||
let body = Rect::from_center_size(c - vec2(0.0, s * 0.06), vec2(s, s * 0.72));
|
||||
p.rect_filled(body, s * 0.24, color);
|
||||
let tail = vec![
|
||||
pos2(body.left() + s * 0.22, body.bottom() - 1.0),
|
||||
pos2(body.left() + s * 0.12, body.bottom() + s * 0.24),
|
||||
pos2(body.left() + s * 0.44, body.bottom() - 1.0),
|
||||
];
|
||||
p.add(egui::Shape::convex_polygon(tail, color, Stroke::NONE));
|
||||
let eye = s * 0.09;
|
||||
let bg = if color.r() as u32 + color.g() as u32 + color.b() as u32 > 380 { DISCORD } else { Color32::WHITE };
|
||||
p.circle_filled(body.center() - vec2(s * 0.18, 0.0), eye, bg);
|
||||
p.circle_filled(body.center() + vec2(s * 0.18, 0.0), eye, bg);
|
||||
}
|
||||
|
||||
/// The big "Join our Discord" button. Returns true when clicked.
|
||||
pub fn discord_button(ui: &mut egui::Ui, label: &str, size: egui::Vec2) -> bool {
|
||||
let (r, resp) = ui.allocate_exact_size(size, Sense::click());
|
||||
let resp = resp.on_hover_text(links::DISCORD).on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
let fill = if resp.hovered() { Color32::from_rgb(0x47, 0x52, 0xc4) } else { DISCORD };
|
||||
ui.painter().rect_filled(r, size.y / 2.0, fill);
|
||||
let icon = size.y * 0.5;
|
||||
let font = semibold((size.y * 0.42).max(11.0));
|
||||
let galley = crate::rtl::plain(ui.ctx(), label, font, Color32::WHITE);
|
||||
let total = icon + 8.0 + galley.size().x;
|
||||
let x0 = r.center().x - total / 2.0;
|
||||
paint_chat_icon(ui.painter(), pos2(x0 + icon / 2.0, r.center().y), icon, Color32::WHITE);
|
||||
ui.painter().galley(pos2(x0 + icon + 8.0, r.center().y - galley.size().y / 2.0), galley, Color32::WHITE);
|
||||
resp.clicked()
|
||||
}
|
||||
|
||||
/// A text link that opens `url`. Returns true when clicked.
|
||||
fn link(ui: &mut egui::Ui, text: &str, url: &str, color: Color32) -> bool {
|
||||
let resp = ui.add(egui::Label::new(egui::RichText::new(text).size(13.0).color(color).underline()).sense(Sense::click()));
|
||||
@@ -137,9 +55,6 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
}
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.add_space(8.0);
|
||||
let (r, _) = ui.allocate_exact_size(vec2(72.0, 72.0), Sense::hover());
|
||||
paint_mark(ui, r, BRAND);
|
||||
ui.add_space(6.0);
|
||||
ui.label(crate::rtl::widget(
|
||||
ui,
|
||||
egui::RichText::new(crate::i18n::tr(&app.ui.language, "DesignCraft")).font(semibold(26.0)).color(t.text_strong),
|
||||
@@ -157,62 +72,18 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
.size(13.0)
|
||||
.color(t.text),
|
||||
));
|
||||
ui.label(crate::rtl::widget(
|
||||
ui,
|
||||
egui::RichText::new(crate::i18n::tr(&app.ui.language, "Part of the ArtCraft suite of open creative tools."))
|
||||
.size(12.0)
|
||||
.color(t.text_dim),
|
||||
));
|
||||
ui.add_space(16.0);
|
||||
if discord_button(ui, "Join the ArtCraft Discord", vec2(300.0, 40.0)) {
|
||||
open = Some("discord");
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
ui.label(crate::rtl::widget(
|
||||
ui,
|
||||
egui::RichText::new(crate::i18n::tr(&app.ui.language, "discord.gg/artcraft — help, feedback and show-and-tell"))
|
||||
.size(11.0)
|
||||
.color(t.text_dim),
|
||||
));
|
||||
ui.add_space(14.0);
|
||||
ui.horizontal(|ui| {
|
||||
// Centre the row of links.
|
||||
let w = 330.0;
|
||||
let w = 190.0;
|
||||
ui.add_space(((ui.available_width() - w) / 2.0).max(0.0));
|
||||
for (text, key) in
|
||||
[("DesignCraft page", "appPage"), ("GitHub", "github"), ("getartcraft.com", "website"), ("Report an issue", "issues")]
|
||||
{
|
||||
for (text, key) in [("GitHub", "github"), ("Report an issue", "issues")] {
|
||||
if link(ui, text, links::get(key).unwrap_or_default(), t.accent) {
|
||||
open = Some(key);
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
});
|
||||
ui.add_space(16.0);
|
||||
ui.separator();
|
||||
ui.add_space(6.0);
|
||||
ui.label(crate::rtl::widget(
|
||||
ui,
|
||||
egui::RichText::new(crate::i18n::tr(&app.ui.language, "More ArtCraft apps")).font(semibold(12.0)).color(t.text_strong),
|
||||
));
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
let pad = ui.spacing().button_padding.x * 2.0 + ui.spacing().item_spacing.x;
|
||||
let w: f32 = SIBLINGS
|
||||
.iter()
|
||||
.map(|(_, n, _)| ui.painter().layout_no_wrap(n.to_string(), egui::FontId::proportional(12.0), Color32::WHITE).size().x + pad)
|
||||
.sum::<f32>()
|
||||
- ui.spacing().item_spacing.x;
|
||||
ui.add_space(((ui.available_width() - w) / 2.0).max(0.0));
|
||||
for (slug, name, what) in SIBLINGS {
|
||||
let url = format!("{}/apps/{slug}", links::WEBSITE);
|
||||
let b =
|
||||
ui.add(egui::Button::new(egui::RichText::new(*name).size(12.0)).corner_radius(10.0)).on_hover_text(format!("{what} — {url}"));
|
||||
if b.clicked() {
|
||||
app.ui.pending_urls.push(url);
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
ui.label(crate::rtl::widget(
|
||||
ui,
|
||||
|
||||
@@ -123,11 +123,6 @@ pub fn app_bar(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
if icons::button(ui, "share", 22.0, false, crate::i18n::tr(&app.ui.language, "Share")).clicked() {
|
||||
app.status("Export a PDF, IDML or package to share — no cloud account needed.");
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
// Always one click away: the ArtCraft community Discord.
|
||||
if crate::about::discord_button(ui, "Discord", vec2(78.0, 22.0)) {
|
||||
let _ = app.run("help.discord", json!({}));
|
||||
}
|
||||
tools_start = ui.min_rect().min.x;
|
||||
ui.min_rect().width()
|
||||
});
|
||||
@@ -684,8 +679,6 @@ pub fn start_screen(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
let _ = app.run("file.newSample", json!({}));
|
||||
}
|
||||
});
|
||||
ui.add_space(20.0);
|
||||
community_card(app, ui);
|
||||
ui.add_space(24.0);
|
||||
ui.label(crate::rtl::widget(
|
||||
ui,
|
||||
@@ -718,50 +711,6 @@ pub fn start_screen(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
let _ = Content::Unassigned;
|
||||
}
|
||||
|
||||
/// Start screen: the ArtCraft community (Discord first) and project links.
|
||||
fn community_card(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
egui::Frame::NONE.fill(t.panel).corner_radius(10.0).inner_margin(egui::Margin::same(14)).show(ui, |ui| {
|
||||
ui.set_max_width(640.0);
|
||||
ui.horizontal(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(44.0, 44.0), Sense::hover());
|
||||
crate::about::paint_mark(ui, r, crate::about::BRAND);
|
||||
ui.add_space(8.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.label(crate::rtl::widget(
|
||||
ui,
|
||||
egui::RichText::new(crate::i18n::tr(&app.ui.language, "Join the ArtCraft community")).font(semibold(14.0)).color(t.text_strong),
|
||||
));
|
||||
ui.label(crate::rtl::widget(
|
||||
ui,
|
||||
egui::RichText::new(crate::i18n::tr(&app.ui.language, "Get help, share your layouts and shape what we build next."))
|
||||
.size(12.0)
|
||||
.color(t.text_dim),
|
||||
));
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
for (text, id) in [("DesignCraft page", "help.appPage"), ("GitHub", "help.github"), ("getartcraft.com", "help.website")] {
|
||||
let url = designcraft_engine::links::get(&id[5..]).unwrap_or_default();
|
||||
let l = ui
|
||||
.add(egui::Label::new(egui::RichText::new(text).size(12.0).color(t.accent).underline()).sense(Sense::click()))
|
||||
.on_hover_text(url)
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
if l.clicked() {
|
||||
let _ = app.run(id, json!({}));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if crate::about::discord_button(ui, crate::i18n::tr(&app.ui.language, "Join our Discord"), vec2(190.0, 38.0)) {
|
||||
let _ = app.run("help.discord", json!({}));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use egui::{pos2, vec2};
|
||||
|
||||
@@ -353,6 +353,8 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
"app.save" => wrap(app.run("file.save", json!({"path": s("path")}))),
|
||||
"app.export" => wrap(app.run("app.exportPng", p.clone())),
|
||||
"app.quit" => {
|
||||
// Automation must not wait on a prompt: quit without asking about unsaved changes.
|
||||
app.quit_without_asking = true;
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
ok(Value::Null)
|
||||
}
|
||||
|
||||
@@ -2078,6 +2078,9 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
|
||||
"closeDocument" => {
|
||||
// The document may have moved in the tab row (or gone) while the dialog was open.
|
||||
let uid = d.fields.get("uid").and_then(Value::as_u64);
|
||||
if d.b("quitting") {
|
||||
return app.confirm_quit_document(uid.ok_or("Missing document in quit confirmation")?, d.b("discard"));
|
||||
}
|
||||
let index = |app: &DesignApp| app.session.documents().iter().position(|doc| Some(doc.uid) == uid);
|
||||
let Some(i) = index(app) else { return Ok(Value::Null) };
|
||||
if !d.b("discard") {
|
||||
|
||||
@@ -699,6 +699,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
|
||||
),
|
||||
("for", ["for", "pour", "por", "に対して", "行"]),
|
||||
("About DesignCraft", ["About DesignCraft", "About DesignCraft", "About DesignCraft", "About DesignCraft", "关于 DesignCraft"]),
|
||||
("Quit DesignCraft", ["DesignCraft beenden", "Quitter DesignCraft", "Salir de DesignCraft", "DesignCraft を終了", "退出 DesignCraft"]),
|
||||
("Accept All Changes", ["Accept All Changes", "Accept All Changes", "Accept All Changes", "Accept All Changes", "接受所有更改"]),
|
||||
("Activate Document", ["Activate Document", "Activate Document", "Activate Document", "Activate Document", "激活文档"]),
|
||||
("Add Anchor Point", ["Add Anchor Point", "Add Anchor Point", "Add Anchor Point", "Add Anchor Point", "添加锚点"]),
|
||||
@@ -743,8 +744,6 @@ const TABLE: &[(&str, [&str; 5])] = &[
|
||||
"Apply Parent to Pages…",
|
||||
["Apply Parent to Pages…", "Apply Parent to Pages…", "Apply Parent to Pages…", "Apply Parent to Pages…", "将主页应用于页面…"],
|
||||
),
|
||||
("ArtCraft App Page…", ["ArtCraft App Page…", "ArtCraft App Page…", "ArtCraft App Page…", "ArtCraft App Page…", "ArtCraft 应用页面…"]),
|
||||
("ArtCraft Website…", ["ArtCraft Website…", "ArtCraft Website…", "ArtCraft Website…", "ArtCraft Website…", "ArtCraft 网站…"]),
|
||||
("Attributes", ["Attributes", "Attributes", "Attributes", "Attributes", "属性"]),
|
||||
("Bevel and Emboss", ["Bevel and Emboss", "Bevel and Emboss", "Bevel and Emboss", "Bevel and Emboss", "斜面和浮雕"]),
|
||||
("Buttons", ["Buttons", "Buttons", "Buttons", "Buttons", "按钮"]),
|
||||
@@ -1020,16 +1019,6 @@ const TABLE: &[(&str, [&str; 5])] = &[
|
||||
("Insert Variable", ["Insert Variable", "Insert Variable", "Insert Variable", "Insert Variable", "插入变量"]),
|
||||
("Inspect Document", ["Inspect Document", "Inspect Document", "Inspect Document", "Inspect Document", "检查文档"]),
|
||||
("Interface Color Theme", ["Interface Color Theme", "Interface Color Theme", "Interface Color Theme", "Interface Color Theme", "界面颜色主题"]),
|
||||
(
|
||||
"Join the ArtCraft Discord…",
|
||||
[
|
||||
"Join the ArtCraft Discord…",
|
||||
"Join the ArtCraft Discord…",
|
||||
"Join the ArtCraft Discord…",
|
||||
"Join the ArtCraft Discord…",
|
||||
"加入 ArtCraft Discord…",
|
||||
],
|
||||
),
|
||||
("Last Object Below", ["Last Object Below", "Last Object Below", "Last Object Below", "Last Object Below", "下方最后一个对象"]),
|
||||
("Library Contents", ["Library Contents", "Library Contents", "Library Contents", "Library Contents", "库内容"]),
|
||||
("Library Items", ["Library Items", "Library Items", "Library Items", "Library Items", "库项目"]),
|
||||
@@ -1556,20 +1545,9 @@ const TABLE: &[(&str, [&str; 5])] = &[
|
||||
("Hyphens and Dashes", ["Hyphens and Dashes", "Hyphens and Dashes", "Hyphens and Dashes", "Hyphens and Dashes", "连字符和破折号"]),
|
||||
("Interactive", ["Interactive", "Interactive", "Interactive", "Interactive", "交互式"]),
|
||||
("Invert", ["Invert", "Invert", "Invert", "Invert", "反相"]),
|
||||
(
|
||||
"Join the ArtCraft community",
|
||||
[
|
||||
"Join the ArtCraft community",
|
||||
"Join the ArtCraft community",
|
||||
"Join the ArtCraft community",
|
||||
"Join the ArtCraft community",
|
||||
"加入 ArtCraft 社区",
|
||||
],
|
||||
),
|
||||
("Keyboard Increments", ["Keyboard Increments", "Keyboard Increments", "Keyboard Increments", "Keyboard Increments", "键盘增量"]),
|
||||
("Liquid guides", ["Liquid guides", "Liquid guides", "Liquid guides", "Liquid guides", "自适应参考线"]),
|
||||
("Markers", ["Markers", "Markers", "Markers", "Markers", "标记"]),
|
||||
("More ArtCraft apps", ["More ArtCraft apps", "More ArtCraft apps", "More ArtCraft apps", "More ArtCraft apps", "更多 ArtCraft 应用"]),
|
||||
("Nested Line Styles", ["Nested Line Styles", "Nested Line Styles", "Nested Line Styles", "Nested Line Styles", "嵌套行样式"]),
|
||||
("Nested Styles", ["Nested Styles", "Nested Styles", "Nested Styles", "Nested Styles", "嵌套样式"]),
|
||||
(
|
||||
@@ -1688,16 +1666,6 @@ const TABLE: &[(&str, [&str; 5])] = &[
|
||||
],
|
||||
),
|
||||
("Page · PPI", ["Page · PPI", "Page · PPI", "Page · PPI", "Page · PPI", "页面 · PPI"]),
|
||||
(
|
||||
"Part of the ArtCraft suite of open creative tools.",
|
||||
[
|
||||
"Part of the ArtCraft suite of open creative tools.",
|
||||
"Part of the ArtCraft suite of open creative tools.",
|
||||
"Part of the ArtCraft suite of open creative tools.",
|
||||
"Part of the ArtCraft suite of open creative tools.",
|
||||
"ArtCraft 开源创意工具套件的一部分。",
|
||||
],
|
||||
),
|
||||
("Pasteboard Options", ["Pasteboard Options", "Pasteboard Options", "Pasteboard Options", "Pasteboard Options", "粘贴板选项"]),
|
||||
("Paths", ["Paths", "Paths", "Paths", "Paths", "路径"]),
|
||||
(
|
||||
@@ -1872,16 +1840,6 @@ const TABLE: &[(&str, [&str; 5])] = &[
|
||||
"[无] — 在色带上选择一种颜色。",
|
||||
],
|
||||
),
|
||||
(
|
||||
"discord.gg/artcraft — help, feedback and show-and-tell",
|
||||
[
|
||||
"discord.gg/artcraft — help, feedback and show-and-tell",
|
||||
"discord.gg/artcraft — help, feedback and show-and-tell",
|
||||
"discord.gg/artcraft — help, feedback and show-and-tell",
|
||||
"discord.gg/artcraft — help, feedback and show-and-tell",
|
||||
"discord.gg/artcraft — 帮助、反馈和展示",
|
||||
],
|
||||
),
|
||||
(
|
||||
"⌥-click clears overrides",
|
||||
["⌥-click clears overrides", "⌥-click clears overrides", "⌥-click clears overrides", "⌥-click clears overrides", "⌥-单击清除覆盖"],
|
||||
@@ -2607,7 +2565,6 @@ const TABLE: &[(&str, [&str; 5])] = &[
|
||||
("H&J Violations", ["H&J Violations", "H&J Violations", "H&J Violations", "H&J Violations", "断字与对齐违规"]),
|
||||
("Include Bleed", ["Include Bleed", "Include Bleed", "Include Bleed", "Include Bleed", "包含出血"]),
|
||||
("Include Stroke Weight", ["Include Stroke Weight", "Include Stroke Weight", "Include Stroke Weight", "Include Stroke Weight", "包含描边粗细"]),
|
||||
("Join our Discord", ["Join our Discord", "Join our Discord", "Join our Discord", "Join our Discord", "加入我们的 Discord 社区"]),
|
||||
("Keep Violations", ["Keep Violations", "Keep Violations", "Keep Violations", "Keep Violations", "保持选项违规"]),
|
||||
(
|
||||
"Overprint [Black] Swatch at 100%",
|
||||
|
||||
@@ -149,6 +149,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Bookmarks", "إشارات مرجعية"),
|
||||
("Show All Menu Items", "إظهار كل عناصر القائمة"),
|
||||
("About DesignCraft", "حول DesignCraft"),
|
||||
("Quit DesignCraft", "إنهاء DesignCraft"),
|
||||
("Welcome to DesignCraft", "مرحباً بك في DesignCraft"),
|
||||
("Page layout for print and screen — fast, open and scriptable.", "تخطيط الصفحات للطباعة والشاشة — سريع ومفتوح وقابل للبرمجة."),
|
||||
(" New file ", " ملف جديد "),
|
||||
@@ -156,12 +157,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
(" Open sample magazine ", " فتح نموذج المجلة "),
|
||||
("Start a new document", "بدء مستند جديد"),
|
||||
("No document open", "لا يوجد مستند مفتوح"),
|
||||
("Join the ArtCraft community", "انضم إلى مجتمع ArtCraft"),
|
||||
("Get help, share your layouts and shape what we build next.", "احصل على المساعدة وشارك تصميماتك وساهم في تطوير التطبيق."),
|
||||
("Join our Discord", "انضم إلى Discord"),
|
||||
("Community", "المجتمع"),
|
||||
("Join the community", "انضم إلى المجتمع"),
|
||||
("discord.gg/artcraft — help, feedback and show-and-tell", "discord.gg/artcraft — مساعدة وآراء وعرض الأعمال"),
|
||||
(" OK ", " موافق "),
|
||||
("Float panel", "تعويم اللوحة"),
|
||||
("Dock panel", "إرساء اللوحة"),
|
||||
@@ -895,7 +893,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Merge into Active Layer", "دمج في الطبقة النشطة"),
|
||||
("Misspelled word → Correction (one per line):", "كلمة خاطئة ← تصحيح (واحد في كل سطر):"),
|
||||
("Mobile", "جوال"),
|
||||
("More ArtCraft apps", "المزيد من تطبيقات ArtCraft"),
|
||||
("Move Selection Here", "نقل التحديد إلى هنا"),
|
||||
("Move to Color Group", "نقل إلى مجموعة ألوان"),
|
||||
("Move to Group", "نقل إلى مجموعة"),
|
||||
@@ -934,7 +931,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Page Numbering", "ترقيم الصفحات"),
|
||||
("Page · PPI", "صفحة · PPI"),
|
||||
("Pages:", "الصفحات:"),
|
||||
("Part of the ArtCraft suite of open creative tools.", "جزء من مجموعة ArtCraft للأدوات الإبداعية المفتوحة."),
|
||||
("Pasteboard Options", "خيارات مساحة اللصق"),
|
||||
("Path", "مسار"),
|
||||
("Place the insertion point in a table, or create one.", "ضع نقطة الإدراج داخل جدول أو أنشئ جدولًا."),
|
||||
@@ -1110,8 +1106,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Apply Cell Style", "تطبيق نمط الخلية"),
|
||||
("Apply Condition", "تطبيق شرط"),
|
||||
("Apply Parent to Pages…", "تطبيق الصفحة الأصلية على الصفحات…"),
|
||||
("ArtCraft App Page…", "صفحة تطبيق ArtCraft…"),
|
||||
("ArtCraft Website…", "موقع ArtCraft…"),
|
||||
("Attributes", "السمات"),
|
||||
("Bevel and Emboss", "القطع المشطوف والنقش البارز"),
|
||||
("Buttons", "أزرار"),
|
||||
@@ -1213,7 +1207,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Insert Variable", "إدراج متغير"),
|
||||
("Inspect Document", "فحص المستند"),
|
||||
("Interface Color Theme", "نسق ألوان الواجهة"),
|
||||
("Join the ArtCraft Discord…", "الانضمام إلى Discord الخاص بـ ArtCraft…"),
|
||||
("Last Object Below", "آخر كائن أسفل"),
|
||||
("Library Contents", "محتويات المكتبة"),
|
||||
("Library Items", "عناصر المكتبة"),
|
||||
|
||||
@@ -396,6 +396,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Spreads of up to 10 pages", "Doppie pagine con al massimo 10 pagine"),
|
||||
("for", "per"),
|
||||
("About DesignCraft", "Informazioni su DesignCraft"),
|
||||
("Quit DesignCraft", "Esci da DesignCraft"),
|
||||
("Accept All Changes", "Accetta tutte le modifiche"),
|
||||
("Activate Document", "Attiva documento"),
|
||||
("Add Anchor Point", "Aggiungi punto di ancoraggio"),
|
||||
@@ -413,8 +414,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Apply Color", "Applica colore"),
|
||||
("Apply Condition", "Applica condizione"),
|
||||
("Apply Parent to Pages…", "Applica pagina mastro alle pagine…"),
|
||||
("ArtCraft App Page…", "Pagina dell'app ArtCraft…"),
|
||||
("ArtCraft Website…", "Sito web di ArtCraft…"),
|
||||
("Attributes", "Attributi"),
|
||||
("Bevel and Emboss", "Smusso e rilievo"),
|
||||
("Buttons", "Pulsanti"),
|
||||
@@ -541,7 +540,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Insert Variable", "Inserisci variabile"),
|
||||
("Inspect Document", "Ispeziona documento"),
|
||||
("Interface Color Theme", "Tema colore dell'interfaccia"),
|
||||
("Join the ArtCraft Discord…", "Unisciti al Discord di ArtCraft…"),
|
||||
("Last Object Below", "Ultimo oggetto sotto"),
|
||||
("Library Contents", "Contenuto libreria"),
|
||||
("Library Items", "Elementi libreria"),
|
||||
@@ -846,11 +844,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Hyphens and Dashes", "Trattini e lineette"),
|
||||
("Interactive", "Interattivo"),
|
||||
("Invert", "Inverti"),
|
||||
("Join the ArtCraft community", "Unisciti alla community ArtCraft"),
|
||||
("Keyboard Increments", "Incrementi da tastiera"),
|
||||
("Liquid guides", "Guide liquide"),
|
||||
("Markers", "Indicatori"),
|
||||
("More ArtCraft apps", "Altre app ArtCraft"),
|
||||
("Nested Line Styles", "Stili di riga nidificati"),
|
||||
("Nested Styles", "Stili nidificati"),
|
||||
("No articles: exports follow page order.", "Nessun articolo: le esportazioni seguono l'ordine delle pagine."),
|
||||
@@ -885,7 +881,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
"Impaginazione per la stampa e lo schermo — veloce, aperta e automatizzabile con script.",
|
||||
),
|
||||
("Page · PPI", "Pagina · PPI"),
|
||||
("Part of the ArtCraft suite of open creative tools.", "Parte della suite ArtCraft di strumenti creativi aperti."),
|
||||
("Pasteboard Options", "Opzioni tavolo di montaggio"),
|
||||
("Paths", "Tracciati"),
|
||||
("Place the insertion point in a table, or create one.", "Posiziona il punto di inserimento in una tabella o creane una."),
|
||||
@@ -937,7 +932,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("[No Group]", "[Nessun gruppo]"),
|
||||
("[None]", "[Nessuno]"),
|
||||
("[None] — choose a colour on the ramp.", "[Nessuno] — scegli un colore sulla barra dei colori."),
|
||||
("discord.gg/artcraft — help, feedback and show-and-tell", "discord.gg/artcraft — aiuto, feedback e condivisione dei lavori"),
|
||||
("⌥-click clears overrides", "⌥-clic cancella le modifiche locali"),
|
||||
("⚠ out of date", "⚠ non aggiornato"),
|
||||
("General", "Generale"),
|
||||
@@ -1338,7 +1332,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("H&J Violations", "Violazioni di sillabazione e giustificazione"),
|
||||
("Include Bleed", "Includi pagina al vivo"),
|
||||
("Include Stroke Weight", "Includi spessore traccia"),
|
||||
("Join our Discord", "Unisciti al nostro Discord"),
|
||||
("Keep Violations", "Violazioni opzioni di mantenimento"),
|
||||
("Overprint [Black] Swatch at 100%", "Sovrastampa campione [Nero] al 100%"),
|
||||
("Printer's Marks", "Segni di stampa"),
|
||||
|
||||
@@ -398,6 +398,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Spreads of up to 10 pages", "最大10ページのスプレッド"),
|
||||
("for", "に対して"),
|
||||
("About DesignCraft", "DesignCraft について"),
|
||||
("Quit DesignCraft", "DesignCraft を終了"),
|
||||
("Accept All Changes", "すべての変更を採用"),
|
||||
("Activate Document", "ドキュメントをアクティブ化"),
|
||||
("Add Anchor Point", "アンカーポイントを追加"),
|
||||
@@ -415,8 +416,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Apply Color", "カラーを適用"),
|
||||
("Apply Condition", "条件を適用"),
|
||||
("Apply Parent to Pages…", "ページに親ページを適用…"),
|
||||
("ArtCraft App Page…", "ArtCraft アプリページ…"),
|
||||
("ArtCraft Website…", "ArtCraft ウェブサイト…"),
|
||||
("Attributes", "属性"),
|
||||
("Bevel and Emboss", "ベベルとエンボス"),
|
||||
("Buttons", "ボタン"),
|
||||
@@ -543,7 +542,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Insert Variable", "変数を挿入"),
|
||||
("Inspect Document", "ドキュメントを調査"),
|
||||
("Interface Color Theme", "インターフェイスのカラーテーマ"),
|
||||
("Join the ArtCraft Discord…", "ArtCraft の Discord に参加…"),
|
||||
("Last Object Below", "最背面のオブジェクト"),
|
||||
("Library Contents", "ライブラリの内容"),
|
||||
("Library Items", "ライブラリの項目"),
|
||||
@@ -845,11 +843,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Hyphens and Dashes", "ハイフンとダッシュ"),
|
||||
("Interactive", "インタラクティブ"),
|
||||
("Invert", "反転"),
|
||||
("Join the ArtCraft community", "ArtCraft コミュニティに参加"),
|
||||
("Keyboard Increments", "キーボードの移動量"),
|
||||
("Liquid guides", "リキッドガイド"),
|
||||
("Markers", "マーカー"),
|
||||
("More ArtCraft apps", "他の ArtCraft アプリ"),
|
||||
("Nested Line Styles", "入れ子行スタイル"),
|
||||
("Nested Styles", "入れ子スタイル"),
|
||||
("No articles: exports follow page order.", "アーティクルがありません。ページ順に書き出します。"),
|
||||
@@ -884,7 +880,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
"印刷・画面向けの高速なページレイアウトツールです。オープンで、スクリプトにも対応します。",
|
||||
),
|
||||
("Page · PPI", "ページ・PPI"),
|
||||
("Part of the ArtCraft suite of open creative tools.", "オープンな制作ツール群 ArtCraft の一部です。"),
|
||||
("Pasteboard Options", "ペーストボードのオプション"),
|
||||
("Paths", "パス"),
|
||||
("Place the insertion point in a table, or create one.", "表内に挿入位置を置くか、表を作成してください。"),
|
||||
@@ -936,7 +931,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("[No Group]", "[グループなし]"),
|
||||
("[None]", "[なし]"),
|
||||
("[None] — choose a colour on the ramp.", "[なし] — カラーバーでカラーを選択してください。"),
|
||||
("discord.gg/artcraft — help, feedback and show-and-tell", "discord.gg/artcraft — サポート・ご意見・作品紹介"),
|
||||
("⌥-click clears overrides", "⌥ を押しながらクリックするとオーバーライドを消去します"),
|
||||
("⚠ out of date", "⚠ 要更新"),
|
||||
("General", "一般"),
|
||||
@@ -1334,7 +1328,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("H&J Violations", "H&J の制約違反"),
|
||||
("Include Bleed", "裁ち落としを含める"),
|
||||
("Include Stroke Weight", "線の太さを含める"),
|
||||
("Join our Discord", "Discord に参加"),
|
||||
("Keep Violations", "段落保持の制約違反"),
|
||||
("Overprint [Black] Swatch at 100%", "100% の「黒」スウォッチをオーバープリント"),
|
||||
("Printer's Marks", "プリント用マーク"),
|
||||
@@ -1459,8 +1452,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
// few field labels.
|
||||
("Anchored Object Options", "アンカー付きオブジェクトのオプション"),
|
||||
("Apply Parent to Pages", "ページに親ページを適用"),
|
||||
("ArtCraft App Page", "ArtCraft アプリページ"),
|
||||
("ArtCraft Website", "ArtCraft ウェブサイト"),
|
||||
("Check Spelling", "スペルチェック"),
|
||||
("Corner Options", "角のオプション"),
|
||||
("Create Guides", "ガイドを作成"),
|
||||
@@ -1489,7 +1480,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Insert Cross-Reference", "相互参照を挿入"),
|
||||
("Insert Math Expression", "数式を挿入"),
|
||||
("Insert Pages", "ページを挿入"),
|
||||
("Join the ArtCraft Discord", "ArtCraft の Discord に参加"),
|
||||
("Load DTD", "DTD を読み込み"),
|
||||
("Load Swatches", "スウォッチを読み込み"),
|
||||
("Margins and Columns", "マージン・段組"),
|
||||
|
||||
@@ -149,6 +149,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Bookmarks", "Indicadores"),
|
||||
("Show All Menu Items", "Mostrar todos os itens de menu"),
|
||||
("About DesignCraft", "Sobre o DesignCraft"),
|
||||
("Quit DesignCraft", "Encerrar o DesignCraft"),
|
||||
("Welcome to DesignCraft", "Bem-vindo ao DesignCraft"),
|
||||
("Page layout for print and screen — fast, open and scriptable.", "Layout de página para impressão e tela — rápido, aberto e scriptável."),
|
||||
(" New file ", " Novo arquivo "),
|
||||
@@ -156,15 +157,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
(" Open sample magazine ", " Abrir revista de exemplo "),
|
||||
("Start a new document", "Iniciar um novo documento"),
|
||||
("No document open", "Nenhum documento aberto"),
|
||||
("Join the ArtCraft community", "Junte-se à comunidade ArtCraft"),
|
||||
(
|
||||
"Get help, share your layouts and shape what we build next.",
|
||||
"Obtenha ajuda, compartilhe seus layouts e ajude a moldar o que construímos em seguida.",
|
||||
),
|
||||
("Join our Discord", "Junte-se ao nosso Discord"),
|
||||
("Community", "Comunidade"),
|
||||
("Join the community", "Junte-se à comunidade"),
|
||||
("discord.gg/artcraft — help, feedback and show-and-tell", "discord.gg/artcraft — ajuda, feedback e demonstrações"),
|
||||
(" OK ", " OK "),
|
||||
("Float panel", "Painel flutuante"),
|
||||
("Dock panel", "Painel encaixado"),
|
||||
@@ -897,7 +895,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Merge into Active Layer", "Mesclar na camada ativa"),
|
||||
("Misspelled word → Correction (one per line):", "Palavra incorreta → Correção (uma por linha):"),
|
||||
("Mobile", "Dispositivo móvel"),
|
||||
("More ArtCraft apps", "Mais aplicativos ArtCraft"),
|
||||
("Move Selection Here", "Mover a seleção para cá"),
|
||||
("Move to Color Group", "Mover para grupo de cores"),
|
||||
("Move to Group", "Mover para grupo"),
|
||||
@@ -942,7 +939,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Page Numbering", "Numeração de página"),
|
||||
("Page · PPI", "Página · PPI"),
|
||||
("Pages:", "Páginas:"),
|
||||
("Part of the ArtCraft suite of open creative tools.", "Parte do conjunto ArtCraft de ferramentas criativas abertas."),
|
||||
("Pasteboard Options", "Opções da área de transferência"),
|
||||
("Path", "Caminho"),
|
||||
("Place the insertion point in a table, or create one.", "Coloque o ponto de inserção em uma tabela ou crie uma."),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Ukrainian interface translations; publishing terms are reviewed in context.
|
||||
//! Common phrases are adapted from the MIT OR Apache-2.0 ArtCraft sibling catalogs
|
||||
//! Common phrases are adapted from the MIT OR Apache-2.0 sibling catalogs
|
||||
//! (VectorCraft, PhotoCraft, WordCraft and DeckCraft); see ASSETS.md for pinned sources.
|
||||
pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("File", "Файл"),
|
||||
@@ -399,6 +399,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Spreads of up to 10 pages", "Розвороти до 10 сторінок"),
|
||||
("for", "для"),
|
||||
("About DesignCraft", "Про DesignCraft"),
|
||||
("Quit DesignCraft", "Завершити DesignCraft"),
|
||||
("Accept All Changes", "Прийняти всі зміни"),
|
||||
("Activate Document", "Активувати документ"),
|
||||
("Add Anchor Point", "Додати опорну точку"),
|
||||
@@ -416,8 +417,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Apply Color", "Застосувати колір"),
|
||||
("Apply Condition", "Застосувати умову"),
|
||||
("Apply Parent to Pages…", "Застосувати шаблон до сторінок…"),
|
||||
("ArtCraft App Page…", "Сторінка програми ArtCraft…"),
|
||||
("ArtCraft Website…", "Вебсайт ArtCraft…"),
|
||||
("Attributes", "Атрибути"),
|
||||
("Bevel and Emboss", "Скіс і рельєф"),
|
||||
("Buttons", "Кнопки"),
|
||||
@@ -544,7 +543,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Insert Variable", "Вставити змінну"),
|
||||
("Inspect Document", "Перевірити документ"),
|
||||
("Interface Color Theme", "Колірна тема інтерфейсу"),
|
||||
("Join the ArtCraft Discord…", "Приєднатися до Discord ArtCraft…"),
|
||||
("Last Object Below", "Останній об’єкт нижче"),
|
||||
("Library Contents", "Вміст бібліотеки"),
|
||||
("Library Items", "Елементи бібліотеки"),
|
||||
@@ -846,11 +844,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Hyphens and Dashes", "Дефіси та тире"),
|
||||
("Interactive", "Інтерактивність"),
|
||||
("Invert", "Інвертувати"),
|
||||
("Join the ArtCraft community", "Приєднатися до спільноти ArtCraft"),
|
||||
("Keyboard Increments", "Кроки переміщення клавішами"),
|
||||
("Liquid guides", "Напрямні гнучкого макета"),
|
||||
("Markers", "Маркери"),
|
||||
("More ArtCraft apps", "Інші програми ArtCraft"),
|
||||
("Nested Line Styles", "Вкладені стилі рядків"),
|
||||
("Nested Styles", "Вкладені стилі"),
|
||||
("No articles: exports follow page order.", "Немає статей: експорт дотримується порядку сторінок."),
|
||||
@@ -882,7 +878,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Page Numbering", "Нумерація сторінок"),
|
||||
("Page layout for print and screen — fast, open and scriptable.", "Верстка для друку й екрана — швидка, відкрита, зі сценаріями."),
|
||||
("Page · PPI", "Сторінка · PPI"),
|
||||
("Part of the ArtCraft suite of open creative tools.", "Частина набору відкритих творчих інструментів ArtCraft."),
|
||||
("Pasteboard Options", "Параметри монтажного столу"),
|
||||
("Paths", "Контури"),
|
||||
("Place the insertion point in a table, or create one.", "Установіть курсор у таблиці або створіть її."),
|
||||
@@ -934,7 +929,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("[No Group]", "[Без групи]"),
|
||||
("[None]", "[Немає]"),
|
||||
("[None] — choose a colour on the ramp.", "[Немає] — виберіть колір на шкалі."),
|
||||
("discord.gg/artcraft — help, feedback and show-and-tell", "discord.gg/artcraft — допомога, відгуки та обмін роботами"),
|
||||
("⌥-click clears overrides", "⌥-клацання скидає перевизначення"),
|
||||
("⚠ out of date", "⚠ застаріле"),
|
||||
("General", "Загальні"),
|
||||
@@ -1335,7 +1329,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("H&J Violations", "Порушення переносів і вирівнювання"),
|
||||
("Include Bleed", "Включати виліт"),
|
||||
("Include Stroke Weight", "Враховувати товщину обведення"),
|
||||
("Join our Discord", "Приєднуйтесь до нашого Discord"),
|
||||
("Keep Violations", "Порушення правил утримання рядків"),
|
||||
("Overprint [Black] Swatch at 100%", "Накладання зразка [Чорний] на 100%"),
|
||||
("Printer's Marks", "Друкарські позначки"),
|
||||
|
||||
@@ -510,6 +510,18 @@ pub struct DesignApp {
|
||||
last_time: f64,
|
||||
/// When recovery data was last written (seconds, egui time).
|
||||
pub last_recovery: f64,
|
||||
/// Quit was chosen: close the window on the next frame, as its close button does.
|
||||
quit_requested: bool,
|
||||
/// Close the window without asking about unsaved changes (the control channel's `app.quit`).
|
||||
pub(crate) quit_without_asking: bool,
|
||||
/// No document is discarded until every unsaved document has an up-to-date approval.
|
||||
quitting: Option<QuitState>,
|
||||
}
|
||||
|
||||
struct QuitState {
|
||||
original_active: Option<u64>,
|
||||
accepted: HashMap<u64, u64>,
|
||||
pending: Option<u64>,
|
||||
}
|
||||
|
||||
impl DesignApp {
|
||||
@@ -547,6 +559,9 @@ impl DesignApp {
|
||||
native_shortcuts: Default::default(),
|
||||
last_time: 0.0,
|
||||
last_recovery: 0.0,
|
||||
quit_requested: false,
|
||||
quit_without_asking: false,
|
||||
quitting: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -802,6 +817,151 @@ impl DesignApp {
|
||||
let _ = self.open_dropped(&p);
|
||||
}
|
||||
}
|
||||
self.handle_quit(ctx);
|
||||
}
|
||||
|
||||
/// Quit: close the window the way its close button does, so unsaved documents ask first.
|
||||
pub fn request_quit(&mut self) {
|
||||
self.quit_requested = true;
|
||||
}
|
||||
|
||||
/// Ask about unsaved documents without closing any of them yet. A later Cancel must also
|
||||
/// keep documents whose earlier answer was Don't Save, including their history and recovery.
|
||||
fn handle_quit(&mut self, ctx: &egui::Context) {
|
||||
if std::mem::take(&mut self.quit_requested) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
if ctx.input(|i| i.viewport().close_requested()) {
|
||||
if self.quit_without_asking {
|
||||
self.quitting = None;
|
||||
return;
|
||||
}
|
||||
if self.quitting.is_none() && !self.session.documents().iter().any(|d| d.is_dirty()) {
|
||||
return;
|
||||
}
|
||||
let approved =
|
||||
self.quitting.as_ref().is_some_and(|q| q.pending.is_none()) && self.ui.dialog.is_none() && self.next_unapproved_document().is_none();
|
||||
if approved {
|
||||
// Commit only when the backend receives the final close, after checking for
|
||||
// edits that arrived since the last answer. Closing also discards recovery data.
|
||||
if let Err(e) = self.finish_confirmed_quit() {
|
||||
self.status(e);
|
||||
self.cancel_quit();
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
}
|
||||
return;
|
||||
}
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
// Dock Quit can arrive while the app is hidden or minimized. Make its question
|
||||
// visible through the normal safe window commands before asking about documents.
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true));
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
|
||||
if self.quitting.is_none() {
|
||||
self.quitting = Some(QuitState { original_active: self.session.active().map(|d| d.uid), accepted: HashMap::new(), pending: None });
|
||||
}
|
||||
}
|
||||
let Some(q) = self.quitting.as_ref() else { return };
|
||||
if let Some(uid) = q.pending {
|
||||
let asking = self.ui.dialog.as_ref().is_some_and(|d| {
|
||||
d.id == "closeDocument"
|
||||
&& d.fields.get("quitting").and_then(Value::as_bool) == Some(true)
|
||||
&& d.fields.get("uid").and_then(Value::as_u64) == Some(uid)
|
||||
});
|
||||
if !asking {
|
||||
self.cancel_quit();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if self.ui.dialog.is_some() {
|
||||
self.cancel_quit();
|
||||
return;
|
||||
}
|
||||
self.ask_next_unsaved(ctx);
|
||||
}
|
||||
|
||||
/// Finalize a complete, still-current quit plan at an orderly host exit. The host can
|
||||
/// terminate before egui receives its queued Close; an unfinished/stale plan leaves every
|
||||
/// document live so the host can preserve its recovery data instead.
|
||||
pub fn finish_confirmed_quit(&mut self) -> Result<(), String> {
|
||||
let approved =
|
||||
self.quitting.as_ref().is_some_and(|q| q.pending.is_none()) && self.ui.dialog.is_none() && self.next_unapproved_document().is_none();
|
||||
if !approved {
|
||||
return Ok(());
|
||||
}
|
||||
while !self.session.documents().is_empty() {
|
||||
self.run("file.close", json!({"index": 0}))?;
|
||||
}
|
||||
self.quitting = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn next_unapproved_document(&self) -> Option<(usize, u64)> {
|
||||
let q = self.quitting.as_ref()?;
|
||||
self.session
|
||||
.documents()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, d)| d.is_dirty() && q.accepted.get(&d.uid) != Some(&d.revision))
|
||||
.map(|(i, d)| (i, d.uid))
|
||||
}
|
||||
|
||||
fn cancel_quit(&mut self) {
|
||||
let Some(q) = self.quitting.take() else { return };
|
||||
if let Some(i) = q.original_active.and_then(|uid| self.session.documents().iter().position(|d| d.uid == uid)) {
|
||||
let _ = self.run("file.activate", json!({"index": i}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Save/Don't Save during Quit approves this UID's current revision, without destroying it.
|
||||
pub(crate) fn confirm_quit_document(&mut self, uid: u64, discard: bool) -> Result<Value, String> {
|
||||
if self.quitting.as_ref().and_then(|q| q.pending) != Some(uid) {
|
||||
return Err("Quit confirmation is no longer active".into());
|
||||
}
|
||||
let Some(i) = self.session.documents().iter().position(|d| d.uid == uid) else {
|
||||
if let Some(q) = self.quitting.as_mut() {
|
||||
q.pending = None;
|
||||
}
|
||||
return Ok(Value::Null);
|
||||
};
|
||||
if !discard {
|
||||
let saved = self.run("file.activate", json!({"index": i})).and_then(|_| self.run("app.save", json!({})));
|
||||
if let Err(e) = saved {
|
||||
self.cancel_quit();
|
||||
return Err(e);
|
||||
}
|
||||
if self.session.documents().iter().find(|d| d.uid == uid).is_none_or(|d| d.is_dirty()) {
|
||||
// No file chosen (or an asynchronous host save has not completed): stop quitting.
|
||||
self.cancel_quit();
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
}
|
||||
if let Some(q) = self.quitting.as_mut() {
|
||||
if let Some(d) = self.session.documents().iter().find(|d| d.uid == uid) {
|
||||
q.accepted.insert(uid, d.revision);
|
||||
}
|
||||
q.pending = None;
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Show the next unapproved document, or request a final close that revalidates all approvals.
|
||||
fn ask_next_unsaved(&mut self, ctx: &egui::Context) {
|
||||
let Some((i, uid)) = self.next_unapproved_document() else {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
return;
|
||||
};
|
||||
if self.run("file.activate", json!({"index": i})).is_err() {
|
||||
self.cancel_quit();
|
||||
return;
|
||||
}
|
||||
menus::close_document(self, Some(i));
|
||||
if let Some(d) = self.ui.dialog.as_mut() {
|
||||
d.fields.insert("quitting".into(), json!(true));
|
||||
}
|
||||
if let Some(q) = self.quitting.as_mut() {
|
||||
q.pending = Some(uid);
|
||||
}
|
||||
}
|
||||
|
||||
/// A file dropped on the window: documents open, anything else is placed.
|
||||
@@ -812,6 +972,7 @@ impl DesignApp {
|
||||
|
||||
/// Open (`file.open`, `file.openBytes`) or place (`file.place`) a file the user chose. A file
|
||||
/// that can't be opened or placed gets an alert with the reason, as well as the status line.
|
||||
/// A successful import with warnings keeps the document and shows the warnings in an alert.
|
||||
pub fn open_file(&mut self, cmd: &str, params: Value) -> Result<Value, String> {
|
||||
let file = match params.get("path").and_then(Value::as_str) {
|
||||
Some(path) => std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string()),
|
||||
@@ -821,6 +982,20 @@ impl DesignApp {
|
||||
if let Err(e) = &r {
|
||||
let title = if cmd == "file.place" { "Can't Place the File" } else { "Can't Open the File" };
|
||||
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
|
||||
.get("warnings")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.filter(|warning| !warning.is_empty())
|
||||
.collect();
|
||||
if !warnings.is_empty() {
|
||||
self.status(format!("{file}: {}", warnings.join("; ")));
|
||||
self.ui.dialog =
|
||||
Some(dialogs::Dialog::new("alert", json!({"title": "Import Warnings", "file": file, "message": warnings.join("\n")})));
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
@@ -1158,6 +1333,425 @@ mod tests {
|
||||
assert!(app.ui.dialog.is_none());
|
||||
}
|
||||
|
||||
/// One frame of the quit handling, with the window asked to close when `close`; returns the
|
||||
/// commands sent to the window.
|
||||
fn quit_frame(app: &mut DesignApp, ctx: &egui::Context, close: bool) -> Vec<egui::ViewportCommand> {
|
||||
let mut raw = egui::RawInput::default();
|
||||
if close {
|
||||
raw.viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close);
|
||||
}
|
||||
let mut out = ctx.run_ui(raw, |ui| app.handle_quit(ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
out.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn dialog_uid(app: &DesignApp) -> Option<u64> {
|
||||
app.ui.dialog.as_ref().filter(|d| d.id == "closeDocument").and_then(|d| d.fields.get("uid")?.as_u64())
|
||||
}
|
||||
|
||||
/// Quitting closed the window at once, losing unsaved changes (#544).
|
||||
#[test]
|
||||
fn quitting_with_unsaved_changes_asks_and_cancel_keeps_everything() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app_with_unsaved_document();
|
||||
let cmds = quit_frame(&mut app, &ctx, true);
|
||||
assert!(cmds.contains(&egui::ViewportCommand::CancelClose), "{cmds:?}");
|
||||
assert_eq!(dialog_uid(&app), Some(app.session.documents()[0].uid));
|
||||
// Cancel: the window and the document stay as they were.
|
||||
app.ui.dialog = None;
|
||||
for _ in 0..2 {
|
||||
assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close));
|
||||
}
|
||||
assert_eq!(app.session.documents().len(), 1);
|
||||
assert!(app.session.documents()[0].is_dirty());
|
||||
// A Save whose file picker is cancelled stops quitting too.
|
||||
quit_frame(&mut app, &ctx, true);
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close));
|
||||
assert!(app.session.documents()[0].is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quitting_asks_about_each_unsaved_document_then_closes() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app_with_unsaved_document();
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
app.run("frame.create", json!({"rect": [36, 36, 136, 136]})).unwrap();
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
let uids: Vec<u64> = app.session.documents().iter().map(|d| d.uid).collect();
|
||||
assert!(quit_frame(&mut app, &ctx, true).contains(&egui::ViewportCommand::CancelClose));
|
||||
for (n, uid) in uids.iter().take(2).enumerate() {
|
||||
assert_eq!(dialog_uid(&app), Some(*uid), "question {n}");
|
||||
assert_eq!(app.session.active().map(|d| d.uid), Some(*uid), "the document asked about is shown");
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true));
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
let cmds = quit_frame(&mut app, &ctx, false);
|
||||
assert_eq!(cmds.contains(&egui::ViewportCommand::Close), n == 1, "{cmds:?}");
|
||||
}
|
||||
// All documents stay open until the backend's final close commits the decision.
|
||||
assert_eq!(app.session.documents().len(), 3);
|
||||
assert!(quit_frame(&mut app, &ctx, true).is_empty());
|
||||
assert!(app.session.documents().is_empty());
|
||||
}
|
||||
|
||||
/// A later Cancel must not destroy documents already approved for discard.
|
||||
#[test]
|
||||
fn quitting_later_cancel_retains_every_document_and_active_tab() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app_with_unsaved_document();
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
app.run("frame.create", json!({"rect": [20, 20, 120, 120]})).unwrap();
|
||||
let uids: Vec<u64> = app.session.documents().iter().map(|d| d.uid).collect();
|
||||
let active = app.session.active().unwrap().uid;
|
||||
let revisions: Vec<u64> = app.session.documents().iter().map(|d| d.revision).collect();
|
||||
let docs: Vec<_> = app.session.documents().iter().map(|d| d.doc.clone()).collect();
|
||||
quit_frame(&mut app, &ctx, true);
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true));
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
quit_frame(&mut app, &ctx, false);
|
||||
assert_eq!(dialog_uid(&app), Some(uids[1]));
|
||||
app.ui.dialog = None;
|
||||
assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close));
|
||||
assert_eq!(app.session.documents().iter().map(|d| d.uid).collect::<Vec<_>>(), uids);
|
||||
assert_eq!(app.session.active().unwrap().uid, active);
|
||||
assert_eq!(app.session.documents().iter().map(|d| d.revision).collect::<Vec<_>>(), revisions);
|
||||
for (d, original) in app.session.documents().iter().zip(docs) {
|
||||
assert!(std::sync::Arc::ptr_eq(&d.doc, &original));
|
||||
assert!(d.is_dirty());
|
||||
assert!(!d.history.undo.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
fn second_unsaved_document(app: &mut DesignApp) {
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
app.run("frame.create", json!({"rect": [20, 20, 120, 120]})).unwrap();
|
||||
}
|
||||
|
||||
fn discard_quit_dialog(app: &mut DesignApp) {
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true));
|
||||
dialogs::confirm(app).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_quit_prompts_restore_window_visibility_without_focusing_completed_quits() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app_with_unsaved_document();
|
||||
let commands = quit_frame(&mut app, &ctx, true);
|
||||
assert!(commands.contains(&egui::ViewportCommand::CancelClose));
|
||||
for command in [egui::ViewportCommand::Visible(true), egui::ViewportCommand::Minimized(false), egui::ViewportCommand::Focus] {
|
||||
assert!(commands.contains(&command), "unsaved prompt must restore the window: {commands:?}");
|
||||
}
|
||||
discard_quit_dialog(&mut app);
|
||||
let approved = quit_frame(&mut app, &ctx, false);
|
||||
assert!(approved.contains(&egui::ViewportCommand::Close));
|
||||
let completed = quit_frame(&mut app, &ctx, true);
|
||||
assert!(app.session.documents().is_empty());
|
||||
|
||||
let mut clean = DesignApp::new(designcraft_engine::Session::new(), Services::default());
|
||||
clean.run("file.new", json!({})).unwrap();
|
||||
let clean_close = quit_frame(&mut clean, &ctx, true);
|
||||
let mut control = app_with_unsaved_document();
|
||||
control.quit_without_asking = true;
|
||||
let control_close = quit_frame(&mut control, &ctx, true);
|
||||
for commands in [&approved, &completed, &clean_close, &control_close] {
|
||||
assert!(
|
||||
!commands.iter().any(|command| matches!(
|
||||
command,
|
||||
egui::ViewportCommand::Visible(true) | egui::ViewportCommand::Minimized(false) | egui::ViewportCommand::Focus
|
||||
)),
|
||||
"completed and immediate quits must not activate the window: {commands:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dock Quit may arrive again while the first request's dialogs are still open.
|
||||
#[test]
|
||||
fn repeated_native_quit_requests_preserve_partial_approvals_and_cancel() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app_with_unsaved_document();
|
||||
second_unsaved_document(&mut app);
|
||||
let original_active = app.session.active().unwrap().uid;
|
||||
let uids: Vec<_> = app.session.documents().iter().map(|d| d.uid).collect();
|
||||
let docs: Vec<_> = app.session.documents().iter().map(|d| d.doc.clone()).collect();
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
app.session.recovery_dir = Some(directory.path().to_path_buf());
|
||||
app.run("file.recovery.save", json!({})).unwrap();
|
||||
assert!(quit_frame(&mut app, &ctx, true).contains(&egui::ViewportCommand::CancelClose));
|
||||
assert_eq!(dialog_uid(&app), Some(uids[0]));
|
||||
discard_quit_dialog(&mut app);
|
||||
quit_frame(&mut app, &ctx, false);
|
||||
assert_eq!(dialog_uid(&app), Some(uids[1]));
|
||||
for _ in 0..3 {
|
||||
assert!(quit_frame(&mut app, &ctx, true).contains(&egui::ViewportCommand::CancelClose));
|
||||
assert_eq!(dialog_uid(&app), Some(uids[1]));
|
||||
}
|
||||
app.ui.dialog = None; // Cancel the second document, retaining the first Don't Save answer.
|
||||
assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close));
|
||||
assert_eq!(app.session.active().unwrap().uid, original_active);
|
||||
assert_eq!(app.session.documents().iter().map(|d| d.uid).collect::<Vec<_>>(), uids);
|
||||
for (document, original) in app.session.documents().iter().zip(docs) {
|
||||
assert!(std::sync::Arc::ptr_eq(&document.doc, &original));
|
||||
assert!(document.is_dirty());
|
||||
assert!(!document.history.undo.is_empty());
|
||||
assert!(directory.path().join(format!("{}.designcraft", document.uid)).exists());
|
||||
}
|
||||
// A fresh Dock Quit must ask about the first document again after cancellation.
|
||||
assert!(quit_frame(&mut app, &ctx, true).contains(&egui::ViewportCommand::CancelClose));
|
||||
assert_eq!(dialog_uid(&app), Some(uids[0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_native_quit_requests_save_and_discard_only_after_all_answers() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app_with_unsaved_document();
|
||||
second_unsaved_document(&mut app);
|
||||
let uids: Vec<_> = app.session.documents().iter().map(|d| d.uid).collect();
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("saved.designcraft");
|
||||
let chosen = path.to_string_lossy().to_string();
|
||||
app.services.pick_save = Some(Box::new(move |_| Some(chosen.clone())));
|
||||
app.session.recovery_dir = Some(directory.path().join("recovery"));
|
||||
app.run("file.recovery.save", json!({})).unwrap();
|
||||
quit_frame(&mut app, &ctx, true);
|
||||
assert_eq!(dialog_uid(&app), Some(uids[0]));
|
||||
dialogs::confirm(&mut app).unwrap(); // Save the first document.
|
||||
assert!(!app.session.documents()[0].is_dirty());
|
||||
quit_frame(&mut app, &ctx, false);
|
||||
assert_eq!(dialog_uid(&app), Some(uids[1]));
|
||||
assert!(quit_frame(&mut app, &ctx, true).contains(&egui::ViewportCommand::CancelClose));
|
||||
assert_eq!(dialog_uid(&app), Some(uids[1]));
|
||||
discard_quit_dialog(&mut app);
|
||||
assert!(quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close));
|
||||
assert_eq!(app.session.documents().len(), 2);
|
||||
assert!(app.session.recovery_dir.as_ref().unwrap().join(format!("{}.designcraft", uids[1])).exists());
|
||||
assert!(quit_frame(&mut app, &ctx, true).is_empty());
|
||||
assert!(app.session.documents().is_empty());
|
||||
assert!(path.exists());
|
||||
assert_eq!(designcraft_engine::Session::new().execute("file.open", &json!({"path": path})).unwrap()["index"], 0);
|
||||
assert_eq!(std::fs::read_dir(app.session.recovery_dir.as_ref().unwrap()).unwrap().count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quitting_first_cancel_restores_original_clean_active_document() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app_with_unsaved_document();
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
let original_active = app.session.active().unwrap().uid;
|
||||
quit_frame(&mut app, &ctx, true);
|
||||
assert_ne!(app.session.active().unwrap().uid, original_active);
|
||||
app.ui.dialog = None;
|
||||
assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close));
|
||||
assert_eq!(app.session.active().unwrap().uid, original_active);
|
||||
assert_eq!(app.session.documents().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quitting_save_as_cancel_after_discard_keeps_all_documents() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app_with_unsaved_document();
|
||||
second_unsaved_document(&mut app);
|
||||
let active = app.session.active().unwrap().uid;
|
||||
quit_frame(&mut app, &ctx, true);
|
||||
discard_quit_dialog(&mut app);
|
||||
quit_frame(&mut app, &ctx, false);
|
||||
dialogs::confirm(&mut app).unwrap(); // No picker: Save As was cancelled.
|
||||
assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close));
|
||||
assert_eq!(app.session.documents().len(), 2);
|
||||
assert!(app.session.documents().iter().all(|d| d.is_dirty()));
|
||||
assert_eq!(app.session.active().unwrap().uid, active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quitting_save_failure_after_discard_keeps_documents_and_can_retry() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app_with_unsaved_document();
|
||||
second_unsaved_document(&mut app);
|
||||
let path = std::env::temp_dir()
|
||||
.join(format!("designcraft-quit-missing-{}-{}", std::process::id(), app.session.active().unwrap().uid))
|
||||
.join("missing")
|
||||
.join("document.designcraft");
|
||||
assert!(!path.parent().unwrap().exists());
|
||||
let picked = path.to_string_lossy().to_string();
|
||||
app.services.pick_save = Some(Box::new(move |_| Some(picked.clone())));
|
||||
quit_frame(&mut app, &ctx, true);
|
||||
discard_quit_dialog(&mut app);
|
||||
quit_frame(&mut app, &ctx, false);
|
||||
assert!(dialogs::confirm(&mut app).is_err());
|
||||
assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close));
|
||||
assert_eq!(app.session.documents().len(), 2);
|
||||
assert!(app.session.documents().iter().all(|d| d.is_dirty()));
|
||||
// Starting over must ask about the previously discarded first document again.
|
||||
quit_frame(&mut app, &ctx, true);
|
||||
assert_eq!(dialog_uid(&app), Some(app.session.documents()[0].uid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quitting_save_then_later_cancel_keeps_saved_document_open() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app_with_unsaved_document();
|
||||
second_unsaved_document(&mut app);
|
||||
let first = app.session.documents()[0].uid;
|
||||
let path = std::env::temp_dir().join(format!("designcraft-quit-save-{}-{first}.designcraft", std::process::id()));
|
||||
let picked = path.to_string_lossy().to_string();
|
||||
app.services.pick_save = Some(Box::new(move |_| Some(picked.clone())));
|
||||
quit_frame(&mut app, &ctx, true);
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
quit_frame(&mut app, &ctx, false);
|
||||
assert_eq!(app.session.documents().len(), 2);
|
||||
assert!(!app.session.documents()[0].is_dirty());
|
||||
assert!(path.exists());
|
||||
app.ui.dialog = None;
|
||||
assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close));
|
||||
assert_eq!(app.session.documents()[0].uid, first);
|
||||
assert!(app.session.documents()[1].is_dirty());
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quitting_rechecks_discard_approval_after_a_later_edit() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app_with_unsaved_document();
|
||||
second_unsaved_document(&mut app);
|
||||
let first = app.session.documents()[0].uid;
|
||||
quit_frame(&mut app, &ctx, true);
|
||||
discard_quit_dialog(&mut app);
|
||||
quit_frame(&mut app, &ctx, false);
|
||||
app.run("file.activate", json!({"index": 0})).unwrap();
|
||||
app.run("frame.create", json!({"rect": [200, 200, 300, 300]})).unwrap();
|
||||
discard_quit_dialog(&mut app); // Approval is bound to the second UID, despite the switch.
|
||||
assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close));
|
||||
assert_eq!(dialog_uid(&app), Some(first), "new edit needs a fresh decision");
|
||||
assert_eq!(app.session.documents().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quitting_final_close_rechecks_new_unsaved_document() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app_with_unsaved_document();
|
||||
quit_frame(&mut app, &ctx, true);
|
||||
discard_quit_dialog(&mut app);
|
||||
assert!(quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close));
|
||||
second_unsaved_document(&mut app); // Arrives after Close was queued, before its event.
|
||||
let new_uid = app.session.active().unwrap().uid;
|
||||
assert!(quit_frame(&mut app, &ctx, true).contains(&egui::ViewportCommand::CancelClose));
|
||||
assert_eq!(dialog_uid(&app), Some(new_uid));
|
||||
assert_eq!(app.session.documents().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quitting_pending_save_uses_dialog_uid_after_tab_switch() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app_with_unsaved_document();
|
||||
second_unsaved_document(&mut app);
|
||||
let first = app.session.documents()[0].uid;
|
||||
let path = std::env::temp_dir().join(format!("designcraft-quit-uid-{}-{first}.designcraft", std::process::id()));
|
||||
let picked = path.to_string_lossy().to_string();
|
||||
app.services.pick_save = Some(Box::new(move |_| Some(picked.clone())));
|
||||
quit_frame(&mut app, &ctx, true);
|
||||
app.run("file.activate", json!({"index": 1})).unwrap();
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(app.session.documents()[0].path.as_deref(), path.to_str());
|
||||
assert!(!app.session.documents()[0].is_dirty());
|
||||
assert!(app.session.documents()[1].is_dirty());
|
||||
quit_frame(&mut app, &ctx, false);
|
||||
assert_eq!(dialog_uid(&app), Some(app.session.documents()[1].uid));
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quitting_discard_preserves_recovery_until_final_commit() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app_with_unsaved_document();
|
||||
let uid = app.session.active().unwrap().uid;
|
||||
let dir = std::env::temp_dir().join(format!("designcraft-quit-recovery-{}-{uid}", std::process::id()));
|
||||
app.session.recovery_dir = Some(dir.clone());
|
||||
app.run("file.recovery.save", json!({})).unwrap();
|
||||
let entry = dir.join(format!("{uid}.designcraft"));
|
||||
quit_frame(&mut app, &ctx, true);
|
||||
discard_quit_dialog(&mut app);
|
||||
assert!(quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close));
|
||||
assert!(entry.exists(), "a queued close is not a committed close");
|
||||
assert!(quit_frame(&mut app, &ctx, true).is_empty());
|
||||
assert!(!entry.exists(), "accepted Don't Save must not be recovered on restart");
|
||||
assert!(app.session.documents().is_empty());
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quitting_native_exit_commits_completed_approvals_before_close_event() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app_with_unsaved_document();
|
||||
quit_frame(&mut app, &ctx, true);
|
||||
discard_quit_dialog(&mut app);
|
||||
assert!(quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close));
|
||||
assert_eq!(app.session.documents().len(), 1);
|
||||
app.finish_confirmed_quit().unwrap();
|
||||
assert!(app.session.documents().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quitting_native_exit_retains_incomplete_stale_and_new_document_plans() {
|
||||
for case in ["incomplete", "stale", "new document"] {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app_with_unsaved_document();
|
||||
if case == "incomplete" {
|
||||
second_unsaved_document(&mut app);
|
||||
}
|
||||
quit_frame(&mut app, &ctx, true);
|
||||
discard_quit_dialog(&mut app);
|
||||
quit_frame(&mut app, &ctx, false);
|
||||
if case == "stale" {
|
||||
app.run("frame.create", json!({"rect": [200, 200, 300, 300]})).unwrap();
|
||||
} else if case == "new document" {
|
||||
second_unsaved_document(&mut app);
|
||||
}
|
||||
let active = app.session.active().unwrap().uid;
|
||||
let before: Vec<_> = app
|
||||
.session
|
||||
.documents()
|
||||
.iter()
|
||||
.map(|d| (d.uid, d.revision, d.doc.clone(), d.selection.items.clone(), d.history.undo.len(), d.history.redo.len()))
|
||||
.collect();
|
||||
app.finish_confirmed_quit().unwrap();
|
||||
assert_eq!(app.session.documents().len(), before.len(), "{case}");
|
||||
assert_eq!(app.session.active().unwrap().uid, active, "{case}");
|
||||
for (d, (uid, revision, doc, selection, undo, redo)) in app.session.documents().iter().zip(before) {
|
||||
assert_eq!(
|
||||
(d.uid, d.revision, &d.selection.items, d.history.undo.len(), d.history.redo.len()),
|
||||
(uid, revision, &selection, undo, redo),
|
||||
"{case}"
|
||||
);
|
||||
assert!(std::sync::Arc::ptr_eq(&d.doc, &doc), "{case}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quitting_without_unsaved_changes_closes_at_once() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
// Quit (⌘Q) closes the window like its close button.
|
||||
app.run("app.quit", json!({})).unwrap();
|
||||
assert!(quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close));
|
||||
assert!(quit_frame(&mut app, &ctx, true).is_empty());
|
||||
assert!(app.ui.dialog.is_none());
|
||||
}
|
||||
|
||||
/// Scripts must not hang on a question: the control channel's quit never asks.
|
||||
#[test]
|
||||
fn control_channel_quit_does_not_ask() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app_with_unsaved_document();
|
||||
let (req, _rx) = control::ControlRequest::new("app.quit", json!({}));
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
control::handle(&mut app, ui.ctx(), &req);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
assert!(quit_frame(&mut app, &ctx, true).is_empty());
|
||||
assert!(app.ui.dialog.is_none());
|
||||
}
|
||||
|
||||
/// The Control panel's two-row groups were centred as if they were one row tall, so the second
|
||||
/// row hung below the panel and was cut off.
|
||||
#[test]
|
||||
@@ -1319,6 +1913,99 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod import_warning_tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
fn idml(missing_parts: bool) -> Vec<u8> {
|
||||
let missing = if missing_parts { r#"<idPkg:Spread src="Spreads/missing.xml"/><idPkg:Story src="Stories/missing.xml"/>"# } else { "" };
|
||||
let map = format!(
|
||||
r#"<Document xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" Self="d"><idPkg:Spread src="Spreads/one.xml"/>{missing}</Document>"#
|
||||
);
|
||||
let spread = r#"<idPkg:Spread xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"><Spread Self="s"><Page Self="p" GeometricBounds="0 0 100 100" ItemTransform="1 0 0 1 0 0"/><Rectangle Self="r" ItemTransform="1 0 0 1 0 0"/></Spread></idPkg:Spread>"#;
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
for (name, text) in [("mimetype", designcraft_idml::MIMETYPE), ("designmap.xml", &map), ("Spreads/one.xml", spread)] {
|
||||
zip.start_file(name, options).unwrap();
|
||||
zip.write_all(text.as_bytes()).unwrap();
|
||||
}
|
||||
zip.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn app() -> DesignApp {
|
||||
let mut app = DesignApp::new(Session::new(), Services { inbox: Some(Inbox::default()), ..Default::default() });
|
||||
app.run("file.new", json!({"title": "Existing work"})).unwrap();
|
||||
app.run("frame.create", json!({"rect": [10, 10, 50, 50]})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
fn assert_partial_import_warning(app: &mut DesignApp, file: &str) {
|
||||
let dialog = app.ui.dialog.as_ref().expect("partial import must show an alert");
|
||||
assert_eq!(dialog.id, "alert");
|
||||
assert_eq!(dialog.fields["title"], "Import Warnings");
|
||||
assert_eq!(dialog.fields["file"], file);
|
||||
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");
|
||||
}
|
||||
assert_eq!(app.session.documents().len(), 2);
|
||||
assert_eq!(app.session.documents()[0].doc.title, "Existing work");
|
||||
assert!(app.session.documents()[0].is_dirty(), "existing unsaved work stays intact");
|
||||
assert_eq!(app.session.documents()[0].doc.spreads[0].items.len(), 1);
|
||||
assert_eq!(app.session.doc().unwrap().doc.spreads.len(), 1);
|
||||
assert_eq!(app.session.doc().unwrap().doc.spreads[0].items.len(), 1, "available document content is kept");
|
||||
dialogs::confirm(app).unwrap();
|
||||
assert!(app.ui.dialog.is_none(), "OK dismisses the warning");
|
||||
assert_eq!(app.session.documents().len(), 2, "dismissing the warning keeps the imported document");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_open_reports_partial_import_warnings_without_losing_documents() {
|
||||
let mut app = app();
|
||||
let result =
|
||||
app.open_file("file.openBytes", json!({"name": "partial.idml", "base64": designcraft_engine::cmd::base64_encode(&idml(true))})).unwrap();
|
||||
assert_eq!(result["warnings"].as_array().unwrap().len(), 2);
|
||||
assert_partial_import_warning(&mut app, "partial.idml");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_open_reports_partial_import_warnings_without_losing_documents() {
|
||||
let path = std::env::temp_dir().join(format!("designcraft-partial-import-{}", std::process::id())).join("partial.idml");
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, idml(true)).unwrap();
|
||||
let picked = path.to_string_lossy().to_string();
|
||||
let mut app = app();
|
||||
app.services.pick_open = Some(Box::new(move |_| Some(picked.clone())));
|
||||
let result = app.run("app.openDialog", json!({}));
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
std::fs::remove_dir(path.parent().unwrap()).unwrap();
|
||||
assert_eq!(result.unwrap()["warnings"].as_array().unwrap().len(), 2);
|
||||
assert_partial_import_warning(&mut app, "partial.idml");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inbox_open_reports_partial_import_warnings_without_losing_documents() {
|
||||
let mut app = app();
|
||||
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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_import_does_not_show_a_warning() {
|
||||
let mut app = app();
|
||||
let result = app
|
||||
.open_file("file.openBytes", json!({"name": "complete.idml", "base64": designcraft_engine::cmd::base64_encode(&idml(false))}))
|
||||
.unwrap();
|
||||
assert_eq!(result["warnings"], json!([]));
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert!(app.ui.status.is_empty());
|
||||
assert_eq!(app.session.documents().len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole window in a headless test harness, at a chosen window size.
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_window {
|
||||
|
||||
@@ -11,6 +11,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
|
||||
("app.placeDialog", "Place…", Some("Cmd+D"), "{}"),
|
||||
("app.saveDialog", "Save As…", Some("Cmd+Shift+S"), "{}"),
|
||||
("app.save", "Save", Some("Cmd+S"), "{}"),
|
||||
("app.quit", "Quit DesignCraft", Some("Cmd+Q"), "{} — close the window; each document with unsaved changes asks first"),
|
||||
("app.saveCopyDialog", "Save a Copy…", Some("Cmd+Alt+S"), "{} — choose where to write a copy (the document stays as it is)"),
|
||||
("app.loadSwatches", "Load Swatches…", None, "{} — pick a swatch exchange (.ase) file"),
|
||||
("app.packageDialog", "Package…", Some("Cmd+Alt+Shift+P"), "{} — choose the package folder to create (file.package)"),
|
||||
@@ -158,12 +159,8 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
|
||||
"{on?: bool, at?: [x, y] (points from the canvas's top-left)} — the Contextual Task Bar stays where it is (or at `at`) instead of following the selection; with neither, toggles",
|
||||
),
|
||||
("window.taskBarReset", "Reset Bar Position", None, "{} — the Contextual Task Bar follows the selection again, under it"),
|
||||
("help.discord", "Join the ArtCraft Discord…", None, "{} — opens https://discord.gg/artcraft in the browser"),
|
||||
("help.appPage", "DesignCraft Website…", None, "{} — opens https://getartcraft.com/apps/designcraft"),
|
||||
("help.github", "DesignCraft on GitHub…", None, "{} — opens https://github.com/storytold/designcraft"),
|
||||
("help.issues", "Report an Issue…", None, "{} — opens the GitHub issue tracker"),
|
||||
("help.website", "ArtCraft Website…", None, "{} — opens https://getartcraft.com"),
|
||||
("help.app", "ArtCraft App Page…", None, "{app} — opens https://getartcraft.com/apps/{app} (e.g. photocraft)"),
|
||||
(
|
||||
"help.about",
|
||||
"About DesignCraft",
|
||||
@@ -700,21 +697,7 @@ pub const MENUS: &[(&str, &[&str])] = &[
|
||||
"ui:window.brightness",
|
||||
],
|
||||
),
|
||||
(
|
||||
"Help",
|
||||
&[
|
||||
"ui:help.discord",
|
||||
"-",
|
||||
"ui:help.appPage",
|
||||
"ui:help.github",
|
||||
"ui:help.issues",
|
||||
"ui:help.website",
|
||||
"-",
|
||||
"cmd:file.newSample",
|
||||
"-",
|
||||
"ui:help.about",
|
||||
],
|
||||
),
|
||||
("Help", &["ui:help.github", "ui:help.issues", "-", "cmd:file.newSample", "-", "ui:help.about"]),
|
||||
];
|
||||
|
||||
pub fn ui_label(id: &str) -> Option<(&'static str, Option<&'static str>)> {
|
||||
@@ -769,6 +752,10 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"app.quit" => {
|
||||
app.request_quit();
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"app.saveCopyDialog" => {
|
||||
let name = app.session.active().map(|d| format!("{} copy.designcraft", d.doc.title)).unwrap_or_default();
|
||||
if let Some(path) = app.services.pick_save.as_mut().and_then(|f| f(&name)) {
|
||||
@@ -1368,14 +1355,6 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
|
||||
app.ui.about = p.get("open").and_then(Value::as_bool).unwrap_or(true);
|
||||
Ok(json!(app.ui.about))
|
||||
}
|
||||
"help.app" => match p.get("app").and_then(Value::as_str) {
|
||||
Some(slug) if !slug.is_empty() && slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') => {
|
||||
let url = format!("{}/apps/{slug}", designcraft_engine::links::WEBSITE);
|
||||
app.ui.pending_urls.push(url.clone());
|
||||
Ok(json!({"url": url}))
|
||||
}
|
||||
_ => Err("help.app: give `app` (e.g. \"photocraft\")".into()),
|
||||
},
|
||||
h if h.starts_with("help.") && designcraft_engine::links::get(&h[5..]).is_some() => {
|
||||
let url = designcraft_engine::links::get(&h[5..]).unwrap_or_default().to_string();
|
||||
app.ui.pending_urls.push(url.clone());
|
||||
|
||||
@@ -251,8 +251,9 @@ struct FontMenuState {
|
||||
/// The Font menu, inside any font field's popup or combo box: a search field (it has the keyboard
|
||||
/// while the menu is open, and starts empty each time it opens), favourites (★, Show Favorites
|
||||
/// Only), and each family's name with a sample in that family, the script groups apart. ↑/↓ move
|
||||
/// through the matches and Return picks one. Returns the family picked and closes the menu; the
|
||||
/// search, Favorites and the stars keep it open (the popup closes on a click outside only).
|
||||
/// through the matches, Return picks one and Escape closes the menu; none of them reach a dialog
|
||||
/// under the menu. Returns the family picked and closes the menu; the search, Favorites and the
|
||||
/// stars keep it open (the popup closes on a click outside only).
|
||||
pub fn font_menu_body(app: &mut DesignApp, ui: &mut egui::Ui, menu: &[designcraft_fonts::FamilyInfo], current: &str, width: f32) -> Option<String> {
|
||||
let (fonts, scope) = (fonts(app), font_scope(app));
|
||||
let english = app.session.prefs.show_font_names_in_english;
|
||||
@@ -293,7 +294,12 @@ pub fn font_menu_body(app: &mut DesignApp, ui: &mut egui::Ui, menu: &[designcraf
|
||||
if search.changed() {
|
||||
st.highlight = if q.is_empty() { None } else { Some(0) };
|
||||
}
|
||||
let (down, up, enter) = ui.input(|i| (i.key_pressed(egui::Key::ArrowDown), i.key_pressed(egui::Key::ArrowUp), i.key_pressed(egui::Key::Enter)));
|
||||
// The menu owns these keys while it is open: a dialog under it must not take Return as OK or
|
||||
// Escape as Cancel.
|
||||
let (down, up, enter, escape) = ui.input_mut(|i| {
|
||||
let mut key = |k| i.consume_key(egui::Modifiers::NONE, k);
|
||||
(key(egui::Key::ArrowDown), key(egui::Key::ArrowUp), key(egui::Key::Enter), key(egui::Key::Escape))
|
||||
});
|
||||
let last = shown.len().checked_sub(1);
|
||||
st.highlight = match (st.highlight, last) {
|
||||
(_, None) => None,
|
||||
@@ -374,7 +380,7 @@ pub fn font_menu_body(app: &mut DesignApp, ui: &mut egui::Ui, menu: &[designcraf
|
||||
if favs_changed {
|
||||
let _ = app.run("prefs.set", json!({ "favoriteFonts": favs }));
|
||||
}
|
||||
if pick.is_some() {
|
||||
if pick.is_some() || escape {
|
||||
ui.close();
|
||||
}
|
||||
pick
|
||||
@@ -404,6 +410,12 @@ pub fn font_combo(
|
||||
/// [`font_menu_body`] in a popup under `field` (a font field the caller draws). Returns the family
|
||||
/// picked.
|
||||
pub fn font_popup(app: &mut DesignApp, field: &egui::Response, menu: &[designcraft_fonts::FamilyInfo], current: &str) -> Option<String> {
|
||||
// Screen readers (and tests) see the field as the combo box it looks like.
|
||||
field.widget_info(|| {
|
||||
let mut info = egui::WidgetInfo::new(egui::WidgetType::ComboBox);
|
||||
info.current_text_value = Some(if current.is_empty() { "—".to_string() } else { font_label(app, menu, current) });
|
||||
info
|
||||
});
|
||||
egui::Popup::menu(field)
|
||||
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
|
||||
.show(|ui| font_menu_body(app, ui, menu, current, field.rect.width()))
|
||||
@@ -506,9 +518,22 @@ mod font_menu_ui_tests {
|
||||
/// The Type tool's selection over a new text frame's text, with the Control bar on. Returns the
|
||||
/// story.
|
||||
fn typing() -> (Harness<'static, Window>, u64) {
|
||||
typing_in("Advanced")
|
||||
}
|
||||
|
||||
/// [`typing`] in the workspace `name`.
|
||||
fn typing_in(name: &str) -> (Harness<'static, Window>, u64) {
|
||||
typing_on(name, vec2(1440.0, 900.0), false)
|
||||
}
|
||||
|
||||
/// [`typing_in`] a window of `size`, with the macOS window chrome (the native menu bar and the
|
||||
/// title bar inside the window) when `mac`.
|
||||
fn typing_on(name: &str, size: egui::Vec2, mac: bool) -> (Harness<'static, Window>, u64) {
|
||||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.ui.control_bar = true;
|
||||
let mut h = test_window::open(app, vec2(1440.0, 900.0));
|
||||
app.run("window.workspace", json!({ "name": name })).unwrap();
|
||||
app.native_menu = mac;
|
||||
app.integrated_titlebar = mac;
|
||||
let mut h = test_window::open(app, size);
|
||||
let app = &mut h.state_mut().app;
|
||||
let r = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hello", "caret": true})).unwrap();
|
||||
app.run("tool.select", json!({"tool": "type"})).unwrap();
|
||||
@@ -523,7 +548,7 @@ mod font_menu_ui_tests {
|
||||
|
||||
/// The Control bar's font field, showing `family`.
|
||||
fn field(h: &Harness<'static, Window>, family: &str) -> egui::Rect {
|
||||
h.get_by(|n| n.role() == egui::accesskit::Role::ComboBox && n.value().as_deref() == Some(family)).rect()
|
||||
fields(h, family)[0]
|
||||
}
|
||||
|
||||
/// The text in the focused search field.
|
||||
@@ -582,4 +607,94 @@ mod font_menu_ui_tests {
|
||||
assert_eq!(story_text(&mut h, story), before);
|
||||
assert!(h.state().app.session.active().unwrap().selection.text.is_some(), "the text is still selected");
|
||||
}
|
||||
|
||||
/// The font fields showing `family`, left to right and top to bottom.
|
||||
fn fields(h: &Harness<'static, Window>, family: &str) -> Vec<egui::Rect> {
|
||||
let mut r: Vec<egui::Rect> =
|
||||
h.query_all_by(|n| n.role() == egui::accesskit::Role::ComboBox && n.value().as_deref() == Some(family)).map(|n| n.rect()).collect();
|
||||
r.sort_by(|a, b| a.min.x.total_cmp(&b.min.x).then(a.min.y.total_cmp(&b.min.y)));
|
||||
r
|
||||
}
|
||||
|
||||
/// Opens the font menu of the field at `at`, clicks its search field, types, checks the menu
|
||||
/// stays open and lists only the matches, then picks the first with ↓ and Return.
|
||||
fn search_in_menu(h: &mut Harness<'static, Window>, at: egui::Rect, what: &str) {
|
||||
click_at(h, at.center());
|
||||
let field = h.query_by(|n| n.role() == egui::accesskit::Role::TextInput && n.is_focused()).map(|n| n.rect());
|
||||
let field = field.unwrap_or_else(|| panic!("{what}: the menu opens with the search field focused"));
|
||||
click_at(h, field.center());
|
||||
assert!(h.ctx.text_edit_focused(), "{what}: a click on the search field keeps the menu open");
|
||||
h.event(egui::Event::Text("SOURCE SA".into()));
|
||||
h.run_steps(4);
|
||||
assert_eq!(search(h), "SOURCE SA", "{what}: the typing went to the search");
|
||||
assert!(h.query_by_label("Source Sans 3").is_some(), "{what}: a match is listed");
|
||||
assert!(h.query_by_label(designcraft_fonts::DEFAULT_FAMILY).is_none(), "{what}: families that don't match aren't");
|
||||
h.key_press(egui::Key::ArrowDown);
|
||||
h.run_steps(2);
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(4);
|
||||
assert!(!h.ctx.text_edit_focused(), "{what}: the menu closed");
|
||||
}
|
||||
|
||||
/// Every font field over selected text (Control bar, Contextual Task Bar, Properties ›
|
||||
/// Character, Character panel) searches, in the workspaces that show them and in a small
|
||||
/// window with the macOS window chrome.
|
||||
#[test]
|
||||
fn every_font_field_searches_in_every_workspace() {
|
||||
for (name, size, mac, panel) in [
|
||||
("Advanced", vec2(1440.0, 900.0), false, None),
|
||||
("Printing and Proofing", vec2(1440.0, 900.0), false, None),
|
||||
("Essentials", vec2(1440.0, 900.0), false, Some("character")),
|
||||
("Printing and Proofing", vec2(900.0, 560.0), true, None),
|
||||
] {
|
||||
let (mut h, story) = typing_on(name, size, mac);
|
||||
if panel.is_some() {
|
||||
h.state_mut().app.ui.open_panel = panel.map(str::to_string);
|
||||
h.run_steps(4);
|
||||
}
|
||||
let name = &format!("{name} {size:?} {panel:?}");
|
||||
let before = story_text(&mut h, story);
|
||||
let count = fields(&h, designcraft_fonts::DEFAULT_FAMILY).len();
|
||||
assert_eq!(count, 3, "{name}: the font fields are shown");
|
||||
for k in 0..count {
|
||||
h.state_mut().app.run("type.char", json!({"attrs": {"fontFamily": designcraft_fonts::DEFAULT_FAMILY}})).unwrap();
|
||||
h.run_steps(4);
|
||||
let at = fields(&h, designcraft_fonts::DEFAULT_FAMILY)[k];
|
||||
search_in_menu(&mut h, at, &format!("{name}, field {k}"));
|
||||
assert_eq!(family(&mut h), "Source Sans 3", "{name}, field {k}");
|
||||
assert_eq!(story_text(&mut h, story), before, "{name}, field {k}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The font fields away from the text: the Glyphs panel, Paragraph Style Options › Basic
|
||||
/// Character Formats and Find/Replace Font's Replace With.
|
||||
#[test]
|
||||
fn font_fields_in_panels_and_dialogs_search() {
|
||||
let paragraph = json!({"name": designcraft_doc::BASIC_PARAGRAPH, "section": "chars"});
|
||||
for (what, panel, dialog, shown) in [
|
||||
("Glyphs", Some("glyphs"), None, designcraft_fonts::DEFAULT_FAMILY),
|
||||
("Paragraph Style Options", None, Some(("paragraphStyleOptions", paragraph)), designcraft_fonts::DEFAULT_FAMILY),
|
||||
("Find/Replace Font", None, Some(("findFont", json!({}))), "—"),
|
||||
] {
|
||||
let app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
let mut h = test_window::open(app, vec2(1440.0, 900.0));
|
||||
let dialog_id = dialog.as_ref().map(|(id, _)| id.to_string());
|
||||
h.state_mut().app.ui.open_panel = panel.map(str::to_string);
|
||||
h.state_mut().app.ui.dialog = dialog.map(|(id, fields)| crate::dialogs::Dialog::new(id, fields));
|
||||
h.run_steps(4);
|
||||
let at = *fields(&h, shown).first().unwrap_or_else(|| panic!("{what}: the font field is shown"));
|
||||
search_in_menu(&mut h, at, what);
|
||||
assert_eq!(h.state().app.ui.dialog.as_ref().map(|d| d.id.clone()), dialog_id, "{what}: Return in the menu leaves the dialog open");
|
||||
assert!(!fields(&h, "Source Sans 3").is_empty(), "{what}: the field shows the family picked");
|
||||
// Escape closes the menu only.
|
||||
let at = fields(&h, "Source Sans 3")[0];
|
||||
click_at(&mut h, at.center());
|
||||
assert!(h.ctx.text_edit_focused(), "{what}: the menu opened");
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(4);
|
||||
assert!(!h.ctx.text_edit_focused(), "{what}: Escape closed the menu");
|
||||
assert_eq!(h.state().app.ui.dialog.as_ref().map(|d| d.id.clone()), dialog_id, "{what}: Escape in the menu leaves the dialog open");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# DesignCraft architecture
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first committed version, from the code on main and the local plan) · **Target:** Adobe InDesign 2026
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (cancelable macOS Dock Quit) · **Target:** Adobe InDesign 2026
|
||||
|
||||
How DesignCraft is built today. The design notes behind it are in `plan/architecture.md`
|
||||
(local, gitignored); this file describes what the code on `main` does. Agents: AGENTS.md has the
|
||||
@@ -57,8 +57,15 @@ and the control channel all call `Session::execute(id, params)`. UI-only command
|
||||
panels, zoom, dialogs) are `UI_COMMANDS` in `crates/ui-egui/src/menus.rs`, reachable the same way.
|
||||
Tool gestures emit Begin/Preview/Commit commands so a drag is one undo step. Every journaled
|
||||
command is replayable as a script. Escaped panics during a command or an import/export become an
|
||||
error (`engine/src/guard.rs`), never a lost document; unsaved documents are written to a recovery
|
||||
folder every 30 s.
|
||||
error (`engine/src/guard.rs`), never a lost document; native recovery is written periodically
|
||||
into each running app's locked folder. Orderly native shutdown finalizes complete, current quit
|
||||
approvals and writes the remaining documents' latest recovery state, including clean-document
|
||||
cleanup after Undo. Unavailable or failed recovery is reported. The desktop opts into a pinned
|
||||
winit 0.30 patch that cancels AppKit termination and queues ordinary window close requests,
|
||||
including Quit from the macOS Dock. The existing unsaved-document transaction can cancel
|
||||
those requests or exit after current approvals are complete. A pending prompt restores and
|
||||
focuses a hidden or minimized window; completed and immediate quits do not activate it. The
|
||||
AppKit callback never runs the confirmation dialogs inside a modal termination loop.
|
||||
|
||||
## Composition and rendering
|
||||
|
||||
@@ -95,4 +102,6 @@ builds (on manifest changes), packaging lint and the release workflow; `xtask ci
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Documented cancelable Dock Quit through the native event-loop boundary |
|
||||
| 2026-10-12 | minor | Documented orderly native exit recovery and the remaining cancelable Dock Quit gap |
|
||||
| 2026-10-10 | major | Created from the code on main (92649ff) and `plan/architecture.md` |
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
ArtCraft brand assets: license
|
||||
|
||||
The ArtCraft name, wordmark and mark (the "ArtCraft Marks"), including every file in this
|
||||
directory in every color, size and format, are owned by the ArtCraft Team. They are trademarks
|
||||
and are NOT open source. They are not covered by DesignCraft's MIT OR Apache-2.0 license, and nothing in
|
||||
that license, in NOTICE or in any other file of this repository grants any right to them.
|
||||
|
||||
Permission
|
||||
|
||||
The ArtCraft Team grants a limited, non-exclusive, non-transferable, revocable, royalty-free
|
||||
permission to use the ArtCraft Marks only:
|
||||
|
||||
1. as part of copies of this repository; and
|
||||
2. within DesignCraft itself, its documentation and its official builds, to identify DesignCraft as made
|
||||
by the ArtCraft team and community and to link to getartcraft.com and its community.
|
||||
|
||||
Conditions
|
||||
|
||||
The permission holds only while every one of these is true:
|
||||
|
||||
- The marks are used exactly as supplied in this directory. Do not redraw, recolor, crop,
|
||||
stretch, rotate, animate, outline or add effects to them, extract parts of them, or combine
|
||||
them with any other name, mark or artwork.
|
||||
- They are used only in the context of DesignCraft, and only to say that the ArtCraft team makes it.
|
||||
They must never suggest that the ArtCraft Team made, sponsors, endorses, certifies or supports
|
||||
any other product, service, fork, modified version, plug-in, organization or person.
|
||||
- Neither the marks nor the ArtCraft name appear in the name, logo, icon, domain, social media
|
||||
handle, app-store listing, advertising or marketing of anything other than DesignCraft.
|
||||
|
||||
Forks and modified versions
|
||||
|
||||
If you distribute or publish a modified version of DesignCraft, or any work derived from it, you must
|
||||
remove the ArtCraft Marks from it (or replace them with your own) and must not call it ArtCraft or
|
||||
present it as an ArtCraft product. You may say, in plain text, that your work is based on DesignCraft by
|
||||
the ArtCraft team. A fork kept only to propose changes back to this repository may keep the marks
|
||||
unchanged.
|
||||
|
||||
Everything else
|
||||
|
||||
Any use not expressly permitted here needs prior written permission from the ArtCraft Team
|
||||
(https://getartcraft.com/). The permission ends automatically for anyone who breaks these terms.
|
||||
All other rights are reserved.
|
||||
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 26 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -19.42 136.34 136.34" role="img" aria-label="ArtCraft">
|
||||
<title>ArtCraft</title>
|
||||
<path fill="#0b0b0c" d="M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,75.85l21.55,21.55,63.79-36.94,16.99,37.04,14.01-21.74-12.06-26.27ZM32.89,65.66l32.42-48.87,10.91,23.77-43.32,25.09Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 341 B |
|
Before Width: | Height: | Size: 24 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -19.42 136.34 136.34" role="img" aria-label="ArtCraft">
|
||||
<title>ArtCraft</title>
|
||||
<path fill="#4e7bfb" d="M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,75.85l21.55,21.55,63.79-36.94,16.99,37.04,14.01-21.74-12.06-26.27ZM32.89,65.66l32.42-48.87,10.91,23.77-43.32,25.09Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 341 B |
@@ -46,7 +46,8 @@ so only enable it while you use it. Transport: `apps/designcraft/src/control_ser
|
||||
| `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) |
|
||||
| `app.open` / `app.save` / `app.export` / `app.quit` | | Files |
|
||||
| `app.open` / `app.save` / `app.export` | | Files |
|
||||
| `app.quit` | | Quit without asking about unsaved changes (the `app.quit` command, like ⌘Q, asks) |
|
||||
|
||||
Data merge runs through `engine.execute` like any command: `data.source.select`, `data.fields`, `data.placeholder.add` /
|
||||
`.remove`, `data.options`, `data.preview` / `data.preview.stop`, `data.merge`. `data.merge` creates and activates a new
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version: every format InDesign 2026 declares, measured against the code) · **Target:** Adobe InDesign 2026 (21.6)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026 (21.6)
|
||||
|
||||
Every format InDesign 2026 reads or writes, with DesignCraft's support, fidelity and tests. The
|
||||
InDesign list comes from its bundle's `Info.plist` (`CFBundleDocumentTypes`,
|
||||
@@ -46,7 +46,7 @@ score below).
|
||||
| EPS export | `.eps` | ✓ | ✗ | | <1% |
|
||||
| Text Only / RTF / Tagged Text | `.txt`, `.rtf` | ✓ | ✓ (`file.exportText` txt, rtf, tagged) | | 1% |
|
||||
| XML | `.xml` | ✓ | ✓ export and import, DTD validate | depth unmeasured | 1% |
|
||||
| Print / PostScript | | ✓ printers, PPDs, separations | ~ PDF to `lpr` (no Windows, no PostScript) | | 3% |
|
||||
| Print / PostScript | | ✓ printers, PPDs, separations | ~ PDF to CUPS/Windows (craft-print; no PostScript) | | 3% |
|
||||
| Package | folder | ✓ fonts, links, IDML, PDF, report | ✓ (#340: IDML inside links to absolute paths) | | 2% |
|
||||
|
||||
## Placed and imported content
|
||||
@@ -97,7 +97,7 @@ for real work** (estimated). INDD alone moves this dimension by up to 30 points.
|
||||
| INDD import via a clean-room converter (owner decision, #114) | 20–60 |
|
||||
| IDML real-file corpus, metric, fixes (#193, #338, #339, #340) | 60–100 |
|
||||
| PDF/X-1a and X-3 (transparency flattener), PDF presets and `.joboptions`, external validator | 40–70 |
|
||||
| Print path (Windows, PostScript level 3 output or system print APIs) | 25–45 |
|
||||
| Print fidelity, PostScript level 3 output and print presets | 25–45 |
|
||||
| ICML story import, IDMS snippets, INDT templates (once INDD reads) | 15–25 |
|
||||
| HTML / fixed-layout EPUB with live text | 20–35 |
|
||||
| JPEG export dialog, SVG export, EPS export | 10–20 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Where DesignCraft falls short of InDesign
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (stage re-normalized to pre-alpha by the core-workflow gate; earlier today: major, first version: gaps from the full re-measure and the issue tracker) · **Target:** Adobe InDesign 2026 (21.6)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (book chapter update safety) · **Target:** Adobe InDesign 2026 (21.6)
|
||||
|
||||
Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an
|
||||
InDesign professional from doing real work. This is the work list: agents pick from the top.
|
||||
@@ -117,8 +117,10 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format ·
|
||||
- **Estimate:** 40–70 h. **Doc:** [file-format-parity.md](file-format-parity.md)
|
||||
|
||||
### 10. Printing (H, F)
|
||||
- **Missing:** a real print path: `file.print` sends PDF to `lpr`, so Windows can't print; no
|
||||
PostScript, printer PPDs, separations to device, print presets, or trapping.
|
||||
- **Missing:** PostScript, a printer-driver options UI, separations to device, print presets, or trapping.
|
||||
- **Have:** `file.print` exports selected pages/spreads with marks and bleed, then submits through
|
||||
craft-print: CUPS on macOS/Linux and the Windows PDF raster spooler. Physical printer fidelity
|
||||
remains a hardware check.
|
||||
- **Estimate:** 25–45 h. **Doc:** [hardware-parity.md](hardware-parity.md)
|
||||
|
||||
### 11. Paragraph and character style editing reliability (F)
|
||||
@@ -147,8 +149,11 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format ·
|
||||
## Medium
|
||||
|
||||
### 15. Long documents in depth (F)
|
||||
- **Missing:** Books beyond a list of documents: style/swatch synchronization, book-wide
|
||||
pagination, book PDF export, book TOC and index; Table of Contents Styles.
|
||||
- **Missing:** book-wide TOC and index; Table of Contents Styles and deeper production workflows.
|
||||
- **Present:** basic style synchronization, continuous book numbering and merged PDF export.
|
||||
Chapter updates prepare the full batch and write only closed native chapters; IDML is a
|
||||
read-only source. Each file is atomic, but a later I/O failure can leave completed chapters;
|
||||
the error lists them. Cross-file transactional saving remains unavailable.
|
||||
- **Estimate:** 30–50 h.
|
||||
|
||||
### 16. Menu items still missing (U)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Hardware and platform parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version) · **Target:** Adobe InDesign 2026 (21.6)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026 (21.6)
|
||||
|
||||
What InDesign does with hardware, per platform, against DesignCraft. Layout is not a
|
||||
GPU-hungry workload, so this dimension carries little weight, but a start that fails on a
|
||||
@@ -19,7 +19,7 @@ graphics driver loses the user entirely. Overall numbers: [target-app-parity.md]
|
||||
| HiDPI / Retina | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| UI scaling | ✓ | ✓ (menus and dialogs usable at larger scales, #281) | ✓ | ✓ | ✓ |
|
||||
| Multi-monitor, floating document windows | ✓ | ~ one window; New Window for the same document | ~ | ~ | ✗ |
|
||||
| Printing | system print with PPDs, PostScript, separations | PDF to `lpr` | ✗ (no `lpr`) | PDF to `lpr` (CUPS) | browser download |
|
||||
| Printing | system print with PPDs, PostScript, separations | PDF to CUPS (`craft-print`) | Windows PDF raster spooler (`craft-print`) | PDF to CUPS (`craft-print`) | browser download |
|
||||
| Colour-managed display (monitor profile) | ✓ | ✗ (working CMYK profile simulated; monitor profile not applied) | ✗ | ✗ | ✗ |
|
||||
| Touch / pen | Touch workspace (Windows), gestures | trackpad pinch/scroll | ~ | ~ | ~ |
|
||||
| Multi-core composition | single-threaded composer per story (documented by Adobe as mostly single-threaded) | parallel story composition (rayon), 6.7× on the sample | ✓ | ✓ | single-threaded |
|
||||
@@ -43,7 +43,7 @@ the developers don't use daily.
|
||||
| Work | Opus 5.5 h |
|
||||
|---|---|
|
||||
| Start-up robustness on every GPU: confirm #297 with reporters, Intel Metal fix, CPU-only present fallback | 15–30 |
|
||||
| Printing on Windows (system print API) and print presets | 15–30 (counted in file-format-parity.md) |
|
||||
| Printing fidelity, PostScript and print presets | 15–30 (counted in file-format-parity.md) |
|
||||
| Monitor profile in display colour management | 5–10 |
|
||||
| Windows and Linux runtime smoke tests in CI | 20–40 |
|
||||
| Multi-window document views | 15–30 |
|
||||
@@ -54,3 +54,7 @@ the developers don't use daily.
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | major | Created from the code, InDesign 2026's GPU Performance features and the issue tracker |
|
||||
|
||||
Windows submission now uses shared craft-print. It selects matching or default driver paper,
|
||||
shrinks oversized sheets to the printable area without enlarging smaller ones, and preserves
|
||||
the queue's duplex default; printer-driver options UI and physical output validation remain open.
|
||||
|
||||
@@ -51,7 +51,8 @@ claude mcp add designcraft -- designcraft-cli mcp
|
||||
| UI-only commands via `execute` (`view.*`, `window.*`, `help.*`, `edit.dynamicSpelling`, and `app.*` except `app.links`) | Error | Work |
|
||||
|
||||
In connected mode every tool maps to a control-channel method (`engine.execute`, `ui.render`, `ui.pointer`, …), so
|
||||
what the agent does shows up live in the window. The connection is re-established once if the app restarts.
|
||||
what the agent does shows up live in the window. A failed request is not resent: the operation may have completed,
|
||||
so inspect the document before retrying. The next request re-establishes the connection.
|
||||
|
||||
## Coordinates
|
||||
|
||||
@@ -118,4 +119,4 @@ key {"key": "Escape"}
|
||||
|
||||
## Community links
|
||||
|
||||
`app.links` returns the ArtCraft Discord (https://discord.gg/artcraft), the website (https://getartcraft.com), DesignCraft's app page (https://getartcraft.com/apps/designcraft), its GitHub repository (https://github.com/storytold/designcraft) and the issue tracker. In the running app, the UI commands `help.discord`, `help.appPage`, `help.github`, `help.issues`, `help.website`, `help.app {app}` open them in the browser and `help.about {tab?}` shows the About window (tabs `about`, `contributors`, `models`). The server's `serverInfo.websiteUrl` is the app page.
|
||||
`app.links` returns the GitHub repository (https://github.com/storytold/designcraft) and the issue tracker. In the running app, the UI commands `help.github` and `help.issues` open them in the browser and `help.about {tab?}` shows the About window (tabs `about`, `contributors`, `models`). The server's `serverInfo.websiteUrl` is the GitHub repository.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# DesignCraft roadmap (detail)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (stage re-normalized to pre-alpha by the core-workflow gate; earlier today: major, milestones re-assessed; beta plan from the full re-measure) · **Target:** Adobe InDesign 2026 (21.6)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (stage re-normalized to pre-alpha by the core-workflow gate; earlier today: major, milestones re-assessed; beta plan from the full re-measure) · **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
|
||||
@@ -88,7 +88,7 @@ that is ~28 points and **420–750 h** (~120–200 wall-clock hours with four to
|
||||
| Start-up robustness, fonts | 5, 6 | 25–50 |
|
||||
| CI, Windows/Linux runtime tests | 7, 14 | 35–70 |
|
||||
| IME | 8 | 10–20 |
|
||||
| PDF/X-1a/X-3, presets, print on Windows | 9, 10 | 65–110 |
|
||||
| PDF/X-1a/X-3, presets, physical print validation | 9, 10 | 65–110 |
|
||||
| Styles, tables, missing menu items (the most-used half) | 11, 12, 16 | 60–100 |
|
||||
| Long documents (Books) | 15 | 30–50 |
|
||||
| **Total** | | **420–750** |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# DesignCraft parity with Adobe InDesign
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (stage re-normalized to pre-alpha by the core-workflow gate; earlier today: major, full re-measure against InDesign 2026 21.6; replaces the 2026-10-04 estimate in ROADMAP.md) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (cancelable macOS Dock Quit) · **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
|
||||
@@ -66,7 +66,7 @@ they receive, setting type exactly, and basic editing that never fails.
|
||||
| Performance | 5% | 65% | 40–80 | 300k characters compose in ~38 ms warm; typing repaints in < 1 ms (damage regions); no incremental composition; nothing benchmarked against InDesign; no large real documents | [gaps.md](gaps.md) |
|
||||
| Localization | 3% | 45% | 120–180 | 9 translations beyond English; Japanese 95%, most others 83–85%, pt-BR 61%; Hindi, Indonesian, Korean, Vietnamese none | [localization-parity.md](localization-parity.md) |
|
||||
| Platforms | 3% | 55% | 40–80 | macOS is where it's tested; Windows and Linux users hit basic failures; web and FreeBSD builds exist | [hardware-parity.md](hardware-parity.md) |
|
||||
| Hardware | 2% | 45% | 20–40 | Canvas rasterizes on the CPU (vello_cpu, multithreaded); GPU only presents; printing is `lpr` (no Windows printing, no PostScript) | [hardware-parity.md](hardware-parity.md) |
|
||||
| Hardware | 2% | 45% | 20–40 | Canvas rasterizes on the CPU (vello_cpu, multithreaded); GPU only presents; printing uses shared CUPS/Windows PDF spoolers (no PostScript; physical output needs validation) | [hardware-parity.md](hardware-parity.md) |
|
||||
| Ecosystem (scripting, plug-ins) | 2% | 15% | 150–250 | Own command/script/MCP API is strong; no ExtendScript/UXP DOM, so InDesign scripts don't run; no plug-ins | [gaps.md](gaps.md) |
|
||||
| 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** | | |
|
||||
@@ -117,7 +117,7 @@ twice.
|
||||
| Tables | 5% | 55% | anchored objects in cells, Table Options submenus |
|
||||
| Layers | 3% | 75% | |
|
||||
| Effects and transparency | 3% | 50% | fidelity unmeasured |
|
||||
| Output: PDF export, print, package | 8% | 60% | PDF/X-4 strong; no X-1a, presets, Windows printing |
|
||||
| Output: PDF export, print, package | 8% | 60% | PDF/X-4 strong; no X-1a or presets; physical print validation pending |
|
||||
| View, navigation, undo, save | 3% | 70% | |
|
||||
| **Weighted depth** | 100% | **60.7%** | |
|
||||
|
||||
@@ -152,7 +152,7 @@ features), exchange edge cases, and everything excluded above.
|
||||
| Undo / redo | 5% | 85% | |
|
||||
| Save and reopen | 5% | 85% | `.designcraft` round trip, recovery |
|
||||
| Export PDF / PNG | 10% | 80% | |
|
||||
| Print | 5% | 40% | PDF to `lpr` only, nothing on Windows |
|
||||
| Print | 5% | 40% | PDF to CUPS/Windows via craft-print; physical output validation pending |
|
||||
| **Weighted depth** | 100% | **72.0%** | |
|
||||
|
||||
| Discount | Factor | Evidence |
|
||||
@@ -217,7 +217,7 @@ table is the one summed.
|
||||
| Tables | 100% | 55% | 25–40 | anchored objects in cells not drawn (#163); Table ▸ Table Options submenus, Diagonal Lines, Convert Rows, Edit Header/Footer |
|
||||
| Long documents | 100% | 45% | 30–50 | Books are shallow (sync, book pagination, book PDF/TOC/index); Table of Contents Styles |
|
||||
| Interactivity & digital | 100% | 40% | 30–50 | HTML export basic (#314); fixed-layout EPUB is page images; no animation, timing, media playback |
|
||||
| Output & production | 92% | 55% | 40–70 | PDF/X-4 only (no X-1a/X-3), no PDF/print presets or `.joboptions`, print via `lpr` (no Windows), no trapping; Output Preview panel |
|
||||
| Output & production | 92% | 55% | 40–70 | PDF/X-4 only (no X-1a/X-3), no PDF/print presets or `.joboptions`, print via craft-print (CUPS/Windows), no trapping; Output Preview panel |
|
||||
| XML & automation | 100% | 35% | (ecosystem dimension) | no ExtendScript/UXP; XML structure depth |
|
||||
| View & navigation | 100% | 65% | 10–20 | Navigator, display badges, Show/Hide extras (content grabber, live corners) |
|
||||
| Undo, history, saving | 100% | 70% | 5–10 | Undo/Redo name the action; File ▸ Open Recent |
|
||||
@@ -288,7 +288,10 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
|
||||
- Missing fonts: highlighted pink on screen (never in output), listed by Preflight; Type › Find/Replace Font (fonts in the document, missing first; replace in text, cells, footnotes and styles); `font.list` / `font.replace`.
|
||||
- Missing glyphs, as in InDesign: a character the applied font lacks is drawn as that font's missing-glyph box (its .notdef; a drawn box when the font's is empty), on screen and in PDF (as a path, so PDF/A and PDF/UA exports take it), and Preflight lists the characters by font. Preferences › Composition › Draw Missing Glyphs from Fallback Fonts (`document.preferences {glyphFallback}`, stored with the document) draws them from other fonts instead; it is off in new documents and IDML imports, on in documents saved before it existed. A missing font's substitute always uses fallback fonts.
|
||||
- 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).
|
||||
- Crash recovery: unsaved documents are written to the recovery folder every 30 s and reopened (unsaved, remembering their files) after a crash; File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`.
|
||||
- Native engine saves, Save a Copy, snippets, books, libraries, swatches and engine/MCP exports use same-folder atomic replacement, as do desktop Services writes and CLI raster exports. Failed writes before replacement retain the original; existing Unix permissions apply before staging document bytes, and already-open readers retain the old bytes. Parent-folder flushing is best effort, so this is not a power-loss durability guarantee.
|
||||
- Save As prepares destination-relative data-source paths before writing and installs them only after a successful save. Failed Save As retains the document, saved state and history; Save a Copy rebases only the saved copy. Library and book collection edits likewise update live values only after persistence succeeds.
|
||||
- Crash recovery: unsaved documents are written every 30 s into a locked folder owned by that running app and reopened (unsaved, remembering their files) after a crash. Startup takes entries from ended runs and leaves live runs alone; writes and cleanup validate current folder and lock ownership. Failed isolation disables recovery with a warning. Recovered checkpoints remain available through another crash before autosave; restarting IDs and explicit recovery from another folder preserve unopened snapshots. Successful Save, Close or Revert clears the document's checkpoints, including manual recovery while autosave is disabled; failed republishing keeps the original. Concurrent older versions still use unlocked root entries; mixed-version isolation remains open. File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`.
|
||||
- Window close, application/menu Quit and macOS Dock Quit ask about every unsaved document. Cancel or a cancelled/failed save keeps all tabs open; Don't Save approvals discard nothing until the complete plan is current. Later edits and new documents are checked before closing. Repeated native requests preserve an active dialog and partial approvals. Control-channel quit remains immediate.
|
||||
- Graphics backend chosen before the window exists (DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, OpenGL as the fallback; `WGPU_BACKEND` overrides) with a start-up fallback: a backend whose start never showed a frame is skipped at the next start and the status bar says so (`gpu.json`). Found on an AMD hybrid-graphics laptop whose Vulkan driver faulted at the first present.
|
||||
- Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool).
|
||||
- Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`.
|
||||
@@ -346,8 +349,9 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
|
||||
- Edit ▸ Menus (`window.hideMenuItem`): hide menu items; menus with hidden items end in Show All Menu Items.
|
||||
- View ▸ Separations Preview (`view.separations`): one process plate as ink density, or areas over an ink limit in red (a GCR preview of the rendered view; spot plates and the flattener preview aren't there).
|
||||
- Separations Preview plates now come from each object's own colour (CMYK as authored; RGB and images through the colour settings, images via a cached 17³ table), so pure black shows only on the black plate.
|
||||
- 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`). Spanish is hyphenated with Javier Bezos's Liang patterns (`hyph-es.tex` 5.0, MIT) and Portuguese with hyph-utf8's `hyph-pt.tex` 1.4 (BSD-3-Clause), both through the `hypher` crate; 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, style sync from a style source, one merged PDF.
|
||||
- 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.
|
||||
- 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.
|
||||
- Move table rows and columns (`table.moveRow`, `table.moveColumn`, Table panel buttons; with whole rows or columns selected, drag them with the Type tool — `table.dropCells`).
|
||||
|
||||
@@ -50,7 +50,7 @@ basics of editing fail for too many users on Windows and Linux.
|
||||
| Workspaces | Essentials, Advanced, Book, Digital Publishing, Interactive for PDF, Printing and Proofing, Typography, Touch | ✓ named workspaces + custom | ~ | tab bar overlaps menus in Book/Advanced (#182) |
|
||||
| Story Editor | ✓ | ✓ | ~ | style-name column, depth ruler missing |
|
||||
| Undo granularity (a drag = one step) | ✓ | ✓ (Begin/Preview/Commit) | ✓ | |
|
||||
| Closing with unsaved changes asks | ✓ | ✓ (#146) | ✓ | |
|
||||
| Closing with unsaved changes asks | ✓ | ✓ File › Close and window/menu quit (#146, #545) | ✓ | macOS Dock Quit still bypasses the prompt (#544) |
|
||||
| UI scaling, System appearance | ✓ | scaling ✓ (#281); follows macOS light/dark ✗ (#237) | ~ | |
|
||||
| Window controls, full screen | ✓ | ~ | ~ | misaligned on macOS (#333) |
|
||||
|
||||
|
||||