mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-05 10:10:35 +00:00
Merge main into graphic-frame-x-corners
This commit is contained in:
commit
747abf4ac5
286 files changed
+27097
-2026
No files matched your search
@@ -63,6 +63,7 @@ jobs:
|
||||
needs: git-sources
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
|
||||
@@ -26,8 +26,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
windows-arm64:
|
||||
# Immutable supporting workflow revision until the new install contract lands upstream.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
|
||||
with:
|
||||
app-name: designcraft
|
||||
app-dir: DesignCraft
|
||||
|
||||
@@ -9,7 +9,7 @@ DesignCraft is a clean-room, open-source, Rust-native page-layout application ta
|
||||
`plan/` is gitignored (local only).
|
||||
|
||||
## Never crash
|
||||
People trust DesignCraft with their layouts; a crash loses their work. **This outranks feature work**: never ship a feature through a panic path, and fix a crash before building on top of it. Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
|
||||
People trust DesignCraft with their layouts; a crash loses their work. **This outranks feature work**: never ship a feature through a panic path, and fix a crash before building on top of it.
|
||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The one exception is a provably infallible literal (a constant regex, `write!` to a `String`): `#[allow(clippy::expect_used)]` + `.expect("why it cannot fail")`.
|
||||
- **Errors are `Result<T, E>`** through the crate's error type and `?` (`ok_or(..)?`, `let … else`, `if let`, `map_err` for context). An unfinished feature returns an "unsupported" error. Don't `unwrap_or_default()` where a silent default would corrupt a document; return an error.
|
||||
- **Input-derived numbers are hostile** (files, commands, MCP/control params, settings): `get()` instead of `[i]`/`[a..b]`, slice strings at char boundaries, checked/saturating arithmetic for lengths and offsets, no division by zero or NaN casts, cap input-sized allocations.
|
||||
@@ -21,8 +21,8 @@ People trust DesignCraft with their layouts; a crash loses their work. **This ou
|
||||
## Non-negotiables
|
||||
- **Clean-room.** InDesign is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic documents, take screenshots (by window id) stored only under `plan/indesign/screenshots/` (never committed). Never read, disassemble or copy anything inside the InDesign bundle (names/listings only), never copy Adobe icons, artwork, presets or wording beyond feature names, never commit files produced by InDesign. Never copy GPL/AGPL/LGPL code (Scribus, LibreOffice, Ghostscript…).
|
||||
- **Assets:** no Adobe iconography or images — ever. Every asset is original / OSI / CC0 / redistributable CC (except the ArtCraft trademarks in `docs/brand/`, under `docs/brand/LICENSE-brand.txt`) and has a row in `ASSETS.md` (`cargo xtask assets` enforces it). Prefer art generated in code.
|
||||
- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo: don't commit new font files (the Latin UI/document fonts already in `assets/fonts/` stay). It is an optional build input, never a `Cargo.toml` dependency: `CRAFT_FONTS_DIR=/abs/path/to/craft-fonts cargo build` makes `crates/fonts/build.rs` embed its fonts as `designcraft_fonts::CRAFT_FONTS` (Japanese: BIZ UDPGothic for the UI, Mincho first for document text; web builds embed BIZ UDPGothic Regular only). Unset, `CRAFT_FONTS` is empty and the app uses its bundled and system fonts; code and tests must work that way (tests on its glyphs skip). Releases build with it. Standard: [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
|
||||
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
|
||||
- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo: don't commit new font files (the Latin UI/document fonts already in `assets/fonts/` stay). It is an optional build input, never a `Cargo.toml` dependency: `CRAFT_FONTS_DIR=/abs/path/to/craft-fonts cargo build` makes `crates/fonts/build.rs` embed its fonts as `designcraft_fonts::CRAFT_FONTS` (Japanese: BIZ UDPGothic for the UI, Mincho first for document text; web builds embed BIZ UDPGothic Regular only). Unset, `CRAFT_FONTS` is empty and the app uses its bundled and system fonts; code and tests must work that way (tests on its glyphs skip). Releases build with it.
|
||||
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
|
||||
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. Tools emit commands (Begin/Preview/Commit). UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The control channel and MCP reach all of them.
|
||||
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd. The UI crate is swappable.
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
|
||||
@@ -39,22 +39,24 @@ People trust DesignCraft with their layouts; a crash loses their work. **This ou
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk).
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It follows craftrules' [`standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md). Details: `docs/target-app-parity.md` (assessment), `docs/gaps.md` (ranked work list: pick from the top), `docs/roadmap.md` (milestones, current focus), `docs/parity-checklist.md` (presence checklist, `cargo xtask parity`), and the typography, file-format, UI, hardware and localization parity docs in `docs/`. When work lands, update the affected docs and their status lines, delete or shrink the closed gap, and add a progress-log line to ROADMAP.md.
|
||||
`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. Details: `docs/target-app-parity.md` (assessment), `docs/gaps.md` (ranked work list: pick from the top), `docs/roadmap.md` (milestones, current focus), `docs/parity-checklist.md` (presence checklist, `cargo xtask parity`), and the typography, file-format, UI, hardware and localization parity docs in `docs/`. When work lands, update the affected docs and their status lines, delete or shrink the closed gap, and add a progress-log line to ROADMAP.md.
|
||||
|
||||
## Contributor credits (About window)
|
||||
|
||||
- About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json`
|
||||
(commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for
|
||||
(commit stats; never hand-edit) and `contributors/people.toml` (names people chose for
|
||||
themselves). See `docs/contributors.md`.
|
||||
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
|
||||
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
|
||||
whether they want to be credited by more than their username: a real name, a display name, and/or
|
||||
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
|
||||
(copy the template at the top of the file, or run
|
||||
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
|
||||
(copy the template at the top of the file)
|
||||
and include it in their PR, committed as them. If no, change nothing: they are credited as
|
||||
`@username` anyway.
|
||||
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
|
||||
GitHub profiles). Never hand-edit `contributors.json`.
|
||||
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
|
||||
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
Every non-code asset in this repository (fonts, images, example art, presets) is listed here with its author, source and licence. `cargo xtask assets` (part of `cargo xtask ci`) fails if an asset file is missing from this table.
|
||||
|
||||
**Policy (mandatory):** DesignCraft contains **no Adobe iconography, images, artwork, presets, swatch libraries or ICC profiles**. Every asset is original work by DesignCraft contributors or third-party material under an open licence (OSI open source, public domain / CC0, or Creative Commons that allows redistribution). Screenshots of Adobe software are never committed. Font files are not added here: shared fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), an optional build input (`CRAFT_FONTS_DIR`, see AGENTS.md and craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). The one exception is the ArtCraft brand files in `docs/brand/`: ArtCraft trademarks, not open source, used under `docs/brand/LICENSE-brand.txt`.
|
||||
**Policy (mandatory):** DesignCraft contains **no Adobe iconography, images, artwork, presets, swatch libraries or ICC profiles**. Every asset is original work by DesignCraft contributors or third-party material under an open licence (OSI open source, public domain / CC0, or Creative Commons that allows redistribution). Screenshots of Adobe software are never committed. Font files are not added here: shared fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), an optional build input (`CRAFT_FONTS_DIR`, see AGENTS.md). The one exception is the ArtCraft brand files in `docs/brand/`: ArtCraft trademarks, not open source, used under `docs/brand/LICENSE-brand.txt`.
|
||||
|
||||
Generated-in-code art is original and has no file to list: the UI icon set (`crates/ui-egui/src/icons.rs`), default swatches, and the sample magazine's imagery (`crates/engine/src/sample.rs`).
|
||||
|
||||
| Asset | Author | Source | Licence | Notes |
|
||||
|---|---|---|---|---|
|
||||
| German hyphenation patterns (embedded by `hypher` 0.1.8, feature `german`) | Deutschsprachige Trennmustermannschaft: Stephan Hennig, Werner Lemberg, Günter Milde, Sander van Geloven, Georg Pfeiffer, Gisbert W. Selke, Tobias Wendorf, Keno Wehr | https://github.com/typst/hypher/blob/d81cc506416bffef2a75ee2dc969d4b41bc36613/patterns/hyph-de-1996.tex | MIT (copyright 2013–2024; full text in NOTICE) | Version 2024-02-28; reformed German spelling (2006), despite the `de-1996` filename; no separate old-spelling or Swiss patterns |
|
||||
| `epaint_default_fonts/fonts/egui-icons.ttf` (egui dependency) | John Slegers (emoji-icon-font) | https://github.com/storytold/egui/blob/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts/egui-icons.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full text in NOTICE) | Small special-icon subset embedded by default_fonts |
|
||||
| `epaint_default_fonts/fonts/emoji-icon-font.ttf` (egui dependency) | John Slegers | https://github.com/storytold/egui/blob/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full text in NOTICE) | Parent face embedded by monochrome_emoji_fonts |
|
||||
| `crates/ui-egui/src/i18n/uk.rs` (332 common phrases) | VectorCraft contributors | https://github.com/storytold/vectorcraft/blob/01f329aa99fa0c9517311c7b6cf7233ffc207742/crates/ui-egui/src/i18n/uk.tsv | MIT OR Apache-2.0 | Adapted for DesignCraft context; remaining Ukrainian phrases are original or credited below |
|
||||
@@ -51,10 +52,10 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
||||
| `assets/app-icon/LICENSE.txt` | (licence text) | this project | — | Licence for the app icon |
|
||||
| `assets/app-icon/designcraft-1024.png` | DesignCraft project owner (drawn in ArtCraft) | rendered from `assets/app-icon/designcraft.svg` by `packaging/icons.sh` | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon: calico cat in a polka-dot scarf (see `assets/app-icon/README.md`) |
|
||||
| `assets/app-icon/designcraft-macos-512.png` | DesignCraft project owner (drawn in ArtCraft) | rendered from `assets/app-icon/designcraft.svg` by `packaging/icons.sh` | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon: calico cat in a polka-dot scarf (see `assets/app-icon/README.md`) |
|
||||
| `assets/app-icon/designcraft-small.svg` | DesignCraft project owner (drawn in ArtCraft) | owner's ArtCraft drawing, vectorised with craftrules `assets/logo-options/_tools/vectorize_tile.py` | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon: calico cat in a polka-dot scarf (see `assets/app-icon/README.md`) |
|
||||
| `assets/app-icon/designcraft-small.svg` | DesignCraft project owner (drawn in ArtCraft) | owner's ArtCraft drawing | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon: calico cat in a polka-dot scarf (see `assets/app-icon/README.md`) |
|
||||
| `assets/app-icon/designcraft.icns` | DesignCraft project owner (drawn in ArtCraft) | rendered from `assets/app-icon/designcraft.svg` by `packaging/icons.sh` | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon: calico cat in a polka-dot scarf (see `assets/app-icon/README.md`) |
|
||||
| `assets/app-icon/designcraft.ico` | DesignCraft project owner (drawn in ArtCraft) | rendered from `assets/app-icon/designcraft.svg` by `packaging/icons.sh` | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon: calico cat in a polka-dot scarf (see `assets/app-icon/README.md`) |
|
||||
| `assets/app-icon/designcraft.svg` | DesignCraft project owner (drawn in ArtCraft) | owner's ArtCraft drawing, vectorised with craftrules `assets/logo-options/_tools/vectorize_tile.py` | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon: calico cat in a polka-dot scarf (see `assets/app-icon/README.md`) |
|
||||
| `assets/app-icon/designcraft.svg` | DesignCraft project owner (drawn in ArtCraft) | owner's ArtCraft drawing | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon: calico cat in a polka-dot scarf (see `assets/app-icon/README.md`) |
|
||||
| `assets/app-icon/hicolor/128x128/apps/ai.storyteller.designcraft.png` | DesignCraft project owner (drawn in ArtCraft) | rendered from `assets/app-icon/designcraft.svg` by `packaging/icons.sh` | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon: calico cat in a polka-dot scarf (see `assets/app-icon/README.md`) |
|
||||
| `assets/app-icon/hicolor/16x16/apps/ai.storyteller.designcraft.png` | DesignCraft project owner (drawn in ArtCraft) | rendered from `assets/app-icon/designcraft.svg` by `packaging/icons.sh` | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon: calico cat in a polka-dot scarf (see `assets/app-icon/README.md`) |
|
||||
| `assets/app-icon/hicolor/24x24/apps/ai.storyteller.designcraft.png` | DesignCraft project owner (drawn in ArtCraft) | rendered from `assets/app-icon/designcraft.svg` by `packaging/icons.sh` | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon: calico cat in a polka-dot scarf (see `assets/app-icon/README.md`) |
|
||||
@@ -63,7 +64,7 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
||||
| `assets/app-icon/hicolor/48x48/apps/ai.storyteller.designcraft.png` | DesignCraft project owner (drawn in ArtCraft) | rendered from `assets/app-icon/designcraft.svg` by `packaging/icons.sh` | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon: calico cat in a polka-dot scarf (see `assets/app-icon/README.md`) |
|
||||
| `assets/app-icon/hicolor/512x512/apps/ai.storyteller.designcraft.png` | DesignCraft project owner (drawn in ArtCraft) | rendered from `assets/app-icon/designcraft.svg` by `packaging/icons.sh` | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon: calico cat in a polka-dot scarf (see `assets/app-icon/README.md`) |
|
||||
| `assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png` | DesignCraft project owner (drawn in ArtCraft) | rendered from `assets/app-icon/designcraft.svg` by `packaging/icons.sh` | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon: calico cat in a polka-dot scarf (see `assets/app-icon/README.md`) |
|
||||
| `assets/app-icon/hicolor/scalable/apps/ai.storyteller.designcraft.svg` | DesignCraft project owner (drawn in ArtCraft) | owner's ArtCraft drawing, vectorised with craftrules `assets/logo-options/_tools/vectorize_tile.py` | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon: calico cat in a polka-dot scarf (see `assets/app-icon/README.md`) |
|
||||
| `assets/app-icon/hicolor/scalable/apps/ai.storyteller.designcraft.svg` | DesignCraft project owner (drawn in ArtCraft) | owner's ArtCraft drawing | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon: calico cat in a polka-dot scarf (see `assets/app-icon/README.md`) |
|
||||
| `docs/brand/artcraft-logo.svg`, `docs/brand/artcraft-logo.png` | ArtCraft / Storyteller | ArtCraft brand kit (project owner) | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | ArtCraft wordmark (README header) |
|
||||
| `docs/brand/artcraft-logo-white.svg`, `docs/brand/artcraft-logo-white.png` | ArtCraft / Storyteller | ArtCraft brand kit (project owner) | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Wordmark for dark backgrounds |
|
||||
| `docs/brand/artcraft-mark.svg`, `docs/brand/artcraft-mark.png` | ArtCraft / Storyteller | ArtCraft brand kit (project owner) | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | The mark; its outline is drawn in code by `crates/ui-egui/src/about.rs` (About splash, start screen) |
|
||||
|
||||
@@ -9,7 +9,7 @@ DesignCraft is a clean-room, open-source, Rust-native page-layout application ta
|
||||
`plan/` is gitignored (local only).
|
||||
|
||||
## Never crash
|
||||
People trust DesignCraft with their layouts; a crash loses their work. **This outranks feature work**: never ship a feature through a panic path, and fix a crash before building on top of it. Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
|
||||
People trust DesignCraft with their layouts; a crash loses their work. **This outranks feature work**: never ship a feature through a panic path, and fix a crash before building on top of it.
|
||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The one exception is a provably infallible literal (a constant regex, `write!` to a `String`): `#[allow(clippy::expect_used)]` + `.expect("why it cannot fail")`.
|
||||
- **Errors are `Result<T, E>`** through the crate's error type and `?` (`ok_or(..)?`, `let … else`, `if let`, `map_err` for context). An unfinished feature returns an "unsupported" error. Don't `unwrap_or_default()` where a silent default would corrupt a document; return an error.
|
||||
- **Input-derived numbers are hostile** (files, commands, MCP/control params, settings): `get()` instead of `[i]`/`[a..b]`, slice strings at char boundaries, checked/saturating arithmetic for lengths and offsets, no division by zero or NaN casts, cap input-sized allocations.
|
||||
@@ -21,8 +21,8 @@ People trust DesignCraft with their layouts; a crash loses their work. **This ou
|
||||
## Non-negotiables
|
||||
- **Clean-room.** InDesign is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic documents, take screenshots (by window id) stored only under `plan/indesign/screenshots/` (never committed). Never read, disassemble or copy anything inside the InDesign bundle (names/listings only), never copy Adobe icons, artwork, presets or wording beyond feature names, never commit files produced by InDesign. Never copy GPL/AGPL/LGPL code (Scribus, LibreOffice, Ghostscript…).
|
||||
- **Assets:** no Adobe iconography or images — ever. Every asset is original / OSI / CC0 / redistributable CC (except the ArtCraft trademarks in `docs/brand/`, under `docs/brand/LICENSE-brand.txt`) and has a row in `ASSETS.md` (`cargo xtask assets` enforces it). Prefer art generated in code.
|
||||
- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo: don't commit new font files (the Latin UI/document fonts already in `assets/fonts/` stay). It is an optional build input, never a `Cargo.toml` dependency: `CRAFT_FONTS_DIR=/abs/path/to/craft-fonts cargo build` makes `crates/fonts/build.rs` embed its fonts as `designcraft_fonts::CRAFT_FONTS` (Japanese: BIZ UDPGothic for the UI, Mincho first for document text; web builds embed BIZ UDPGothic Regular only). Unset, `CRAFT_FONTS` is empty and the app uses its bundled and system fonts; code and tests must work that way (tests on its glyphs skip). Releases build with it. Standard: [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
|
||||
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
|
||||
- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo: don't commit new font files (the Latin UI/document fonts already in `assets/fonts/` stay). It is an optional build input, never a `Cargo.toml` dependency: `CRAFT_FONTS_DIR=/abs/path/to/craft-fonts cargo build` makes `crates/fonts/build.rs` embed its fonts as `designcraft_fonts::CRAFT_FONTS` (Japanese: BIZ UDPGothic for the UI, Mincho first for document text; web builds embed BIZ UDPGothic Regular only). Unset, `CRAFT_FONTS` is empty and the app uses its bundled and system fonts; code and tests must work that way (tests on its glyphs skip). Releases build with it.
|
||||
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
|
||||
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. Tools emit commands (Begin/Preview/Commit). UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The control channel and MCP reach all of them.
|
||||
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd. The UI crate is swappable.
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
|
||||
@@ -38,4 +38,9 @@ People trust DesignCraft with their layouts; a crash loses their work. **This ou
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk).
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It follows craftrules' [`standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md). Details: `docs/target-app-parity.md` (assessment), `docs/gaps.md` (ranked work list: pick from the top), `docs/roadmap.md` (milestones, current focus), `docs/parity-checklist.md` (presence checklist, `cargo xtask parity`), and the typography, file-format, UI, hardware and localization parity docs in `docs/`. When work lands, update the affected docs and their status lines, delete or shrink the closed gap, and add a progress-log line to ROADMAP.md.
|
||||
`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. Details: `docs/target-app-parity.md` (assessment), `docs/gaps.md` (ranked work list: pick from the top), `docs/roadmap.md` (milestones, current focus), `docs/parity-checklist.md` (presence checklist, `cargo xtask parity`), and the typography, file-format, UI, hardware and localization parity docs in `docs/`. When work lands, update the affected docs and their status lines, delete or shrink the closed gap, and add a progress-log line to ROADMAP.md.
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
Generated
+69
-28
@@ -1138,13 +1138,14 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
"egui",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"image",
|
||||
"log",
|
||||
"plist",
|
||||
"semver",
|
||||
@@ -1305,7 +1306,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"craft-update",
|
||||
@@ -1329,7 +1330,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-cli"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"designcraft-color",
|
||||
"designcraft-compose",
|
||||
@@ -1340,12 +1341,13 @@ dependencies = [
|
||||
"designcraft-mcp",
|
||||
"designcraft-pdf",
|
||||
"designcraft-render",
|
||||
"image",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-color"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-cms",
|
||||
"kurbo",
|
||||
@@ -1357,7 +1359,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-compose"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"designcraft-color",
|
||||
"designcraft-doc",
|
||||
@@ -1377,7 +1379,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-doc"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"designcraft-color",
|
||||
@@ -1390,7 +1392,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-engine"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"caseless",
|
||||
"craft-atomic",
|
||||
@@ -1430,7 +1432,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-epub"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"designcraft-color",
|
||||
@@ -1440,7 +1442,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-fonts"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"core-text",
|
||||
"harfrust 0.13.3",
|
||||
@@ -1453,7 +1455,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-format"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"designcraft-doc",
|
||||
"designcraft-images",
|
||||
@@ -1465,7 +1467,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-geom"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"flo_curves",
|
||||
"kurbo",
|
||||
@@ -1478,7 +1480,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-idml"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"designcraft-color",
|
||||
"designcraft-compose",
|
||||
@@ -1493,7 +1495,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-images"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-cms",
|
||||
"designcraft-color",
|
||||
@@ -1503,6 +1505,7 @@ dependencies = [
|
||||
"log",
|
||||
"quick-xml 0.41.0",
|
||||
"resvg",
|
||||
"skrifa 0.47.0",
|
||||
"tiff",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
@@ -1510,7 +1513,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-mcp"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"designcraft-engine",
|
||||
"designcraft-geom",
|
||||
@@ -1523,7 +1526,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-pdf"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-cms",
|
||||
@@ -1535,6 +1538,7 @@ dependencies = [
|
||||
"designcraft-fonts",
|
||||
"designcraft-geom",
|
||||
"designcraft-images",
|
||||
"flate2",
|
||||
"hayro-cmap",
|
||||
"hayro-interpret",
|
||||
"hayro-syntax",
|
||||
@@ -1548,7 +1552,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-pptx"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"designcraft-color",
|
||||
@@ -1566,7 +1570,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-render"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-pdf",
|
||||
"designcraft-color",
|
||||
@@ -1585,7 +1589,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-textimport"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"designcraft-doc",
|
||||
"quick-xml 0.41.0",
|
||||
@@ -1594,7 +1598,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-tools"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"designcraft-color",
|
||||
"designcraft-compose",
|
||||
@@ -1606,7 +1610,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-ui-egui"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-dialogs",
|
||||
"craft-keymap",
|
||||
@@ -1638,7 +1642,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-web"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"designcraft-engine",
|
||||
"designcraft-format",
|
||||
@@ -3135,7 +3139,7 @@ checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c"
|
||||
[[package]]
|
||||
name = "indd"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/indd-utils?branch=main#7f3060e5bce78d21ea70e0d5eacb96edce15dfbe"
|
||||
source = "git+https://github.com/storytold/indd-utils?rev=7f3060e5bce78d21ea70e0d5eacb96edce15dfbe#7f3060e5bce78d21ea70e0d5eacb96edce15dfbe"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
@@ -3334,7 +3338,7 @@ dependencies = [
|
||||
"skrifa 0.42.1",
|
||||
"smallvec",
|
||||
"subsetter",
|
||||
"tiny-skia-path",
|
||||
"tiny-skia-path 0.12.0",
|
||||
"xmp-writer",
|
||||
"yoke",
|
||||
"zune-jpeg",
|
||||
@@ -3352,7 +3356,7 @@ dependencies = [
|
||||
"resvg",
|
||||
"skrifa 0.42.1",
|
||||
"smallvec",
|
||||
"tiny-skia",
|
||||
"tiny-skia 0.12.0",
|
||||
"usvg",
|
||||
]
|
||||
|
||||
@@ -4929,7 +4933,7 @@ dependencies = [
|
||||
"pico-args",
|
||||
"rgb",
|
||||
"svgtypes",
|
||||
"tiny-skia",
|
||||
"tiny-skia 0.12.0",
|
||||
"usvg",
|
||||
"zune-jpeg",
|
||||
]
|
||||
@@ -5207,6 +5211,17 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sctk-adwaita"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
|
||||
dependencies = [
|
||||
"log",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"tiny-skia 0.11.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -6029,6 +6044,20 @@ dependencies = [
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"log",
|
||||
"tiny-skia-path 0.11.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia"
|
||||
version = "0.12.0"
|
||||
@@ -6041,7 +6070,18 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"log",
|
||||
"png",
|
||||
"tiny-skia-path",
|
||||
"tiny-skia-path 0.12.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia-path"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"bytemuck",
|
||||
"strict-num",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6465,7 +6505,7 @@ dependencies = [
|
||||
"siphasher",
|
||||
"strict-num",
|
||||
"svgtypes",
|
||||
"tiny-skia-path",
|
||||
"tiny-skia-path 0.12.0",
|
||||
"ttf-parser",
|
||||
"unicode-bidi",
|
||||
"unicode-script",
|
||||
@@ -7358,6 +7398,7 @@ dependencies = [
|
||||
"raw-window-handle",
|
||||
"redox_syscall 0.4.1",
|
||||
"rustix 0.38.44",
|
||||
"sctk-adwaita",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"smol_str",
|
||||
"tracing",
|
||||
@@ -7602,7 +7643,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+8
-4
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -17,7 +17,8 @@ craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff1
|
||||
craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", features = ["egui", "ui"] }
|
||||
# Separations, ink coverage and the ink manager; same revision as craft-pdf and the krilla patch.
|
||||
craft-prepress = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", features = ["flatten"] }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
|
||||
# internal
|
||||
designcraft-geom = { path = "crates/geom" }
|
||||
designcraft-color = { path = "crates/color" }
|
||||
@@ -75,9 +76,12 @@ flo_curves = "0.8"
|
||||
qrcodegen = "1.8"
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
# INDD/INDT → IDML conversion for File › Open: storytold/indd-utils, tracking its main branch
|
||||
# INDD/INDT → IDML conversion for File › Open: storytold/indd-utils, pinned by rev as every other
|
||||
# git dependency here is. It parses untrusted INDD files, and `branch = "main"` meant any
|
||||
# `cargo update` -- Dependabot runs them -- pulled whatever was on that branch into the parser
|
||||
# without review (#1599).
|
||||
# (MIT OR Apache-2.0, no dependencies, builds for wasm).
|
||||
indd = { git = "https://github.com/storytold/indd-utils", branch = "main" }
|
||||
indd = { git = "https://github.com/storytold/indd-utils", rev = "7f3060e5bce78d21ea70e0d5eacb96edce15dfbe" }
|
||||
|
||||
# Which paths stored in documents may be followed (craft-libs).
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
|
||||
@@ -32,6 +32,20 @@ list (author, source, license) is in ASSETS.md.
|
||||
AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
- Dutch hyphenation patterns, embedded through the hypher crate (feature "dutch"):
|
||||
hyph-nl.tex 1.1 from hyph-utf8, https://github.com/hyphenation/tex-hyphen.
|
||||
Copyright (C) 1996 Piet Tutelaers. MIT License:
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software
|
||||
and associated documentation files (the "Software"), to deal in the Software without
|
||||
restriction, including without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following conditions: The above copyright notice
|
||||
and this permission notice shall be included in all copies or substantial portions of the
|
||||
Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
||||
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE
|
||||
AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
- Portuguese hyphenation patterns, embedded through the hypher crate (feature "portuguese"):
|
||||
hyph-pt.tex 1.4 from hyph-utf8, https://github.com/hyphenation/tex-hyphen.
|
||||
Copyright (C) 1987, 1994, 1996, 2015 Pedro J. de Rezende, 1996, 2015 J. Joao Dias Almeida,
|
||||
@@ -68,12 +82,56 @@ list (author, source, license) is in ASSETS.md.
|
||||
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
- French hyphenation patterns, embedded through the hypher crate (feature "french"):
|
||||
hyph-fr.tex 2.13 from hyph-utf8, https://github.com/hyphenation/tex-hyphen.
|
||||
Copyright (C) 1994-2002 Daniel Flipo, Bernard Gaulle, 2016 Arthur Reutenauer. MIT License:
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software
|
||||
and associated documentation files (the "Software"), to deal in the Software without
|
||||
restriction, including without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following conditions: The above copyright notice
|
||||
and this permission notice shall be included in all copies or substantial portions of the
|
||||
Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
||||
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE
|
||||
AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
- Italian hyphenation patterns, embedded through the hypher crate (feature "italian"):
|
||||
hyph-it.tex 4.9 from hyph-utf8, https://github.com/hyphenation/tex-hyphen.
|
||||
Copyright (C) 2008-2011 Claudio Beccari. Available under the LPPL (1.3 or later) or the MIT
|
||||
License; taken here under the MIT License: Permission is hereby granted, free of charge, to any person obtaining a copy of this software
|
||||
and associated documentation files (the "Software"), to deal in the Software without
|
||||
restriction, including without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following conditions: The above copyright notice
|
||||
and this permission notice shall be included in all copies or substantial portions of the
|
||||
Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
||||
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE
|
||||
AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
- Norwegian hyphenation patterns, embedded through the hypher crate (feature "norwegian"):
|
||||
hyph-no.tex from hyph-utf8, https://github.com/hyphenation/tex-hyphen.
|
||||
Copyright (C) Rune Kleveland and Ole Michael Selberg. Copying and distribution of this file,
|
||||
with or without modification, is permitted in any medium without royalty provided the copyright
|
||||
notice and this notice are preserved (additionally distributed under the LaTeX Project Public
|
||||
License in CTAN distributions).
|
||||
- German hyphenation patterns, embedded through the hypher crate (feature "german"):
|
||||
hyph-de-1996.tex 2024-02-28, German reformed orthography (2006), from hyph-utf8,
|
||||
https://github.com/typst/hypher/blob/d81cc506416bffef2a75ee2dc969d4b41bc36613/patterns/hyph-de-1996.tex.
|
||||
Copyright (c) 2013-2024 Stephan Hennig, Werner Lemberg, Günter Milde, Sander van Geloven,
|
||||
Georg Pfeiffer, Gisbert W. Selke, Tobias Wendorf, Keno Wehr. MIT License:
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software
|
||||
and associated documentation files (the "Software"), to deal in the Software without
|
||||
restriction, including without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following conditions: The above copyright notice
|
||||
and this permission notice shall be included in all copies or substantial portions of the
|
||||
Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
||||
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE
|
||||
AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
- Japanese fonts (craft-fonts): builds made with craft-fonts (CRAFT_FONTS_DIR; all official
|
||||
releases) embed the fonts listed in
|
||||
https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic, BIZ UDMincho,
|
||||
@@ -115,6 +173,37 @@ Assets already bundled through Hayro's default features retain their licences:
|
||||
Adobe and contributor names do not endorse or promote this product.
|
||||
Per-file hashes and sources are recorded in ASSETS.md.
|
||||
|
||||
Linux Wayland window decorations
|
||||
-------------------------------
|
||||
sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
|
||||
commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux
|
||||
for vector window controls. Its title-font features are disabled: no Cantarell
|
||||
font is embedded by this dependency and no font file is added to this repo.
|
||||
|
||||
sctk-adwaita's MIT licence:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Bartłomiej Maryńczak
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
Bundled egui font icons
|
||||
|
||||
The pinned egui dependency includes egui-icons.ttf, a small subset of John Slegers'
|
||||
|
||||
@@ -123,9 +123,7 @@ Japanese text (UI and documents) uses fonts from
|
||||
[storytold/craft-fonts](https://github.com/storytold/craft-fonts), an optional build input (font
|
||||
files are never committed here; see the public
|
||||
[craft-fonts integration guide](https://github.com/storytold/craft-fonts/blob/main/docs/integration.md)
|
||||
for build instructions and craftrules
|
||||
[`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)
|
||||
for the shared standard):
|
||||
for build instructions):
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/craft-fonts ../craft-fonts
|
||||
@@ -338,7 +336,16 @@ stand on its own.
|
||||
| <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) |
|
||||
| <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 | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
|
||||
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
|
||||
|
||||
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
|
||||
|
||||
|
||||
+107
-10
@@ -4,7 +4,7 @@
|
||||
- 2026-10-23: placed-artwork layer options survive IDML and native round trips per placement, independently of shared assets. Unsupported visibility overrides are explicit import/preflight/PDF warnings; format 4 prevents older native readers from silently discarding them. Rendering parity is still open; see [graphic-layer-options.md](docs/graphic-layer-options.md).
|
||||
- FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Linear/radial, midpoint, archival and print policies have synthetic regression coverage.
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (placed graphic layer-option preservation and explicit rendering warnings; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-01 · **Change:** minor (placed graphic layer-option preservation and explicit rendering warnings; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026
|
||||
- AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it.
|
||||
|
||||
- AR1: Arabic IDML controls now retain character direction, kashida switches, diacritic offsets/forms, paragraph policy values and independent story/table directions. Shaping preserves joining context and script/language selection; paragraph-level bidi, contextual digit conversion, mark-safe elongation, RTL columns/tables and physical column insertion have regression coverage. Vendor-specific justification and diacritic presets remain explicit preflight warnings; see docs/arabic-typography.md.
|
||||
@@ -12,6 +12,10 @@
|
||||
|
||||
DesignCraft aims at full Adobe InDesign parity — and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web.
|
||||
|
||||
- **2026-10-28** · Inline table rows leave room for column and spanning footnotes, continuing in
|
||||
the next column or frame when needed (#1901). Cell-local footnotes contribute to row height
|
||||
and sit inside the cell rather than outside its measuring frame. Vertical justification keeps
|
||||
paragraphs and table fragments above the footnote area. No readiness percentage change.
|
||||
- **2026-10-25** · The application bar uses craft-window's shared title-bar code. A drag that
|
||||
starts on a floating panel over the bar moves the panel, not the window, and in full screen the
|
||||
bar no longer zooms or moves the window. No readiness percentage change.
|
||||
@@ -42,7 +46,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
|
||||
default layer queries find a placed PDF anywhere in mixed selections. Regression tests cover
|
||||
IDML export after rejection and both selection orders.
|
||||
|
||||
## Status (2026-10-01)
|
||||
## Status (2026-10-21)
|
||||
|
||||
**Working today**
|
||||
- Document model: facing/non-facing spreads, parent pages with page-number markers, sections, layers, frames (text / graphic / unassigned), threaded stories, paragraph/character/object styles with based-on, swatches (process/spot/tints/gradients), corner options, text wrap, drop shadow, opacity/blend.
|
||||
@@ -127,7 +131,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
|
||||
- Linked stories (Edit ▸ Place and Link, `story.placeAndLink/links/updateLink/unlink`): child stories copy a parent, show out of date in the Links panel and update.
|
||||
- Edit ▸ Menus (`window.hideMenuItem`): hide menu items; menus with hidden items end in Show All Menu Items.
|
||||
- View ▸ Separations Preview (`view.separations`, Window ▸ Output ▸ Separations Preview, `ink.separations`): the plates the print PDF puts on film, spot plates included. Each visible spread is exported as Export PDF writes it and separated with craft-prepress, so the Ink Manager's aliases and spots to process, overprint and the flattener show as printed. All inks as printed, one plate as film, or an ink limit in red over gray; the panel lists the plates with each ink's percentage under the pointer.
|
||||
- Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Spanish is hyphenated with Javier Bezos's Liang patterns (`hyph-es.tex` 5.0, MIT) through the `hypher` crate; hyphenation for other languages isn't bundled yet.
|
||||
- Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). English, Spanish, Portuguese, Norwegian, Greek and German (2006 reform) hyphenate. German uses `hyph-de-1996.tex` 2024-02-28 (MIT) through `hypher`; the filename denotes the reformed 2006 rules. Other languages have no bundled patterns.
|
||||
- Books (`book.new/open/add/remove/list/styleSource/paginate/syncStyles/exportPdf`, Book panel): `.dcbook` files, continuous page numbering, style sync from a style source, one merged PDF.
|
||||
- Track Changes (`changes.track/list/acceptAll/rejectAll`, Type ▸ Track Changes): typing is marked added (highlighted on screen), deletions are kept but hidden from layout and exports until accepted or rejected. Per-change accept/reject and authors aren't there yet.
|
||||
- Track Changes: per-change Accept / Reject (`changes.accept`, `changes.reject`) and a Track Changes panel listing every change.
|
||||
@@ -350,7 +354,7 @@ A string without a translation shows in English and fails no check; `cargo xtask
|
||||
| Vietnamese | vi | none | 0% |
|
||||
|
||||
Also shipped: Czech (99%), Italian (85%), Ukrainian (83%), Polish (99%).
|
||||
Also shipped: Italian (85%), Ukrainian (83%), Georgian (100%).
|
||||
Also shipped: Italian (85%), Ukrainian (83%), Georgian (100%), Swedish (99%).
|
||||
|
||||
## Upcoming
|
||||
|
||||
@@ -378,13 +382,37 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-11-02** · EPUB Export Options names a cover image file (PNG or JPEG, typed or chosen with Choose…) for the reflowable export; left empty, the first page stays the cover (#1934). No readiness percentage change.
|
||||
- **2026-11-02** · The Pen shows the segment a dragged smooth point shapes while it is dragged, and its rubber band curves out of a smooth point's direction line (#1438, first item). No readiness percentage change.
|
||||
- **2026-11-02** · A Custom text variable defined with a number (a typed `2026` reaches the command as one) shows its digits instead of nothing (#2093, first item). No readiness percentage change.
|
||||
- **2026-11-02** · Windows: the custom title bar window stays borderless, so the content is no longer drawn offset by the caption and border (black bars at the top and left, clicks landing off target). The workaround goes when winit carries the fix (storytold/winit#17). No readiness percentage change.
|
||||
- **2026-10-30** · The Color Picker takes an eyedropper: dragged from the picker onto the page, it takes the colour the canvas shows (proofed under Proof Colors). No readiness percentage change.
|
||||
- **2026-10-30** · The Color Picker is laid out as the reference app's: a field and slider for the component chosen, HSB, Lab, RGB and CMYK values, the old colour, and Add CMYK Swatch; Enter and Esc close it once. No readiness percentage change.
|
||||
- **2026-10-18** · Table of Contents gains Create PDF Bookmarks: included headings become nested page destinations, regenerated in their existing outline position on update without replacing or moving surrounding manual bookmarks. The setting survives native and IDML files. No readiness percentage change.
|
||||
|
||||
Newest first.
|
||||
|
||||
- **2026-11-03** · `type.char` says when the font it applies isn't available (#2189): it returns `{"warnings": ["Missing font: Courier Prime (using Source Sans 3)"]}`, or `Missing font style: …` for a style the family doesn't have, in preflight's words. The font is still applied, as before, so Find Font and preflight list it. A script that asked for a font the machine doesn't have exported in the substitute with no word about it. No readiness percentage change.
|
||||
- **2026-11-01** · Parent page resizing (#1620): Properties, the Page Tool Control panel and on-page handles resize the selected parent page independently of document pages. Stable parent IDs, repeated resizing, anchors and liquid rules are preserved on the current command modules. Inline parent margin editing targets only the selected parent page and preserves opposite-page margins and document overrides; document commands retain their first-page default before the canvas is initialized. No readiness percentage change.
|
||||
- **2026-10-31** · `font.list` reports the embedding licence of the face that sets each font's text: its OS/2 `fsType` and what the bits mean (`restrictedLicence`, `bitmapOnly`, `noSubsetting`, `editable`, `previewPrint`; null when the font is missing or has no OS/2 table). A script or host that makes PDFs can check every face, bundled and installed ones included, without reading font files. Export behaviour is unchanged. No readiness percentage change.
|
||||
- **2026-10-31** · A `.designcraft` file opened from bytes (`file.openBytes` with its file name, as a browser or host passes it) is titled without the extension, as an IDML file opened that way is. Saving it in the browser downloaded `Brochure.designcraft.designcraft`, and the File Name variable showed the doubled name. Browser Open and Drop pass the full file name, so only the engine removes the extension and a repeated suffix is kept in the title. Native and IDML content takes priority over a misleading `.indd` or `.indt` extension. No readiness percentage change.
|
||||
- **2026-10-30** · Import warnings about a story have a Show button: it closes the warnings (the status line keeps them) and selects the story's first frame, fitted in the window. `file.open` returns `warningItems`, the frame each IDML or InDesign warning is about (or null), matching the converter's INDD story numbers (`story 30892`, written to IDML as `u78ac`) and IDML `Self` names to the imported stories. No readiness percentage change.
|
||||
- **2026-10-30** · Parent pages: a running-header variable shows its name in angle brackets on the parent itself (it was blank), as InDesign shows it; on the document pages, and in text with no page, it shows its text as before. No readiness percentage change.
|
||||
- **2026-10-30** · Without facing pages, a page of a multi-page spread shows the parent page in its place, as in InDesign: the second page of a two-page spread shows the parent's second page. Every single-sided page showed the parent's first page, so an InDesign book set up without facing pages but laid out in two-page spreads repeated the left parent page's items (a black heading bar) on its right pages and lost the right parent page's. No readiness percentage change.
|
||||
- **2026-10-30** · File > Package opens a dialog, as the reference app's: a Summary of fonts, links and images and inks, pages for each, a Printing Instructions step, and a PDF made with the options of a preset (#1514, #1515).
|
||||
- **2026-10-30** · IDML import reads TOC separators and entry styles written with the `$ID/` prefix or as `Properties` children, and the TOC separator notation (`^t`, `^y`, `^m`, `^>`) is shared by the dialog and IDML, with a special-character menu on the separator field (#1249, #1255).
|
||||
- **2026-10-29** · Inserting text with many returns no longer takes time that grows with their square (part of #1588): each return copied its paragraph's format with its own insert, moving every format after it. Splitting 20,000 returns into paragraphs took 15.8 s; placing a text file of them now takes 0.2 s, and one of a million returns 5.5 s. No readiness percentage change.
|
||||
- **2026-10-28** · A placed image whose pixels would decode past 1 GiB of RGBA (256 Mpx) is refused from its header, before decoding (#1586). A 288 KB 23,000 px gray PNG used to hang the first draw for 9 s and take the app to 4.5 GB; it now draws as an empty frame, and PDF export skips it with a warning. A 300 ppi A0 scan still fits. No readiness percentage change.
|
||||
- **2026-10-28** · Discretionary hyphens (U+00AD) break in the single-line composer with automatic hyphenation on or off, matching the paragraph composer. Unbroken soft hyphens remain invisible and zero-width, and words containing them keep only their discretionary break points (#1955). No readiness percentage change.
|
||||
- **2026-10-28** · Autoflow no longer sizes the rest of a story by a frame that a Start Paragraph option (next column, frame, page, or odd or even page) ends early (#1209). With a title before a chapter that starts on the next odd page, it estimated a page per few characters, added up to 2000 pages (the autoflow limit) and removed them again: placing 2000 words took 36 s in the app, now 0.4 s, with the same pages. It looks past tracked-deleted paragraphs for the option, as composing does, and asks for every frame that start options and breaks force in one round: 200 one-line chapters on their own pages take 9 ms (58 ms before). A break and the start option it meets count once, and breaks that tracked changes delete or a hidden condition hides don't count. No readiness percentage change.
|
||||
- **2026-10-28** · IDML export writes the document's default `StoryPreference` in `Resources/Preferences.xml`, as InDesign does. Only each story carried one, and IDML readers that take the spread reading order from the document's (Okapi's IDML filter) failed on DesignCraft's files. No readiness percentage change.
|
||||
- **2026-10-27** · An iconic panel's flyout stays open while you work in the canvas or a text field: Auto-Collapse Iconic Panels is off by default, and preferences saved with it on start with it off. Preferences › Interface › Panels holds the setting next to the column bar's menu item. No readiness percentage change.
|
||||
- **2026-10-26** · Inner Shadow, Inner Glow (from the edges) and Bevel and Emboss on a frame holding an image follow what the image paints: a transparent part is outside the object, so the effect lines the image's opaque edge, not the frame (#1622). Satin and a centred Inner Glow still follow the frame. No readiness percentage change.
|
||||
- **2026-10-24** · Column rules run from the top line's ascent to the bottom line's descent, as InDesign draws them, instead of from the top to the bottom of the columns, on screen, in PDF and in PowerPoint export. The top and bottom insets shorten the rule from those lines. Existing documents draw their column rules at the new length. No readiness percentage change.
|
||||
- **2026-10-20** · Edit › Keyboard Shortcuts is craft-keymap's shared dialog: commands grouped by menu with Show and Filter, recording that asks before taking a key from another command, undo and redo, named shortcut sets (edits of Default become Custom; an imported set becomes a set of its own), an on-screen US keyboard, InDesign Show Set files reviewed before they apply, JSON export and an HTML summary. ui.json keeps the `shortcuts` map earlier versions read, for the active set. No readiness percentage change.
|
||||
- **2026-10-19** · Import InDesign Set… runs on craft-keymap's importer: a set's Window menu panel items (Layers F7, Pages F12, Swatches F5) and Tools items set the panel and tool shortcuts, all of an item's Default keys apply, and the report adds numeric keypad keys read as main keys and likely commands for unmatched items (`window.importShortcuts`). On InDesign's Windows defaults it matches 366 of 1,629 items (was 233) and changes 23 commands. No readiness percentage change.
|
||||
- **2026-10-19** · Keyboard shortcuts run on the shared craft-keymap crate. Panels take shortcuts, with InDesign's defaults where the key is free (Swatches F5, Color F6, Layers F7, Info F8, Stroke F10, Paragraph Styles F11, Character Styles Shift+F11, Pages F12, Align Shift+F7); tool keys are editable in Edit › Keyboard Shortcuts and `window.setShortcut`, and tools sharing a key take turns (B). Saved shortcuts load unchanged. No readiness percentage change.
|
||||
- **2026-10-17** · HU1: initial Hungarian (`hu` / Magyar) interface localization (updated 2026-11-01): 1,906 catalog entries, four context captions and all 37 tool labels. AI-assisted; the initial catalog was personally reviewed in the app by Veress Róbert, a native Hungarian speaker with over 30 years of DTP experience. Uncatalogued strings keep their English fallback. Hungarian spelling and hyphenation are separate work in #1484. No readiness percentage change.
|
||||
- **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change.
|
||||
- **2026-10-20** · PDF/X-1a:2003 export (craft-libs#194): `file.exportPdf {standard: "x1a"}`, the Export PDF dialog's PDF/X-1a:2003 preset and standard. The PDF/X-4 print file is flattened in its inks by craft-prepress (no transparency; spot, process, overprint and live text kept; complex regions as DeviceN images at the Transparency Flattener preset's resolution), then written as PDF 1.4 with the X-1a identification and checked; `validate-pdf --standard x1a`. Soft drop shadows reach the flattener in their swatch's inks; other soft effects are still RGB images. No readiness percentage change.
|
||||
- **2026-10-20** · Print color (craft-libs#190): CMYK print PDFs blend in DeviceCMYK through craft-pdf's print settings instead of byte rewrites, with the CMYK working space as the output intent in PDF/X-4 only; overprint (Overprint Fill, Stroke and Gap, overprinting text, 100% [Black]) is written as `/op` `/OP` `/OPM 1`; Knockout Group writes `/K true` instead of becoming an image; File › Print prints the PDF Export PDF writes. No readiness percentage change.
|
||||
@@ -396,6 +424,7 @@ Newest first.
|
||||
- **2026-10-24** · Renaming a paragraph or character style, or moving it into a style group, keeps its Export Tagging under the new name, and undo puts it back under the old one. Deleting a tagged style drops its tagging and leaves the replacement style's own tagging as it was. No readiness percentage change.
|
||||
- **2026-10-24** · Quick Apply and the application bar's search leave out the interface commands that failed or reset a setting when chosen there: those that need a workspace, a command and shortcut, a menu item, a panel, a window edge, an app page, a recent file or a swatch they can't give them, a Contextual Task Bar place, and Interface Color Theme, UI Scaling, Reset Shortcuts, the flattener preset, Screen Mode and Zoom To, which without a value switched to the dark theme, set 100%, cleared every custom shortcut or the preset, or went to Normal mode or 100% zoom. Each stays in the menu, panel or dialog that gives it the value. No readiness percentage change.
|
||||
- **2026-10-24** · EPUB Export Options › Split Document, a dialog before File › Export EPUB… saves (`document.epubSplit`, stored with the document): off, a single paragraph style, or based on paragraph style export tags, with Paragraph Style Options › Export Tagging's Split Document flag marking those styles. A reflowable EPUB is one content file unless Split Document is on; the split before each top-level h1 from #791 is gone. A split before a list item or preformatted line no longer leaves an unclosed list or block in a content file. IDML import and export keep the setting and the flag, and Load Styles the flag. No readiness percentage change.
|
||||
- **2026-10-24** · Linux file managers show a `.designcraft` file's first page instead of a blank icon: the packages install a freedesktop thumbnailer (`share/thumbnailers/ai.storyteller.designcraft.thumbnailer`) that runs `designcraft-cli thumbnail FILE OUT.png SIZE`, which writes the `preview.png` saving already puts in the file, fitted to the size asked for and never enlarged. A file without a preview fails, so the file manager keeps the file type's icon (#1782). No readiness percentage change.
|
||||
- **2026-10-18** · Repeated placed-PDF layer variants are prepared once per asset and hidden-name set, with recovery warnings retained. All-visible named layers keep their original source bytes, including PDFs whose cross-reference tables the reader repairs, in plain, PDF/X-4, booklet and book-merge output. Layer changes that require writing still reject an unusable cross-reference table with a file-named damage error; the first placement error is retained (#906). No readiness percentage change.
|
||||
- **2026-10-18** · Placed PDF optional-content defaults and per-frame overrides survive plain and PDF/X-4 vector export, including repeated placements, booklet output and book merging (#906). Source usage-application arrays are accepted; groups without a catalog configuration stay visible. Missing or unreadable default configurations and unsupported base states use all-ON defaults with a file-named export warning, retaining explicit frame overrides. Readable groups omitted from the catalog list are added with a distinct file-named warning, preserving their default ON/OFF states. Concurrent recovery warnings are combined. Malformed ON data is recoverable when a known ON base and empty OFF list prove it cannot hide artwork; missing display names are generated without changing known visibility when no override needs name matching. Contradictory states, unresolved references and ambiguous name overrides still fail export. Print-usage adjustments and IDML layer overrides remain follow-ups. No readiness percentage change.
|
||||
|
||||
@@ -411,8 +440,10 @@ Newest first.
|
||||
- **2026-10-24** · Optical Margin Alignment per story with a size (`story.options {opticalMargin, opticalMarginSize}`, off by default): when it is on, the story's paragraphs hang punctuation and letter edges by amounts set for that type size, except paragraphs with Ignore Optical Margin (`ignoreOpticalMargin` in `type.para` and paragraph styles). Table cells and footnotes don't take the story setting yet. When it is off, the per-paragraph switch still hangs at the text's own size. IDML imports and exports both (`StoryPreference/@OpticalMarginAlignment`, `@OpticalMarginSize`, `IgnoreEdgeAlignment`) (#947). No readiness percentage change.
|
||||
- **2026-10-24** · Text frames: a custom baseline grid's Relative To (Text Frame Options › Baseline Options, `object.textFrameOptions {baselineGrid: {relativeTo}}`, IDML `BaselineFrameGridRelativeOption`) measures the grid's start from the top of the page, the top margin, the top of the frame or its top inset. Text Frame Options had no custom baseline grid controls, imports dropped the setting and every frame grid started from the page top, so grid-aligned text in a frame measured from its own top sat on the wrong lines. No readiness percentage change.
|
||||
- **2026-10-24** · Paragraph Style Options › Indents and Spacing has an Align to Grid menu: None, All Lines or First Line Only. It shows the style's current setting. A First Line Only style could be set only in the CJK Grid Settings section, which shows only with CJK features on, or through commands. No readiness percentage change.
|
||||
- **2026-10-24** · A guide pulled out of a ruler keeps the resize cursor across it until it is dropped; the tool's cursor took over as soon as the pointer left the ruler, so the drag looked as if it had stopped. No readiness percentage change.
|
||||
- **2026-10-24** · Anchored text frames, such as marginal notes, compose where their host story places them: they align to the baseline grid at that position, and page-number markers and spine-relative alignment inside them read the host's page. They composed against a grid measured from their stored position and on no page, so a note set to Only Align First Line to Grid could sit a fraction of a line off the body text's grid line. No readiness percentage change.
|
||||
- **2026-10-24** · Tables: a gradient swatch in a cell fill draws as a gradient on the canvas and in PDF, in one gradient space for the whole table, so it runs across cells without restarting. Cells showed the gradient's first stop as a solid colour. No readiness percentage change.
|
||||
- **2026-10-22** · Paragraph Style Options › Paragraph Shading and Paragraph Border are laid out as InDesign's: headed with the page's name; Tint is a spinner with InDesign's presets and has Overprint beside it (shading, border and gap; a new attribute each, kept through IDML; PDF export warns it doesn't overprint yet); Corner Size and Shape is InDesign's two-by-two grid of corner icons, sizes and shape menus showing each shape's glyph, with the link in the middle; offsets and weights have spinners. Found in the Learn Skills Daily course, chapter 41. No readiness percentage change.
|
||||
- **2026-10-24** · New documents start with Hyphenate Capitalized Words, Hyphenate Last Word and Hyphenate Across Column off in [Basic Paragraph], as in InDesign. Opened and imported files keep the values they carry. No readiness percentage change.
|
||||
- **2026-10-24** · The baseline grid's Relative To setting applies: with Top Margin, the grid starts at each page's own top margin in text composition, on the canvas and for snapping. It started at the page top, so grid-aligned text sat on the wrong lines and a page could fit one line fewer. No readiness percentage change.
|
||||
- **2026-10-24** · Custom-positioned anchored objects honour Relative to Spine (`anchored.options {spineRelative}`, IDML `SpineRelative`): the settings describe a left-hand page and mirror on a right-hand page, so marginal notes stay in the outside margin on both pages of a spread. On right-hand pages they took the left-hand placement and landed in the inside margin, across the spine or over the body text. No readiness percentage change.
|
||||
@@ -420,17 +451,28 @@ Newest first.
|
||||
- **2026-10-23** · File › File Info… edits the document's Document Title, Author, Description, Keywords, Copyright Notice and Copyright Info URL (`file.info`), and the document saves them. PDF export (PDF/X-4 and Print Booklet included) writes the title, author, description and keywords into the PDF's document information and XMP. EPUB export takes the title and author from File Info, and HTML export takes the title. A title or author given to the export replaces File Info's, and an empty Document Title leaves the document's name as the title. No readiness percentage change.
|
||||
- **2026-10-23** · Save and export dialogs (Save As, Save a Copy, Export PDF and the other exports, Package, libraries and books) start in the active document's folder once it has been saved, and for a document never saved in the folder of the last file saved or exported, remembered across sessions (#1653). A folder deleted since falls back to the system's choice. No readiness percentage change.
|
||||
- **2026-10-22** · Autoflow (Shift-click with the loaded text cursor, autoflow on Place, `text.thread {autoflow}`) fills the pages that follow before it adds any, as InDesign does: one frame per column on each following page, whatever else the page holds, then new pages at the end of the document. It inserted pages right after the story's last frame, in front of the document's own pages. Frames it set past the end of the text go again; a page the document had stays, and a page autoflow added only for such a frame goes with it. Smart Text Reflow of the primary text frame is unchanged. No readiness percentage change.
|
||||
- **2026-10-18** · German text hyphenates using the MIT-licensed 2006 reform patterns (`hyph-de-1996.tex`) through `hypher`. Explicit 2006 names and plain/2006 locale codes reach both composers; traditional and unknown spelling variants stay unhyphenated. Tests cover this distinction, umlauts, ß, compounds, reformed ck/st breaks and paragraph limits. Six of InDesign's ~40 hyphenation languages. No readiness percentage change.
|
||||
- **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change.
|
||||
- **2026-10-23** · Translations: a missing string shows in English and fails no test; `cargo xtask i18n-report`, run by `cargo xtask ci`, prints the missing strings per language. Tests keep duplicate-key and placeholder checks and the language mechanics (lookup, context captions, plural captions, right-to-left). No readiness percentage change.
|
||||
- **2026-10-23** · Show Hidden Characters marks table cells as InDesign does: every cell shows its own spaces, tabs and paragraph returns and an end-of-story # after its text (an empty cell shows just the #), and the table paragraph's ¶ sits after the table's bottom right corner. Cells showed no marks. No readiness percentage change.
|
||||
- **2026-10-23** · Tables: with the Type tool in a cell, Shift+Tab goes back a cell (it went on to the next one) and Esc toggles between the text and the cell, as in InDesign: the insertion point selects its cell, and selected cells select the text of the first one (`table.toggleSelection`). Esc left the table for the frame. No readiness percentage change.
|
||||
- **2026-10-23** · Direct Selection shows a path's direction handles as InDesign: both handles of each selected anchor and, on its neighbours, the handle shaping the segment to it; no handles with no anchor selected, and handles that don't show can't be grabbed. The tool draws the path and its points without the bounding box. Found in the Learn Skills Daily course, chapter 43. No readiness percentage change.
|
||||
- **2026-10-23** · Tables: Convert Text to Table… and Convert Table to Text… open InDesign's dialogs (they converted at once, with tabs and paragraphs only). Column and Row Separator take Tab, Comma, Paragraph or any characters typed; Number of Columns, available when both separators are the same, fills rows of that many cells (a list of paragraphs into two columns); Convert Text to Table also applies a Table Style. `table.convertFromText` takes `rowSeparator`, `columns` and `tableStyle`, and `table.convertToText` its separators. Convert Text to Table is available only with text selected outside a table. No readiness percentage change.
|
||||
- **2026-10-23** · Tables: the Table menu follows InDesign's order (Cell Options before Insert, Delete and Select; Merge, Unmerge and Split before Convert Cell Type and Convert Rows; Distribute Rows Evenly before Distribute Columns Evenly), and its first item is Insert Table… with InDesign's Cmd+Alt+Shift+T, which opens the dialog (the shortcut ran `table.insert` with its defaults, behind the dialog). The Table panel stays in Window › Type & Tables (Shift+F9). The Insert Table dialog is laid out as InDesign's: Body Rows and Columns, then Header and Footer Rows, as numbers with spinners and right-aligned labels, and a Table Style applied to the new table. No readiness percentage change.
|
||||
- **2026-10-22** · Hyphenation Settings, as in InDesign: the Paragraph panel menu's Hyphenation… (`app.hyphenationDialog`) sets the selected paragraphs' Hyphenate, Words with at Least, After First, Before Last, Hyphen Limit, Hyphenation Zone, the Better Spacing ↔ Fewer Hyphens slider (InDesign's 0–10 steps), Hyphenate Capitalized Words, Last Word and Across Column, with Preview, as one undo step; Paragraph Style Options › Hyphenation has the same form. The panel menu lists its items in InDesign's order. Replicated from a hyphenation tutorial: the same text in Minion Pro breaks into the same lines as InDesign's with hyphenation on, off, and at Fewer Hyphens. No readiness percentage change.
|
||||
- **2026-10-22** · Hyphenate Across Column (Hyphenation Settings; IDML HyphenateAcrossColumns) is applied: with it off, a paragraph whose line ending in an automatic hyphen would end a column or frame is re-composed without that hyphenation point, so the word moves on whole, as in InDesign. Opened and imported files keep their own setting, so their layouts don't change. No readiness percentage change.
|
||||
- **2026-10-22** · The Paragraph panel is laid out as InDesign's: the nine alignment buttons in groups; indent, spacing and drop cap fields by their icons in two columns, with Space Between Paragraphs Using Same Style (Ignore or a space); Shading and Border with the Paragraph Borders and Shading button and colour; Hyphenate with Do Not Align / Align to Baseline Grid. Its panel menu follows InDesign's order: Paragraph and Single-line Composer, Only Align First Line to Grid, Balance Ragged Lines, Optical Margin Alignment, Justification…, Keep Options…, Span Columns…, Hyphenation…, Drop Caps and Nested Styles…, GREP Styles… (these three now open for the selected paragraphs), Paragraph Rules…, Paragraph Borders and Shading…, Bullets and Numbering…, Restart/Continue Numbering, Define Lists… and the paragraph direction. Convert Bullets and Numbering to Text is listed but not available yet. No readiness percentage change.
|
||||
- **2026-10-22** · Preferences › Units & Increments with no document open, as in InDesign: the ruler units (horizontal and vertical), the ruler origin and the cursor-key and kerning increments set there are what new documents start with, and New Document opens in those units. They are kept with the application preferences (`prefs.set {defaultUnits}`); a document keeps its own once made. Reported by a reader who works in inches and had to change every file. No readiness percentage change.
|
||||
- **2026-10-22** · French and Italian text is hyphenated, with hyph-utf8's patterns through hypher (hyph-fr 2.13 and hyph-it 4.9, MIT): for the Language set in the Character panel (French, French: Canadian, French: Swiss, Italian, or fr / it locale codes). A word with an elision hyphenates after its apostrophe (l'im-pri-me-rie, dell'im-pa-gi-na-zio-ne), in French and Italian only; other languages leave apostrophe words unbroken. Gap 13 shrinks. No readiness percentage change.
|
||||
- **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change.
|
||||
- **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change.
|
||||
- **2026-10-23** · Type › Text Variables › Convert Variable to Text (`variables.convertToText`): the variable instances in the selected text, in a story, a table cell or a footnote, become plain text of the value each shows on its own page (a running header on page 3 becomes page 3's heading). `variables.convertAllToText {name}` does the same for every instance of one variable in the document, as the Text Variables dialog's Convert to Text button. The variable stays defined; one undo step. No readiness percentage change.
|
||||
- **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change.
|
||||
- **2026-10-22** · Paragraph rules: Rule Above's offset is measured from the first line's baseline to the rule's bottom edge, as in InDesign. Rules sat one ascent too high and crossed the previous paragraph's text (reported from an imported INDD document). No readiness percentage change.
|
||||
- **2026-10-21** · Ruler guides are selected like objects (#1282): a click with the Selection tools selects a guide (also where it crosses an object), Shift-click adds or removes one, a new guide from the rulers is selected, a marquee that touches no object selects the guides it crosses, and Edit › Select All Guides (⌥⌘G, `guide.selectAll`) selects every unlocked guide on the spread. Selected guides draw thicker in their layer's color, drag together (all of them shown moving), nudge with the arrow keys and go with Delete, each as one undo step (`guide.select`, `guide.deselect`, `guide.moveBy`, `guide.delete {}`). With one guide selected the Control bar's X (vertical guide) or Y (horizontal guide) field and Properties › Transform show where it is, as the rulers measure, and typing a value moves it there. No readiness percentage change.
|
||||
- **2026-10-21** · Properties for points selected with Direct Selection, as InDesign's: the title is "Anchor Point(s)", and the panel shows Transform, Appearance, Convert Points (Plain, Corner, Smooth, Symmetrical) and the Delete Point(s) quick action (`path.deletePoints`, one undo step). Found in Zero2Graphic's *How to Page Layout design in Adobe InDesign 2022 CC*. No readiness percentage change.
|
||||
- **2026-10-20** · Direct Selection selects anchor points: a marquee selects the points inside it on every path it reaches, a click selects one point and Shift+click adds or removes one, selected points show solid, and dragging one (Shift: at 45° steps) or pressing the arrow keys moves them all in one step (`selection.set {anchors}`, `path.moveAnchors {points}`). No readiness percentage change.
|
||||
- **2026-10-19** · A PDF export can repeat its bytes: it recorded the second it was made in, so exporting one document twice gave different files though the pages matched. `SOURCE_DATE_EPOCH` stands in for the clock when the export is given no date, as the reproducible-builds specification defines it; a date passed in still wins, and a malformed value is ignored rather than refusing the export. The timestamp was the only thing that differed, so the files are now identical byte for byte. No readiness percentage change.
|
||||
- **2026-10-17** · PDF/X-4 export warns about fonts drawing visible text without an embedded
|
||||
font program, including placed PDF forms; `validate-pdf` reports the font and page (#909).
|
||||
Invisible OCR text is exempt. Detection only: fonts are not embedded or outlined automatically.
|
||||
@@ -444,11 +486,59 @@ Newest first.
|
||||
- **2026-10-16** · IDML: a page keeps column guides that were dragged off centre (InDesign's Custom columns). They were divided evenly on import, so the gutter sprang back to the middle; the imported positions now place the columns, with the canvas guides, snapping and text placement following them, and they are written back on export. Layout › Margins and Columns re-divides them evenly when the count or gutter changes, as InDesign's does. No readiness percentage change.
|
||||
- **2026-10-16** · IDML: custom column positions are checked before they are used (#1096). A set must give two edges per column, ascending from the left margin to the right one; any other (a wrong count, unsorted or non-numeric values, a list longer than 216 columns allow, or positions left behind by a margin or page-size change) is the even split, in the canvas, on import and on export, instead of columns drawn outside the margins. No readiness percentage change.
|
||||
- **2026-10-21** · An empty graphic frame's X joins its corners, so a frame slanted with Direct Selection keeps its X corner to corner, as InDesign draws it; it used to stay across the old rectangle. Found in Zero2Graphic's *How to Page Layout design in Adobe InDesign 2022 CC*. No readiness percentage change.
|
||||
- **2026-10-21** · Paragraph Border and Paragraph Shading link their four sides as InDesign's: the stroke weights, offsets and corners have the "Make all settings the same" link, on when the four are the same, so a side typed in sets all four; with the link off a side is set alone (a border with only a thick left side). Found in the Learn Skills Daily course, chapter 40. No readiness percentage change.
|
||||
- **2026-10-21** · Paragraph rules have InDesign's Type (solid, thick/thin stripes, dashed, dotted, straight hash, wavy, or a stroke style of the document), Gap Color and Gap Tint, and Keep In Frame (rule above); the Paragraph Rules page is laid out as InDesign's (Weight and Type, Color and Tint, Gap Color and Gap Tint, Width and Offset, Left and Right Indent). IDML reads and writes them (`RuleAboveType`, `RuleBelowGapColor`, `KeepRuleAboveInFrame`…). Found in the Learn Skills Daily course, chapter 20. No readiness percentage change.
|
||||
- **2026-10-21** · New Character Style with text selected starts from what the text has of its own over its paragraph style, as InDesign's ("[None] + Bold + color"), and Apply Style to Selection gives the text the new style, with no override left. A new style's dialog is titled New Paragraph Style / New Character Style. `type.selectionAttrs` reports the text's own character attributes (`ownChars`). Found in the Learn Skills Daily course, chapter 22. No readiness percentage change.
|
||||
- **2026-10-21** · New Paragraph Style with text selected starts from it, as InDesign's (Based On [No Paragraph Style], every setting of the text that differs) and Apply Style to Selection gives the paragraph the new style; General has Next Style and Reset To Base; Basic Character Formats is in two columns (Size | Leading, Kerning | Tracking, Case | Position). `style.paragraph.edit` honours `nextStyle` (it was ignored) and takes `resetToBase`. Found in the Learn Skills Daily course, chapter 20. No readiness percentage change.
|
||||
- **2026-10-21** · Find/Change has InDesign's Change and Change/Find: Change changes the text Find Next found and keeps it selected, Change/Find also finds the next match, and the search keeps its domain (a selection or story) across them. The Search list reads Document, Story and Selection. `find.change` takes `found`. Found in the Learn Skills Daily course, chapter 22. No readiness percentage change.
|
||||
- **2026-10-21** · Find/Change has InDesign's Find Format: Specify Attributes opens Find Format Settings (the same Style Options and character sections as Change Format Settings), and only text with the format is found. With nothing in Find what, each stretch of text with the format is found, so Change All can restyle every word in a character style. `find.find`, `find.next` and `find.change` take `findFormat {charStyle?, paraStyle?, chars?}`. Found in the Learn Skills Daily course, chapter 22. No readiness percentage change.
|
||||
- **2026-10-21** · Find/Change has InDesign's Change Format. Specify Attributes opens Change Format Settings: Style Options (the character and paragraph style to apply, [Any Style] for none), then Basic and Advanced Character Formats, Character Color, OpenType Features, Underline and Strikethrough Options. The box shows the format ("Character Style: eggs"), and Change All gives it to the changed text. With a Change Format and nothing in Change to, the found text stays and is only formatted, as InDesign's (an ⓘ beside Change to says so). `find.change` takes `charStyle` and `paraStyle`. Found in the Learn Skills Daily course, chapter 22. No readiness percentage change.
|
||||
- **2026-10-21** · Preferences › Interface follows InDesign's: Appearance has a Color Theme swatch per interface theme (a click shows it at once, Cancel goes back) and Panels sets the Tools panel to a single or double column; UI Size moves to a UI Scaling section of its own. Interface held only UI Size, and the theme could be changed only from the Window menu. Found in the Learn Skills Daily course, chapter 26. No readiness percentage change.
|
||||
- **2026-10-21** · Preferences › File Handling shows Number of Recent Items to Display as a whole number ("20"; it read "20.0"). Found in the Learn Skills Daily course, chapter 26. No readiness percentage change.
|
||||
- **2026-10-21** · Insert Pages… opens InDesign's dialog: Pages, Insert (After Page, Before Page, At Start of Document, At End of Document) with the page by name, and Parent ([None] or a parent), set to the page in view and its parent. It was a form asking for a page index and a parent id, and pages could not go before the first page; `layout.pages.insert` takes `before` (0: at the start). Found in the Learn Skills Daily course, chapter 25. No readiness percentage change.
|
||||
- **2026-10-21** · Spelling (Check Spelling and Dynamic Spelling) no longer flags everyday words the bundled hyphenation word list lacks: "we", "us", "if", titles such as "Mr", and words such as email, online, website, database, desktop and download, with their plurals and other forms. Contractions typed with a typographer's apostrophe (don’t, we’re), which Use Typographer's Quotes produces by default, count like their straight-apostrophe forms. Dynamic Spelling used to underline most words of an ordinary sentence. Found in the Learn Skills Daily course, chapter 26. No readiness percentage change.
|
||||
- **2026-10-21** · While a parent is shown, the status bar's page box and Properties › Page name it ("A-Parent") instead of page 1, as InDesign's; their lists have the parents after the pages, and choosing one shows that parent (a page leaves the parents). Found in the Learn Skills Daily course, chapter 25. No readiness percentage change.
|
||||
- **2026-10-21** · Effects dialog: the Angle field of the shadows, Bevel and Emboss, Satin and Gradient Feather has InDesign's dial beside it, its radius pointing where the light comes from; a click or drag on it sets the angle. Found in the Learn Skills Daily course, chapter 14. No readiness percentage change.
|
||||
- **2026-10-21** · A double-click on a Fill or Stroke chip of Properties › Appearance opens the Color Picker with its colour, as in InDesign (a click still opens the swatches); for selected text, its own chips open it for the text. Found in the Learn Skills Daily course, chapter 23. No readiness percentage change.
|
||||
- **2026-10-20** · Properties › Appearance: with several text frames selected, the Fill and Stroke popups offer Apply To Frame or Text, as with one frame, so their text can be coloured at once, as in InDesign. No readiness percentage change.
|
||||
- **2026-10-20** · Properties › Appearance: the arrow beside Opacity opens a slider under the field, as in InDesign, instead of the Effects panel; a drag shows the opacity live and is one undo step (the Opacity link still opens Effects). No readiness percentage change.
|
||||
- **2026-10-20** · Properties › Frame Fitting has InDesign's Auto-Fit checkbox and Options button (Frame Fitting Options); the app's checkboxes are now named and ticked-or-not for screen readers. No readiness percentage change.
|
||||
- **2026-10-20** · Properties › Text Wrap shows the four wrap offsets under the wrap buttons, with the link that makes them the same, as InDesign's Properties panel does (greyed with no wrap). No readiness percentage change.
|
||||
- **2026-10-21** · Properties › Bullets and Numbering › Options opens the Bullets and Numbering dialog for the selected paragraphs, as InDesign's, instead of the Paragraph panel. Found in the Learn Skills Daily course, chapter 21. No readiness percentage change.
|
||||
- **2026-10-20** · Properties › Appearance: the corner shape list shows each shape's glyph beside its name, drawn as InDesign draws them (Fancy has its own glyph now), and the stroke type list shows each type's line; both dropdowns are named for screen readers. No readiness percentage change.
|
||||
- **2026-10-21** · Properties › Align: the Align To button shows what the objects align to, one glyph each for the selection, the key object, the margins, the page and the spread, as InDesign's. Found in the Learn Skills Daily course, chapter 12. No readiness percentage change.
|
||||
- **2026-10-20** · Object › Corner Options opens a Corner Options dialog laid out as InDesign's: each corner's size and shape, the link button that makes all four the same, and Preview. The Corner link in Properties › Appearance opens it too (it opened a Rectangle size dialog). No readiness percentage change.
|
||||
- **2026-10-20** · The Object menu is in InDesign's order and groups; Paths has Join, Open Path and Close Path (Convert Shape lists only shapes, as InDesign's does) and a Convert Point submenu (Plain, Corner, Smooth, Symmetrical) is added. No readiness percentage change.
|
||||
- **2026-10-20** · Layout › Create Guides puts its guides on both pages of the spread in view, as InDesign does; "This page" in Apply to narrows them to one page. No readiness percentage change.
|
||||
- **2026-10-21** · Object › Fitting › Clear Frame Fitting Options (`object.clearFittingOptions`): the frames' auto-fit, crop amounts and reference point go back to their defaults, and the picture stays where it is. The Fitting submenu is grouped as InDesign's. Found in the Learn Skills Daily course, chapter 11. No readiness percentage change.
|
||||
- **2026-10-21** · Swatches panel rows show InDesign's columns, drawn in code: a crossed pencil on the swatches that can't be edited ([None], [Registration], [Black]), the colour type (process or spot) and the colour mode (CMYK, RGB, Gray, or [None]'s and [Registration]'s own glyphs), in place of a lock and "CMYK" text. Tint reads 100 with nothing selected. No readiness percentage change.
|
||||
- **2026-10-21** · With a gradient fill or stroke, the Color panel shows the stop selected in the Gradient panel (the stop before a selected midpoint) with its values, and its C/M/Y/K or R/G/B sliders and spectrum colour that stop, keeping the others, as in InDesign. It showed "[None] — choose a colour on the ramp", and a spectrum click replaced the whole gradient with a flat colour. Found in the Learn Skills Daily course, chapter 24. No readiness percentage change.
|
||||
- **2026-10-20** · A new document's Swatches panel lists InDesign's defaults: [None], [Registration], [Paper], [Black] and six process colours (C=100, M=100, Y=100, C=15 M=100 Y=100, C=75 M=5 Y=100, C=100 M=90 Y=10); the extra M=100 Y=100, C=100 Y=100 and C=100 M=100 swatches are gone. No readiness percentage change.
|
||||
- **2026-10-21** · The readout beside the cursor while drawing or measuring ("W: 3.13 cm") is InDesign's light grey box with white text, measured from the tutorials' frames; High Contrast keeps it black. It was a dark translucent box. No readiness percentage change.
|
||||
- **2026-10-20** · Drawing a frame or shape shows its width and height in the document's ruler units, to two decimals and on two lines ("W: 6.08 cm", "H: 6.08 cm"), as InDesign does; it was always whole points. No readiness percentage change.
|
||||
- **2026-10-20** · Type tool: a drag that starts in an empty frame draws a new text frame over it, as in InDesign; a click still turns the frame into a text frame. No readiness percentage change.
|
||||
- **2026-10-20** · Measures in centimetres show four decimals, as InDesign's fields do (a 1p gutter reads 0.4233 cm, not 0.423 cm). No readiness percentage change.
|
||||
- **2026-10-21** · Text Frame Options is laid out as InDesign's, with Preview: General, Column Rules, Baseline Options, Auto-Size; framed Columns (Number and Width, Gutter and Maximum, Balance Columns), Inset Spacing (Top/Bottom, the link, Left/Right) and Vertical Justification (Align, Paragraph Spacing Limit) with spinner fields; Fixed Number shows each column's width as the fields change. Preview shows the change on the page until OK (one undo step) or Cancel. Justify honours the Paragraph Spacing Limit (`paragraphSpacingLimit` on `object.textFrameOptions`): the space goes between paragraphs up to the limit, the rest between lines. Found in the Learn Skills Daily course, chapter 18. No readiness percentage change.
|
||||
- **2026-10-21** · Swatches panel: a swatch is renamed in place, as in InDesign: click it, then click its name again (a slow double-click), type, and Enter (Escape keeps the name). Everything that uses it follows; a double-click still opens Swatch Options, and [None], [Registration], [Paper] and [Black] keep their names. Found in the Learn Skills Daily course, chapter 23. No readiness percentage change.
|
||||
- **2026-10-20** · The loaded place cursor shows a thumbnail of the graphic, as InDesign's does; dragging with it draws the frame at the graphic's proportions from where the drag started, with its scale beside the pointer, and places exactly that frame. No readiness percentage change.
|
||||
- **2026-10-21** · Drop Shadow, Inner Shadow and Outer Glow have a blend mode, as InDesign's (Effects dialog › Blending › Mode; `mode` on `object.dropShadow`, `object.innerShadow`, `object.outerGlow`; IDML `BlendMode`). New shadows start Multiply and new outer glows Screen in white ([Paper]); effects saved before keep Normal, so documents look as they did. Found in the Learn Skills Daily course, chapter 14. No readiness percentage change.
|
||||
- **2026-10-14** · The Pages panel labels spreads as InDesign does ("2–3", "2, 1" across a section start, "8, A"), marks each section's first page with a triangle, and scrolls to the page in view. No readiness percentage change.
|
||||
- **2026-10-18** · Double-clicking a section triangle in the Pages panel opens that page’s numbering options, including from parent-page editing. No readiness percentage change.
|
||||
- **2026-10-18** · TOC separators preserve right-indent tabs through the dialog and IDML, including localized `$ID/^y` values. A separator picker exposes Tab Character, Right Indent Tab, Em Space and En Space. Existing paragraph tab leaders now work with the tutorial's TOC workflow. No readiness percentage change.
|
||||
- **2026-10-21** · The Package dialog's Include PDF (Print) has a PDF Preset (the Export PDF presets, the last one used first), as InDesign's Select PDF Preset; `file.package` takes `pdf: {…file.exportPdf options}`, refusing bad options before writing anything. The packaged PDF always had the default settings. Found in the Learn Skills Daily course, chapter 27. No readiness percentage change.
|
||||
- **2026-10-21** · File › Package… opens InDesign's Package dialog: Summary counts the fonts, links and images and inks the package carries, with a warning where some are missing or modified, and Fonts, Links and Images and Colors and Inks list them (Show Problems Only). Create Printing Instructions asks for the contact details and instructions that go into the package's Instructions.txt, and Include IDML / Include PDF (Print) choose the copies it carries. Package… went straight to the folder picker with fixed options. Found in the Learn Skills Daily course, chapter 27. No readiness percentage change.
|
||||
- **2026-10-21** · Properties › Character and Paragraph: ••• (More Options) expands the section in place, as InDesign's: Character adds vertical and horizontal scale, baseline shift, skew, language and OpenType, and All Caps, Small Caps, Superscript, Subscript, Underline and Strikethrough; Paragraph adds the left, right, first line and last line indents, space before and after, the drop cap's lines and characters, Align to Baseline Grid and Hyphenate. It used to open the Character or Paragraph panel; the language field moved under ••• as in InDesign. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change.
|
||||
- **2026-10-21** · The text Control panel has InDesign's two modes: A (Character Formatting Controls: font and style, size and leading, All Caps/Superscript/Underline and Small Caps/Subscript/Strikethrough, kerning and tracking, vertical and horizontal scale, baseline shift and skew, character style and language) and ¶ (Paragraph Formatting Controls: the nine alignments, left/first line and right/last line indents, space before and after, the drop cap's lines and characters, paragraph style, Hyphenate and Align to Baseline Grid). It used to show a fixed mix of both. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change.
|
||||
- **2026-10-20** · With the Type tool, the Control panel's font size, leading and tracking fields have a spinner and a list of common values, as in InDesign (auto leading in parentheses). No readiness percentage change.
|
||||
- **2026-10-21** · The ¶ (Paragraph Formatting Controls) mode of the text Control panel has InDesign's Bulleted List and Numbered List buttons (with the default hanging indent) and Shading and Border: a checkbox, a button opening Paragraph Borders and Shading on that tab, and the colour. Found in the Learn Skills Daily course, chapter 21. No readiness percentage change.
|
||||
- **2026-10-21** · The Bulleted and Numbered List buttons (Properties, and Type › Bulleted & Numbered Lists › Apply Bullets / Apply Numbers) work as InDesign's: a paragraph with no indents gets the default hanging indent (0.25 in, −0.25 in), so the text lines up after the bullet instead of at the next default tab stop, and pressing the button again removes the list and that indent (`list.bullets`, `list.numbers`). Found in the Learn Skills Daily course, chapter 21. No readiness percentage change.
|
||||
- **2026-10-18** · Table of Contents offers More Options / Fewer Options, preserving page-number, separator, sorting and level settings while their controls are hidden. No readiness percentage change.
|
||||
- **2026-10-18** · Table of Contents reserves the title field's width so Contents is readable next to its style picker; the grid previously squeezed it to a few characters. No readiness percentage change.
|
||||
- **2026-10-17** · Inline and anchored objects paint under their frame's text, on screen and in PDF, as InDesign draws them: a picture whose frame reaches into the next line (a negative Y offset) no longer hides the top of that line. No readiness percentage change.
|
||||
- **2026-10-17** · Running headers: *first/last on page* read a page in text order (story order, then column by column), not by height — a multi-column page's right header showed the lowest entry of column 1 instead of the page's last entry. No readiness percentage change.
|
||||
- **2026-10-17** · Cross-references keep text typed over their generated text (IDML import and export), with the page number still live, as InDesign shows them until updated; a cross-reference whose destination is gone shows its saved text and page instead of "??"; and a cross-reference's text breaks at its spaces instead of inside words. No readiness percentage change.
|
||||
- **2026-10-17** · Parent pages: while parents are edited, the document pages' hidden characters, spelling squiggles and other overlays no longer pile onto the right parent page. No readiness percentage change.
|
||||
- **2026-10-16** · A Direct Selection click on an image whose frame is already selected selects the image, so Delete removes only the image and leaves the empty frame, as in InDesign. No readiness percentage change.
|
||||
- **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change.
|
||||
- **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change.
|
||||
- **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change.
|
||||
@@ -458,9 +548,14 @@ Newest first.
|
||||
|
||||
- **2026-10-16** · Layout: autoflow, inserted pages and new parents follow dragged column guides (#1096). Autoflow sets one frame per dragged column and counts the pages it adds from them; Insert Pages and New Parent keep the custom positions with the column count and gutter; `document.inspect` reports each page's gutter, column rects and whether its columns are custom. No readiness percentage change.
|
||||
- **2026-10-16** · Lock Guides and Lock Column Guides are commands in View ▸ Grids & Guides (`view.lockGuides`, ⌥⌘;, and `view.lockColumnGuides`), so the control channel and MCP reach them. Lock Column Guides is saved with the document, on in new ones, and read from and written to IDML's `ColumnGuideLocked`, which export always wrote as locked (#1096). No readiness percentage change.
|
||||
- **2026-10-15** · Swedish interface (`sv`, Preferences ▸ General ▸ Interface Language ▸ Svenska): menus, panels,
|
||||
dialogs and messages, 2,857 of the 2,891 interface strings (99%) in the missing-translations report; the other 34
|
||||
show in English. AI-drafted with Claude Code from the English source; spot-checked by a native speaker, not every
|
||||
string reviewed. No readiness percentage change.
|
||||
- **2026-10-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change.
|
||||
- **2026-10-16** · Greek text hyphenates. The patterns were never compiled in, so Greek set unhyphenated however narrow the column; `"Greek"`, `"Greek: Greece"`, `el` and `el-GR` all resolve now and ανθρωπος breaks αν-θρω-πος. They are the monotonic patterns (hyph-el-monoton, Dimitrios Filippou, under MIT), which is what polytonic text hyphenates by too. Five of InDesign's ~40 hyphenation languages. No readiness percentage change.
|
||||
- **2026-10-16** · A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign: IDML's `<Image FillColor>` is read and written, greyscale pixels become a tint of the swatch on opaque white and a bitmap's white lets the frame show through, on screen and in PDF (spot swatches print on their plate). Colour images keep their colours. Applying the swatch from the Swatches panel comes next. No readiness percentage change.
|
||||
- **2026-10-15** · Anchored objects wrap their own story as their anchor lines are set, as in InDesign: the layout no longer depends on where an earlier composition put them (a long story whose pictures moved between frames from pass to pass never settled). No readiness percentage change.
|
||||
- **2026-10-15** · Color Theme: the toolbar tool previews up to five colours from a placed raster and adds them as one undoable swatch group. Local deterministic extraction; transparent padding is ignored. No readiness percentage change.
|
||||
- **2026-10-15** · Applying text styles removes matching local formatting so capture sources
|
||||
inherit later edits; real overrides and named character styles remain.
|
||||
@@ -520,6 +615,8 @@ Newest first.
|
||||
- **2026-10-15** · Flex Layout (InDesign 2026): Object › Flex Layout (Add to, Convert to, Remove) and a Flex Layout panel laid out as InDesign's, also in Properties. A container lays out its objects by direction, wrap, justification, the alignment grid, Fixed/Auto width and height, padding and spacing, after every edit; Add detects aligned objects (direction, Auto size, Space Between at their spacing); resizing a container reflows its objects instead of scaling them; dragging an object in one reorders it. Montilla Design's tutorial replays step for step. No readiness percentage change.
|
||||
- **2026-10-15** · IDML: the CJK leading model InDesign writes on [No paragraph style] of every document (Aki Below) only applies when the document uses a Japanese composer, as in InDesign; a Western document's paragraphs after a size change no longer sit lower than in InDesign (Montilla Design's Flex Layout file: its captions came out overset). No readiness percentage change.
|
||||
- **2026-10-15** · IDML: linked images in a subfolder beside the package load when the package is opened on another machine (a link to `/images/logo.png` finds `images/logo.png` next to the .idml); they showed as missing. No readiness percentage change.
|
||||
- **2026-10-14** · Numbering & Section Options opened on a page that doesn't start a section is New Section, as in InDesign: Start Section on, 1 offered, and the start numbers stay editable (typing one chooses it). No readiness percentage change.
|
||||
- **2026-10-14** · The Direct Selection tool pressed on a corner or anchor of an object that isn't selected selects it and pulls just that point, as in InDesign (it moved the whole object). No readiness percentage change.
|
||||
- **2026-10-14** · Overset text is marked on every frame that ends an overset story, selected or not and inside groups too (the red mark at the out port, with frame edges shown); a selected group shows the in/out ports of the text frames inside it. InDesign shows it only on the selected frame, which hides overset labels in grouped objects until Preflight. No readiness percentage change.
|
||||
- **2026-10-14** · Load Swatches adds the colors of an `.ase` file that repeat a name or have none: a repeated name gets a number (`Red 2`), and a color without a name is named by its value (`C=0 M=0 Y=0 K=55`). Before, a repeated name kept one of its colors, and only the first unnamed color was added, with an empty name. No readiness percentage change.
|
||||
- **2026-10-14** · Objects snap to the bleed guide's four sides while moving, resizing or drawing; snapping and the canvas share one bleed rectangle (`Spread::bleed_rect`). No readiness percentage change.
|
||||
@@ -692,14 +789,14 @@ Newest first.
|
||||
- **2026-10-10 (UI.4–UI.5):** Panel tabs can move into other groups, split the dock, float, resize and return to their saved locations through shared docking transactions. Layouts persist in custom workspaces; UI and control commands share the same validated action path. Shared pane geometry and widget-state theme application retain all five palettes and local fonts. Split panes send keyboard input only to the focused canvas and scope contextual toolbar IDs per pane. No readiness percentage change.
|
||||
- **2026-10-10** · Version 0.6.0; releases add a Linux riscv64 tar.gz (cross-compiled, CLI
|
||||
smoke-tested under QEMU).
|
||||
- **2026-10-10** · Readiness per audience: full ~47% (method aligned with the standard, was
|
||||
- **2026-10-10** · Readiness per audience: full ~47% (method corrected, was
|
||||
42%), mainstream practitioner ~42%, essentials user ~54%, each with hours to ~95%.
|
||||
- **2026-10-10** · Stage re-normalized to **pre-alpha** under craftrules' core-workflow gate: text
|
||||
- **2026-10-10** · Stage re-normalized to **pre-alpha** under the core-workflow gate: text
|
||||
can't be threaded by hand (no out-port click, no thread command).
|
||||
- **2026-10-10** · Full re-measure against InDesign 2026 21.6 (menu dump, bundle formats and
|
||||
localizations, 124 issues): breadth ~80%, ready ~42% (down from the ~83% "including depth" of
|
||||
2026-10-04, on user evidence and the INDD gap); progress docs restructured to the craftrules
|
||||
standard (`docs/parity.md` → `docs/parity-checklist.md`; new target-app-parity, gaps, roadmap,
|
||||
2026-10-04, on user evidence and the INDD gap); progress docs restructured
|
||||
(`docs/parity.md` → `docs/parity-checklist.md`; new target-app-parity, gaps, roadmap,
|
||||
architecture, typography, file-format, UI, hardware and localization docs). Landed today:
|
||||
split columns (#295), PDF export options dialog (#196), column rules (#274), Paragraph Rules
|
||||
dialog (#263), Character Style Options (#144), PDF/X-4 RGB handling (#223), Spanish
|
||||
@@ -728,10 +825,10 @@ Newest first.
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Manual text threading landed; alpha distance 15–25 h → 5–7 h (Primary Text Frame page fixes, #165) |
|
||||
| 2026-10-11 | minor | Localized Japanese print, import and table controls; readiness estimates unchanged |
|
||||
| 2026-10-10 | minor | Full number 42% → 47% (method aligned with the standard: plain weighted sum, no new evidence); readiness table with hours per audience |
|
||||
| 2026-10-10 | minor | Full number 42% → 47% (plain weighted sum, no new evidence); readiness table with hours per audience |
|
||||
| 2026-10-10 | minor | Headline adds mainstream practitioner (~42%) and essentials user (~54%); method in docs/target-app-parity.md |
|
||||
| 2026-10-10 | minor | Stage alpha → pre-alpha under the new core-workflow gate: manual text threading is missing; alpha gate table added to docs/roadmap.md |
|
||||
| 2026-10-10 | major | Restructured to the progress-docs standard; full re-measure; the "Working today" inventory moved to docs/target-app-parity.md, milestones to docs/roadmap.md, the CLI/MCP section left to docs/agents.md (which already covered it) |
|
||||
| 2026-10-10 | major | Progress docs restructured; full re-measure; the "Working today" inventory moved to docs/target-app-parity.md, milestones to docs/roadmap.md, the CLI/MCP section left to docs/agents.md (which already covered it) |
|
||||
| 2026-10-04 | major | Parity estimate: breadth 99%, depth ~83%, ~170 h |
|
||||
| 2026-10-01 | major | First roadmap: status, milestones |
|
||||
|
||||
|
||||
@@ -22,4 +22,5 @@ designcraft-compose = { workspace = true }
|
||||
designcraft-format = { workspace = true }
|
||||
designcraft-pdf = { workspace = true }
|
||||
designcraft-mcp = { workspace = true }
|
||||
image = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -16,6 +16,8 @@
|
||||
//! designcraft-cli bench FILE [--runs N] # the same measurements on one document
|
||||
//! designcraft-cli validate-pdf FILE.pdf [--standard x4|x1a] # the built-in PDF/X-4 or PDF/X-1a checks (default: the
|
||||
//! # file's own GTS_PDFXVersion)
|
||||
//! designcraft-cli thumbnail FILE.designcraft OUT.png [SIZE] # the file's first-page preview, fitted to SIZE
|
||||
//! # (the freedesktop thumbnailer for Linux file managers)
|
||||
//! designcraft-cli links # Discord, website, app page and GitHub links
|
||||
//! designcraft-cli --version # print the version
|
||||
//! ```
|
||||
@@ -40,7 +42,7 @@ use designcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// The usage block. Shared by `--help` (stdout, success) and an unknown command (stderr, failure).
|
||||
const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR]... [--automation-write-root DIR]...\n the roots confine every file the agent's commands read (open, placed images, data sources, libraries) and\n write (save, export, package) to those folders, links followed. Each may be given more than once; a kind\n left out grants none of its access.\n They run the in-process server, so they conflict with --connect (a running app is not confined by them).\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli validate-pdf FILE.pdf # built-in PDF/X-4 checks (including visible unembedded fonts)\n designcraft-cli links\n designcraft-cli --version";
|
||||
const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR]... [--automation-write-root DIR]...\n the roots confine every file the agent's commands read (open, placed images, data sources, libraries) and\n write (save, export, package) to those folders, links followed. Each may be given more than once; a kind\n left out grants none of its access.\n They run the in-process server, so they conflict with --connect (a running app is not confined by them).\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli validate-pdf FILE.pdf # built-in PDF/X-4 checks (including visible unembedded fonts)\n designcraft-cli thumbnail FILE.designcraft OUT.png [SIZE] # the saved first-page preview, at most SIZE px\n designcraft-cli links\n designcraft-cli --version";
|
||||
|
||||
/// `script --help`: the script syntax, otherwise only in `describe script.run` and docs/agents.md.
|
||||
const SCRIPT_HELP: &str = r#"usage: designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]
|
||||
@@ -124,6 +126,7 @@ fn main() -> ExitCode {
|
||||
Some("perf") => report(perf::perf(&args[1..])),
|
||||
Some("bench") => report(perf::bench(&args[1..])),
|
||||
Some("validate-pdf") => report(validate_pdf(args.get(1..).unwrap_or_default())),
|
||||
Some("thumbnail") => report(thumbnail(&args[1..])),
|
||||
Some("links") => {
|
||||
use designcraft_engine::links::{APP_PAGE, DISCORD, GITHUB, ISSUES, WEBSITE};
|
||||
outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}\nIssues {ISSUES}");
|
||||
@@ -156,7 +159,6 @@ fn report(r: Result<(), String>) -> ExitCode {
|
||||
|
||||
fn validate_pdf(args: &[String]) -> Result<(), String> {
|
||||
const USAGE: &str = "usage: designcraft-cli validate-pdf FILE.pdf [--standard x4|x1a]";
|
||||
const X1A: &[u8] = b"/GTS_PDFXVersion(PDF/X-1:";
|
||||
let mut path = None;
|
||||
let mut standard = None;
|
||||
let mut it = args.iter();
|
||||
@@ -172,7 +174,7 @@ fn validate_pdf(args: &[String]) -> Result<(), String> {
|
||||
let standard = match standard.map(designcraft_pdf::Standard::parse) {
|
||||
Some(Some(s)) => s,
|
||||
Some(None) => return Err(USAGE.into()),
|
||||
None if bytes.windows(X1A.len()).any(|w| w == X1A) => designcraft_pdf::Standard::PdfX1a,
|
||||
None if designcraft_pdf::pdfx_version(&bytes).is_some_and(|v| v.starts_with("PDF/X-1:")) => designcraft_pdf::Standard::PdfX1a,
|
||||
None => designcraft_pdf::Standard::PdfX4,
|
||||
};
|
||||
let (name, issues) = match standard {
|
||||
@@ -188,6 +190,61 @@ fn validate_pdf(args: &[String]) -> Result<(), String> {
|
||||
if valid { Ok(()) } else { Err(format!("{path} failed {name} validation")) }
|
||||
}
|
||||
|
||||
/// `thumbnail FILE OUT [SIZE]`: the first-page preview saved in a `.designcraft` file, as a PNG
|
||||
/// whose longer side is at most SIZE pixels (never enlarged). It is the freedesktop thumbnailer
|
||||
/// (`packaging/linux/ai.storyteller.designcraft.thumbnailer`), so it only reads the preview: a file
|
||||
/// without one fails, and the file manager keeps the file type's icon.
|
||||
fn thumbnail(args: &[String]) -> Result<(), String> {
|
||||
const USAGE: &str = "usage: designcraft-cli thumbnail FILE.designcraft OUT.png [SIZE]";
|
||||
let [input, out, rest @ ..] = args else { return Err(USAGE.into()) };
|
||||
let size = match rest {
|
||||
[] => None,
|
||||
[s] => Some(s.parse::<u32>().ok().filter(|s| *s > 0).ok_or_else(|| format!("bad SIZE `{s}`: expected a positive number of pixels"))?),
|
||||
_ => return Err(USAGE.into()),
|
||||
};
|
||||
if names_the_same_file(input.as_ref(), out.as_ref()) {
|
||||
return Err(format!("{out} is the document {input}: the thumbnail would replace it"));
|
||||
}
|
||||
let file = std::fs::File::open(input).map_err(|e| format!("{input}: {e}"))?;
|
||||
let preview = designcraft_format::read_preview(std::io::BufReader::new(file))
|
||||
.ok_or_else(|| format!("{input} has no preview (a DesignCraft file saved before they had one, or not a DesignCraft file)"))?;
|
||||
let png = fit_png(preview, size).map_err(|e| format!("{input}: the preview can't be read: {e}"))?;
|
||||
designcraft_engine::atomic::write_atomic(std::path::Path::new(out), &png).map_err(|e| format!("{out}: {e}"))
|
||||
}
|
||||
|
||||
/// Whether `out` is the file `input` names: the same path, a symlink to it or, on Unix, a hard
|
||||
/// link (the same device and inode). An `out` that doesn't exist yet can't be the input.
|
||||
fn names_the_same_file(input: &std::path::Path, out: &std::path::Path) -> bool {
|
||||
let (Ok(a), Ok(b)) = (std::fs::canonicalize(input), std::fs::canonicalize(out)) else { return false };
|
||||
if a == b {
|
||||
return true;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
if let (Ok(a), Ok(b)) = (std::fs::metadata(&a), std::fs::metadata(&b)) {
|
||||
return (a.dev(), a.ino()) == (b.dev(), b.ino());
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// `png` with its longer side brought down to `size`. A picture already that small is returned
|
||||
/// as it is; it is decoded anyway, so a broken one is an error rather than an unreadable thumbnail.
|
||||
fn fit_png(png: Vec<u8>, size: Option<u32>) -> Result<Vec<u8>, String> {
|
||||
// The preview comes from the file: it may claim any size.
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(4096);
|
||||
limits.max_image_height = Some(4096);
|
||||
let mut reader = image::ImageReader::with_format(std::io::Cursor::new(&png), image::ImageFormat::Png);
|
||||
reader.limits(limits);
|
||||
let img = reader.decode().map_err(|e| e.to_string())?;
|
||||
let Some(size) = size.filter(|s| img.width().max(img.height()) > *s) else { return Ok(png) };
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
img.resize(size, size, image::imageops::FilterType::Triangle).write_to(&mut out, image::ImageFormat::Png).map_err(|e| e.to_string())?;
|
||||
Ok(out.into_inner())
|
||||
}
|
||||
|
||||
/// `mcp` (headless, in-process engine) or `mcp --connect PORT|HOST:PORT` (drive a running app
|
||||
/// started with `designcraft --control PORT`). JSON-RPC on stdin/stdout; logs on stderr.
|
||||
fn mcp(args: &[String]) -> Result<(), String> {
|
||||
|
||||
@@ -556,3 +556,110 @@ fn usage_lists_the_automation_root_flags() {
|
||||
let usage = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(usage.contains("--automation-read-root DIR") && usage.contains("--automation-write-root DIR"), "{usage}");
|
||||
}
|
||||
|
||||
/// Width and height from a PNG's IHDR chunk.
|
||||
fn png_size(png: &[u8]) -> (u32, u32) {
|
||||
assert!(png.starts_with(b"\x89PNG\r\n\x1a\n"), "not a PNG");
|
||||
let be = |at: usize| u32::from_be_bytes(png[at..at + 4].try_into().unwrap());
|
||||
(be(16), be(20))
|
||||
}
|
||||
|
||||
/// Linux file managers (Dolphin, Nautilus, Thunar) draw a `.designcraft` file's icon through the
|
||||
/// freedesktop thumbnailer, which runs `designcraft-cli thumbnail %i %o %s` (#1782). The picture is
|
||||
/// the first-page preview the file already holds, fitted to the size asked for and never enlarged.
|
||||
#[test]
|
||||
fn thumbnail_writes_the_saved_preview_fitted_to_the_size() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-cli-thumb-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let doc = dir.join("a.designcraft");
|
||||
make_document(&doc);
|
||||
let preview = designcraft_format::read_preview(std::fs::File::open(&doc).unwrap()).unwrap();
|
||||
let (pw, ph) = png_size(&preview);
|
||||
assert!(pw.max(ph) > 128, "the saved preview is {pw}×{ph}");
|
||||
|
||||
let small = dir.join("small.png");
|
||||
let out = cli().arg("thumbnail").arg(&doc).arg(&small).arg("128").output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let (w, h) = png_size(&std::fs::read(&small).unwrap());
|
||||
assert_eq!(w.max(h), 128, "{w}×{h} from a {pw}×{ph} preview");
|
||||
// The page's proportions stay.
|
||||
let ratio = |a: u32, b: u32| f64::from(a) / f64::from(b);
|
||||
assert!((ratio(w, h) - ratio(pw, ph)).abs() < 0.02, "{w}×{h} from a {pw}×{ph} preview");
|
||||
|
||||
let large = dir.join("large.png");
|
||||
let out = cli().arg("thumbnail").arg(&doc).arg(&large).arg("1024").output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
assert_eq!(std::fs::read(&large).unwrap(), preview, "a preview smaller than the size is written as saved");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A file saved without a preview (by an older version, or not by DesignCraft) has no picture: the
|
||||
/// thumbnailer fails and writes nothing, so the file manager keeps the file type's icon.
|
||||
#[test]
|
||||
fn thumbnail_of_a_file_without_a_preview_fails_and_writes_nothing() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-cli-thumb-none-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let with = dir.join("with.designcraft");
|
||||
make_document(&with);
|
||||
let doc = designcraft_format::load(&std::fs::read(&with).unwrap()).unwrap();
|
||||
let without = dir.join("without.designcraft");
|
||||
std::fs::write(&without, designcraft_format::save(&doc).unwrap()).unwrap();
|
||||
let text = dir.join("notes.designcraft");
|
||||
std::fs::write(&text, "not a document").unwrap();
|
||||
for input in [&without, &text] {
|
||||
let png = dir.join("out.png");
|
||||
let out = cli().arg("thumbnail").arg(input).arg(&png).arg("256").output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(!out.status.success(), "{}", input.display());
|
||||
assert!(err.contains("no preview"), "{}: {err}", input.display());
|
||||
assert!(!png.exists(), "{}", input.display());
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `thumbnail a.designcraft a.designcraft` would replace the document with a PNG. An output that
|
||||
/// names the input, by the same path, through a symlink or (on Unix) as a hard link, is refused
|
||||
/// before anything is read or written, and the document keeps its bytes.
|
||||
#[test]
|
||||
fn thumbnail_refuses_an_output_that_names_the_input() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-cli-thumb-self-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let doc = dir.join("a.designcraft");
|
||||
make_document(&doc);
|
||||
let saved = std::fs::read(&doc).unwrap();
|
||||
let mut outs = vec![doc.clone(), dir.join(".").join("a.designcraft")];
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let link = dir.join("link.png");
|
||||
std::os::unix::fs::symlink(&doc, &link).unwrap();
|
||||
outs.push(link);
|
||||
let hard = dir.join("hard.png");
|
||||
std::fs::hard_link(&doc, &hard).unwrap();
|
||||
outs.push(hard);
|
||||
}
|
||||
for out_path in &outs {
|
||||
let out = cli().arg("thumbnail").arg(&doc).arg(out_path).arg("128").output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert_eq!(out.status.code(), Some(1), "{}: {err}", out_path.display());
|
||||
assert!(err.contains("is the document"), "{}: {err}", out_path.display());
|
||||
assert!(std::fs::read(&doc).unwrap() == saved, "{} changed the document", out_path.display());
|
||||
assert!(std::fs::read(out_path).unwrap() == saved, "{} was written", out_path.display());
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Another existing file at the output path is still replaced by the thumbnail.
|
||||
#[test]
|
||||
fn thumbnail_replaces_another_existing_file() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-cli-thumb-other-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let doc = dir.join("a.designcraft");
|
||||
make_document(&doc);
|
||||
let png = dir.join("old.png");
|
||||
std::fs::write(&png, b"an older thumbnail").unwrap();
|
||||
let out = cli().arg("thumbnail").arg(&doc).arg(&png).arg("128").output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let (w, h) = png_size(&std::fs::read(&png).unwrap());
|
||||
assert_eq!(w.max(h), 128, "{w}×{h}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -37,7 +37,8 @@ winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
craft-update = { workspace = true }
|
||||
# EnumDisplayDevices (src/gpu.rs): which GPU drives the display, so the window draws on the GPU
|
||||
# that shows it; drawing on a GPU without a monitor reset the display driver. Safe wrappers over
|
||||
# Win32 (MIT); the `user` feature is User32.dll only.
|
||||
# Win32 (MIT); the `user` feature is User32.dll only. Also the window frame for the custom title
|
||||
# bar (src/window_frame.rs, window styles).
|
||||
winsafe = { version = "0.0.29", default-features = false, features = ["user"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
@@ -55,3 +56,8 @@ craft-window = { workspace = true }
|
||||
# PdfCraft and WordCraft) registers the handler behind a safe API (Objective-C interop needs unsafe,
|
||||
# which the workspace forbids). Pinned exactly: review the diff before bumping.
|
||||
fmv-macos-events = "=0.1.0"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Native GNOME/Wayland controls without a bundled title font.
|
||||
winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita-notitle"] }
|
||||
|
||||
@@ -678,7 +678,12 @@ fn restart_without<'a>(graphics_failed: bool, frames: u64, picked: Option<&'a st
|
||||
/// ended it.
|
||||
pub fn finish(outcome: Result<eframe::Result, String>, startup: &Startup) -> Result<(), String> {
|
||||
let (why, graphics_failed) = match outcome {
|
||||
Ok(Ok(())) => return Ok(()),
|
||||
// The window closed normally, perhaps before its first frame or before the app was
|
||||
// created (so `on_exit` never ran): no driver fault took this start down (#2116).
|
||||
Ok(Ok(())) => {
|
||||
startup.presented();
|
||||
return Ok(());
|
||||
}
|
||||
Ok(Err(e @ eframe::Error::Wgpu(_))) => (e.to_string(), true),
|
||||
Ok(Err(e)) => (e.to_string(), false),
|
||||
Err(panic) => (panic, GRAPHICS_PANIC.load(Ordering::Relaxed)),
|
||||
|
||||
@@ -14,8 +14,9 @@
|
||||
//! fallback off.
|
||||
//!
|
||||
//! The fallback: before the window is created, `gpu.json` in the settings directory records the
|
||||
//! backend being tried; once a frame has been presented, or the graphics failed with an error the
|
||||
//! app caught (see `gpu::finish`), that entry is cleared. An entry still there at the next start
|
||||
//! backend being tried; once a frame has been presented, the window closed normally (even before
|
||||
//! its first frame), or the graphics failed with an error the app caught (see `gpu::finish`), that
|
||||
//! entry is cleared. An entry still there at the next start
|
||||
//! means the previous start never got that far — the driver crashed, or the app was killed
|
||||
//! meanwhile — so that backend joins `failed`, the next candidate is tried and the status bar says
|
||||
//! so. Backends a restart left out (`--gpu-skip=`, see `gpu`) aren't chosen for that start but
|
||||
@@ -538,6 +539,23 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(path.parent().expect("dir"));
|
||||
}
|
||||
|
||||
/// A window closed before its first frame (#2116): the app was created slowly or not at all,
|
||||
/// so neither a frame nor `on_exit` cleared the record, but eframe ended cleanly. No driver
|
||||
/// fault took that start down, so the next start keeps the backend.
|
||||
#[test]
|
||||
fn a_start_that_ends_cleanly_before_its_first_frame_does_not_fail_its_backend() {
|
||||
let path = temp_path("clean-end");
|
||||
let fallback = Fallback::begin_unforced(Some(path.clone()), V, &[]).expect("candidates");
|
||||
let first = fallback.backend;
|
||||
assert_eq!(read(&path).trying, Some(first));
|
||||
let startup = super::super::Startup::new(Some(fallback), Vec::new());
|
||||
assert_eq!(super::super::finish(Ok(Ok(())), &startup), Ok(()));
|
||||
assert_eq!(read(&path).trying, None, "a clean end leaves no backend being tried");
|
||||
let c = choose(candidates(), &read(&path), V, &[]).expect("candidates");
|
||||
assert_eq!((c.backend, c.record.failed, c.switched_from), (first, Vec::new(), None));
|
||||
let _ = std::fs::remove_dir_all(path.parent().expect("dir"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_path_nothing_is_written_and_a_bad_path_is_not_fatal() {
|
||||
let mut s = Fallback::begin_unforced(None, V, &[]).expect("candidates");
|
||||
|
||||
@@ -22,6 +22,7 @@ mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
mod profile;
|
||||
mod window_frame;
|
||||
|
||||
use designcraft_engine::Session;
|
||||
use designcraft_ui_egui::{DesignApp, Services};
|
||||
@@ -255,7 +256,7 @@ fn control_port_from(source: &str, value: Option<String>) -> Option<u16> {
|
||||
fn main() -> std::process::ExitCode {
|
||||
// Windows: the update helper is a copy of this program started with `--finish-update`.
|
||||
#[cfg(windows)]
|
||||
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::DESIGNCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
if let Some(result) = craft_update::self_update::finish(&designcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
std::process::exit(i32::from(result.is_err()));
|
||||
}
|
||||
// First, so every start-up record (and the engine's panic hook, installed with the first
|
||||
@@ -428,6 +429,9 @@ fn main() -> std::process::ExitCode {
|
||||
}
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
app.custom_titlebar = cfg!(target_os = "windows");
|
||||
// The custom title bar's undecorated window must stay borderless, or its caption
|
||||
// and border offset the content and the pointer on some Windows systems (#1295).
|
||||
app.services.window_frame = window_frame::service(cc, app.custom_titlebar);
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
|
||||
@@ -230,10 +230,12 @@ fn build(
|
||||
path: &str,
|
||||
recent: &mut Option<Submenu>,
|
||||
) {
|
||||
for e in entries {
|
||||
for (i, e) in entries.iter().enumerate() {
|
||||
match e {
|
||||
Item::Sep => {
|
||||
let _ = parent.append(&PredefinedMenuItem::separator());
|
||||
if menus::has_shown_item(app, &entries[i + 1..]) {
|
||||
let _ = parent.append(&PredefinedMenuItem::separator());
|
||||
}
|
||||
}
|
||||
Item::Sub(label, children) => {
|
||||
let sub = Submenu::new(menu_title(app, label), true);
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
//! Keeps the custom (undecorated) title bar window truly borderless (#1295).
|
||||
//!
|
||||
//! winit 0.30 gives an undecorated window the `WS_CAPTION | WS_BORDER` styles and hides the
|
||||
//! non-client area by spreading the client rect over it (`WM_NCCALCSIZE`). On some systems
|
||||
//! (reported: Windows 10 LTSB, Intel HD Graphics 4600, 1366 × 768) the desktop compositor still
|
||||
//! reserves that frame for the window surface: the content is drawn offset by the caption and
|
||||
//! border, leaving black bars at the window's top-left, while the pointer keeps the un-offset
|
||||
//! client coordinates, so it no longer points at what is drawn under it.
|
||||
//!
|
||||
//! winit 0.27 — before rust-windowing/winit#2419 — simply omitted those styles for undecorated
|
||||
//! windows, and there was no offset. [`service`] restores that: it strips `WS_CAPTION` and
|
||||
//! `WS_BORDER` and forces Windows to recalculate the frame. winit puts them back whenever it
|
||||
//! changes the window flags (maximize, restore, …), so the returned guard checks every frame.
|
||||
//!
|
||||
//! Windows sizes a maximize by the frame its styles describe, so a window maximized while its
|
||||
//! caption is gone is sized without one: its rect covers the whole monitor, taskbar included.
|
||||
//! The shell reads a caption-less window covering its monitor as a full-screen app and drops the
|
||||
//! taskbar's backdrop behind it — a black band on stock Windows 11, a glitched unresponsive one
|
||||
//! under ExplorerPatcher (photocraft#3144). The guard therefore also trims a parked maximize's rect back to
|
||||
//! the work area plus its own frame, clear of the taskbar. winit pins the client area to the work
|
||||
//! area either way (`WM_NCCALCSIZE`), so the app's pixels do not move.
|
||||
//!
|
||||
//! An auto-hidden taskbar reserves no work area, so that trim leaves the maximize covering the
|
||||
//! whole monitor, and the shell, taking it for a full-screen app, no longer reveals the taskbar
|
||||
//! when the pointer reaches its edge (photocraft#4845). On each edge with an auto-hidden taskbar the guard
|
||||
//! instead stops the window one pixel short of the monitor, as other custom-frame apps do.
|
||||
//!
|
||||
//! The long-term fix is in winit itself (storytold/winit#17); this module goes when that lands.
|
||||
//!
|
||||
//! The Windows calls go through safe `winsafe` wrappers; the workspace forbids `unsafe`, and
|
||||
//! winsafe can't wrap a raw handle without it, so the window is found by its handle instead.
|
||||
|
||||
pub use platform::service;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod platform {
|
||||
use designcraft_ui_egui::WindowFrameFn;
|
||||
use eframe::wgpu::rwh::{HasWindowHandle, RawWindowHandle};
|
||||
use winsafe::prelude::Handle;
|
||||
use winsafe::{AtomStr, HWND, HwndPlace, POINT, SIZE, co};
|
||||
|
||||
/// A rect in desktop coordinates: `(left, top, right, bottom)`, right and bottom exclusive.
|
||||
type Rect = (i32, i32, i32, i32);
|
||||
|
||||
/// A monitor edge.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Edge {
|
||||
Left,
|
||||
Top,
|
||||
Right,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
/// A guard that keeps the window borderless each frame, or `None` when the app uses the
|
||||
/// system title bar (the window is decorated) or its handle can't be found.
|
||||
pub fn service(cc: &eframe::CreationContext<'_>, custom_titlebar: bool) -> Option<WindowFrameFn> {
|
||||
if !custom_titlebar {
|
||||
return None;
|
||||
}
|
||||
let target = match cc.window_handle().ok()?.as_raw() {
|
||||
RawWindowHandle::Win32(h) => h.hwnd.get(),
|
||||
_ => return None,
|
||||
};
|
||||
let hwnd = find_window(target)?;
|
||||
reapply(&hwnd);
|
||||
Some(Box::new(move || reapply(&hwnd)))
|
||||
}
|
||||
|
||||
/// The top-level window with the given raw handle. `EnumWindows` hands out real `HWND`s, so
|
||||
/// this matches winit's own window without wrapping a raw handle (which needs `unsafe`).
|
||||
fn find_window(target: isize) -> Option<HWND> {
|
||||
let mut found = None;
|
||||
let _ = winsafe::EnumWindows(|hwnd| {
|
||||
if hwnd.ptr() as isize == target {
|
||||
found = Some(hwnd);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
found
|
||||
}
|
||||
|
||||
/// One pass of the per-frame guard: the styles winit put back come off again, and a parked
|
||||
/// maximize stays clear of the taskbar.
|
||||
fn reapply(hwnd: &HWND) {
|
||||
strip(hwnd);
|
||||
keep_clear_of_the_taskbar(hwnd);
|
||||
}
|
||||
|
||||
/// Removes `WS_CAPTION`/`WS_BORDER` and has Windows recalculate the frame. A no-op once they
|
||||
/// are gone, so it is cheap to call every frame.
|
||||
fn strip(hwnd: &HWND) {
|
||||
let style = hwnd.style();
|
||||
let without = borderless(style);
|
||||
if without.raw() == style.raw() {
|
||||
return;
|
||||
}
|
||||
hwnd.set_style(without);
|
||||
let _ = hwnd.SetWindowPos(
|
||||
HwndPlace::None,
|
||||
POINT::new(),
|
||||
SIZE::new(),
|
||||
co::SWP::FRAMECHANGED | co::SWP::NOCOPYBITS | co::SWP::NOMOVE | co::SWP::NOSIZE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
|
||||
);
|
||||
}
|
||||
|
||||
/// How far a parked maximize reaches past a monitor/work-area edge: the resize
|
||||
/// border (about 8 pt at 100 %, more with DPI). Past that, a maximized window's rect is not
|
||||
/// parked but being dragged or snapped, and is left alone.
|
||||
const FRAME: i32 = 32;
|
||||
|
||||
/// Keep a parked maximize inside all four work-area edges, including a top taskbar.
|
||||
fn keep_clear_of_the_taskbar(hwnd: &HWND) {
|
||||
if !hwnd.IsZoomed() {
|
||||
return;
|
||||
}
|
||||
let (Ok(r), Ok(mi)) = (hwnd.GetWindowRect(), hwnd.MonitorFromWindow(co::MONITOR::DEFAULTTONEAREST).GetMonitorInfo()) else {
|
||||
return;
|
||||
};
|
||||
let (m, w) = (mi.rcMonitor, mi.rcWork);
|
||||
let (rect, monitor, work) = ((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom));
|
||||
// The taskbars (a couple of `FindWindowEx` calls) are only looked up while the window
|
||||
// covers the whole monitor, which a trimmed maximize no longer does.
|
||||
let Some((left, top, right, bottom)) = clamped_rect(rect, monitor, work)
|
||||
.or_else(|| covers(rect, monitor).then(|| short_of_autohide_taskbars(rect, monitor, work, &taskbar_edges(monitor))).flatten())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let (Some(width), Some(height)) = (right.checked_sub(left), bottom.checked_sub(top)) else {
|
||||
return;
|
||||
};
|
||||
// Moving the outer frame while winit pins the client rect to the work area must
|
||||
// not copy the old frame's pixels into the custom title bar.
|
||||
let _ = hwnd.SetWindowPos(
|
||||
HwndPlace::None,
|
||||
POINT { x: left, y: top },
|
||||
SIZE { cx: width, cy: height },
|
||||
co::SWP::FRAMECHANGED | co::SWP::NOCOPYBITS | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
|
||||
);
|
||||
let _ = hwnd.InvalidateRect(None, false);
|
||||
}
|
||||
|
||||
/// Return the corrected outer rect in desktop coordinates, preserving the resize border.
|
||||
/// Each edge must be parked just outside either the monitor or work-area edge. Accepting
|
||||
/// both makes the guard idempotent and handles Windows already correcting some edges.
|
||||
/// Dragged/snapped windows are left alone; no monitor-origin or DPI assumptions are needed.
|
||||
fn clamped_rect(rect: (i32, i32, i32, i32), monitor: (i32, i32, i32, i32), work: (i32, i32, i32, i32)) -> Option<(i32, i32, i32, i32)> {
|
||||
let (l, t, r, b) = rect;
|
||||
let (ml, mt, mr, mb) = monitor;
|
||||
let (wl, wt, wr, wb) = work;
|
||||
if ml >= mr || mt >= mb || wl < ml || wt < mt || wr > mr || wb > mb || wl >= wr || wt >= wb {
|
||||
return None;
|
||||
}
|
||||
// Infer the frame from an edge without a taskbar. On an obstructed edge, the
|
||||
// monitor and work-area offsets can both look like valid borders (e.g. auto-hide).
|
||||
let border = [
|
||||
(ml == wl).then(|| ml.checked_sub(l)).flatten(),
|
||||
(mt == wt).then(|| mt.checked_sub(t)).flatten(),
|
||||
(mr == wr).then(|| r.checked_sub(mr)).flatten(),
|
||||
(mb == wb).then(|| b.checked_sub(mb)).flatten(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|n| (0..=FRAME).contains(n))
|
||||
.min()?;
|
||||
let parked = |monitor: Option<i32>, work: Option<i32>| monitor == Some(border) || work == Some(border);
|
||||
if !parked(ml.checked_sub(l), wl.checked_sub(l))
|
||||
|| !parked(mt.checked_sub(t), wt.checked_sub(t))
|
||||
|| !parked(r.checked_sub(mr), r.checked_sub(wr))
|
||||
|| !parked(b.checked_sub(mb), b.checked_sub(wb))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let corrected = (wl.checked_sub(border)?, wt.checked_sub(border)?, wr.checked_add(border)?, wb.checked_add(border)?);
|
||||
(corrected != rect).then_some(corrected)
|
||||
}
|
||||
|
||||
/// Whether `rect` reaches every edge of `monitor`: what the shell takes for a full-screen app.
|
||||
fn covers(rect: Rect, monitor: Rect) -> bool {
|
||||
rect.0 <= monitor.0 && rect.1 <= monitor.1 && rect.2 >= monitor.2 && rect.3 >= monitor.3
|
||||
}
|
||||
|
||||
/// The edges of `monitor` that have a taskbar (the primary `Shell_TrayWnd` or a secondary
|
||||
/// monitor's `Shell_SecondaryTrayWnd`), shown or hidden.
|
||||
fn taskbar_edges(monitor: Rect) -> Vec<Edge> {
|
||||
let mut edges = Vec::new();
|
||||
for class in ["Shell_TrayWnd", "Shell_SecondaryTrayWnd"] {
|
||||
let mut after: Option<HWND> = None;
|
||||
// One taskbar per monitor; the bound only guards against a misbehaving shell.
|
||||
for _ in 0..64 {
|
||||
let Ok(Some(tray)) = HWND::NULL.FindWindowEx(after.as_ref(), AtomStr::from_str(class), None) else {
|
||||
break;
|
||||
};
|
||||
if let Ok(r) = tray.GetWindowRect() {
|
||||
edges.extend(taskbar_edge((r.left, r.top, r.right, r.bottom), monitor));
|
||||
}
|
||||
after = Some(tray);
|
||||
}
|
||||
}
|
||||
edges
|
||||
}
|
||||
|
||||
/// The edge of `monitor` a taskbar with rect `taskbar` sits on: the one nearest its centre,
|
||||
/// or `None` when it is on another monitor. An auto-hidden taskbar is parked mostly past its
|
||||
/// edge with a sliver left on screen, so its centre may lie outside the monitor.
|
||||
fn taskbar_edge(taskbar: Rect, monitor: Rect) -> Option<Edge> {
|
||||
let (tl, tt, tr, tb) = (i64::from(taskbar.0), i64::from(taskbar.1), i64::from(taskbar.2), i64::from(taskbar.3));
|
||||
let (ml, mt, mr, mb) = (i64::from(monitor.0), i64::from(monitor.1), i64::from(monitor.2), i64::from(monitor.3));
|
||||
if tl >= tr || tt >= tb || ml >= mr || mt >= mb || tr <= ml || tl >= mr || tb <= mt || tt >= mb {
|
||||
return None;
|
||||
}
|
||||
// Doubled centre and distances, so nothing rounds.
|
||||
let (cx, cy) = (tl + tr, tt + tb);
|
||||
[(Edge::Left, cx - 2 * ml), (Edge::Top, cy - 2 * mt), (Edge::Right, 2 * mr - cx), (Edge::Bottom, 2 * mb - cy)]
|
||||
.into_iter()
|
||||
.min_by_key(|&(_, distance)| distance)
|
||||
.map(|(edge, _)| edge)
|
||||
}
|
||||
|
||||
/// A maximize covering its monitor, trimmed to stop one pixel short of each edge where a
|
||||
/// taskbar reserves no work area (it auto-hides), so the shell still reveals it there.
|
||||
/// `None` when there is nothing to trim.
|
||||
fn short_of_autohide_taskbars(rect: Rect, monitor: Rect, work: Rect, taskbars: &[Edge]) -> Option<Rect> {
|
||||
if !covers(rect, monitor) {
|
||||
return None;
|
||||
}
|
||||
let (mut l, mut t, mut r, mut b) = rect;
|
||||
for edge in taskbars {
|
||||
match edge {
|
||||
Edge::Left if work.0 == monitor.0 => l = monitor.0.checked_add(1)?,
|
||||
Edge::Top if work.1 == monitor.1 => t = monitor.1.checked_add(1)?,
|
||||
Edge::Right if work.2 == monitor.2 => r = monitor.2.checked_sub(1)?,
|
||||
Edge::Bottom if work.3 == monitor.3 => b = monitor.3.checked_sub(1)?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let trimmed = (l, t, r, b);
|
||||
(l < r && t < b && trimmed != rect).then_some(trimmed)
|
||||
}
|
||||
|
||||
/// `style` without the OS title bar and frame (`WS_CAPTION`, which already includes
|
||||
/// `WS_BORDER`). The sizing border (`WS_THICKFRAME`) stays, so edge resizing and Aero snap
|
||||
/// keep working, as they did with winit 0.27's undecorated windows.
|
||||
fn borderless(style: co::WS) -> co::WS {
|
||||
style & !(co::WS::CAPTION | co::WS::BORDER)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Edge, borderless, clamped_rect, short_of_autohide_taskbars, taskbar_edge};
|
||||
use winsafe::co;
|
||||
|
||||
#[test]
|
||||
fn the_borderless_style_drops_the_caption_and_border_only() {
|
||||
// `WS_CAPTION` is 0x00c0_0000 and already contains `WS_BORDER` (0x0080_0000).
|
||||
let decorated = co::WS::CAPTION | co::WS::BORDER | co::WS::SYSMENU | co::WS::THICKFRAME | co::WS::MAXIMIZEBOX | co::WS::MINIMIZEBOX;
|
||||
let bare = borderless(decorated);
|
||||
assert!(!bare.has(co::WS::CAPTION), "caption gone");
|
||||
assert!(!bare.has(co::WS::BORDER), "border gone");
|
||||
assert!(bare.has(co::WS::THICKFRAME), "edge resizing kept");
|
||||
assert!(bare.has(co::WS::MAXIMIZEBOX) && bare.has(co::WS::SYSMENU), "window buttons kept");
|
||||
// Already borderless: unchanged, so the per-frame call does nothing.
|
||||
assert_eq!(bare.raw(), borderless(bare).raw());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_maximize_respects_taskbars_on_every_edge() {
|
||||
let monitor = (0, 0, 1920, 1080);
|
||||
let rect = (-8, -8, 1928, 1088);
|
||||
for (work, expected) in [
|
||||
((0, 0, 1920, 1046), (-8, -8, 1928, 1054)),
|
||||
((0, 48, 1920, 1080), (-8, 40, 1928, 1088)),
|
||||
((0, 2, 1920, 1080), (-8, -6, 1928, 1088)),
|
||||
((0, 0, 1920, 1078), (-8, -8, 1928, 1086)),
|
||||
((48, 0, 1920, 1080), (40, -8, 1928, 1088)),
|
||||
((0, 0, 1872, 1080), (-8, -8, 1880, 1088)),
|
||||
] {
|
||||
assert_eq!(clamped_rect(rect, monitor, work), Some(expected));
|
||||
assert_eq!(clamped_rect(expected, monitor, work), None, "already corrected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_taskbar_keeps_its_offset_at_other_origins_and_frame_sizes() {
|
||||
let monitor = (-1920, -1080, 0, 0);
|
||||
let work = (-1920, -1032, 0, 0);
|
||||
for border in [0, 8, 16, 32] {
|
||||
let rect = (-1920 - border, -1080 - border, border, border);
|
||||
let expected = (-1920 - border, -1032 - border, border, border);
|
||||
assert_eq!(clamped_rect(rect, monitor, work), Some(expected));
|
||||
assert_eq!(clamped_rect(expected, monitor, work), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_maximize_without_a_reserved_taskbar_is_left_alone() {
|
||||
let monitor = (0, 0, 1920, 1080);
|
||||
assert_eq!(clamped_rect((-8, -8, 1928, 1088), monitor, monitor), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_maximize_being_dragged_or_snapped_is_left_alone() {
|
||||
let monitor = (0, 0, 1920, 1080);
|
||||
let work = (0, 48, 1920, 1080);
|
||||
assert_eq!(clamped_rect((100, 200, 1100, 900), monitor, work), None, "moved");
|
||||
assert_eq!(clamped_rect((-500, -8, 1428, 1088), monitor, work), None, "dragged");
|
||||
assert_eq!(clamped_rect((0, 48, 960, 1080), monitor, work), None, "half-screen");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_maximize_leaves_a_pixel_strip_on_an_auto_hidden_taskbar_edge() {
|
||||
// photocraft#4845: an auto-hidden taskbar reserves no work area.
|
||||
let monitor = (0, 0, 1920, 1080);
|
||||
let rect = (-8, -8, 1928, 1088);
|
||||
for (edge, expected) in [
|
||||
(Edge::Bottom, (-8, -8, 1928, 1079)),
|
||||
(Edge::Top, (-8, 1, 1928, 1088)),
|
||||
(Edge::Left, (1, -8, 1928, 1088)),
|
||||
(Edge::Right, (-8, -8, 1919, 1088)),
|
||||
] {
|
||||
assert_eq!(short_of_autohide_taskbars(rect, monitor, monitor, &[edge]), Some(expected));
|
||||
assert_eq!(short_of_autohide_taskbars(expected, monitor, monitor, &[edge]), None, "already trimmed");
|
||||
}
|
||||
assert_eq!(short_of_autohide_taskbars(rect, monitor, monitor, &[]), None, "no taskbar");
|
||||
// A taskbar that reserves its work area is the clamp's job.
|
||||
assert_eq!(short_of_autohide_taskbars(rect, monitor, (0, 0, 1920, 1032), &[Edge::Bottom]), None);
|
||||
assert_eq!(short_of_autohide_taskbars((0, 0, 960, 1080), monitor, monitor, &[Edge::Bottom]), None, "half-screen");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_taskbar_sits_on_the_monitor_edge_nearest_its_centre() {
|
||||
let monitor = (0, 0, 1920, 1080);
|
||||
assert_eq!(taskbar_edge((0, 1032, 1920, 1080), monitor), Some(Edge::Bottom), "shown");
|
||||
assert_eq!(taskbar_edge((0, 1078, 1920, 1126), monitor), Some(Edge::Bottom), "auto-hidden");
|
||||
assert_eq!(taskbar_edge((0, -46, 1920, 2), monitor), Some(Edge::Top));
|
||||
assert_eq!(taskbar_edge((-60, 0, 2, 1080), monitor), Some(Edge::Left));
|
||||
assert_eq!(taskbar_edge((1918, 0, 1980, 1080), monitor), Some(Edge::Right));
|
||||
assert_eq!(taskbar_edge((1920, 1032, 3840, 1080), monitor), None, "other monitor");
|
||||
assert!(taskbar_edge((i32::MIN, i32::MIN, i32::MAX, i32::MAX), monitor).is_some(), "no overflow");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
mod platform {
|
||||
use designcraft_ui_egui::WindowFrameFn;
|
||||
|
||||
pub fn service(_cc: &eframe::CreationContext<'_>, _custom_titlebar: bool) -> Option<WindowFrameFn> {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -22,10 +22,8 @@ transparent margin.
|
||||
|
||||
## Provenance
|
||||
|
||||
The owner drew the cat in ArtCraft. The 2880 px drawing, keyed to the three-colour palette, lives
|
||||
outside this repo in craftrules at `assets/app-icons/designcraft/source.png`. It was vectorised with
|
||||
craftrules' `assets/logo-options/_tools/vectorize_tile.py`: `designcraft.svg` is traced at 2048 px and
|
||||
`designcraft-small.svg` at 1024 px. Design rules: craftrules `standards/icon-design.md`.
|
||||
The owner drew the cat in ArtCraft. `designcraft.svg` is traced from the drawing at 2048 px and
|
||||
`designcraft-small.svg` at 1024 px.
|
||||
|
||||
## Files
|
||||
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
# Contributor names for the About window (craftrules standards/contributors.md).
|
||||
# Contributor names for the About window.
|
||||
#
|
||||
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
|
||||
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
|
||||
# maintainer may add a person only when that person asked, recording where in consent_source.
|
||||
#
|
||||
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
|
||||
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
|
||||
#
|
||||
# Or copy this block, replacing your-github-username (every field is optional):
|
||||
# Copy this block, replacing your-github-username (every field is optional):
|
||||
#
|
||||
# [people.your-github-username]
|
||||
# real_name = "Your Name" # shown when the viewer picks "Real name"
|
||||
@@ -66,8 +63,27 @@ real_name = "Marco Arnulfo"
|
||||
display_name = "Marco Arnulfo"
|
||||
sync_github_name = false
|
||||
|
||||
[people.macrobi]
|
||||
real_name = "Veress Róbert"
|
||||
display_name = "Veress Róbert"
|
||||
sync_github_name = false
|
||||
|
||||
[people.4waymedia]
|
||||
real_name = "Paul Gemignani"
|
||||
|
||||
[people.skylartaylor]
|
||||
display_name = "skylartaylor"
|
||||
|
||||
[people.y-az]
|
||||
real_name = "Youssef Azriouil"
|
||||
display_name = "youssef"
|
||||
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Tom-R-Main]
|
||||
sync_github_name = true
|
||||
|
||||
[people.JonRC]
|
||||
real_name = "Jonathan Cardoso"
|
||||
display_name = "Jonathan Cardoso"
|
||||
@@ -118,6 +118,8 @@ impl Color {
|
||||
} else {
|
||||
60.0 * ((r - g) / d + 4.0)
|
||||
};
|
||||
// A tiny negative `rem_euclid` input can round up to exactly 6.0 (hue 360).
|
||||
let h = if h >= 360.0 { 0.0 } else { h };
|
||||
let s = if max <= 0.0 { 0.0 } else { d / max };
|
||||
[h, s, max]
|
||||
}
|
||||
@@ -251,6 +253,12 @@ impl Paint {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hsb_hue_stays_below_360() {
|
||||
let h = Color::rgb(1.0, 0.0, 1e-8).to_hsb()[0];
|
||||
assert!((0.0..360.0).contains(&h), "hue {h}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_roundtrip() {
|
||||
let c = Color::from_hex("#ff8000").unwrap();
|
||||
|
||||
+35
-13
@@ -96,7 +96,9 @@ pub fn value_name(c: Color) -> String {
|
||||
Color::Cmyk { c, m, y, k } => cmyk_name(c, m, y, k),
|
||||
other => {
|
||||
let [r, g, b] = other.to_rgb();
|
||||
format!("R={} G={} B={}", (r * 255.0).round(), (g * 255.0).round(), (b * 255.0).round())
|
||||
// `+ 0.0` turns -0.0 into 0.0 so it prints as "0", not "-0".
|
||||
let p = |v: f32| (v * 255.0).round() + 0.0;
|
||||
format!("R={} G={} B={}", p(r), p(g), p(b))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,7 +145,9 @@ pub fn apply_tint(c: Color, tint: f32) -> Color {
|
||||
}
|
||||
}
|
||||
|
||||
/// A new document's swatches, in panel order.
|
||||
/// A new document's swatches, in panel order: InDesign's four specials, then its six process
|
||||
/// colours (cyan, magenta, yellow, red, green and blue as C=15 M=100 Y=100, C=75 M=5 Y=100 and
|
||||
/// C=100 M=90 Y=10).
|
||||
pub fn default_swatches() -> Vec<Swatch> {
|
||||
let special = |name: &str, value: SwatchValue| Swatch { name: name.into(), value, locked: true, named: true, hidden: false };
|
||||
let mut v = vec![
|
||||
@@ -152,17 +156,9 @@ pub fn default_swatches() -> Vec<Swatch> {
|
||||
special(PAPER, SwatchValue::Paper { color: Color::WHITE }),
|
||||
special(BLACK, SwatchValue::Color { color: Color::cmyk(0.0, 0.0, 0.0, 1.0), color_type: ColorType::Process }),
|
||||
];
|
||||
for (c, m, y, k) in [
|
||||
(1.0, 0.0, 0.0, 0.0),
|
||||
(0.0, 1.0, 0.0, 0.0),
|
||||
(0.0, 0.0, 1.0, 0.0),
|
||||
(0.0, 1.0, 1.0, 0.0),
|
||||
(1.0, 0.0, 1.0, 0.0),
|
||||
(1.0, 1.0, 0.0, 0.0),
|
||||
(0.15, 1.0, 1.0, 0.0),
|
||||
(0.75, 0.05, 1.0, 0.0),
|
||||
(1.0, 0.9, 0.1, 0.0),
|
||||
] {
|
||||
for (c, m, y, k) in
|
||||
[(1.0, 0.0, 0.0, 0.0), (0.0, 1.0, 0.0, 0.0), (0.0, 0.0, 1.0, 0.0), (0.15, 1.0, 1.0, 0.0), (0.75, 0.05, 1.0, 0.0), (1.0, 0.9, 0.1, 0.0)]
|
||||
{
|
||||
v.push(Swatch::cmyk(c, m, y, k));
|
||||
}
|
||||
v
|
||||
@@ -172,6 +168,11 @@ pub fn default_swatches() -> Vec<Swatch> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn value_name_has_no_negative_zero() {
|
||||
assert_eq!(value_name(Color::rgb(-0.001, 0.5, 1.0)), "R=0 G=128 B=255");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_start_with_specials() {
|
||||
let s = default_swatches();
|
||||
@@ -181,6 +182,27 @@ mod tests {
|
||||
assert!(s.iter().any(|s| s.name == "C=100 M=0 Y=0 K=0"));
|
||||
}
|
||||
|
||||
/// The Swatches panel of a new InDesign document (Zero2Graphic's page layout tutorial, 3:22).
|
||||
#[test]
|
||||
fn defaults_are_indesigns_ten() {
|
||||
let names: Vec<String> = default_swatches().into_iter().map(|s| s.name).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
"[None]",
|
||||
"[Registration]",
|
||||
"[Paper]",
|
||||
"[Black]",
|
||||
"C=100 M=0 Y=0 K=0",
|
||||
"C=0 M=100 Y=0 K=0",
|
||||
"C=0 M=0 Y=100 K=0",
|
||||
"C=15 M=100 Y=100 K=0",
|
||||
"C=75 M=5 Y=100 K=0",
|
||||
"C=100 M=90 Y=10 K=0",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_tints_and_specials() {
|
||||
let mut s = default_swatches();
|
||||
|
||||
@@ -21,7 +21,7 @@ log = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
fancy-regex = { workspace = true }
|
||||
miniz_oxide = "0.8"
|
||||
hypher = { version = "0.1.8", default-features = false, features = ["alloc", "greek", "portuguese", "spanish", "norwegian"] }
|
||||
hypher = { version = "0.1.8", default-features = false, features = ["alloc", "dutch", "french", "german", "greek", "italian", "portuguese", "spanish", "norwegian"] }
|
||||
|
||||
[dev-dependencies]
|
||||
designcraft-fonts = { workspace = true, features = ["testing"] }
|
||||
|
||||
@@ -972,7 +972,15 @@ pub fn greedy(
|
||||
x += adv;
|
||||
shrink += sp.box_elastic(g).1.iter().sum::<f64>();
|
||||
if i + 1 < n && !glyphs[i + 1].is_space() && !g.no_break && g.break_after != Some(false) {
|
||||
if matches!(g.ch, '-' | '\u{2010}' | '\u{2013}' | '\u{2014}' | '/')
|
||||
if g.ch == SOFT_HYPHEN {
|
||||
if may_hyphenate(hyphens) {
|
||||
let hw = hyphen_width(g, &mut hy_cache);
|
||||
let hy = (hw - hyphen_hang(g, hw, sp)).max(0.0);
|
||||
if x + hy <= w + shrink + FIT_EPS {
|
||||
last_ok = Some((i + 1, true));
|
||||
}
|
||||
}
|
||||
} else if matches!(g.ch, '-' | '\u{2010}' | '\u{2013}' | '\u{2014}' | '/')
|
||||
|| g.break_after.unwrap_or_else(|| cjk_break(g, &glyphs[i + 1], sp))
|
||||
{
|
||||
last_ok = Some((i + 1, false));
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! story's composition places it. Entries hold clones of those `Arc`s, so the pointers
|
||||
//! can't be reused by other allocations while the entry lives.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use designcraft_doc::{Document, Item, Story, StoryId, Styles};
|
||||
@@ -39,6 +39,12 @@ impl From<Option<usize>> for ShownOn {
|
||||
/// (`true`) or not.
|
||||
type Key = (StoryId, ShownOn, bool);
|
||||
|
||||
/// The hosts and wrap neighbours already resolved while answering one [`Cache::get_on`], by
|
||||
/// story, whether other stories wrap it, and depth. A story reached from many others (frames of
|
||||
/// many stories anchored in it, at several levels) is resolved once instead of once per path to
|
||||
/// it.
|
||||
type Resolved = HashMap<(StoryId, bool, usize), Arc<ComposedStory>>;
|
||||
|
||||
struct Entry {
|
||||
sig: Vec<usize>,
|
||||
_keep: (Arc<Story>, Arc<Styles>, Vec<Arc<Item>>, Vec<Arc<Story>>),
|
||||
@@ -58,6 +64,14 @@ pub struct Cache {
|
||||
previous: Mutex<HashMap<StoryId, (Vec<usize>, Arc<ComposedStory>)>>,
|
||||
}
|
||||
|
||||
/// A signature's hash, standing in for it in another story's signature.
|
||||
fn hash_of(sig: &[usize]) -> usize {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
sig.hash(&mut h);
|
||||
h.finish() as usize
|
||||
}
|
||||
|
||||
/// Identity of everything a running header can depend on (stories, spreads, styles, variables).
|
||||
fn doc_signature(doc: &Document) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
@@ -100,29 +114,44 @@ impl Cache {
|
||||
|
||||
/// [`Cache::get`] for any [`ShownOn`].
|
||||
pub fn get_on(&self, doc: &Document, sid: StoryId, on: ShownOn) -> Arc<ComposedStory> {
|
||||
self.get_with(doc, sid, on, true, 0)
|
||||
self.get_with(doc, sid, on, true, 0, &mut Resolved::new())
|
||||
}
|
||||
|
||||
/// [`Cache::get_with`] for a host or a wrap neighbour (shown in its own frames), once per
|
||||
/// traversal.
|
||||
fn resolve(&self, doc: &Document, sid: StoryId, cross: bool, depth: usize, resolved: &mut Resolved) -> Arc<ComposedStory> {
|
||||
if let Some(c) = resolved.get(&(sid, cross, depth)) {
|
||||
return c.clone();
|
||||
}
|
||||
let c = self.get_with(doc, sid, ShownOn::Own, cross, depth, resolved);
|
||||
resolved.insert((sid, cross, depth), c.clone());
|
||||
c
|
||||
}
|
||||
|
||||
/// [`Cache::get_on`], wrapped by other stories' anchored objects only when `cross` is set, for
|
||||
/// a story `depth` hosts down from the story asked for.
|
||||
fn get_with(&self, doc: &Document, sid: StoryId, on: ShownOn, cross: bool, depth: usize) -> Arc<ComposedStory> {
|
||||
fn get_with(&self, doc: &Document, sid: StoryId, on: ShownOn, cross: bool, depth: usize, resolved: &mut Resolved) -> Arc<ComposedStory> {
|
||||
let Some(story) = doc.stories.get(&sid) else { return Arc::new(ComposedStory { story: sid, ..Default::default() }) };
|
||||
let (mut sig, mut keep_items) = signature(doc, story);
|
||||
let neighbours = if cross { crate::wrap::neighbours(doc, sid) } else { Vec::new() };
|
||||
let mut keep_stories = Vec::new();
|
||||
// A neighbour's signature goes in as its hash, and its items only once each: stories on
|
||||
// one spread share its wrapping items, so copying every neighbour's whole signature and
|
||||
// items made each entry as large as all of theirs together.
|
||||
let mut kept: HashSet<*const Item> = keep_items.iter().map(Arc::as_ptr).collect();
|
||||
for n in &neighbours {
|
||||
if let Some(ns) = doc.stories.get(n) {
|
||||
let (s, k) = signature(doc, ns);
|
||||
sig.push(usize::MAX - 1);
|
||||
sig.extend(s);
|
||||
keep_items.extend(k);
|
||||
sig.push(hash_of(&s));
|
||||
keep_items.extend(k.into_iter().filter(|it| kept.insert(Arc::as_ptr(it))));
|
||||
keep_stories.push(ns.clone());
|
||||
}
|
||||
}
|
||||
// Anchored frames compose where their host story places them, so the host is composed
|
||||
// first and the placements are part of the signature.
|
||||
let placed = if depth < crate::placed::MAX_DEPTH {
|
||||
crate::placed::placements(doc, sid, &mut |host| self.get_with(doc, host, ShownOn::Own, true, depth + 1))
|
||||
crate::placed::placements(doc, sid, &mut |host| self.resolve(doc, host, true, depth + 1, resolved))
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
@@ -229,7 +258,7 @@ impl Cache {
|
||||
// Parent-page items are composed per page: markers show its name, variables need its index.
|
||||
let page = page.filter(|_| with_vars);
|
||||
let running = running.then(|| self.running_index(doc));
|
||||
let others: Vec<Arc<ComposedStory>> = neighbours.iter().map(|n| self.neighbour(doc, *n, depth)).collect();
|
||||
let others: Vec<Arc<ComposedStory>> = neighbours.iter().map(|n| self.neighbour(doc, *n, depth, resolved)).collect();
|
||||
let out = Arc::new(crate::wrap::compose_among(
|
||||
doc,
|
||||
sid,
|
||||
@@ -315,13 +344,13 @@ impl Cache {
|
||||
/// Story `sid` composed for the wrap of its anchored objects in other stories: without other
|
||||
/// stories' anchored objects (no cycles), and outside the cache when it needs the document-wide
|
||||
/// indexes (they compose every story).
|
||||
fn neighbour(&self, doc: &Document, sid: StoryId, depth: usize) -> Arc<ComposedStory> {
|
||||
fn neighbour(&self, doc: &Document, sid: StoryId, depth: usize, resolved: &mut Resolved) -> Arc<ComposedStory> {
|
||||
let indexed =
|
||||
doc.stories.get(&sid).is_some_and(|st| crate::xref::has_xrefs(st) || (crate::vars::has_vars(st) && crate::vars::has_running(doc, st)));
|
||||
if indexed {
|
||||
Arc::new(crate::wrap::compose_among(doc, sid, &ComposeOptions::default(), &[]))
|
||||
} else {
|
||||
self.get_with(doc, sid, ShownOn::Own, false, depth)
|
||||
self.resolve(doc, sid, false, depth, resolved)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -385,6 +385,16 @@ impl Grep {
|
||||
self.re.find_iter(text).take(limit).map(|m| m.map(|m| m.range()).map_err(run_err)).collect()
|
||||
}
|
||||
|
||||
/// The first match starting at or after byte `start`, with its replacement: `template`
|
||||
/// expanded as [`Self::replacements`] does.
|
||||
pub fn replacement_at(&self, text: &str, start: usize, template: &str) -> Result<Option<(Range<usize>, String)>, GrepError> {
|
||||
if start > text.len() || !text.is_char_boundary(start) {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(caps) = self.re.captures_from_pos(text, start).map_err(run_err)? else { return Ok(None) };
|
||||
Ok(caps.get(0).map(|m| (m.range(), fancy_regex::Expander::default().expansion(template, &caps))))
|
||||
}
|
||||
|
||||
/// Non-empty matches in `text`, at most `limit` of them: an empty match styles nothing, so it
|
||||
/// doesn't count toward a GREP style's cap.
|
||||
pub fn find_nonempty(&self, text: &str, limit: usize) -> Result<Vec<Range<usize>>, GrepError> {
|
||||
|
||||
@@ -3,9 +3,16 @@
|
||||
//! `hyph-es.tex` 5.0 from hyph-utf8, MIT; Portuguese is `hyph-pt.tex` 1.4 from hyph-utf8 (Pedro J.
|
||||
//! de Rezende, J. Joao Dias Almeida, Leonardo Araujo, Aline Benevides), BSD-3-Clause; Norwegian is
|
||||
//! `hyph-no.tex` from hyph-utf8 (Rune Kleveland, Ole Michael Selberg), permissive (see NOTICE);
|
||||
//! Greek is `hyph-el-monoton.tex` 5.0 from hyph-utf8 (Dimitrios Filippou), LPPL or MIT, taken
|
||||
//! Dutch is Piet Tutelaers's `hyph-nl.tex` 1.1 from hyph-utf8, MIT; Greek is `hyph-el-monoton.tex` 5.0 from hyph-utf8 (Dimitrios Filippou), LPPL or MIT, taken
|
||||
//! under MIT. Those are the **monotonic** patterns, which is what `hypher` maps `el` to, so
|
||||
//! polytonic text hyphenates by them as well.
|
||||
//! polytonic text hyphenates by them as well. French is `hyph-fr.tex` 2.13 from hyph-utf8 (Daniel
|
||||
//! Flipo, Bernard Gaulle, Arthur Reutenauer), MIT; Italian is `hyph-it.tex` 4.9 from hyph-utf8
|
||||
//! (Claudio Beccari), LPPL or MIT, taken under MIT. A word with an elision (`l'imprimerie`)
|
||||
//! hyphenates after its apostrophe.
|
||||
//! German uses `hyph-de-1996.tex` (2024-02-28, Deutschsprachige Trennmustermannschaft, MIT):
|
||||
//! despite the filename, these are the reformed 2006 spelling patterns. Only the two 2006 reform
|
||||
//! names and German locale codes without a reform year or with 2006 use this set. Traditional,
|
||||
//! 1901 and unknown German variants have no automatic hyphenation; no old-spelling set is bundled.
|
||||
//!
|
||||
//! English lookup order for each word:
|
||||
//! 1. the **dictionary** ([`Dictionary::en_us`]) — the public-domain Moby Hyphenator II word list
|
||||
@@ -84,6 +91,11 @@ impl Lang {
|
||||
}
|
||||
let tag = designcraft_doc::language_tag(language)?;
|
||||
let sub = designcraft_doc::language_subtag(tag);
|
||||
// language_tag deliberately drops spelling variants for shaping and quotes. Hyphenation
|
||||
// must retain that distinction: the 2006 patterns are wrong for traditional ck/st breaks.
|
||||
if sub == "de" && !german_2006_language(language) {
|
||||
return None;
|
||||
}
|
||||
if sub == "en" {
|
||||
return Some(Lang::English);
|
||||
}
|
||||
@@ -95,6 +107,15 @@ impl Lang {
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept explicit reform names and unqualified German locales (our modern default), but never
|
||||
/// silently substitute reform patterns for another spelling variant imported from IDML.
|
||||
fn german_2006_language(language: &str) -> bool {
|
||||
let language = language.trim();
|
||||
let normalized = language.strip_prefix("$ID/").unwrap_or(language).to_lowercase();
|
||||
let words: Vec<&str> = normalized.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect();
|
||||
matches!(words.as_slice(), ["german", "2006", "reform"] | ["german", "swiss", "2006", "reform"] | ["de"] | ["de", _] | ["de", _, "2006"])
|
||||
}
|
||||
|
||||
/// Where a word's break points came from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Source {
|
||||
@@ -119,8 +140,14 @@ fn core_breaks(core: &[char], lang: Lang) -> (Vec<usize>, Source) {
|
||||
return (vec![], Source::None);
|
||||
}
|
||||
if let Lang::Patterns(l) = lang {
|
||||
if core.contains(&'\'') {
|
||||
return (vec![], Source::None);
|
||||
if let Some(ap) = core.iter().rposition(|&c| c == '\'') {
|
||||
// An elision (French "l'", Italian "dell'"): the word after the apostrophe hyphenates,
|
||||
// with no break up to it. Elsewhere (German "Rock'n'Roll") an apostrophe word gets none.
|
||||
if !matches!(l, hypher::Lang::French | hypher::Lang::Italian) {
|
||||
return (vec![], Source::None);
|
||||
}
|
||||
let (pts, src) = if ap + 1 < n { core_breaks(&core[ap + 1..], lang) } else { (vec![], Source::None) };
|
||||
return (pts.into_iter().map(|p| p + ap + 1).collect(), src);
|
||||
}
|
||||
let s: String = core.iter().collect();
|
||||
let mut out = Vec::new();
|
||||
@@ -211,6 +238,7 @@ pub fn hyphen_points_in(word: &str, lim: &Limits, lang: Lang) -> Vec<usize> {
|
||||
}
|
||||
let min_before = lim.after_first.max(1);
|
||||
let min_after = lim.before_last.max(1);
|
||||
let elides = matches!(lang, Lang::Patterns(hypher::Lang::French | hypher::Lang::Italian));
|
||||
// Run bounds of each point's letter run (trimmed of apostrophes).
|
||||
let run_of = |p: usize| {
|
||||
let mut a = p;
|
||||
@@ -227,6 +255,10 @@ pub fn hyphen_points_in(word: &str, lim: &Limits, lang: Lang) -> Vec<usize> {
|
||||
while b > a && is_apostrophe(chars[b - 1]) {
|
||||
b -= 1;
|
||||
}
|
||||
// After an elision the word is the part after its last apostrophe: the limits count that.
|
||||
if elides && let Some(ap) = chars[a..b].iter().rposition(|&c| is_apostrophe(c)) {
|
||||
a += ap + 1;
|
||||
}
|
||||
(a, b)
|
||||
};
|
||||
pts.into_iter()
|
||||
@@ -287,6 +319,25 @@ fn heuristic_points(c: &[char]) -> Vec<usize> {
|
||||
return vec![];
|
||||
}
|
||||
let v: Vec<bool> = c.iter().map(|&x| is_vowel(x)).collect();
|
||||
// A trailing silent `ed`, `es` or `e` comes off the right part. Which one depends only on how
|
||||
// the word ends, so the right part's core is `c[i..n - silent]` (nothing comes off a right
|
||||
// part shorter than the ending). Prefix sums of vowels and UTF-8 lengths then answer "does the
|
||||
// core have a vowel" and "is it longer than one byte" without building each suffix, which was
|
||||
// quadratic in the word's length.
|
||||
let ends = |s: &str| c.ends_with(&s.chars().collect::<Vec<char>>());
|
||||
let silent = if ends("ed") || ends("es") {
|
||||
2
|
||||
} else if ends("e") {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let mut vowels = vec![0_usize; n + 1];
|
||||
let mut bytes = vec![0_usize; n + 1];
|
||||
for (k, ch) in c.iter().enumerate() {
|
||||
vowels[k + 1] = vowels[k] + usize::from(v[k]);
|
||||
bytes[k + 1] = bytes[k] + ch.len_utf8();
|
||||
}
|
||||
let mut out = vec![];
|
||||
for i in 1..n {
|
||||
let ok = if v[i - 1] && !v[i] {
|
||||
@@ -297,9 +348,8 @@ fn heuristic_points(c: &[char]) -> Vec<usize> {
|
||||
false
|
||||
};
|
||||
// Both parts need a sounded vowel: a trailing silent `e`, `ed` or `es` doesn't count.
|
||||
let right: String = c[i..].iter().collect();
|
||||
let core = right.strip_suffix("ed").or_else(|| right.strip_suffix("es")).or_else(|| right.strip_suffix('e')).unwrap_or(&right);
|
||||
if ok && core.chars().any(is_vowel) && core.len() > 1 && v[..i].iter().any(|x| *x) {
|
||||
let core_end = if n - i >= silent { n - silent } else { n };
|
||||
if ok && vowels[core_end] > vowels[i] && bytes[core_end] - bytes[i] > 1 && vowels[i] > 0 {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
@@ -379,6 +429,82 @@ mod tests {
|
||||
assert!(hyphen_points("typography", &lim).is_empty());
|
||||
}
|
||||
|
||||
/// Only French and Italian elide at an apostrophe; other languages leave such words unbroken.
|
||||
#[test]
|
||||
fn apostrophe_words_hyphenate_only_in_french_and_italian() {
|
||||
let de = Lang::for_language("German: 2006 Reform").unwrap();
|
||||
assert!(hyphen_points_in("Rock'n'Roll", &Limits::default(), de).is_empty());
|
||||
let fr = Lang::for_language("French").unwrap();
|
||||
assert!(!hyphen_points_in("l'information", &Limits::default(), fr).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn german_2006_uses_reformed_patterns() {
|
||||
let de = Lang::for_language("German: 2006 Reform").unwrap();
|
||||
assert_eq!(de, Lang::Patterns(hypher::Lang::German));
|
||||
for name in [
|
||||
"$ID/German: 2006 Reform",
|
||||
" german: 2006 reform ",
|
||||
"de",
|
||||
"de-DE",
|
||||
"de_AT",
|
||||
"de_CH",
|
||||
"de_DE_2006",
|
||||
"German: Swiss 2006 Reform",
|
||||
"$ID/German: Swiss 2006 Reform",
|
||||
"de_CH_2006",
|
||||
] {
|
||||
assert_eq!(Lang::for_language(name), Some(de), "{name}");
|
||||
}
|
||||
for (word, expected) in [
|
||||
("Silbentrennung", "Sil-ben-tren-nung"),
|
||||
("Zucker", "Zu-cker"),
|
||||
("Bäcker", "Bä-cker"),
|
||||
("Fenster", "Fens-ter"),
|
||||
("Straße", "Stra-ße"),
|
||||
("Fußgänger", "Fuß-gän-ger"),
|
||||
("Donaudampfschifffahrt", "Do-nau-dampf-schiff-fahrt"),
|
||||
("SILBENTRENNUNG", "SIL-BEN-TREN-NUNG"),
|
||||
] {
|
||||
let pts = hyphen_points_in(word, &Limits::default(), de);
|
||||
let actual: String = word.chars().enumerate().map(|(i, c)| if pts.contains(&i) { format!("-{c}") } else { c.to_string() }).collect();
|
||||
assert_eq!(actual, expected, "{word}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_2006_german_has_no_bundled_hyphenation() {
|
||||
for name in [
|
||||
"German",
|
||||
"German: Traditional",
|
||||
"$ID/German: Traditional",
|
||||
"German: Swiss",
|
||||
"German: 1901",
|
||||
"German: 1996 Reform",
|
||||
"German: Unknown",
|
||||
"German: Traditional 2006 Reform",
|
||||
"de_DE_1901",
|
||||
"de-CH-1901",
|
||||
"de_AT_1996",
|
||||
"de_DE_2099",
|
||||
] {
|
||||
assert_eq!(designcraft_doc::language_subtag(designcraft_doc::language_tag(name).unwrap()), "de", "{name}");
|
||||
assert_eq!(Lang::for_language(name), None, "{name}: do not use 2006 ck/st breaks for unsupported spelling variants");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn german_2006_respects_paragraph_limits() {
|
||||
let de = Lang::for_language("de_DE_2006").unwrap();
|
||||
let lim = Limits { after_first: 4, before_last: 5, ..Limits::default() };
|
||||
assert_eq!(hyphen_points_in("Silbentrennung", &lim, de), vec![6]);
|
||||
let lim = Limits { min_word: 15, ..Limits::default() };
|
||||
assert!(hyphen_points_in("Silbentrennung", &lim, de).is_empty());
|
||||
let lim = Limits { capitalized: false, ..Limits::default() };
|
||||
assert!(hyphen_points_in("Silbentrennung", &lim, de).is_empty());
|
||||
assert!(!hyphen_points_in("silbentrennung", &lim, de).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spanish_uses_the_bezos_patterns() {
|
||||
let es = Lang::for_language("Spanish").unwrap();
|
||||
@@ -393,7 +519,7 @@ mod tests {
|
||||
assert_eq!(h("desesperación"), "de-ses-pe-ra-ción");
|
||||
assert_eq!(h("teatro"), "tea-tro", "a vowel run stays whole");
|
||||
assert_eq!(h("calle"), "ca-lle", "ll stays whole");
|
||||
assert!(Lang::for_language("French").is_none(), "only languages built in");
|
||||
assert!(Lang::for_language("Swedish").is_none(), "only languages built in");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -435,9 +561,149 @@ mod tests {
|
||||
assert_eq!(h("beveger"), "be-ve-ger");
|
||||
}
|
||||
|
||||
/// The paragraph limits count the letters of the word after an elision, not the elided prefix.
|
||||
#[test]
|
||||
fn elision_limits_apply_to_the_word_after_it() {
|
||||
let fr = Lang::for_language("French").unwrap();
|
||||
let lim = Limits { min_word: 5, after_first: 3, before_last: 2, capitalized: true };
|
||||
assert_eq!(hyphen_points_in("l'imprimerie", &lim, fr), vec![7, 9]);
|
||||
let lim = Limits { min_word: 11, ..Limits::default() };
|
||||
assert!(hyphen_points_in("l'imprimerie", &lim, fr).is_empty());
|
||||
}
|
||||
|
||||
/// A word with an elision ("l'", "dell'") hyphenates as the word after it.
|
||||
fn marked(w: &str, l: Lang) -> String {
|
||||
let pts = hyphen_points_in(w, &Limits::default(), l);
|
||||
w.chars().enumerate().map(|(i, c)| if pts.contains(&i) { format!("-{c}") } else { c.to_string() }).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn french_uses_the_hyph_fr_patterns() {
|
||||
let fr = Lang::for_language("French").unwrap();
|
||||
for name in ["fr", "fr_FR", "French: Canadian", "French: Swiss"] {
|
||||
assert_eq!(Lang::for_language(name), Some(fr), "{name}");
|
||||
}
|
||||
assert_eq!(marked("hyphénation", fr), "hy-phé-na-tion");
|
||||
assert_eq!(marked("typographie", fr), "ty-po-gra-phie");
|
||||
assert_eq!(marked("Développement", fr), "Dé-ve-lop-pe-ment");
|
||||
assert_eq!(marked("anticonstitutionnellement", fr), "an-ti-cons-ti-tu-tion-nel-le-ment");
|
||||
assert_eq!(marked("paragraphe", fr), "pa-ra-graphe", "no break before a final mute syllable of two letters");
|
||||
assert_eq!(marked("l'imprimerie", fr), "l'im-pri-me-rie", "after an elision");
|
||||
assert_eq!(marked("l’imprimerie", fr), "l’im-pri-me-rie", "a typographer's apostrophe too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn italian_uses_the_hyph_it_patterns() {
|
||||
let it = Lang::for_language("Italian").unwrap();
|
||||
for name in ["it", "it_IT"] {
|
||||
assert_eq!(Lang::for_language(name), Some(it), "{name}");
|
||||
}
|
||||
assert_ne!(it, Lang::for_language("French").unwrap());
|
||||
assert_eq!(marked("sillabazione", it), "sil-la-ba-zio-ne");
|
||||
assert_eq!(marked("tipografia", it), "ti-po-gra-fia");
|
||||
assert_eq!(marked("Repubblica", it), "Re-pub-bli-ca");
|
||||
assert_eq!(marked("impaginazione", it), "im-pa-gi-na-zio-ne");
|
||||
assert_eq!(marked("dell'impaginazione", it), "dell'im-pa-gi-na-zio-ne", "after an elision");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dutch_uses_the_hyph_nl_patterns() {
|
||||
let nl = Lang::for_language("Dutch").unwrap();
|
||||
// InDesign writes the 2005 spelling as `nl_NL_2005`.
|
||||
assert_eq!(nl, Lang::for_language("nl_NL_2005").unwrap());
|
||||
assert_ne!(nl, Lang::for_language("Spanish").unwrap());
|
||||
let h = |w: &str| {
|
||||
let pts = hyphen_points_in(w, &Limits::default(), nl);
|
||||
w.chars().enumerate().map(|(i, c)| if pts.contains(&i) { format!("-{c}") } else { c.to_string() }).collect::<String>()
|
||||
};
|
||||
assert_eq!(h("uitspraak"), "uit-spraak");
|
||||
assert_eq!(h("kinderen"), "kin-de-ren");
|
||||
assert_eq!(h("leesvaardigheid"), "lees-vaar-dig-heid");
|
||||
assert_eq!(h("zelfvertrouwen"), "zelf-ver-trou-wen");
|
||||
assert_eq!(h("Ontwikkelen"), "Ont-wik-ke-len");
|
||||
// Long compounds break at their parts and at the syllables inside them.
|
||||
assert_eq!(h("arbeidsongeschiktheidsverzekering"), "ar-beids-on-ge-schikt-heids-ver-ze-ke-ring");
|
||||
assert_eq!(h("ziekenhuisopname"), "zie-ken-huis-op-na-me");
|
||||
assert_eq!(h("gemeenteraadsverkiezingen"), "ge-meen-te-raads-ver-kie-zin-gen");
|
||||
assert_eq!(h("kinderdagverblijf"), "kin-der-dag-ver-blijf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heuristic_fallback() {
|
||||
let c: Vec<char> = "happen".chars().collect();
|
||||
assert_eq!(heuristic_points(&c), vec![3]);
|
||||
}
|
||||
|
||||
/// The heuristic as it was written before the prefix sums, building each right part.
|
||||
fn heuristic_by_suffixes(c: &[char]) -> Vec<usize> {
|
||||
let n = c.len();
|
||||
if n < 4 || !c.iter().all(|c| c.is_alphabetic()) {
|
||||
return vec![];
|
||||
}
|
||||
let v: Vec<bool> = c.iter().map(|&x| is_vowel(x)).collect();
|
||||
let mut out = vec![];
|
||||
for i in 1..n {
|
||||
let ok = if v[i - 1] && !v[i] {
|
||||
(i + 1 < n && v[i + 1]) || (i + 2 < n && onset_pair(c[i], c[i + 1]) && v[i + 2])
|
||||
} else if !v[i - 1] && !v[i] {
|
||||
i >= 2 && v[i - 2] && i + 1 < n && v[i + 1] && !onset_pair(c[i - 1], c[i]) && c[i - 1] != c[i]
|
||||
|| (c[i - 1] == c[i] && i >= 2 && v[i - 2])
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let right: String = c[i..].iter().collect();
|
||||
let core = right.strip_suffix("ed").or_else(|| right.strip_suffix("es")).or_else(|| right.strip_suffix('e')).unwrap_or(&right);
|
||||
if ok && core.chars().any(is_vowel) && core.len() > 1 && v[..i].iter().any(|x| *x) {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heuristic_matches_building_each_suffix() {
|
||||
for w in [
|
||||
"happen",
|
||||
"typography",
|
||||
"zzzzed",
|
||||
"bakes",
|
||||
"baked",
|
||||
"flute",
|
||||
"strengths",
|
||||
"aeiou",
|
||||
"rhythms",
|
||||
"bookkeeper",
|
||||
"naïveté",
|
||||
"çabcde",
|
||||
"ABCDE",
|
||||
"plopped",
|
||||
"staples",
|
||||
"queued",
|
||||
"ed",
|
||||
"eded",
|
||||
"abed",
|
||||
"abes",
|
||||
"cafés",
|
||||
"aed",
|
||||
"xaxed",
|
||||
"xaxes",
|
||||
"xaxe",
|
||||
"ŝtupoe",
|
||||
"ŝtupoed",
|
||||
"ababababed",
|
||||
] {
|
||||
let c: Vec<char> = w.chars().collect();
|
||||
assert_eq!(heuristic_points(&c), heuristic_by_suffixes(&c), "{w}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A very long word (no dictionary or pattern entry) takes time linear in its length.
|
||||
#[test]
|
||||
fn heuristic_on_a_long_word_is_linear() {
|
||||
let c: Vec<char> = "taco".repeat(100_000).chars().collect();
|
||||
let t = std::time::Instant::now();
|
||||
let pts = heuristic_points(&c);
|
||||
assert!(!pts.is_empty());
|
||||
assert!(t.elapsed().as_secs() < 5, "{:?}", t.elapsed());
|
||||
}
|
||||
}
|
||||
+255
-57
@@ -268,6 +268,8 @@ pub struct Deco {
|
||||
pub path: Option<designcraft_geom::BezPath>,
|
||||
/// Shown on screen only (not printed or exported).
|
||||
pub nonprinting: bool,
|
||||
/// Set to overprint (paragraph shading or border); PDF export doesn't overprint yet.
|
||||
pub overprint: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -579,8 +581,10 @@ pub struct FrameSpec {
|
||||
pub page_name: Option<String>,
|
||||
/// Marker text of the section the frame's page is in (None on parent pages).
|
||||
pub section_marker: Option<String>,
|
||||
/// Absolute document page the frame is on (None on parent pages).
|
||||
/// Absolute document page the frame is on (None on parent pages and the pasteboard).
|
||||
pub page: Option<usize>,
|
||||
/// The frame is on a parent spread.
|
||||
pub parent: bool,
|
||||
/// Baseline grid in inner space: (first grid line y, increment).
|
||||
pub grid: Option<(f64, f64)>,
|
||||
/// The frame is on a left page (for towards/away-from-spine alignment).
|
||||
@@ -815,6 +819,7 @@ pub(crate) fn frame_specs_placed(doc: &Document, sid: StoryId, placed: &[(ItemId
|
||||
page_name,
|
||||
section_marker,
|
||||
page,
|
||||
parent: matches!(spread_ref, Some(designcraft_doc::SpreadRef::Parent(_))),
|
||||
grid,
|
||||
left_page,
|
||||
right_page,
|
||||
@@ -828,16 +833,31 @@ pub(crate) fn frame_specs_placed(doc: &Document, sid: StoryId, placed: &[(ItemId
|
||||
/// objects ([`wrap`]).
|
||||
/// Anchored frames are composed where their host story places them (unless `opts` places them).
|
||||
pub fn compose_story(doc: &Document, sid: StoryId, opts: &ComposeOptions) -> ComposedStory {
|
||||
compose_story_at(doc, sid, opts, 0)
|
||||
compose_story_at(doc, sid, opts, 0, &mut HashMap::new())
|
||||
}
|
||||
|
||||
/// [`compose_story`] for a story `depth` hosts down from the story asked for.
|
||||
fn compose_story_at(doc: &Document, sid: StoryId, opts: &ComposeOptions, depth: usize) -> ComposedStory {
|
||||
/// [`compose_story`] for a story `depth` hosts down from the story asked for. `hosts` holds the
|
||||
/// hosts already composed (by story and depth) while composing the story asked for, so a host
|
||||
/// that many anchored frames lead to is composed once.
|
||||
fn compose_story_at(
|
||||
doc: &Document,
|
||||
sid: StoryId,
|
||||
opts: &ComposeOptions,
|
||||
depth: usize,
|
||||
hosts: &mut HashMap<(StoryId, usize), Arc<ComposedStory>>,
|
||||
) -> ComposedStory {
|
||||
let others: Vec<ComposedStory> =
|
||||
wrap::neighbours(doc, sid).into_iter().map(|o| wrap::compose_among(doc, o, &ComposeOptions::default(), &[])).collect();
|
||||
let others: Vec<&ComposedStory> = others.iter().collect();
|
||||
if opts.placed.is_empty() && depth < placed::MAX_DEPTH {
|
||||
let placed = placed::placements(doc, sid, &mut |host| Arc::new(compose_story_at(doc, host, &ComposeOptions::default(), depth + 1)));
|
||||
let placed = placed::placements(doc, sid, &mut |host| {
|
||||
if let Some(c) = hosts.get(&(host, depth + 1)) {
|
||||
return c.clone();
|
||||
}
|
||||
let c = Arc::new(compose_story_at(doc, host, &ComposeOptions::default(), depth + 1, hosts));
|
||||
hosts.insert((host, depth + 1), c.clone());
|
||||
c
|
||||
});
|
||||
if !placed.is_empty() {
|
||||
return wrap::compose_among(doc, sid, &ComposeOptions { placed, ..opts.clone() }, &others);
|
||||
}
|
||||
@@ -860,7 +880,7 @@ struct SubstitutionInput<'a> {
|
||||
frame: Option<&'a FrameSpec>,
|
||||
opts: &'a ComposeOptions,
|
||||
var_story: bool,
|
||||
var_cache: &'a mut HashMap<Option<usize>, Arc<Vec<String>>>,
|
||||
var_cache: &'a mut HashMap<(Option<usize>, bool), Arc<Vec<String>>>,
|
||||
notes: &'a mut Notes,
|
||||
}
|
||||
|
||||
@@ -868,7 +888,9 @@ fn paragraph_substitution(input: SubstitutionInput<'_>) -> SubstCtx {
|
||||
let SubstitutionInput { doc, story, range: prange, props: pp, frame: cur_frame, opts, var_story, var_cache, notes } = input;
|
||||
let var_values = if var_story {
|
||||
let page = opts.page.or_else(|| cur_frame.and_then(|f| f.page));
|
||||
var_cache.entry(page).or_insert_with(|| std::sync::Arc::new(vars::values(doc, page, opts.running.as_deref()))).clone()
|
||||
// A parent page itself (not composed for one of the pages it applies to).
|
||||
let parent = page.is_none() && cur_frame.is_some_and(|f| f.parent);
|
||||
var_cache.entry((page, parent)).or_insert_with(|| std::sync::Arc::new(vars::values_shown(doc, page, parent, opts.running.as_deref()))).clone()
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
@@ -930,18 +952,25 @@ fn line_has_wrapping_exclusion(frame: &FrameSpec, range: Range<usize>, column: R
|
||||
})
|
||||
}
|
||||
|
||||
fn previous_visible_break(doc: &Document, story: &Story, ranges: &[Range<usize>], deleted: &[bool], pi: usize) -> Option<StartParagraph> {
|
||||
/// The visible break ending the last placed paragraph before `pi`, which sends `pi` on.
|
||||
pub fn previous_visible_break(doc: &Document, story: &Story, ranges: &[Range<usize>], deleted: &[bool], pi: usize) -> Option<StartParagraph> {
|
||||
(0..pi).rev().find(|&p| !deleted.get(p).copied().unwrap_or(false)).and_then(|p| {
|
||||
let range = ranges.get(p)?;
|
||||
let (index, character) = story.text.get(range.clone())?.char_indices().next_back()?;
|
||||
let start = break_start(character)?;
|
||||
let (_, chars) = doc.styles.resolve_para(story.paras.get(p)?);
|
||||
let props = doc.styles.resolve_char(&chars, story.char_format_at(range.start + index + character.len_utf8()));
|
||||
let gone = props.change == designcraft_doc::ChangeMark::Deleted || doc.conditions_hide(&props.conditions);
|
||||
(!gone).then_some(start)
|
||||
let (index, _) = story.text.get(range.clone())?.char_indices().next_back()?;
|
||||
visible_break(doc, story, p, range.start + index)
|
||||
})
|
||||
}
|
||||
|
||||
/// The start option of the break at byte `at` in paragraph `p`, unless deleted or hidden.
|
||||
pub fn visible_break(doc: &Document, story: &Story, p: usize, at: usize) -> Option<StartParagraph> {
|
||||
let character = story.text.get(at..)?.chars().next()?;
|
||||
let start = break_start(character)?;
|
||||
let (_, chars) = doc.styles.resolve_para(story.paras.get(p)?);
|
||||
let props = doc.styles.resolve_char(&chars, story.char_format_at(at + character.len_utf8()));
|
||||
let gone = props.change == designcraft_doc::ChangeMark::Deleted || doc.conditions_hide(&props.conditions);
|
||||
(!gone).then_some(start)
|
||||
}
|
||||
|
||||
fn named_list_labels(doc: &Document, story: &Story, ranges: &[Range<usize>], deleted: &[bool]) -> Vec<Option<String>> {
|
||||
let mut counters: std::collections::HashMap<String, ListCounter> = std::collections::HashMap::new();
|
||||
story
|
||||
@@ -1099,7 +1128,7 @@ fn place_table_paragraph(input: TableParagraphInput<'_>) -> TableParagraphResult
|
||||
}
|
||||
let at_top = cursor.last_baseline.is_none();
|
||||
let start_at = (cursor.fi, cursor.col);
|
||||
if !table::place_table(doc, table, anchor, paragraph, props, frames, columns, cursor, out, opts) {
|
||||
if !table::place_table(doc, table, anchor, paragraph, props, frames, columns, cursor, out, notes, opts) {
|
||||
if let Some(j) = trial_failed(trial, limits, *balance_runs < balance_budget) {
|
||||
*balance_runs += 1;
|
||||
rewind(j, snaps, note_snaps, notes, out, cursor, list_counter, force_col, line_cap, no_hyph);
|
||||
@@ -1803,6 +1832,8 @@ fn place_sub_objects(
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets one paragraph's lines, column after column and frame after frame. It stops when the
|
||||
/// paragraph ends, when the frames run out, or when an earlier paragraph has to be set again.
|
||||
fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLines<'_>) -> LinePlacement {
|
||||
let PlacementShared {
|
||||
doc,
|
||||
@@ -1835,7 +1866,7 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi
|
||||
let ParagraphLines {
|
||||
paragraph: pi,
|
||||
range: prange,
|
||||
props: pp,
|
||||
props: para_props,
|
||||
glyphs,
|
||||
hyph_after,
|
||||
drop_cap,
|
||||
@@ -1864,7 +1895,10 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi
|
||||
let mut hyphen_at: Option<usize> = None;
|
||||
let has_tabs = glyphs.iter().any(|g| g.ch == '\t');
|
||||
let mut first_line_rect: Option<(usize, f64, f64, f64)> = None; // frame, baseline, ascent, x-span
|
||||
let rtl = pp.direction == designcraft_doc::TextDirection::RightToLeft;
|
||||
let rtl = para_props.direction == designcraft_doc::TextDirection::RightToLeft;
|
||||
// The paragraph's settings with the start-side indent an Indent to Here character set for
|
||||
// the lines after it (InDesign's ^i): those lines start where the character sits.
|
||||
let mut indented: Option<designcraft_doc::ParaProps> = None;
|
||||
// Paragraph gyoudori: the (frame, column) of the block and the grid line after it.
|
||||
let mut para_block: Option<((usize, usize), f64)> = None;
|
||||
// Segments of a line already placed (beside an object with text on both sides) that the
|
||||
@@ -1898,6 +1932,10 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi
|
||||
no_hyph,
|
||||
});
|
||||
}
|
||||
let pp = indented.as_ref().unwrap_or(para_props);
|
||||
// The Indent to Here character sits past the drop cap, so the lines that start at it
|
||||
// aren't indented by the drop cap again.
|
||||
let beside_cap = drop_cap.filter(|_| indented.is_none());
|
||||
let f = &frames[cur.fi];
|
||||
let base = cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)];
|
||||
let col = cur.clip(base);
|
||||
@@ -1923,7 +1961,7 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi
|
||||
props: pp,
|
||||
slots: &slots,
|
||||
column: col,
|
||||
drop_cap,
|
||||
drop_cap: beside_cap,
|
||||
line_no,
|
||||
col_first_line,
|
||||
rtl,
|
||||
@@ -1931,6 +1969,8 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi
|
||||
spacing,
|
||||
});
|
||||
let mut moved = false;
|
||||
// Set by a line with an Indent to Here character, for the lines broken again after it.
|
||||
let mut reindented = None;
|
||||
for (k, b) in breaks.iter().enumerate() {
|
||||
let (s, e) = (g0 + b.start, g0 + b.end);
|
||||
let line_glyphs = &glyphs[s..e.max(s)];
|
||||
@@ -2046,7 +2086,7 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi
|
||||
let col_w = notes.area(&cols[cur.fi], cur.col).map_or(0.0, |r| r.width());
|
||||
let note_column = NoteColumn { frame_index: cur.fi, column_index: cur.col, width: col_w, frame: f };
|
||||
// A drop cap sits at the start-side indent; the first-line indent doesn't move it.
|
||||
let beside = drop_cap.filter(|dc| line_no < dc.lines && col_first_line == 0).map(|dc| dc.indent());
|
||||
let beside = beside_cap.filter(|dc| line_no < dc.lines && col_first_line == 0).map(|dc| dc.indent());
|
||||
let first_line = if line_no == 0 { pp.first_line_indent } else { 0.0 };
|
||||
let (ind_l, ind_r) = match beside {
|
||||
Some(w) if rtl => (pp.left_indent, pp.right_indent + w),
|
||||
@@ -2098,6 +2138,7 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi
|
||||
}
|
||||
ruby::annotate(db, styles_tab, &story.text, &mut placed, doc.settings.glyph_fallback, f.vertical);
|
||||
kenten::annotate(db, styles_tab, &mut placed, doc.settings.glyph_fallback);
|
||||
let here_props = indent_here(&placed, pp, x0, x1, rtl);
|
||||
let range_end = if last { prange.end } else { glyphs.get(g0 + b.next).map(|g| g.byte).unwrap_or(prange.end) };
|
||||
let range_start = glyphs.get(s).map(|g| g.byte).unwrap_or(prange.start).min(range_end);
|
||||
let range_start = if line_no == 0 { prange.start } else { range_start };
|
||||
@@ -2156,6 +2197,10 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi
|
||||
hyphen_at =
|
||||
e.checked_sub(1).filter(|&i| b.hyphen && hyph_after.get(i) == Some(&true)).and_then(|i| glyphs.get(i)).map(|g| g.byte + g.len);
|
||||
line_no += 1;
|
||||
// An Indent to Here character in this line moves the start of the lines after it:
|
||||
// break them again with that indent (it holds into the next columns too).
|
||||
let reindent = here_props.is_some();
|
||||
reindented = here_props.or(reindented);
|
||||
// A column / frame / page break inside the paragraph: the rest of it continues in the
|
||||
// new column/frame/page, re-broken there. (A break that ends the paragraph moves the
|
||||
// next paragraph instead.)
|
||||
@@ -2169,12 +2214,13 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi
|
||||
}
|
||||
// An own-story anchored object anchored in this line wraps the lines after it,
|
||||
// which were broken without it: break them again.
|
||||
if k + 1 < breaks.len() && line_has_wrapping_exclusion(f, range_start..range_end, col, baseline) {
|
||||
if k + 1 < breaks.len() && (reindent || line_has_wrapping_exclusion(f, range_start..range_end, col, baseline)) {
|
||||
g0 += b.next;
|
||||
moved = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
indented = reindented.or(indented);
|
||||
if !moved {
|
||||
break;
|
||||
}
|
||||
@@ -2562,7 +2608,12 @@ fn finish_paragraph(input: ParagraphFinishInput<'_>) -> ParagraphFinish {
|
||||
.map(|l| if r.column_width { (l.x0, l.x1) } else { (l.x0, l.end_x) })
|
||||
.unwrap_or((0.0, 0.0));
|
||||
let (color, tint) = rule_color(r, above);
|
||||
ft.decos.push(Deco { rect: Rect::new(col.0 + r.left_indent, y - r.weight, col.1 - r.right_indent, y), color, tint, ..Deco::default() });
|
||||
// Keep In Frame: not above the frame, as InDesign's (its top, not the text area's
|
||||
// below the top inset).
|
||||
let top = frames.get(fi).map_or(f64::NEG_INFINITY, |f| f.area.y0 - if f.vertical { 0.0 } else { f.opts.inset[0] });
|
||||
let y = if r.keep_in_frame && y - r.weight < top { top + r.weight } else { y };
|
||||
let rect = Rect::new(col.0 + r.left_indent, y - r.weight, col.1 - r.right_indent, y);
|
||||
ft.decos.extend(rule_decos(doc, r, rect, (color, tint), above));
|
||||
}
|
||||
}
|
||||
if pp.rule_below.on
|
||||
@@ -2573,7 +2624,7 @@ fn finish_paragraph(input: ParagraphFinishInput<'_>) -> ParagraphFinish {
|
||||
let y = l.baseline + r.offset;
|
||||
let (x0, x1) = if r.column_width { (l.x0, l.x1) } else { (l.x0, l.end_x) };
|
||||
let (color, tint) = rule_color(r, below);
|
||||
ft.decos.push(Deco { rect: Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), color, tint, ..Deco::default() });
|
||||
ft.decos.extend(rule_decos(doc, r, Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), (color, tint), below));
|
||||
}
|
||||
// The end of a split block: text after it continues below its deepest sub-column, which
|
||||
// are balanced first. A block that ends the story fills its sub-columns in turn.
|
||||
@@ -2667,7 +2718,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
|
||||
let var_story = vars::has_vars(story);
|
||||
let mut notes = Notes::new(doc, story);
|
||||
let mut note_snaps: Vec<(u32, Option<usize>, usize)> = Vec::with_capacity(np);
|
||||
let mut var_cache: std::collections::HashMap<Option<usize>, std::sync::Arc<Vec<String>>> = Default::default();
|
||||
let mut var_cache: std::collections::HashMap<(Option<usize>, bool), std::sync::Arc<Vec<String>>> = Default::default();
|
||||
// Column balancing above spanning paragraphs: settled and trial bottoms by band, the search
|
||||
// in progress, and a budget for the re-lays.
|
||||
let mut limits: Rc<Vec<(BandKey, f64)>> = Rc::default();
|
||||
@@ -3108,7 +3159,7 @@ fn finish_composition(input: FinishInput<'_>) -> ComposedStory {
|
||||
let before: Vec<f64> = ft.tables.iter().map(|t| ft.lines.get(t.line).map_or(0.0, |l| l.baseline)).collect();
|
||||
let spanned = ft.columns.len() > 1 && ft.lines.iter().any(|l| span_paras.get(l.para) == Some(&true))
|
||||
|| ft.lines.iter().any(|l| split_paras.get(l.para) == Some(&true));
|
||||
vertical_justify(ft, f, spanned);
|
||||
vertical_justify(ft, f, spanned, &doc.footnote_options);
|
||||
for (t, b) in ft.tables.iter_mut().zip(before) {
|
||||
let now = ft.lines.get(t.line).map_or(b, |l| l.baseline);
|
||||
t.shift(now - b);
|
||||
@@ -3289,15 +3340,22 @@ pub fn column_rule_rects(columns: &[Rect], lines: &[Line], opts: &TextFrameOptio
|
||||
continue;
|
||||
}
|
||||
// Vertical bands of the paragraphs whose lines cross this gutter.
|
||||
// Grouped by paragraph through an index, in first-seen order: searching the bands for
|
||||
// each line was quadratic in the number of paragraphs.
|
||||
let mut spans: Vec<(usize, f64, f64)> = Vec::new();
|
||||
let mut band_of: HashMap<usize, usize> = HashMap::new();
|
||||
for l in lines.iter().filter(|l| l.x0 < cx && l.x1 > cx) {
|
||||
let (t, z) = (l.baseline - l.ascent, l.baseline + l.descent);
|
||||
match spans.iter_mut().find(|s| s.0 == l.para) {
|
||||
Some(s) => {
|
||||
match band_of.get(&l.para) {
|
||||
Some(&k) => {
|
||||
let s = &mut spans[k];
|
||||
s.1 = s.1.min(t);
|
||||
s.2 = s.2.max(z);
|
||||
}
|
||||
None => spans.push((l.para, t, z)),
|
||||
None => {
|
||||
band_of.insert(l.para, spans.len());
|
||||
spans.push((l.para, t, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
spans.sort_by(|p, q| p.1.total_cmp(&q.1));
|
||||
@@ -3483,8 +3541,8 @@ fn rewind(
|
||||
}
|
||||
|
||||
/// Paragraphs that tracked changes delete: all their text is deleted, and so is their return or
|
||||
/// the return before them (then they join a paragraph and add nothing to it).
|
||||
fn deleted_paras(story: &Story, ranges: &[Range<usize>]) -> Vec<bool> {
|
||||
/// the return before them (then they join a paragraph and add nothing to it). Composing skips them.
|
||||
pub fn deleted_paras(story: &Story, ranges: &[Range<usize>]) -> Vec<bool> {
|
||||
let mut spans: Vec<Range<usize>> = Vec::new();
|
||||
for (r, f) in story.runs() {
|
||||
if f.over.change != Some(designcraft_doc::ChangeMark::Deleted) {
|
||||
@@ -4930,6 +4988,34 @@ fn distribute(line: &[Glyph], spaces: &[usize], extra: f64, sp: &Spacing, add: &
|
||||
}
|
||||
}
|
||||
|
||||
/// The paragraph settings for the lines after the `placed` line when it holds an Indent to Here
|
||||
/// character (the last one, if several): the start-side indent reaches the character, measured
|
||||
/// from the start edge of the line's segment `x0..x1`. Like the paragraph's own indents it then
|
||||
/// applies from each later line's segment, so beside a text wrap it starts at the wrap's edge.
|
||||
/// `None` when there is none or the indent is unchanged.
|
||||
fn indent_here(placed: &[PlacedGlyph], pp: &designcraft_doc::ParaProps, x0: f64, x1: f64, rtl: bool) -> Option<designcraft_doc::ParaProps> {
|
||||
// The placed glyphs are in the line's order (bidi only moves them): the drop cap's, then a
|
||||
// list label's, which is set beside the drop cap, then the text's. A character in the drop
|
||||
// cap or the label counts too, and one in the label comes after one in the drop cap.
|
||||
let x = placed.iter().rev().find(|g| g.rendered_char == story::INDENT_HERE)?.x;
|
||||
let indent = if rtl { x1 - x } else { x - x0 };
|
||||
if !indent.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let indent = indent.max(0.0);
|
||||
let current = if rtl { pp.right_indent } else { pp.left_indent };
|
||||
if (indent - current).abs() < 0.01 {
|
||||
return None;
|
||||
}
|
||||
let mut props = pp.clone();
|
||||
if rtl {
|
||||
props.right_indent = indent;
|
||||
} else {
|
||||
props.left_indent = indent;
|
||||
}
|
||||
Some(props)
|
||||
}
|
||||
|
||||
fn place(g: &Glyph, x: f64) -> PlacedGlyph {
|
||||
let visible = !(g.ch == '\t'
|
||||
|| g.ch == '\n'
|
||||
@@ -5240,6 +5326,40 @@ fn suppress_hyphens(hy: &mut [bool], glyphs: &[Glyph], at: &[usize]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Hyphenation points of `word`, remembered per set of limits: words repeat a lot. The points of
|
||||
/// a word not seen yet are worked out without holding the cache's lock, so a long word in one
|
||||
/// paragraph doesn't hold up hyphenation in others.
|
||||
fn cached_hyphen_points(lim: &hyphen::Limits, lang: hyphen::Lang, word: &str) -> Arc<[usize]> {
|
||||
type Words = HashMap<(hyphen::Lang, Box<str>), Arc<[usize]>>;
|
||||
static CACHE: Mutex<Vec<(LimitsKey, Words)>> = Mutex::new(Vec::new());
|
||||
let key = (lim.min_word, lim.after_first, lim.before_last, lim.capitalized);
|
||||
let word_key = (lang, Box::<str>::from(word));
|
||||
{
|
||||
let guard = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(pts) = guard.iter().find(|e| e.0 == key).and_then(|e| e.1.get(&word_key)) {
|
||||
return pts.clone();
|
||||
}
|
||||
}
|
||||
let pts: Arc<[usize]> = hyphen::hyphen_points_in(word, lim, lang).into();
|
||||
let mut guard = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let idx = match guard.iter().position(|e| e.0 == key) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
if guard.len() > 32 {
|
||||
guard.clear();
|
||||
}
|
||||
guard.push((key, HashMap::new()));
|
||||
guard.len() - 1
|
||||
}
|
||||
};
|
||||
let cache = &mut guard[idx].1;
|
||||
if cache.len() > 100_000 {
|
||||
cache.clear();
|
||||
}
|
||||
cache.insert(word_key, pts.clone());
|
||||
pts
|
||||
}
|
||||
|
||||
fn hyphenation_points(
|
||||
text: &str,
|
||||
glyphs: &[Glyph],
|
||||
@@ -5257,24 +5377,6 @@ fn hyphenation_points(
|
||||
before_last: pp.hyph_before_last as usize,
|
||||
capitalized: pp.hyph_capitalized,
|
||||
};
|
||||
// Words repeat a lot: remember their points per set of limits.
|
||||
static CACHE: Mutex<Vec<(LimitsKey, HashMap<(hyphen::Lang, Box<str>), Box<[usize]>>)>> = Mutex::new(Vec::new());
|
||||
let key = (lim.min_word, lim.after_first, lim.before_last, lim.capitalized);
|
||||
let mut guard = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let idx = match guard.iter().position(|e| e.0 == key) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
if guard.len() > 32 {
|
||||
guard.clear();
|
||||
}
|
||||
guard.push((key, HashMap::new()));
|
||||
guard.len() - 1
|
||||
}
|
||||
};
|
||||
let cache = &mut guard[idx].1;
|
||||
if cache.len() > 100_000 {
|
||||
cache.clear();
|
||||
}
|
||||
let mut i = 0;
|
||||
while i < glyphs.len() {
|
||||
if !glyphs[i].is_letter() || glyphs[i].len == 0 {
|
||||
@@ -5300,16 +5402,29 @@ fn hyphenation_points(
|
||||
let lang = foreign.iter().find(|(r, _)| r.contains(&a)).map_or(Some(hyphen::Lang::English), |(_, l)| *l);
|
||||
if !is_last_word && let Some(lang) = lang {
|
||||
let user = (!exceptions.is_empty()).then(|| exceptions.get(&word.to_lowercase())).flatten();
|
||||
let cached;
|
||||
let pts: &[usize] = match user {
|
||||
// User dictionary exceptions win over the patterns and the built-in list.
|
||||
Some(v) => v,
|
||||
None => cache.entry((lang, word.into())).or_insert_with(|| hyphen::hyphen_points_in(word, &lim, lang).into_boxed_slice()),
|
||||
None => {
|
||||
cached = cached_hyphen_points(&lim, lang, word);
|
||||
&cached
|
||||
}
|
||||
};
|
||||
for &p in pts.iter() {
|
||||
// char index p → byte → glyph whose cluster ends at that byte.
|
||||
let byte = a + word.char_indices().nth(p).map(|(bi, _)| bi).unwrap_or(word.len());
|
||||
if let Some(k) = (i..j).find(|&k| glyphs[k].byte + glyphs[k].len == byte && glyphs[k].len > 0) {
|
||||
out[k] = true;
|
||||
if !pts.is_empty() {
|
||||
// char index p → byte → the first glyph whose cluster ends at that byte. Both
|
||||
// indexed once per word: looking each point up by scanning was quadratic in
|
||||
// a long word's length.
|
||||
let char_bytes: Vec<usize> = word.char_indices().map(|(bi, _)| bi).collect();
|
||||
let mut glyph_ending_at: HashMap<usize, usize> = HashMap::new();
|
||||
for k in (i..j).filter(|&k| glyphs[k].len > 0) {
|
||||
glyph_ending_at.entry(glyphs[k].byte + glyphs[k].len).or_insert(k);
|
||||
}
|
||||
for &p in pts.iter() {
|
||||
let byte = a + char_bytes.get(p).copied().unwrap_or(word.len());
|
||||
if let Some(&k) = glyph_ending_at.get(&byte) {
|
||||
out[k] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5319,8 +5434,57 @@ fn hyphenation_points(
|
||||
out
|
||||
}
|
||||
|
||||
/// A paragraph rule drawn in `rect` (its length and weight) with its Type: the gap colour under
|
||||
/// it, then the line, as dashes, dots, stripes, waves or hash marks. A pattern that can't be laid
|
||||
/// out along the rule draws solid.
|
||||
/// `(color, tint)` is the rule's (Text Color already resolved); `text`, the colour of the text
|
||||
/// the rule goes with, for a gap in Text Color.
|
||||
fn rule_decos(doc: &Document, r: &designcraft_doc::Rule, rect: Rect, (color, tint): (String, f32), text: &(String, f32)) -> Vec<Deco> {
|
||||
use designcraft_doc::StrokeType;
|
||||
let kind = doc.stroke_kind(&r.kind);
|
||||
let (w, len) = (rect.height(), rect.width());
|
||||
let mut out = Vec::new();
|
||||
if !matches!(kind, StrokeType::Solid) && r.gap_color != designcraft_color::swatch::NONE && w > 0.0 && len > 0.0 {
|
||||
let gap = if r.gap_color == designcraft_doc::TEXT_COLOR { text.0.clone() } else { r.gap_color.clone() };
|
||||
out.push(Deco { rect, color: gap, tint: r.gap_tint, ..Deco::default() });
|
||||
}
|
||||
let y = rect.center().y;
|
||||
let mut line = designcraft_geom::BezPath::new();
|
||||
line.move_to((rect.x0, y));
|
||||
line.line_to((rect.x1, y));
|
||||
let path = match &kind {
|
||||
StrokeType::Dashed { pattern } if StrokeType::expandable_dashes(pattern, len) => {
|
||||
let mut p = designcraft_geom::BezPath::new();
|
||||
let (mut x, mut k, mut on) = (rect.x0, 0, true);
|
||||
while x < rect.x1 {
|
||||
let step = pattern[k % pattern.len()];
|
||||
if on && step > 0.0 {
|
||||
let r = Rect::new(x, rect.y0, (x + step).min(rect.x1), rect.y1);
|
||||
p.extend(designcraft_geom::Shape::path_elements(&r, 0.1));
|
||||
}
|
||||
x += step;
|
||||
k += 1;
|
||||
on = !on;
|
||||
}
|
||||
Some(p)
|
||||
}
|
||||
StrokeType::Dotted if StrokeType::expandable_dashes(&[w, w], len) => {
|
||||
let mut p = designcraft_geom::BezPath::new();
|
||||
let mut x = rect.x0 + w / 2.0;
|
||||
while x <= rect.x1 - w / 2.0 + 1e-9 {
|
||||
p.extend(designcraft_geom::Shape::path_elements(&designcraft_geom::kurbo::Circle::new((x, y), w / 2.0), 0.1));
|
||||
x += 2.0 * w;
|
||||
}
|
||||
Some(p)
|
||||
}
|
||||
k => k.outline(&line, w, 0.05),
|
||||
};
|
||||
out.push(Deco { rect, color, tint, path, ..Deco::default() });
|
||||
out
|
||||
}
|
||||
|
||||
/// `spanned`: a paragraph spans the frame's columns, so the lines move together.
|
||||
fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool) {
|
||||
fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool, notes: &designcraft_doc::FootnoteOptions) {
|
||||
if ft.lines.is_empty() || f.opts.vertical_justification == VerticalJustification::Top {
|
||||
return;
|
||||
}
|
||||
@@ -5333,11 +5497,19 @@ fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool) {
|
||||
let idx: Vec<usize> = ft.lines.iter().enumerate().filter(|(_, l)| spanned || l.column == c).map(|(i, _)| i).collect();
|
||||
let (Some(&a), Some(_)) = (idx.first(), idx.last()) else { continue };
|
||||
let bottom = idx.iter().map(|&i| ft.lines[i].baseline + ft.lines[i].descent).fold(f64::NEG_INFINITY, f64::max);
|
||||
let space = f.area.y1 - bottom;
|
||||
let bottom = ft.tables.iter().filter(|t| spanned || t.column == c).map(|t| t.rect.y1).fold(bottom, f64::max);
|
||||
let limit = ft
|
||||
.notes
|
||||
.iter()
|
||||
.filter(|n| spanned || notes.span_columns || n.column == c)
|
||||
.map(|n| n.rect.y0 - notes.space_before)
|
||||
.fold(f.area.y1, f64::min);
|
||||
let space = limit - bottom;
|
||||
if space <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let _ = a;
|
||||
let before: Vec<f64> = idx.iter().map(|&i| ft.lines[i].baseline).collect();
|
||||
match f.opts.vertical_justification {
|
||||
VerticalJustification::Center => idx.iter().for_each(|&i| ft.lines[i].baseline += space / 2.0),
|
||||
VerticalJustification::Bottom => idx.iter().for_each(|&i| ft.lines[i].baseline += space),
|
||||
@@ -5356,14 +5528,39 @@ fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool) {
|
||||
.collect();
|
||||
let n = rows.last().copied().unwrap_or(0);
|
||||
if n > 0 {
|
||||
let per = space / n as f64;
|
||||
// Paragraph Spacing Limit: the space goes between paragraphs, up to the limit
|
||||
// each; what is left spreads over every line (all of it with no limit).
|
||||
let mut starts = vec![false; n + 1];
|
||||
for (&i, &k) in idx.iter().zip(&rows) {
|
||||
ft.lines[i].baseline += per * k as f64;
|
||||
if k > 0 && ft.lines[i].first_in_para {
|
||||
starts[k] = true;
|
||||
}
|
||||
}
|
||||
let gaps = starts.iter().filter(|s| **s).count();
|
||||
let limit = f.opts.vj_paragraph_spacing_limit.max(0.0);
|
||||
let para = if gaps > 0 && limit > 0.0 { (space / gaps as f64).min(limit) } else { 0.0 };
|
||||
let per = (space - para * gaps as f64) / n as f64;
|
||||
let mut shift = vec![0.0; n + 1];
|
||||
for k in 1..=n {
|
||||
shift[k] = shift[k - 1] + per + if starts[k] { para } else { 0.0 };
|
||||
}
|
||||
for (&i, &k) in idx.iter().zip(&rows) {
|
||||
ft.lines[i].baseline += shift[k];
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// A drop cap moves with its line.
|
||||
for (&i, b) in idx.iter().zip(before) {
|
||||
let line = &mut ft.lines[i];
|
||||
let dy = line.baseline - b;
|
||||
if let Some(dc) = &mut line.drop_cap {
|
||||
dc.baseline += dy;
|
||||
dc.rect.y0 += dy;
|
||||
dc.rect.y1 += dy;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5881,6 +6078,7 @@ mod column_allocation_safety_tests {
|
||||
page_name: None,
|
||||
section_marker: None,
|
||||
page: None,
|
||||
parent: false,
|
||||
grid: None,
|
||||
left_page: false,
|
||||
right_page: false,
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
//! With Span Footnotes Across Columns the notes of a frame share one area across all its columns,
|
||||
//! composed at the frame's text width: every column of the frame makes room for it.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -146,11 +146,13 @@ impl Notes {
|
||||
}
|
||||
|
||||
/// Footnote indices referenced by a line's glyphs.
|
||||
/// In the order they first appear; a set keeps a line with many references linear.
|
||||
pub fn refs_in(&self, glyphs: &[Glyph]) -> Vec<usize> {
|
||||
let mut v: Vec<usize> = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for g in glyphs.iter().filter(|g| g.ch == FOOTNOTE_REF && g.len > 0) {
|
||||
if let Ok(k) = self.refs.binary_search(&g.byte)
|
||||
&& !v.contains(&k)
|
||||
&& seen.insert(k)
|
||||
{
|
||||
v.push(k);
|
||||
}
|
||||
@@ -176,6 +178,7 @@ impl Notes {
|
||||
page_name: f.page_name.clone(),
|
||||
section_marker: f.section_marker.clone(),
|
||||
page: f.page,
|
||||
parent: f.parent,
|
||||
grid: None,
|
||||
left_page: f.left_page,
|
||||
right_page: f.right_page,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! through or up to a delimiter) and GREP styles (every match of a pattern).
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::ops::Range;
|
||||
|
||||
use designcraft_doc::{GrepStyle, NestedStyle, NestedUntil};
|
||||
@@ -47,7 +47,9 @@ fn with_pattern<R>(pattern: &str, f: impl FnOnce(&mut Option<Grep>) -> R) -> R {
|
||||
})
|
||||
}
|
||||
|
||||
fn is_event(u: &NestedUntil, c: char, prev: Option<char>, next: Option<char>) -> bool {
|
||||
/// Whether `c` ends one unit of `u`. `delims` is the character set of a [`NestedUntil::Chars`]
|
||||
/// delimiter, indexed once per nested style.
|
||||
fn is_event(u: &NestedUntil, delims: &HashSet<char>, c: char, prev: Option<char>, next: Option<char>) -> bool {
|
||||
match u {
|
||||
NestedUntil::Sentences => matches!(c, '.' | '!' | '?') && next.is_none_or(char::is_whitespace),
|
||||
NestedUntil::Words => c.is_whitespace() && prev.is_some_and(|p| !p.is_whitespace()),
|
||||
@@ -60,7 +62,7 @@ fn is_event(u: &NestedUntil, c: char, prev: Option<char>, next: Option<char>) ->
|
||||
NestedUntil::EnSpace => c == '\u{2002}',
|
||||
// Counted by position, not by character (see `overlays`).
|
||||
NestedUntil::Dropcap => false,
|
||||
NestedUntil::Chars(s) => s.contains(c),
|
||||
NestedUntil::Chars(_) => delims.contains(&c),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,18 +83,25 @@ pub(crate) fn overlays(text: &str, range: Range<usize>, nested: &[NestedStyle],
|
||||
// Through the drop cap runs to its end; up to it (it starts the paragraph) is nothing.
|
||||
stop = if ns.through { drop_end.max(pos).min(end) } else { pos };
|
||||
} else {
|
||||
// Streamed up to the delimiter, with one character of lookahead: copying the rest of
|
||||
// the paragraph for every nested style multiplied its length by the number of styles.
|
||||
let delims: HashSet<char> = match &ns.until {
|
||||
NestedUntil::Chars(s) => s.chars().collect(),
|
||||
_ => HashSet::new(),
|
||||
};
|
||||
let mut count = 0;
|
||||
let chars: Vec<(usize, char)> = text[pos..end].char_indices().map(|(i, c)| (pos + i, c)).collect();
|
||||
for (k, &(i, c)) in chars.iter().enumerate() {
|
||||
let prev = if k > 0 { Some(chars[k - 1].1) } else { None };
|
||||
let next = chars.get(k + 1).map(|x| x.1);
|
||||
if is_event(&ns.until, c, prev, next) {
|
||||
let mut prev = None;
|
||||
let mut chars = text[pos..end].char_indices().map(|(i, c)| (pos + i, c)).peekable();
|
||||
while let Some((i, c)) = chars.next() {
|
||||
let next = chars.peek().map(|x| x.1);
|
||||
if is_event(&ns.until, &delims, c, prev, next) {
|
||||
count += 1;
|
||||
if count >= ns.count.max(1) {
|
||||
stop = if ns.through { i + c.len_utf8() } else { i };
|
||||
break;
|
||||
}
|
||||
}
|
||||
prev = Some(c);
|
||||
}
|
||||
}
|
||||
if stop > pos && !ns.style.is_empty() {
|
||||
@@ -161,6 +170,19 @@ mod tests {
|
||||
assert!(has_last, "the most recently used pattern is still cached");
|
||||
}
|
||||
|
||||
/// Nested styles that stop where they start (up to a delimiter the paragraph starts with)
|
||||
/// read one character each, not the rest of the paragraph.
|
||||
#[test]
|
||||
fn nested_styles_stopping_at_once_do_not_rescan_the_paragraph() {
|
||||
let text = format!("x{}\n", "y".repeat(200_000));
|
||||
let delim: String = ('a'..='z').chain('A'..='Z').filter(|c| *c != 'y').collect();
|
||||
let rule = NestedStyle { style: "S".into(), through: false, count: 1, until: NestedUntil::Chars(delim) };
|
||||
let nested = vec![rule; 5000];
|
||||
let t = std::time::Instant::now();
|
||||
assert!(overlays(&text, 0..text.len(), &nested, &[], 0).is_empty(), "each style styles nothing");
|
||||
assert!(t.elapsed().as_secs() < 5, "{:?}", t.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_and_grep_ranges() {
|
||||
let text = "Q: what is 42? A drop of 7 words.\n".to_string();
|
||||
|
||||
+156
-12
@@ -32,6 +32,7 @@ struct Shading {
|
||||
bottom: BoxBottom,
|
||||
clip: bool,
|
||||
nonprinting: bool,
|
||||
overprint: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -51,6 +52,8 @@ struct Border {
|
||||
display_if_splits: bool,
|
||||
gap_color: String,
|
||||
gap_tint: f32,
|
||||
overprint: bool,
|
||||
gap_overprint: bool,
|
||||
}
|
||||
|
||||
fn finite(v: f64) -> f64 {
|
||||
@@ -72,6 +75,7 @@ impl ParaBox {
|
||||
bottom: pp.shading_bottom,
|
||||
clip: pp.shading_clip,
|
||||
nonprinting: pp.shading_nonprinting,
|
||||
overprint: pp.shading_overprint,
|
||||
});
|
||||
let border = pp.border_on.then(|| Border {
|
||||
color: pp.border_color.clone(),
|
||||
@@ -88,6 +92,8 @@ impl ParaBox {
|
||||
display_if_splits: pp.border_display_if_splits,
|
||||
gap_color: pp.border_gap_color.clone(),
|
||||
gap_tint: pp.border_gap_tint,
|
||||
overprint: pp.border_overprint,
|
||||
gap_overprint: pp.border_gap_overprint,
|
||||
});
|
||||
Some(ParaBox { shading, border, merge: pp.border_merge })
|
||||
}
|
||||
@@ -136,7 +142,7 @@ fn shading_deco(s: &Shading, part: &Part<'_>, frames: &[FrameSpec], first_part:
|
||||
// Corners stay square where the box is cut by a split.
|
||||
let corners = cut_corners(s.corners, !first_part, !last_part);
|
||||
let path = (!corners.iter().all(Corner::is_none)).then(|| box_outline(rect, &corners, [true; 4]));
|
||||
return Some(Deco { rect, color: s.color.clone(), tint: s.tint, path, nonprinting: s.nonprinting });
|
||||
return Some(Deco { rect, color: s.color.clone(), tint: s.tint, path, nonprinting: s.nonprinting, overprint: s.overprint });
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -149,15 +155,14 @@ fn border_decos(bd: &Border, part: &Part<'_>, first_part: bool, last_part: bool,
|
||||
let open_top = !first_part && !bd.display_if_splits;
|
||||
let open_bottom = !last_part && !bd.display_if_splits;
|
||||
for (path, gap) in border_paths(bd, o, open_top, open_bottom) {
|
||||
let (color, tint) = if gap { (bd.gap_color.clone(), bd.gap_tint) } else { (bd.color.clone(), bd.tint) };
|
||||
decos.push((part.fi, false, Deco { rect: path.bounding_box(), color, tint, path: Some(path), nonprinting: false }));
|
||||
let (color, tint, overprint) =
|
||||
if gap { (bd.gap_color.clone(), bd.gap_tint, bd.gap_overprint) } else { (bd.color.clone(), bd.tint, bd.overprint) };
|
||||
decos.push((part.fi, false, Deco { rect: path.bounding_box(), color, tint, path: Some(path), nonprinting: false, overprint }));
|
||||
}
|
||||
if is_plain(bd, open_top, open_bottom) {
|
||||
decos.extend(
|
||||
plain_edges(bd, o, open_top, open_bottom)
|
||||
.into_iter()
|
||||
.map(|rect| (part.fi, false, Deco { rect, color: bd.color.clone(), tint: bd.tint, path: None, nonprinting: false })),
|
||||
);
|
||||
decos.extend(plain_edges(bd, o, open_top, open_bottom).into_iter().map(|rect| {
|
||||
(part.fi, false, Deco { rect, color: bd.color.clone(), tint: bd.tint, path: None, nonprinting: false, overprint: bd.overprint })
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,10 +358,10 @@ fn stroke_outline(path: &BezPath, w: f64, kind: &StrokeType, cap: Cap, join: Joi
|
||||
let p: Vec<f64> = pattern.iter().copied().filter(|v| v.is_finite() && *v >= 0.0).collect();
|
||||
Some(if p.iter().sum::<f64>() > 1e-3 { p } else { vec![3.0 * w, 2.0 * w] })
|
||||
}
|
||||
StrokeType::Dotted => {
|
||||
st = st.with_caps(kurbo::Cap::Round);
|
||||
Some(vec![0.0, 2.0 * w])
|
||||
}
|
||||
StrokeType::Dotted => match dots(path, w) {
|
||||
Some(d) => return d,
|
||||
None => None,
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
if let Some(d) = dashes {
|
||||
@@ -367,3 +372,142 @@ fn stroke_outline(path: &BezPath, w: f64, kind: &StrokeType, cap: Cap, join: Joi
|
||||
}
|
||||
kurbo::stroke(path.iter(), &st, &kurbo::StrokeOpts::default(), TOL)
|
||||
}
|
||||
|
||||
/// Round dots `w` wide along `path`, about `2w` apart and evenly spaced by arc length: around a
|
||||
/// closed subpath the spacing divides its length exactly, an open one has a dot at each end.
|
||||
/// `None` (stroke solid) when there would be more than [`MAX_DASHES`] dots.
|
||||
fn dots(path: &BezPath, w: f64) -> Option<BezPath> {
|
||||
use kurbo::{ParamCurve, ParamCurveArclen, PathEl, PathSeg};
|
||||
// Subpaths as (segments, closed).
|
||||
let mut subs: Vec<(Vec<PathSeg>, bool)> = Vec::new();
|
||||
let (mut start, mut cur) = (kurbo::Point::ZERO, kurbo::Point::ZERO);
|
||||
for el in path.elements() {
|
||||
let seg = match *el {
|
||||
PathEl::MoveTo(p) => {
|
||||
subs.push((Vec::new(), false));
|
||||
(start, cur) = (p, p);
|
||||
continue;
|
||||
}
|
||||
PathEl::LineTo(p) => PathSeg::Line(kurbo::Line::new(cur, p)),
|
||||
PathEl::QuadTo(a, p) => PathSeg::Quad(kurbo::QuadBez::new(cur, a, p)),
|
||||
PathEl::CurveTo(a, b, p) => PathSeg::Cubic(kurbo::CubicBez::new(cur, a, b, p)),
|
||||
PathEl::ClosePath => {
|
||||
if let Some(s) = subs.last_mut() {
|
||||
s.1 = true;
|
||||
if cur.distance(start) > 1e-9 {
|
||||
s.0.push(PathSeg::Line(kurbo::Line::new(cur, start)));
|
||||
}
|
||||
}
|
||||
cur = start;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
cur = seg.end();
|
||||
if let Some(s) = subs.last_mut() {
|
||||
s.0.push(seg);
|
||||
}
|
||||
}
|
||||
const ACC: f64 = 1e-4;
|
||||
let mut out = BezPath::new();
|
||||
let mut count = 0.0;
|
||||
for (segs, closed) in subs {
|
||||
let lens: Vec<f64> = segs.iter().map(|s| s.arclen(ACC)).collect();
|
||||
let total: f64 = lens.iter().sum();
|
||||
if !(total.is_finite() && total > 1e-9) {
|
||||
continue;
|
||||
}
|
||||
let n = (total / (2.0 * w)).round().max(1.0);
|
||||
count += n + 1.0;
|
||||
if count > MAX_DASHES {
|
||||
return None;
|
||||
}
|
||||
let step = total / n;
|
||||
let dots = if closed { n as usize } else { n as usize + 1 };
|
||||
let (mut k, mut before) = (0, 0.0);
|
||||
for i in 0..dots {
|
||||
let s = i as f64 * step;
|
||||
while k + 1 < segs.len() && before + lens.get(k).copied().unwrap_or(0.0) < s {
|
||||
before += lens.get(k).copied().unwrap_or(0.0);
|
||||
k += 1;
|
||||
}
|
||||
let (Some(seg), Some(&l)) = (segs.get(k), lens.get(k)) else { continue };
|
||||
let t = if l > 1e-12 { seg.inv_arclen((s - before).clamp(0.0, l), ACC) } else { 0.0 };
|
||||
out.extend(kurbo::Circle::new(seg.eval(t), w / 2.0).path_elements(TOL));
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod dotted_tests {
|
||||
use super::*;
|
||||
|
||||
/// The centres of the dots (one subpath each) of a dotted outline.
|
||||
fn dot_centres(p: &BezPath) -> Vec<kurbo::Point> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur = BezPath::new();
|
||||
for el in p.elements() {
|
||||
if matches!(el, kurbo::PathEl::MoveTo(_)) && !cur.elements().is_empty() {
|
||||
out.push(cur.bounding_box().center());
|
||||
cur = BezPath::new();
|
||||
}
|
||||
cur.push(*el);
|
||||
}
|
||||
if !cur.elements().is_empty() {
|
||||
out.push(cur.bounding_box().center());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Distance along the outline of `r`, clockwise from its top left corner, of point `q` on it.
|
||||
fn along(r: Rect, q: kurbo::Point) -> f64 {
|
||||
let (w, h) = (r.width(), r.height());
|
||||
if (q.y - r.y0).abs() < 1e-6 {
|
||||
q.x - r.x0
|
||||
} else if (q.x - r.x1).abs() < 1e-6 {
|
||||
w + q.y - r.y0
|
||||
} else if (q.y - r.y1).abs() < 1e-6 {
|
||||
w + h + r.x1 - q.x
|
||||
} else {
|
||||
2.0 * w + h + r.y1 - q.y
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_even(gaps: &[f64], expected: f64) {
|
||||
for g in gaps {
|
||||
assert!((g - expected).abs() < 0.05, "uneven dot gaps {gaps:?}, expected {expected}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dotted_box_border_dots_are_evenly_spaced() {
|
||||
let r = Rect::new(0.0, 0.0, 100.3, 37.7);
|
||||
let path = box_outline(r, &[Corner::default(); 4], [true; 4]);
|
||||
let len = 2.0 * (r.width() + r.height());
|
||||
let out = stroke_outline(&path, 1.0, &StrokeType::Dotted, Cap::Butt, Join::Miter, len);
|
||||
let mut s: Vec<f64> = dot_centres(&out).into_iter().map(|q| along(r, q)).collect();
|
||||
s.sort_by(f64::total_cmp);
|
||||
let n = (len / 2.0).round() as usize;
|
||||
assert_eq!(s.len(), n, "dot count");
|
||||
let mut gaps: Vec<f64> = s.windows(2).map(|p| p[1] - p[0]).collect();
|
||||
gaps.push(s[0] + len - s[n - 1]);
|
||||
assert_even(&gaps, len / n as f64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dotted_edge_dots_are_evenly_spaced_and_reach_both_ends() {
|
||||
for (w, l) in [(1.0, 50.3), (2.5, 37.7), (0.7, 100.3)] {
|
||||
let mut p = BezPath::new();
|
||||
p.move_to((0.0, 0.0));
|
||||
p.line_to((l, 0.0));
|
||||
let out = stroke_outline(&p, w, &StrokeType::Dotted, Cap::Butt, Join::Miter, l);
|
||||
let mut xs: Vec<f64> = dot_centres(&out).into_iter().map(|q| q.x).collect();
|
||||
xs.sort_by(f64::total_cmp);
|
||||
let n = (l / (2.0 * w)).round() as usize;
|
||||
assert_eq!(xs.len(), n + 1, "dot count for w {w}, length {l}");
|
||||
assert!(xs[0].abs() < 0.05 && (xs[n] - l).abs() < 0.05, "ends {xs:?}");
|
||||
let gaps: Vec<f64> = xs.windows(2).map(|p| p[1] - p[0]).collect();
|
||||
assert_even(&gaps, l / n as f64);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
//! own story is composed against that placement (page, page side, page rect, baseline grid), so
|
||||
//! the host story is composed first.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use designcraft_doc::{Document, Item, ItemId, SpreadRef, StoryId};
|
||||
@@ -23,10 +24,12 @@ pub struct Placement {
|
||||
pub xf: Affine,
|
||||
}
|
||||
|
||||
/// The placements of `sid`'s anchored frames, from their host stories composed by `host`.
|
||||
/// The placements of `sid`'s anchored frames, from their host stories composed by `host`. Each
|
||||
/// host is asked for once, however many of the frames it holds.
|
||||
pub(crate) fn placements(doc: &Document, sid: StoryId, host: &mut dyn FnMut(StoryId) -> Arc<ComposedStory>) -> Vec<(ItemId, Placement)> {
|
||||
let Some(st) = doc.story(sid) else { return Vec::new() };
|
||||
let mut anchored = None;
|
||||
let mut hosts: HashMap<StoryId, Arc<ComposedStory>> = HashMap::new();
|
||||
let mut out = Vec::new();
|
||||
for &fid in &st.frames {
|
||||
if doc.find(fid).is_some() {
|
||||
@@ -35,7 +38,8 @@ pub(crate) fn placements(doc: &Document, sid: StoryId, host: &mut dyn FnMut(Stor
|
||||
let items = anchored.get_or_insert_with(|| doc.anchored_items());
|
||||
// A frame anchored in its own story would need its own composition to place it.
|
||||
let Some(owner) = items.iter().find_map(|(owner, it)| (it.id == fid).then_some(*owner)).filter(|o| *o != sid) else { continue };
|
||||
if let Some(p) = locate(doc, &host(owner), fid) {
|
||||
let composed = hosts.entry(owner).or_insert_with(|| host(owner));
|
||||
if let Some(p) = locate(doc, composed, fid) {
|
||||
out.push((fid, p));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,6 +385,7 @@ fn resolve_pieces(input: &ShapeInput<'_, '_>, table: &mut StyleTable<'_>) -> (Ve
|
||||
overlays.push((range.start..drop_cap.0, s.to_string()));
|
||||
}
|
||||
// Each run, cut where nested / GREP styles start and end.
|
||||
let bands = overlay_bands(&overlays);
|
||||
let mut segments: Vec<(usize, usize, Option<&str>, &designcraft_doc::CharFormat)> = Vec::new();
|
||||
for (rr, fmt) in story.runs() {
|
||||
let a = rr.start.max(range.start);
|
||||
@@ -396,14 +397,16 @@ fn resolve_pieces(input: &ShapeInput<'_, '_>, table: &mut StyleTable<'_>) -> (Ve
|
||||
segments.push((a, b, None, fmt));
|
||||
continue;
|
||||
}
|
||||
let mut cuts: Vec<usize> = overlays.iter().flat_map(|(r, _)| [r.start, r.end]).filter(|x| *x > a && *x < b).collect();
|
||||
cuts.push(a);
|
||||
cuts.push(b);
|
||||
cuts.sort_unstable();
|
||||
cuts.dedup();
|
||||
for w in cuts.windows(2) {
|
||||
let top = overlays.iter().rev().find(|(r, _)| r.start <= w[0] && w[0] < r.end).map(|(_, s)| s.as_str());
|
||||
segments.push((w[0], w[1], top, fmt));
|
||||
// The band holding `a`, then each following band up to `b`. Bands start at 0, so one
|
||||
// holds `a`, and each ends where the next starts (the last one at `b`).
|
||||
let mut k = bands.partition_point(|(at, _)| *at <= a).saturating_sub(1);
|
||||
let mut at = a;
|
||||
while at < b {
|
||||
let to = bands.get(k + 1).map_or(b, |(next, _)| (*next).min(b));
|
||||
let top = bands.get(k).and_then(|(_, top)| *top).map(|i| overlays[i].1.as_str());
|
||||
segments.push((at, to, top, fmt));
|
||||
at = to;
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
// Resolve every segment, then shape runs of segments that differ only in how they are
|
||||
@@ -1201,7 +1204,12 @@ fn shape_segment(
|
||||
for (gi, sg) in shaped.iter().enumerate() {
|
||||
let up = upright.get(gi).copied().unwrap_or(false);
|
||||
let (byte, len, ch) = if replacement.is_some() {
|
||||
(range.start, if gi == 0 { range.len() } else { 0 }, text[range.start..].chars().next().unwrap_or(' '))
|
||||
let marker = text[range.start..].chars().next().unwrap_or(' ');
|
||||
// A cross-reference's text is real text in InDesign: its spaces are word spaces the
|
||||
// line may break at (text variables and page numbers stay whole). The first glyph
|
||||
// keeps the marker, as it owns the marker's bytes.
|
||||
let space = gi > 0 && marker == designcraft_doc::XREF_MARK && src.get(sg.cluster..).is_some_and(|t| t.starts_with(' '));
|
||||
(range.start, if gi == 0 { range.len() } else { 0 }, if space { ' ' } else { marker })
|
||||
} else {
|
||||
let cl = range.start + sg.cluster;
|
||||
let end = shaped[gi + 1..].iter().map(|r| range.start + r.cluster).find(|&c| c > cl).unwrap_or(range.end);
|
||||
@@ -1421,3 +1429,69 @@ fn digit_mode(p: &designcraft_doc::CharProps, arabic_context: Option<bool>) -> d
|
||||
_ => Digits::Native,
|
||||
}
|
||||
}
|
||||
|
||||
/// The stretches between `overlays`' boundaries, as `(start, overlay on top)` sorted by start:
|
||||
/// a stretch runs to the next one's start, and the one on top is the last overlay covering it
|
||||
/// (later ones win). One sweep over the boundaries; picking the top overlay by scanning them all
|
||||
/// for each stretch was quadratic when a GREP style matched every character.
|
||||
fn overlay_bands(overlays: &[(std::ops::Range<usize>, String)]) -> Vec<(usize, Option<usize>)> {
|
||||
let mut starts: Vec<(usize, usize)> = Vec::new();
|
||||
let mut ends: Vec<(usize, usize)> = Vec::new();
|
||||
for (i, (r, _)) in overlays.iter().enumerate().filter(|(_, (r, _))| r.start < r.end) {
|
||||
starts.push((r.start, i));
|
||||
ends.push((r.end, i));
|
||||
}
|
||||
starts.sort_unstable();
|
||||
ends.sort_unstable();
|
||||
let mut cuts: Vec<usize> = starts.iter().chain(&ends).map(|x| x.0).collect();
|
||||
cuts.push(0);
|
||||
cuts.sort_unstable();
|
||||
cuts.dedup();
|
||||
let mut active = std::collections::BTreeSet::new();
|
||||
let (mut s, mut e) = (0, 0);
|
||||
let mut out = Vec::with_capacity(cuts.len());
|
||||
for at in cuts {
|
||||
while e < ends.len() && ends[e].0 <= at {
|
||||
active.remove(&ends[e].1);
|
||||
e += 1;
|
||||
}
|
||||
while s < starts.len() && starts[s].0 <= at {
|
||||
active.insert(starts[s].1);
|
||||
s += 1;
|
||||
}
|
||||
out.push((at, active.last().copied()));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::overlay_bands;
|
||||
|
||||
/// The band sweep picks the same top overlay as checking every overlay at each boundary.
|
||||
#[test]
|
||||
fn overlay_bands_match_the_last_covering_overlay() {
|
||||
let overlays: Vec<(std::ops::Range<usize>, String)> = [(2..9, "a"), (0..4, "b"), (4..4, "empty"), (3..6, "c"), (8..12, "d"), (5..7, "e")]
|
||||
.into_iter()
|
||||
.map(|(r, s)| (r, s.to_string()))
|
||||
.collect();
|
||||
let bands = overlay_bands(&overlays);
|
||||
for at in 0..14 {
|
||||
let k = bands.partition_point(|(start, _)| *start <= at) - 1;
|
||||
let want = overlays.iter().rposition(|(r, _)| r.start <= at && at < r.end);
|
||||
assert_eq!(bands[k].1, want, "at {at}");
|
||||
}
|
||||
}
|
||||
|
||||
/// One overlay per character (a GREP style matching every character) sweeps in one pass.
|
||||
#[test]
|
||||
fn many_overlays_are_banded_quickly() {
|
||||
let n = 200_000;
|
||||
let overlays: Vec<(std::ops::Range<usize>, String)> = (0..n).map(|i| (i..i + 1, "s".to_string())).collect();
|
||||
let t = std::time::Instant::now();
|
||||
let bands = overlay_bands(&overlays);
|
||||
assert_eq!(bands.len(), n + 1);
|
||||
assert_eq!(bands[n / 2], (n / 2, Some(n / 2)));
|
||||
assert!(t.elapsed().as_secs() < 5);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
//! cell (or its fixed height); rows that don't fit continue in the next column / frame, repeating
|
||||
//! header (and footer) rows. Rows tied together by row spans never split.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use designcraft_doc::{Align, Cell, CellStroke, Document, ItemId, ParaProps, RowHeightMode, Table, TextFrameOptions, VerticalJustification};
|
||||
@@ -122,7 +123,7 @@ impl CellLineExtent {
|
||||
|
||||
/// Compose a cell story at width `w` (content space, top-left at the origin).
|
||||
fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, (left_page, right_page): (bool, bool)) -> CellLayout {
|
||||
let spec = FrameSpec {
|
||||
let mut spec = FrameSpec {
|
||||
id: ItemId(0),
|
||||
area: Rect::new(0.0, 0.0, w.max(1.0), 1.0e6),
|
||||
opts: TextFrameOptions { first_baseline: cell.first_baseline, first_baseline_min: cell.first_baseline_min, ..TextFrameOptions::default() },
|
||||
@@ -132,12 +133,21 @@ fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, (lef
|
||||
page_name: opts.page_name.clone(),
|
||||
section_marker: opts.section_marker.clone(),
|
||||
page: opts.page,
|
||||
parent: false,
|
||||
grid: None,
|
||||
left_page,
|
||||
right_page,
|
||||
page_rect: None,
|
||||
};
|
||||
let cs = crate::compose(doc, &cell.text, std::slice::from_ref(&spec), opts);
|
||||
let mut cs = crate::compose(doc, &cell.text, std::slice::from_ref(&spec), opts);
|
||||
// The measuring frame is deliberately tall. Recompose at the actual content height so
|
||||
// cell-local footnotes sit inside the row rather than at the measuring frame's bottom.
|
||||
if let Some(frame) = cs.frames.first().filter(|f| !f.notes.is_empty()) {
|
||||
let body = frame.lines.iter().map(|l| l.baseline + l.descent).fold(0.0, f64::max);
|
||||
let note_top = frame.notes.first().map_or(spec.area.y1, |n| n.rect.y0);
|
||||
spec.area.y1 = body + doc.footnote_options.space_before + (spec.area.y1 - note_top);
|
||||
cs = crate::compose(doc, &cell.text, std::slice::from_ref(&spec), opts);
|
||||
}
|
||||
let line_heights: Vec<f64> = cs
|
||||
.frames
|
||||
.first()
|
||||
@@ -157,7 +167,8 @@ fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, (lef
|
||||
.unwrap_or_default();
|
||||
// Check all lines: a large descender or lowered inline object on an earlier line can
|
||||
// extend below the final baseline when leading is fixed.
|
||||
let content_h = line_heights.iter().copied().fold(0.0, f64::max);
|
||||
let note_bottom = cs.frames.first().map_or(0.0, |f| f.notes.iter().map(|n| n.rect.y1).fold(0.0, f64::max));
|
||||
let content_h = line_heights.iter().copied().fold(note_bottom, f64::max);
|
||||
CellLayout { text: Arc::new(cs), content_h, line_heights }
|
||||
}
|
||||
|
||||
@@ -174,6 +185,7 @@ pub(crate) fn place_table(
|
||||
cols: &[Vec<Rect>],
|
||||
cur: &mut Cursor,
|
||||
out: &mut ComposedStory,
|
||||
notes: &mut crate::notes::Notes,
|
||||
opts: &ComposeOptions,
|
||||
) -> bool {
|
||||
if cur.fi >= frames.len() || table.nrows() == 0 || table.ncols() == 0 {
|
||||
@@ -292,6 +304,13 @@ pub(crate) fn place_table(
|
||||
}
|
||||
};
|
||||
place_rows(0..header, &mut y, false, &mut frag_rows);
|
||||
// Table rows do not add story footnotes; cell-local notes are composed separately.
|
||||
let column_reserve = |notes: &mut crate::notes::Notes, cur: &Cursor, col: Rect| {
|
||||
let note_w = notes.area(&cols[cur.fi], cur.col).map_or(col.width(), |a| a.width());
|
||||
let column = crate::notes::NoteColumn { frame_index: cur.fi, column_index: cur.col, width: note_w, frame: &frames[cur.fi] };
|
||||
notes.reserve(doc, column, &[], opts)
|
||||
};
|
||||
let mut reserve = column_reserve(notes, cur, col);
|
||||
let mut r = header;
|
||||
loop {
|
||||
if r >= body_end {
|
||||
@@ -299,9 +318,10 @@ pub(crate) fn place_table(
|
||||
}
|
||||
let ge = group_end[r].min(body_end).max(r + 1);
|
||||
let gh = sum(r, ge);
|
||||
let bottom = col.y1 - reserve;
|
||||
let at_col_top = cur.last_baseline.is_none() && (top - col.y0).abs() < 1e-6;
|
||||
if y + gh + footer_h <= col.y1 + 0.01 || (body_placed == 0 && at_col_top) {
|
||||
if y + gh + footer_h > col.y1 + 0.01 && body_placed == 0 && at_col_top && y + gh > col.y1 + 0.01 {
|
||||
if y + gh + footer_h <= bottom + 0.01 || (body_placed == 0 && at_col_top && reserve == 0.0) {
|
||||
if y + gh + footer_h > bottom + 0.01 && body_placed == 0 && at_col_top && y + gh > bottom + 0.01 {
|
||||
// A row group taller than an empty column can never fit.
|
||||
return false;
|
||||
}
|
||||
@@ -324,6 +344,7 @@ pub(crate) fn place_table(
|
||||
return false;
|
||||
}
|
||||
col = col_rect(cur);
|
||||
reserve = column_reserve(notes, cur, col);
|
||||
y = col.y0;
|
||||
top = y;
|
||||
frag_rows.clear();
|
||||
@@ -384,7 +405,13 @@ fn emit(
|
||||
out: &mut ComposedStory,
|
||||
) {
|
||||
let nc = table.ncols();
|
||||
let ry = |r: usize| rows.iter().find(|x| x.0 == r).map(|x| x.1);
|
||||
// Each row's top (its first occurrence in the fragment), indexed once: looking it up per cell
|
||||
// by scanning the rows was quadratic in a fragment's row count.
|
||||
let mut row_top: HashMap<usize, f64> = HashMap::with_capacity(rows.len());
|
||||
for &(r, y, _) in rows {
|
||||
row_top.entry(r).or_insert(y);
|
||||
}
|
||||
let ry = |r: usize| row_top.get(&r).copied();
|
||||
let header = table.header_rows();
|
||||
let body = table.body_rows();
|
||||
let nbody = body.len();
|
||||
|
||||
+696
-18
@@ -1103,21 +1103,95 @@ fn hyphenate_across_column_off_keeps_nested_line_styles_on_their_lines() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discretionary_hyphen_replaces_automatic_points() {
|
||||
let text = "aaaa bbbb cccc extraordi\u{AD}narily dddd eeee";
|
||||
let para = ParaAttrs { align: Some(Align::Left), ..Default::default() };
|
||||
let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 120.0, 1000.0), para);
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let soft = text.find('\u{AD}').unwrap();
|
||||
for l in all_lines(&cs) {
|
||||
if l.hyphenated {
|
||||
let last = l.glyphs.iter().rfind(|g| g.len > 0).unwrap();
|
||||
assert!(last.byte + last.len >= soft && last.byte <= soft + 2, "{:?}", &text[l.range.clone()]);
|
||||
fn discretionary_hyphen_breaks_with_both_composers_and_hyphenation_settings() {
|
||||
let text = "Wir möchten uns ganz besonders herzlich bei folgenden Personen bedanken, die auf unterschied\u{ad}lichste Weise zur Entstehung dieses Lehrbuches beigetragen haben:";
|
||||
let soft_end = text.find('\u{ad}').unwrap() + '\u{ad}'.len_utf8();
|
||||
for composer in [Composer::SingleLine, Composer::Paragraph] {
|
||||
for hyphenate in [false, true] {
|
||||
let para = ParaAttrs { composer: Some(composer), hyphenate: Some(hyphenate), ..Default::default() };
|
||||
let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 430.0, 1000.0), para);
|
||||
d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.size = Some(9.5));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let first = all_lines(&cs)[0];
|
||||
assert_eq!(first.range.end, soft_end, "{composer:?}, hyphenate={hyphenate}: {:?}", &text[first.range.clone()]);
|
||||
assert!(first.hyphenated);
|
||||
let hyphen = first.glyphs.last().unwrap();
|
||||
assert!(hyphen.visible && hyphen.len == 0 && hyphen.adv > 0.0);
|
||||
}
|
||||
}
|
||||
// Hyphenation points of the word itself: none besides the discretionary one.
|
||||
let lines = all_lines(&cs);
|
||||
assert!(lines.iter().filter(|l| l.hyphenated).count() <= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discretionary_hyphen_before_cjk_single_line() {
|
||||
discretionary_hyphen_before_cjk(Composer::SingleLine);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discretionary_hyphen_before_cjk_paragraph() {
|
||||
discretionary_hyphen_before_cjk(Composer::Paragraph);
|
||||
}
|
||||
|
||||
fn discretionary_hyphen_before_cjk(composer: Composer) {
|
||||
let text = "abc\u{ad}\u{65e5}\u{672c}\u{8a9e}\u{65e5}\u{672c}\u{8a9e}";
|
||||
let soft_end = text.find('\u{ad}').unwrap() + '\u{ad}'.len_utf8();
|
||||
for hyphenate in [false, true] {
|
||||
let para = ParaAttrs { composer: Some(composer), hyphenate: Some(hyphenate), ..Default::default() };
|
||||
let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 24.0, 1000.0), para);
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let first = all_lines(&cs)[0];
|
||||
assert_eq!(first.range.end, soft_end, "{composer:?}, hyphenate={hyphenate}: {:?}", &text[first.range.clone()]);
|
||||
assert!(first.hyphenated, "{composer:?}, hyphenate={hyphenate}");
|
||||
let hyphen = first.glyphs.last().unwrap();
|
||||
assert!(hyphen.visible && hyphen.len == 0 && hyphen.adv > 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discretionary_hyphen_is_invisible_and_zero_width_when_unbroken() {
|
||||
for composer in [Composer::SingleLine, Composer::Paragraph] {
|
||||
let mut widths = Vec::new();
|
||||
for text in ["unterschiedlichste", "unterschied\u{ad}lichste"] {
|
||||
let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 1000.0, 100.0), ParaAttrs { composer: Some(composer), ..Default::default() });
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let lines = all_lines(&cs);
|
||||
assert_eq!(lines.len(), 1);
|
||||
assert!(!lines[0].hyphenated);
|
||||
if let Some(soft) = text.find('\u{ad}') {
|
||||
let glyph = lines[0].glyphs.iter().find(|g| g.byte == soft).unwrap();
|
||||
assert!(!glyph.visible && glyph.adv == 0.0);
|
||||
}
|
||||
widths.push(lines[0].end_x);
|
||||
}
|
||||
assert!((widths[0] - widths[1]).abs() < 1e-6, "{composer:?}: {widths:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discretionary_hyphen_replaces_automatic_points() {
|
||||
let text = "aaaa bbbb cccc extraordi\u{AD}narily dddd eeee";
|
||||
let soft = text.find('\u{AD}').unwrap();
|
||||
for composer in [Composer::SingleLine, Composer::Paragraph] {
|
||||
let mut discretionary_breaks = 0;
|
||||
let mut automatic_breaks = 0;
|
||||
for width in (90..181).step_by(10) {
|
||||
for discretionary in [false, true] {
|
||||
let source = if discretionary { text.to_string() } else { text.replace('\u{AD}', "") };
|
||||
let para = ParaAttrs { composer: Some(composer), hyphenate: Some(true), align: Some(Align::Left), ..Default::default() };
|
||||
let (d, sid, _) = doc_with(&source, Rect::new(0.0, 0.0, f64::from(width), 1000.0), para);
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
for l in all_lines(&cs).into_iter().filter(|l| l.hyphenated) {
|
||||
if discretionary {
|
||||
assert_eq!(l.range.end, soft + '\u{AD}'.len_utf8(), "{composer:?}: {:?}", &source[l.range.clone()]);
|
||||
discretionary_breaks += 1;
|
||||
} else {
|
||||
automatic_breaks += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(discretionary_breaks > 0, "{composer:?}: discretionary breaks exercised");
|
||||
assert!(automatic_breaks > 0, "{composer:?}: pattern breaks exercised without U+00AD");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1832,6 +1906,141 @@ fn tab_leaders_fill_the_gap() {
|
||||
assert!(last_dot < num_x, "leaders stop before the page number");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indent_to_here_starts_the_paragraphs_later_lines_at_the_character() {
|
||||
// "1.", Indent to Here, then text long enough to wrap: every line after the first starts
|
||||
// where the character sits, in this frame and the next one (#2080).
|
||||
let text = format!("1.\u{e005}{}", [LOREM; 3].join(" "));
|
||||
let para = ParaAttrs { left_indent: Some(6.0), ..Default::default() };
|
||||
let (mut d, sid, a) = doc_with(&text, Rect::new(72.0, 36.0, 272.0, 96.0), para.clone());
|
||||
let lid = d.default_layer();
|
||||
let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(72.0, 200.0, 272.0, 800.0), lid, "", ParaFormat::default()).unwrap();
|
||||
d.thread(a, b).unwrap();
|
||||
let cs = compose_story(&d, sid, &Default::default());
|
||||
assert!(!cs.is_overset());
|
||||
assert!(cs.frames[0].lines.len() > 1 && !cs.frames[1].lines.is_empty(), "the text wraps into the second frame");
|
||||
let lines = all_lines(&cs);
|
||||
let here = lines[0].glyphs.iter().find(|g| g.byte == 2 && g.len > 0).unwrap().x;
|
||||
assert!(here > 72.0 + 6.0 + 1.0, "the character follows the number");
|
||||
for (i, line) in lines.iter().enumerate().skip(1) {
|
||||
let start = line.glyphs.iter().find(|g| g.visible).unwrap().x;
|
||||
assert!((start - here).abs() < 0.01, "line {i} starts at {start}, not at the indent {here}");
|
||||
assert!(line.end_x <= 272.0 + 0.01, "line {i} fits the narrower measure");
|
||||
}
|
||||
// Without the character the later lines keep the paragraph's own indent.
|
||||
let (plain, psid, _) = doc_with(&text.replace('\u{e005}', " "), Rect::new(72.0, 36.0, 272.0, 400.0), para);
|
||||
let pcs = compose_story(&plain, psid, &Default::default());
|
||||
let start = pcs.frames[0].lines[1].glyphs.iter().find(|g| g.visible).unwrap().x;
|
||||
assert!((start - 78.0).abs() < 0.01, "plain second line starts at {start}");
|
||||
}
|
||||
|
||||
/// Where each line after the first starts, and the Indent to Here character's x on the first.
|
||||
fn indent_to_here_starts(text: &str, para: ParaAttrs) -> (f64, Vec<f64>) {
|
||||
let (d, sid, _) = doc_with(text, Rect::new(72.0, 36.0, 272.0, 600.0), para);
|
||||
let cs = compose_story(&d, sid, &Default::default());
|
||||
let lines = all_lines(&cs);
|
||||
assert!(lines.len() > 4, "the text wraps");
|
||||
let byte = text.find('\u{e005}').unwrap();
|
||||
let here = lines[0].glyphs.iter().find(|g| g.byte == byte && g.len > 0).unwrap().x;
|
||||
let starts = lines.iter().skip(1).map(|l| l.glyphs.iter().find(|g| g.visible).unwrap().x).collect();
|
||||
(here, starts)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indent_to_here_after_a_tab_hangs_the_text_after_the_number() {
|
||||
// The usual hanging number: "1.", a tab to the next stop, then Indent to Here.
|
||||
let tabs = vec![designcraft_doc::TabStop { position: 24.0, align: TabAlign::Left, leader: String::new(), align_on: String::new() }];
|
||||
let (here, starts) = indent_to_here_starts(&format!("1.\t\u{e005}{LOREM}"), ParaAttrs { tabs: Some(tabs), ..Default::default() });
|
||||
assert!((here - (72.0 + 24.0)).abs() < 0.01, "the character sits at the tab stop: {here}");
|
||||
for (i, start) in starts.iter().enumerate() {
|
||||
assert!((start - here).abs() < 0.01, "line {} starts at {start}, not at {here}", i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indent_to_here_beside_a_drop_cap_is_not_indented_by_the_drop_cap_again() {
|
||||
// A three-line drop cap, then Indent to Here on the first line: the lines beside the drop
|
||||
// cap and the lines below it all start at the character, none further in.
|
||||
let (here, starts) = indent_to_here_starts(&format!("Drop \u{e005}{LOREM}"), drop_cap(3, 1));
|
||||
for (i, start) in starts.iter().enumerate() {
|
||||
assert!((start - here).abs() < 0.01, "line {} starts at {start}, not at {here}", i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indent_to_here_inside_the_drop_cap_indents_the_lines_below_it() {
|
||||
// A two-character drop cap whose second character is Indent to Here: the lines beside the
|
||||
// drop cap and the ones below it all start at the character, at the drop cap's edge.
|
||||
let (here, starts) = indent_to_here_starts(&format!("A\u{e005}{LOREM}"), drop_cap(3, 2));
|
||||
assert!(here > 72.0 + 10.0, "the character sits past the drop cap: {here}");
|
||||
assert!(starts.len() > 3, "lines below the drop cap");
|
||||
for (i, start) in starts.iter().enumerate() {
|
||||
assert!((start - here).abs() < 0.01, "line {} starts at {start}, not at {here}", i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indent_to_here_in_a_list_number_indents_to_the_generated_character() {
|
||||
// "^#.^i^t": the paragraph's later lines start where the number's Indent to Here sits,
|
||||
// right after "1.", not at the text after the tab.
|
||||
let mut para = numbered(designcraft_doc::NumberStyle::Arabic, "^#.^i^t");
|
||||
para.list_tab = Some(Some(36.0));
|
||||
let (d, sid, _) = doc_with(LOREM, Rect::new(72.0, 36.0, 272.0, 600.0), para);
|
||||
let cs = compose_story(&d, sid, &Default::default());
|
||||
let lines = all_lines(&cs);
|
||||
assert!(lines.len() > 3, "the text wraps");
|
||||
let label = |c: char| lines[0].glyphs.iter().find(|g| g.len == 0 && g.rendered_char == c).unwrap();
|
||||
let (dot, here) = (label('.'), label('\u{e005}').x);
|
||||
assert!((here - (dot.x + dot.adv)).abs() < 0.01, "the character follows the number: {here}");
|
||||
let text = lines[0].glyphs.iter().find(|g| g.len > 0).unwrap().x;
|
||||
assert!((text - (72.0 + 36.0)).abs() < 0.01 && here < text - 1.0, "the text after the tab is further in: {text}");
|
||||
for (i, line) in lines.iter().enumerate().skip(1) {
|
||||
let start = line.glyphs.iter().find(|g| g.visible).unwrap().x;
|
||||
assert!((start - here).abs() < 0.01, "line {i} starts at {start}, not at {here}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indent_to_here_in_a_list_number_after_one_in_the_drop_cap_wins() {
|
||||
// The drop cap "A^i" sits at the indent and the number "1.^i" follows it on the first line,
|
||||
// so the number's character is the line's last one: the later lines start there, not at
|
||||
// the drop cap's.
|
||||
let mut para = ParaAttrs { drop_cap_lines: Some(3), drop_cap_chars: Some(2), ..numbered(designcraft_doc::NumberStyle::Arabic, "^#.^i^t") };
|
||||
para.list_tab = Some(Some(36.0));
|
||||
let (d, sid, _) = doc_with(&format!("A\u{e005}{LOREM}"), Rect::new(72.0, 36.0, 272.0, 600.0), para);
|
||||
let cs = compose_story(&d, sid, &Default::default());
|
||||
let lines = all_lines(&cs);
|
||||
assert!(lines.len() > 4, "the text wraps below the drop cap");
|
||||
let marker = |label: bool| lines[0].glyphs.iter().find(|g| g.rendered_char == '\u{e005}' && (g.len == 0) == label).unwrap().x;
|
||||
let (in_cap, in_label) = (marker(false), marker(true));
|
||||
assert!(in_label > in_cap + 1.0, "the number follows the drop cap: {in_cap} then {in_label}");
|
||||
for (i, line) in lines.iter().enumerate().skip(1) {
|
||||
let start = line.glyphs.iter().find(|g| g.visible).unwrap().x;
|
||||
assert!((start - in_label).abs() < 0.01, "line {i} starts at {start}, not at {in_label}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indent_to_here_in_a_right_to_left_paragraph_indents_from_the_right() {
|
||||
// Hebrew words (the bidi classes decide the order; no Hebrew font has to be present): the
|
||||
// lines after the first end where the character sits, measured from the right edge.
|
||||
let words = "שלום עולם ".repeat(40);
|
||||
let text = format!("שלום \u{e005}{words}");
|
||||
let para = ParaAttrs { direction: Some(designcraft_doc::TextDirection::RightToLeft), align: Some(Align::Right), ..Default::default() };
|
||||
let (d, sid, _) = doc_with(&text, Rect::new(72.0, 36.0, 272.0, 600.0), para);
|
||||
let cs = compose_story(&d, sid, &Default::default());
|
||||
let lines = all_lines(&cs);
|
||||
assert!(lines.len() > 3, "the text wraps");
|
||||
let byte = text.find('\u{e005}').unwrap();
|
||||
let here = lines[0].glyphs.iter().find(|g| g.byte == byte && g.len > 0).unwrap().x;
|
||||
assert!(here < 272.0 - 1.0, "the character follows the first word: {here}");
|
||||
let right_edge = |l: &Line| l.glyphs.iter().filter(|g| g.visible).map(|g| g.x + g.adv).fold(f64::MIN, f64::max);
|
||||
for (i, line) in lines.iter().enumerate().skip(1) {
|
||||
let end = right_edge(line);
|
||||
assert!((end - here).abs() < 0.01, "line {i} ends at {end}, not at {here}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_indent_tab_leaders_follow_stop_precedence_without_moving_text() {
|
||||
let text = "Intro\u{e006}12";
|
||||
@@ -1994,6 +2203,178 @@ fn footnotes_sit_at_the_column_bottom_and_push_text() {
|
||||
assert!(note_caret(&cs, n.id, 0).is_some());
|
||||
}
|
||||
|
||||
fn justified_content_clears_footnotes(mode: designcraft_doc::VerticalJustification, table: bool) {
|
||||
for span in [false, true] {
|
||||
let (mut d, sid, fid) = doc_with("First paragraph.\nSecond paragraph.", Rect::new(0.0, 0.0, 420.0, 240.0), ParaAttrs::default());
|
||||
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2;
|
||||
d.footnote_options.span_columns = span;
|
||||
d.footnote_options.space_before = 60.0;
|
||||
let st = d.story_mut(sid).unwrap();
|
||||
st.insert_note(5, "Synthetic footnote.", ParaFormat::default());
|
||||
if table {
|
||||
let mut t = designcraft_doc::Table::new(77, 1, 1, 0, 0, 180.0);
|
||||
t.rows[0].mode = designcraft_doc::RowHeightMode::Exactly;
|
||||
t.rows[0].height = 90.0;
|
||||
st.insert_table(st.len(), t);
|
||||
}
|
||||
let top = compose_story(&d, sid, &ComposeOptions::default());
|
||||
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.vertical_justification = mode;
|
||||
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
|
||||
assert!(!cs.is_overset());
|
||||
let ft = cs.frame(fid).unwrap();
|
||||
let original = top.frame(fid).unwrap();
|
||||
assert_eq!(ft.notes.len(), 1);
|
||||
assert_eq!(ft.notes[0].rect, original.notes[0].rect, "footnotes must not move");
|
||||
let limit = ft.notes[0].rect.y0 - d.footnote_options.space_before;
|
||||
assert!(ft.lines.iter().all(|l| l.baseline + l.descent <= limit + 0.01), "lines overlap footnotes in {mode:?}, span {span}");
|
||||
assert!(ft.tables.iter().flat_map(|t| &t.cells).all(|c| c.rect.y1 <= limit + 0.01), "cells overlap footnotes in {mode:?}, span {span}");
|
||||
assert!(ft.lines.last().unwrap().baseline > original.lines.last().unwrap().baseline + 0.01, "content must still move down");
|
||||
if table {
|
||||
assert_eq!(ft.tables.len(), 1);
|
||||
assert!(ft.tables[0].rect.y1 > original.tables[0].rect.y1 + 0.01, "table must still move down");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bottom_justified_paragraphs_clear_footnotes() {
|
||||
justified_content_clears_footnotes(designcraft_doc::VerticalJustification::Bottom, false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bottom_justified_tables_clear_footnotes() {
|
||||
justified_content_clears_footnotes(designcraft_doc::VerticalJustification::Bottom, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn center_justified_tables_clear_footnotes() {
|
||||
justified_content_clears_footnotes(designcraft_doc::VerticalJustification::Center, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertically_justified_tables_clear_footnotes() {
|
||||
justified_content_clears_footnotes(designcraft_doc::VerticalJustification::Justify, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_justification_counts_table_fragment_bottoms() {
|
||||
use designcraft_doc::VerticalJustification::{Bottom, Center, Justify};
|
||||
let (mut d, sid, fid) = doc_with("Reference.", Rect::new(0.0, 0.0, 200.0, 240.0), ParaAttrs::default());
|
||||
let st = d.story_mut(sid).unwrap();
|
||||
st.insert_note(5, "Synthetic footnote.", ParaFormat::default());
|
||||
let mut table = designcraft_doc::Table::new(77, 1, 1, 0, 0, 180.0);
|
||||
table.rows[0].mode = designcraft_doc::RowHeightMode::Exactly;
|
||||
table.rows[0].height = 120.0;
|
||||
st.insert_table(st.len(), table);
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
for mode in [Bottom, Center, Justify] {
|
||||
let mut ft = cs.frame(fid).unwrap().clone();
|
||||
let t = &ft.tables[0];
|
||||
let (anchor, bottom, baseline) = (t.line, t.rect.y1, t.rect.y0);
|
||||
// Exercise a fragment extending below its anchor, without a later text line hiding it.
|
||||
ft.lines.truncate(anchor + 1);
|
||||
ft.lines[anchor].baseline = baseline;
|
||||
let mut spec = frame_specs(&d, sid).remove(0);
|
||||
spec.opts.vertical_justification = mode;
|
||||
|
||||
vertical_justify(&mut ft, &spec, false, &d.footnote_options);
|
||||
|
||||
let moved = ft.lines[anchor].baseline - baseline;
|
||||
let limit = ft.notes[0].rect.y0 - d.footnote_options.space_before;
|
||||
assert!(moved > 0.01);
|
||||
assert!(bottom + moved <= limit + 0.01, "fragment overlaps footnotes in {mode:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_rows_clear_footnotes_and_continue_in_the_next_column() {
|
||||
for span in [false, true] {
|
||||
for row_height in [24.0, 150.0] {
|
||||
let (mut d, sid, fid) = doc_with("First paragraph.\nSecond paragraph.", Rect::new(0.0, 0.0, 420.0, 200.0), ParaAttrs::default());
|
||||
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2;
|
||||
d.footnote_options.span_columns = span;
|
||||
let st = d.story_mut(sid).unwrap();
|
||||
st.insert_note(5, "First synthetic footnote.", ParaFormat::default());
|
||||
st.insert_note(25, "Second synthetic footnote.", ParaFormat::default());
|
||||
let mut table = designcraft_doc::Table::new(77, 8, 1, 0, 0, 180.0);
|
||||
for row in &mut table.rows {
|
||||
row.mode = designcraft_doc::RowHeightMode::Exactly;
|
||||
row.height = row_height;
|
||||
}
|
||||
st.insert_table(st.len(), table);
|
||||
let (next, _) =
|
||||
d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 220.0, 420.0, 1720.0), d.default_layer(), "", ParaFormat::default()).unwrap();
|
||||
d.thread(fid, next).unwrap();
|
||||
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
|
||||
assert!(!cs.is_overset());
|
||||
let ft = cs.frame(fid).unwrap();
|
||||
assert_eq!(ft.notes.len(), 2);
|
||||
let top = ft.notes[0].rect.y0 - d.footnote_options.space_before;
|
||||
for frag in &ft.tables {
|
||||
if span || frag.column == 0 {
|
||||
assert!(frag.cells.iter().all(|c| c.rect.y1 <= top + 0.01), "rows overlap footnotes: {:?}, top {top}", frag.rect);
|
||||
}
|
||||
}
|
||||
assert!(cs.frames.iter().skip(1).any(|f| !f.tables.is_empty()) || ft.tables.iter().any(|t| t.column == 1));
|
||||
let rows: Vec<_> = cs.frames.iter().flat_map(|f| &f.tables).flat_map(|t| &t.cells).filter(|c| !c.repeated).map(|c| c.row).collect();
|
||||
assert_eq!(rows, (0..8).collect::<Vec<_>>());
|
||||
if row_height == 150.0 {
|
||||
assert!(!ft.tables.iter().any(|t| t.column == 0), "the whole table moves when no row fits");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_reports_overset_when_every_remaining_column_has_footnotes() {
|
||||
let (mut d, sid, fid) = doc_with("Reference.", Rect::new(0.0, 0.0, 420.0, 200.0), ParaAttrs::default());
|
||||
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 3;
|
||||
d.footnote_options.span_columns = true;
|
||||
d.footnote_options.space_before = 40.0;
|
||||
let st = d.story_mut(sid).unwrap();
|
||||
st.insert_note(5, "Synthetic spanning footnote.", ParaFormat::default());
|
||||
let mut table = designcraft_doc::Table::new(77, 2, 1, 0, 0, 100.0);
|
||||
for row in &mut table.rows {
|
||||
row.mode = designcraft_doc::RowHeightMode::Exactly;
|
||||
row.height = 90.0;
|
||||
}
|
||||
table.cells[0].row_span = 2;
|
||||
let anchor = st.insert_table(st.len(), table);
|
||||
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
|
||||
assert!(cs.is_overset());
|
||||
assert_eq!(cs.overset_at, Some(anchor));
|
||||
let ft = cs.frame(fid).unwrap();
|
||||
assert_eq!(ft.notes.len(), 1);
|
||||
assert!(ft.notes[0].rect.y0 - d.footnote_options.space_before < 180.0);
|
||||
assert!(cs.frames.iter().all(|f| f.tables.is_empty()), "the unplaced row group must remain overset");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_cell_footnotes_fit_inside_the_row() {
|
||||
let (mut d, sid, _) = doc_with("", Rect::new(0.0, 0.0, 200.0, 200.0), ParaAttrs::default());
|
||||
let mut table = designcraft_doc::Table::new(77, 1, 1, 0, 0, 180.0);
|
||||
let cell = &mut table.cells[0];
|
||||
cell.text.insert(0, "Cell reference.");
|
||||
cell.text.insert_note(5, "Synthetic cell footnote.", ParaFormat::default());
|
||||
d.story_mut(sid).unwrap().insert_table(0, table);
|
||||
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
|
||||
let cell = &cs.frames[0].tables[0].cells[0];
|
||||
assert!(!cell.overset);
|
||||
let frame = &cell.text.frames[0];
|
||||
assert_eq!(frame.notes.len(), 1);
|
||||
let note = &frame.notes[0];
|
||||
assert!(cell.origin.y + note.rect.y1 <= cell.clip.y1 + 0.01, "cell footnote must remain within the row");
|
||||
assert!(frame.lines.iter().all(|l| l.baseline + l.descent <= note.rect.y0 - d.footnote_options.space_before + 0.01));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footnotes_stack_baseline_to_baseline() {
|
||||
// Single-line footnotes read as one text: consecutive baselines sit one leading (plus
|
||||
@@ -2836,11 +3217,18 @@ fn only_english_text_gets_english_hyphenation() {
|
||||
let hyphenated = |d: &Document, sid| all_lines(&compose_story(d, sid, &ComposeOptions::default())).iter().filter(|l| l.hyphenated).count();
|
||||
let (d, sid, _) = doc_with(text, narrow, ParaAttrs::default());
|
||||
assert!(hyphenated(&d, sid) > 0, "English hyphenates");
|
||||
let mut d2 = d.clone();
|
||||
let st = d2.story_mut(sid).unwrap();
|
||||
let n = st.len();
|
||||
st.format_chars(0..n, |f| f.over.language = Some("French".into()));
|
||||
assert_eq!(hyphenated(&d2, sid), 0, "French isn't hyphenated with English rules");
|
||||
// A language without bundled patterns isn't hyphenated with English rules.
|
||||
let in_language = |lang: &str| {
|
||||
let mut d2 = d.clone();
|
||||
let st = d2.story_mut(sid).unwrap();
|
||||
let n = st.len();
|
||||
st.format_chars(0..n, |f| f.over.language = Some(lang.into()));
|
||||
hyphenated(&d2, sid)
|
||||
};
|
||||
assert_eq!(in_language("Swedish"), 0, "Swedish isn't hyphenated with English rules");
|
||||
// French and Italian hyphenate with their own patterns.
|
||||
assert!(in_language("French") > 0, "French hyphenates (hyph-fr)");
|
||||
assert!(in_language("Italian") > 0, "Italian hyphenates (hyph-it)");
|
||||
}
|
||||
|
||||
// A text frame's bottom inset constrains its last baseline. The full font descent
|
||||
@@ -3492,6 +3880,21 @@ fn text_x(l: &Line, from: usize) -> f64 {
|
||||
l.glyphs.iter().find(|g| g.len > 0 && g.byte >= from).map(|g| g.x).unwrap()
|
||||
}
|
||||
|
||||
/// Vertical justification moves the drop cap's box with its line.
|
||||
#[test]
|
||||
fn drop_cap_box_follows_vertical_justification() {
|
||||
let (mut d, sid, fid) = drop_doc_in(&[LOREM; 2].join(" "), Rect::new(0.0, 0.0, 300.0, 400.0), drop_cap(3, 1));
|
||||
let top = compose_story(&d, sid, &ComposeOptions::default());
|
||||
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.vertical_justification = designcraft_doc::VerticalJustification::Bottom;
|
||||
let bottom = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let (a, b) = (&top.frame(fid).unwrap().lines[0], &bottom.frame(fid).unwrap().lines[0]);
|
||||
let dy = b.baseline - a.baseline;
|
||||
assert!(dy > 1.0);
|
||||
let (da, db) = (a.drop_cap.unwrap(), b.drop_cap.unwrap());
|
||||
assert!((db.baseline - da.baseline - dy).abs() < 1e-6);
|
||||
assert!((db.rect.y0 - da.rect.y0 - dy).abs() < 1e-6 && (db.rect.y1 - da.rect.y1 - dy).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_cap_spans_lines_and_indents_them() {
|
||||
let text = [LOREM; 2].join(" ");
|
||||
@@ -3889,6 +4292,56 @@ fn drop_cap_scales_with_its_point_size() {
|
||||
assert!(b2 - fitted * 1.5 < b0 - base[0].ascent, "a 15 pt cap rises above the first line");
|
||||
}
|
||||
|
||||
/// Paragraph Rules › Type: a wavy, dashed or dotted rule is drawn as its shape (filled paths in
|
||||
/// the rule's box), a striped one as its bands; Gap Color goes under it. Keep In Frame keeps a rule
|
||||
/// above the first line inside the frame.
|
||||
#[test]
|
||||
fn paragraph_rules_have_a_type_a_gap_colour_and_keep_in_frame() {
|
||||
use designcraft_doc::{Rule, StrokeType};
|
||||
let rule = |kind: StrokeType| Rule { on: true, weight: 4.0, kind, ..Default::default() };
|
||||
let decos = |r: Rule| {
|
||||
let para = ParaAttrs { rule_below: Some(r), ..Default::default() };
|
||||
let (d, sid, fid) = doc_with("Recipe Categories", Rect::new(0.0, 0.0, 300.0, 200.0), para);
|
||||
compose_story(&d, sid, &ComposeOptions::default()).frame(fid).unwrap().decos.clone()
|
||||
};
|
||||
let solid = decos(rule(StrokeType::Solid));
|
||||
assert!(solid.len() == 1 && solid[0].path.is_none());
|
||||
let wavy = decos(rule(StrokeType::Wavy));
|
||||
let p = wavy[0].path.as_ref().expect("a wavy rule is a shape");
|
||||
assert!(designcraft_geom::Shape::area(p).abs() > 0.0 && (wavy[0].rect.height() - 4.0).abs() < 1e-9);
|
||||
let subpaths = |p: &designcraft_geom::BezPath| p.elements().iter().filter(|e| matches!(e, designcraft_geom::PathEl::MoveTo(_))).count();
|
||||
let dashed = decos(rule(StrokeType::Dashed { pattern: vec![12.0, 4.0] }));
|
||||
assert!(subpaths(dashed[0].path.as_ref().unwrap()) > 5, "one box per dash");
|
||||
let dotted = decos(rule(StrokeType::Dotted));
|
||||
assert!(subpaths(dotted[0].path.as_ref().unwrap()) > 10, "one dot per 2 weights");
|
||||
let gap = decos(Rule { gap_color: "[Black]".into(), gap_tint: 0.3, ..rule(StrokeType::Dotted) });
|
||||
assert_eq!((gap.len(), gap[0].color.as_str(), gap[0].tint, gap[0].path.is_none()), (2, "[Black]", 0.3, true), "the gap colour under the dots");
|
||||
assert_eq!(decos(Rule { gap_color: "[Black]".into(), ..rule(StrokeType::Solid) }).len(), 1, "a solid rule has no gaps");
|
||||
// A gap in Text Color takes the text's colour, whatever swatch the rule itself has.
|
||||
let gap = decos(Rule { color: "[Paper]".into(), gap_color: designcraft_doc::TEXT_COLOR.into(), ..rule(StrokeType::Dotted) });
|
||||
assert_eq!((gap[0].color.as_str(), gap[1].color.as_str()), ("[Black]", "[Paper]"));
|
||||
// Keep In Frame: a rule above raised out of the frame comes back to its top.
|
||||
let above = |keep: bool| {
|
||||
let r = Rule { on: true, weight: 2.0, offset: 30.0, keep_in_frame: keep, ..Default::default() };
|
||||
let para = ParaAttrs { rule_above: Some(r), ..Default::default() };
|
||||
let (d, sid, fid) = doc_with("Title", Rect::new(0.0, 50.0, 300.0, 200.0), para);
|
||||
compose_story(&d, sid, &ComposeOptions::default()).frame(fid).unwrap().decos[0].rect
|
||||
};
|
||||
assert!(above(false).y0 < 50.0, "{:?}", above(false));
|
||||
assert!((above(true).y0 - 50.0).abs() < 1e-6, "{:?}", above(true));
|
||||
// With a top inset the rule may still rise into it: raised out of the frame, it comes back
|
||||
// to the frame's edge, not to the text area's below the inset.
|
||||
let r = Rule { on: true, weight: 2.0, offset: 60.0, keep_in_frame: true, ..Default::default() };
|
||||
let para = ParaAttrs { rule_above: Some(r), ..Default::default() };
|
||||
let (mut d, sid, fid) = doc_with("Title", Rect::new(0.0, 50.0, 300.0, 200.0), para);
|
||||
if let Some(tf) = d.item_mut(fid).and_then(|it| it.text_frame_mut()) {
|
||||
tf.options.inset = [20.0, 0.0, 0.0, 0.0];
|
||||
}
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let rule = cs.frame(fid).unwrap().decos[0].rect;
|
||||
assert!((rule.y0 - 50.0).abs() < 1e-6, "kept at the frame's top, in the inset: {rule:?}");
|
||||
}
|
||||
|
||||
/// A rule in Text Color takes the colour of the paragraph's text: the first character's for the
|
||||
/// rule above, the last character's for the rule below. A swatch colour is used as is.
|
||||
#[test]
|
||||
@@ -4076,6 +4529,36 @@ fn empty_paragraph_uses_its_own_mark_not_the_previous_one() {
|
||||
assert!((lines[1].baseline - lines[0].baseline - 8.0).abs() < 1e-6, "{} {}", lines[0].baseline, lines[1].baseline);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn german_2006_text_gets_german_hyphenation() {
|
||||
let text = "Silbentrennung Fußgänger Bäcker Fenster Donaudampfschifffahrt Silbentrennung";
|
||||
let de = hyphen::Lang::for_language("German: 2006 Reform").unwrap();
|
||||
for composer in [Composer::Paragraph, Composer::SingleLine] {
|
||||
for language in ["German: 2006 Reform", "de_DE_2006"] {
|
||||
let para = ParaAttrs { composer: Some(composer), ..Default::default() };
|
||||
let (mut d, sid, _) = doc_with(text, Rect::new(36.0, 36.0, 96.0, 700.0), para);
|
||||
d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.language = Some(language.into()));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
assert!(!cs.is_overset(), "{composer:?}, {language}");
|
||||
let lines = all_lines(&cs);
|
||||
assert!(lines.iter().any(|l| l.hyphenated), "{composer:?}, {language}: German text hyphenates");
|
||||
for line in lines.iter().filter(|l| l.hyphenated) {
|
||||
// Check the actual character offset in the original word, including when the
|
||||
// word spans more than two lines; UTF-8 byte offsets must not become char counts.
|
||||
let end = line.range.end;
|
||||
let start = text[..end].rfind(' ').map_or(0, |at| at + 1);
|
||||
let word = text[start..].split_whitespace().next().unwrap();
|
||||
let point = text[start..end].chars().count();
|
||||
assert!(hyphen::hyphen_points_in(word, &hyphen::Limits::default(), de).contains(&point), "{word} at {point}");
|
||||
}
|
||||
// The existing paragraph switch must still disable automatic German hyphenation.
|
||||
d.story_mut(sid).unwrap().format_paras(0..text.len(), |f| f.para.hyphenate = Some(false));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
assert!(all_lines(&cs).iter().all(|l| !l.hyphenated));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spanish_text_gets_spanish_hyphenation() {
|
||||
let text = "La transición democrática fue una construcción colectiva extraordinariamente compleja y desesperadamente necesaria.";
|
||||
@@ -7155,6 +7638,98 @@ fn anchored_object_wraps_its_own_storys_later_lines() {
|
||||
assert!((o.origin.y - top).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// Vertical Justification › Justify with a Paragraph Spacing Limit: the space goes between the
|
||||
/// paragraphs (up to the limit each), the lines of a paragraph keep their leading; past the limit
|
||||
/// the rest spreads over every line. Without a limit it spreads over every line.
|
||||
#[test]
|
||||
fn justify_paragraph_spacing_limit() {
|
||||
let text = "One line.
|
||||
Two
|
||||
Three";
|
||||
let baselines = |limit: f64| {
|
||||
let (mut d, sid, fid) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 400.0), ParaAttrs::default());
|
||||
let o = &mut d.item_mut(fid).unwrap().text_frame_mut().unwrap().options;
|
||||
o.vertical_justification = designcraft_doc::VerticalJustification::Justify;
|
||||
o.vj_paragraph_spacing_limit = limit;
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
all_lines(&cs).iter().map(|l| l.baseline).collect::<Vec<_>>()
|
||||
};
|
||||
let (none, big, small) = (baselines(0.0), baselines(1000.0), baselines(20.0));
|
||||
assert_eq!(none.len(), 3);
|
||||
let gaps = |b: &[f64]| (b[1] - b[0], b[2] - b[1]);
|
||||
// No limit: equal steps. A big limit: all of it between paragraphs (here every line is one).
|
||||
assert!((gaps(&none).0 - gaps(&none).1).abs() < 1e-6, "{none:?}");
|
||||
assert!((gaps(&big).0 - gaps(&big).1).abs() < 1e-6, "{big:?}");
|
||||
// A small limit: every paragraph gap takes the whole 20 pt, and the rest spreads over lines,
|
||||
// so each gap is wider than the top-aligned one by at least 20 pt and narrower than with a big limit.
|
||||
let top = {
|
||||
let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 400.0), ParaAttrs::default());
|
||||
all_lines(&compose_story(&d, sid, &ComposeOptions::default())).iter().map(|l| l.baseline).collect::<Vec<_>>()
|
||||
};
|
||||
let (natural, small_gaps, big_gaps) = (gaps(&top), gaps(&small), gaps(&big));
|
||||
assert!(small_gaps.0 >= natural.0 + 20.0 - 1e-6 && small_gaps.1 >= natural.1 + 20.0 - 1e-6, "{top:?} {small:?}");
|
||||
assert!(small_gaps.0 <= big_gaps.0 + 1e-6, "{small:?} {big:?}");
|
||||
assert!((none[2] - big[2]).abs() < 1e-6 && (none[2] - small[2]).abs() < 1e-6, "the last line stays at the bottom");
|
||||
// A paragraph of two lines keeps its leading when the limit absorbs the space.
|
||||
let (mut d, sid, fid) = doc_with(
|
||||
&[LOREM, "End."].join(
|
||||
"
|
||||
",
|
||||
),
|
||||
Rect::new(0.0, 0.0, 300.0, 600.0),
|
||||
ParaAttrs::default(),
|
||||
);
|
||||
let o = &mut d.item_mut(fid).unwrap().text_frame_mut().unwrap().options;
|
||||
o.vertical_justification = designcraft_doc::VerticalJustification::Justify;
|
||||
o.vj_paragraph_spacing_limit = 10_000.0;
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let lines = all_lines(&cs);
|
||||
let first: Vec<f64> = lines.iter().filter(|l| l.para == 0).map(|l| l.baseline).collect();
|
||||
assert!(first.len() > 2, "LOREM runs over several lines");
|
||||
let lead = first[1] - first[0];
|
||||
assert!(first.windows(2).all(|w| (w[1] - w[0] - lead).abs() < 1e-6), "the paragraph's lines keep their leading: {first:?}");
|
||||
let last = lines.iter().find(|l| l.para == 1).unwrap().baseline;
|
||||
assert!(last - first[first.len() - 1] > lead + 50.0, "the space went between the paragraphs");
|
||||
}
|
||||
|
||||
/// Each object's wrap pushes the anchors after it down, so where each object lands depends on the
|
||||
/// wraps of all those before it; every object still ends up wrapping the lines it hangs over.
|
||||
#[test]
|
||||
fn chained_anchored_objects_all_wrap_their_own_storys_lines() {
|
||||
const OBJECTS: usize = 8;
|
||||
let text = [LOREM; 12].join(" ");
|
||||
let (mut d, sid, _) = doc_with(&text, Rect::new(0.0, 0.0, 300.0, 3000.0), ParaAttrs::default());
|
||||
let plain = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let lines = all_lines(&plain);
|
||||
assert!(lines.len() > 2 + 6 * OBJECTS);
|
||||
let left = lines[0].x0;
|
||||
// One object every six lines, each hanging from its anchor's baseline over the lines below;
|
||||
// inserted last first so the earlier offsets still hold.
|
||||
let ats: Vec<usize> = (0..OBJECTS).map(|k| lines[2 + 6 * k].range.start + 3).collect();
|
||||
for &at in ats.iter().rev() {
|
||||
anchor_box(&mut d, sid, at, 0.0, 0.0, true, WrapMode::BoundingBox);
|
||||
}
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
assert!(!cs.is_overset());
|
||||
let lines = all_lines(&cs);
|
||||
let anchors: Vec<usize> = d.story(sid).unwrap().text.match_indices(designcraft_doc::OBJECT_MARK).map(|(i, _)| i).collect();
|
||||
let objects = &cs.frames[0].objects;
|
||||
assert_eq!(objects.len(), OBJECTS);
|
||||
for o in objects {
|
||||
let anchor_line = lines.iter().find(|l| l.range.contains(&anchors[o.index])).unwrap();
|
||||
assert!((o.origin.y - anchor_line.baseline).abs() < 1e-6, "object {} at {}, anchor line at {}", o.index, o.origin.y, anchor_line.baseline);
|
||||
let under = lines_in_band(&cs, o.origin.y, o.origin.y + 60.0);
|
||||
assert!(under.len() >= 2);
|
||||
assert!(
|
||||
under.iter().all(|l| l.x0 >= left + 100.0 - 1e-6),
|
||||
"object {} at {}: {:?}",
|
||||
o.index,
|
||||
o.origin.y,
|
||||
under.iter().map(|l| (l.baseline, l.x0)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchored_object_without_wrap_leaves_layout_unchanged() {
|
||||
let long = [LOREM; 3].join(" ");
|
||||
@@ -7751,6 +8326,43 @@ fn greek_text_gets_greek_hyphenation() {
|
||||
assert_eq!(hyphen::hyphen_points_in("ανθρωπος", &hyphen::Limits::default(), el), vec![2, 5]);
|
||||
}
|
||||
|
||||
/// An object's wrap pushes the text so that a later object's anchor goes on into the next frame
|
||||
/// (Learn Skills Daily's long-document exercise: pictures anchored in headings through a 79-page
|
||||
/// story). Each object is placed when its anchor line is set and wraps only the lines after it,
|
||||
/// in the frame its anchor lands in: the layout doesn't depend on where an earlier composition put
|
||||
/// the objects, and composing again gives the same lines.
|
||||
#[test]
|
||||
fn an_object_wraps_the_frame_its_anchor_lands_in_after_an_earlier_object_pushed_it_on() {
|
||||
let text = [LOREM; 6].join(" ");
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let lid = d.default_layer();
|
||||
let (a, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 300.0, 300.0), lid, &text, ParaFormat::default()).unwrap();
|
||||
let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(320.0, 0.0, 620.0, 300.0), lid, "", ParaFormat::default()).unwrap();
|
||||
d.thread(a, b).unwrap();
|
||||
let plain = compose_story(&d, sid, &ComposeOptions::default());
|
||||
// The second object's anchor sits near the end of the first frame without wrap.
|
||||
let late = plain.frames[0].lines.iter().rev().nth(2).unwrap().range.start + 2;
|
||||
anchor_box(&mut d, sid, late, 0.0, 0.0, true, WrapMode::BoundingBox);
|
||||
// The first, early in the first frame: 60 pt over its whole width pushes the later anchor on.
|
||||
let early = plain.frames[0].lines[1].range.start + 2;
|
||||
anchor_box(&mut d, sid, early, 0.0, 0.0, true, WrapMode::JumpObject);
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let late = late + OBJECT_LEN;
|
||||
let anchor_frame = cs.frames.iter().position(|f| f.range.contains(&late) || f.range.end == late).unwrap();
|
||||
assert_eq!(anchor_frame, 1, "the later anchor went on into the second frame");
|
||||
// The first frame's lines under the first object's band keep the full measure (the second
|
||||
// object is in the other frame); in the second frame, the lines under its object move right.
|
||||
let left = cs.frames[0].lines[0].x0;
|
||||
assert!(cs.frames[0].lines.iter().all(|l| (l.x0 - left).abs() < 1e-6));
|
||||
let f1 = &cs.frames[1];
|
||||
let (li, line) = f1.lines.iter().enumerate().find(|(_, l)| l.range.contains(&late)).unwrap();
|
||||
let under: Vec<_> = f1.lines[li + 1..].iter().filter(|l| l.baseline - l.ascent < line.baseline + 60.0).collect();
|
||||
assert!(!under.is_empty() && under.iter().all(|l| l.x0 >= f1.lines[0].x0 + 100.0 - 1e-6));
|
||||
assert_eq!(f1.objects.len(), 1);
|
||||
// Deterministic: the same lines again.
|
||||
assert_eq!(line_layout(&compose_story(&d, sid, &ComposeOptions::default())), line_layout(&cs));
|
||||
}
|
||||
|
||||
/// Rule Above's offset runs from the first line's baseline up to the rule's bottom edge, as in
|
||||
/// InDesign: a rule set clear of the cap height sits between the rows, not across the row above
|
||||
/// (an INDD project list whose rules crossed the previous paragraph's text).
|
||||
@@ -7845,6 +8457,41 @@ fn an_rtl_first_line_indent_is_at_the_right_edge() {
|
||||
assert!((left_edge(lines[1]) - (rect.x0 + 10.0 * MM)).abs() < 1.0, "left to right, wrapped line indented: {}", left_edge(lines[1]));
|
||||
}
|
||||
|
||||
/// A story threaded through several frames anchored in one host story asks for the host's
|
||||
/// composition once, not once per frame.
|
||||
#[test]
|
||||
fn anchored_frames_in_one_host_compose_it_once() {
|
||||
let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() });
|
||||
let lid = d.default_layer();
|
||||
let body = "One.\nTwo.\nThree.\nFour.";
|
||||
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 400.0, 400.0), lid, body, ParaFormat::default()).unwrap();
|
||||
let nsid = StoryId(d.alloc());
|
||||
let ids: Vec<ItemId> = (0..3).map(|_| ItemId(d.alloc())).collect();
|
||||
let mut story = Story::with_text(nsid, "Note", ParaFormat::default());
|
||||
story.frames.extend(&ids);
|
||||
d.stories.insert(nsid, Arc::new(story));
|
||||
for (k, &nid) in ids.iter().enumerate() {
|
||||
let mut note = designcraft_doc::Item::new(
|
||||
nid,
|
||||
lid,
|
||||
designcraft_doc::Shape::Rectangle,
|
||||
designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 60.0, 40.0)),
|
||||
);
|
||||
note.content = Content::Text(designcraft_doc::TextFrame { story: nsid, options: Default::default() });
|
||||
let at = body.find(["Two", "Three", "Four"][k]).unwrap();
|
||||
d.story_mut(sid).unwrap().insert_object(at, designcraft_doc::AnchoredObject::new(note, Default::default()));
|
||||
}
|
||||
let mut asked = 0;
|
||||
let placed = placed::placements(&d, nsid, &mut |h| {
|
||||
asked += 1;
|
||||
Arc::new(compose_story(&d, h, &ComposeOptions::default()))
|
||||
});
|
||||
assert_eq!(asked, 1, "the host is composed once");
|
||||
assert_eq!(placed.len(), 3, "{placed:?}");
|
||||
// The cache and the uncached path agree on where the frames go.
|
||||
assert_eq!(Cache::new().get(&d, nsid, None).frames.len(), compose_story(&d, nsid, &ComposeOptions::default()).frames.len());
|
||||
}
|
||||
|
||||
/// A marginal note (an anchored text frame, Only Align First Line to Grid, 9/10) hangs 15 pt
|
||||
/// above its anchor's baseline on a right-hand page. It composes against the document grid where
|
||||
/// it is placed: its first line moves down onto the grid line, the second follows 10 pt below,
|
||||
@@ -7932,3 +8579,34 @@ fn anchored_text_frames_compose_against_the_grid_where_they_are_placed() {
|
||||
Arc::make_mut(&mut d.spreads[1]).pages[1].side = designcraft_doc::PageSide::Left;
|
||||
assert!(!Arc::ptr_eq(&before, &cache.get(&d, nsid, None)));
|
||||
}
|
||||
|
||||
/// A cross-reference's text breaks at its spaces like ordinary text (it is real text in
|
||||
/// InDesign), not inside words; edited text whose destination is gone shows its saved page.
|
||||
#[test]
|
||||
fn cross_reference_text_breaks_at_its_spaces() {
|
||||
let (mut d, sid, _) = doc_with("See X.", Rect::new(36.0, 36.0, 110.0, 400.0), ParaAttrs::default());
|
||||
let at = d.story(sid).unwrap().text.find('X').unwrap();
|
||||
let st = d.story_mut(sid).unwrap();
|
||||
st.delete(at..at + 1);
|
||||
st.insert_xref(
|
||||
at,
|
||||
designcraft_doc::CrossRef {
|
||||
text: Some("Page <pageNum />: Bove, Vinny. \"Advent Horizon.\" 1996. Volume 6, issue 5.".into()),
|
||||
page: Some("7".into()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let x = d.story(sid).unwrap().xrefs[0].clone();
|
||||
let shown = crate::xref::xref_text(&d, &x, None);
|
||||
assert_eq!(shown, "Page 7: Bove, Vinny. \"Advent Horizon.\" 1996. Volume 6, issue 5.");
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let lines: Vec<String> =
|
||||
cs.frames[0].lines.iter().map(|l| l.glyphs.iter().filter(|g| g.visible).map(|g| g.rendered_char).collect::<String>()).collect();
|
||||
assert!(lines.len() > 3, "{lines:?}");
|
||||
let words: Vec<&str> = format!("See {shown}.").leak().split(' ').collect();
|
||||
for l in &lines {
|
||||
for w in l.split(' ').filter(|w| !w.is_empty()) {
|
||||
assert!(words.contains(&w), "a line breaks inside a word: {w:?} in {lines:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,32 @@ pub(crate) fn place(doc: &Document, cs: &ComposedStory, pos: usize) -> Option<(u
|
||||
Some((doc.first_page_of_spread(si) + pi, ((b.y0 + baseline) * 10.0) as i64, ((b.x0 + x) * 10.0) as i64))
|
||||
}
|
||||
|
||||
/// A paragraph or run a running header may show: where it starts ([`place`]), its story and byte
|
||||
/// there, and its text.
|
||||
struct Hit {
|
||||
at: (usize, i64, i64),
|
||||
story: StoryId,
|
||||
pos: usize,
|
||||
text: String,
|
||||
}
|
||||
|
||||
/// (page, text) in the order running headers read a page: text order within a story (a story's
|
||||
/// second column comes after its first, however high its entries sit), and stories by where their
|
||||
/// first entry on the page is (top to bottom, then left to right).
|
||||
fn in_page_order(hits: Vec<Hit>) -> Vec<(usize, String)> {
|
||||
// Each story on a page is placed by its first hit there in text order (not its highest hit,
|
||||
// which in a later column could put the whole story ahead of another).
|
||||
let mut first: HashMap<(usize, StoryId), (usize, (i64, i64))> = HashMap::new();
|
||||
for h in &hits {
|
||||
let (page, y, x) = h.at;
|
||||
first.entry((page, h.story)).and_modify(|f| *f = (*f).min((h.pos, (y, x)))).or_insert((h.pos, (y, x)));
|
||||
}
|
||||
let mut hits: Vec<_> =
|
||||
hits.into_iter().map(|h| (h.at.0, first.get(&(h.at.0, h.story)).map_or((h.at.1, h.at.2), |f| f.1), h.story, h.pos, h.text)).collect();
|
||||
hits.sort_by_key(|a| (a.0, a.1, a.2.0, a.3));
|
||||
hits.into_iter().map(|(page, _, _, _, text)| (page, text)).collect()
|
||||
}
|
||||
|
||||
impl RunningIndex {
|
||||
/// Index the paragraphs / runs that running-header variables refer to. `get` composes a story
|
||||
/// (normally through the cache); stories that themselves contain running headers are skipped.
|
||||
@@ -65,7 +91,8 @@ impl RunningIndex {
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let mut hits: HashMap<(String, bool), Vec<((usize, i64, i64), String)>> = HashMap::new();
|
||||
// (place, story, byte) of each paragraph or run, with its text.
|
||||
let mut hits: HashMap<(String, bool), Vec<Hit>> = HashMap::new();
|
||||
if wanted.is_empty() {
|
||||
return RunningIndex::default();
|
||||
}
|
||||
@@ -90,7 +117,7 @@ impl RunningIndex {
|
||||
continue;
|
||||
}
|
||||
if let Some(at) = place(doc, &cs, r.start) {
|
||||
hits.entry(key).or_default().push((at, text));
|
||||
hits.entry(key).or_default().push(Hit { at, story: *sid, pos: r.start, text });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,16 +132,15 @@ impl RunningIndex {
|
||||
continue;
|
||||
}
|
||||
if let Some(at) = place(doc, &cs, r.start) {
|
||||
hits.entry(key).or_default().push((at, text));
|
||||
hits.entry(key).or_default().push(Hit { at, story: *sid, pos: r.start, text });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut map = HashMap::new();
|
||||
for (key, mut v) in hits {
|
||||
v.sort_by_key(|(at, _)| *at);
|
||||
for (key, v) in hits {
|
||||
let mut pages: BTreeMap<usize, (String, String)> = BTreeMap::new();
|
||||
for ((page, ..), text) in v {
|
||||
for (page, text) in in_page_order(v) {
|
||||
pages.entry(page).and_modify(|e| e.1 = text.clone()).or_insert((text.clone(), text));
|
||||
}
|
||||
map.insert(key, pages);
|
||||
@@ -134,10 +160,18 @@ impl RunningIndex {
|
||||
|
||||
/// Values of every variable on `page`.
|
||||
pub fn values(doc: &Document, page: Option<usize>, running: Option<&RunningIndex>) -> Vec<String> {
|
||||
values_shown(doc, page, false, running)
|
||||
}
|
||||
|
||||
/// [`values`] as text shows them: on a parent page itself (`parent`, with no `page`), a running
|
||||
/// header has no entries of its own and shows which variable sits there, `<Name>`, as InDesign
|
||||
/// does. Elsewhere without a page (the pasteboard), it is its text before and after.
|
||||
pub fn values_shown(doc: &Document, page: Option<usize>, parent: bool, running: Option<&RunningIndex>) -> Vec<String> {
|
||||
doc.text_variables
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| match &v.kind {
|
||||
VarKind::RunningHeader { .. } if parent && page.is_none() => format!("<{}>", v.name),
|
||||
VarKind::RunningHeader { style, use_, character } => {
|
||||
let body = match (running, page) {
|
||||
(Some(r), Some(p)) => r.value(style, *character, *use_, p),
|
||||
|
||||
@@ -12,16 +12,27 @@
|
||||
//! that come after the line holding its anchor; that line and the lines before it are never
|
||||
//! wrapped by it (an inline object sits in its line and wraps none of its own story). The story
|
||||
//! is composed again with the objects where the previous pass placed them until they stop
|
||||
//! moving, at most [`MAX_PASSES`] more times.
|
||||
//! moving. An object's wrap can push the anchors of the objects after it, so a chain of n such
|
||||
//! objects needs n + 1 more compositions: the k-th pass places the (k + 1)-th object where the
|
||||
//! wraps before it leave its anchor, and the last pass wraps around the last object. The story
|
||||
//! is composed at most that many more times (at least [`MIN_PASSES`]), and never more than
|
||||
//! [`MAX_PASSES`].
|
||||
|
||||
use designcraft_doc::{AnchorPosition, AnchoredObject, Document, Item, OBJECT_MARK, PageSide, SpreadRef, Story, StoryId, WrapMode};
|
||||
use designcraft_geom::Affine;
|
||||
|
||||
use crate::{ComposeOptions, ComposedStory, Exclusion, FrameSpec};
|
||||
|
||||
/// Extra compositions of a story with its own anchored objects' wrap; a layout that keeps moving
|
||||
/// its objects (keep options, vertical justification) stops here.
|
||||
pub(crate) const MAX_PASSES: usize = 4;
|
||||
/// Most extra compositions of a story with its own anchored objects' wrap, however many objects
|
||||
/// it has; a layout that keeps moving its objects (keep options, vertical justification) stops
|
||||
/// sooner, after one per object and one more (but never fewer than [`MIN_PASSES`]).
|
||||
pub(crate) const MAX_PASSES: usize = 64;
|
||||
|
||||
/// Fewest extra compositions allowed before giving up on a layout that keeps moving: a single
|
||||
/// object that keep options or vertical justification move still gets the room it had before.
|
||||
const MIN_PASSES: usize = 4;
|
||||
// `clamp` needs min <= max.
|
||||
const _: () = assert!(MIN_PASSES <= MAX_PASSES);
|
||||
|
||||
/// How far an obstacle may move between passes and still count as settled (points).
|
||||
const SETTLED: f64 = 0.01;
|
||||
@@ -199,8 +210,9 @@ pub(crate) fn compose_among(doc: &Document, sid: StoryId, opts: &ComposeOptions,
|
||||
if !has_wrapping_objects(st) {
|
||||
return out;
|
||||
}
|
||||
let own = st.objects.iter().filter(|o| wraps(o) && !matches!(o.position, AnchorPosition::Inline { .. })).count();
|
||||
let mut prev = vec![Vec::new(); specs.len()];
|
||||
for _ in 0..MAX_PASSES {
|
||||
for _ in 0..(own + 1).clamp(MIN_PASSES, MAX_PASSES) {
|
||||
let added = exclusions(doc, &specs, &obstacles(doc, st, &out, true));
|
||||
if settled(&added, &prev) {
|
||||
break;
|
||||
|
||||
@@ -52,8 +52,14 @@ impl XrefIndex {
|
||||
}
|
||||
|
||||
/// The text a cross-reference shows ("??" when its destination is gone, like an unresolved
|
||||
/// reference in typesetting).
|
||||
/// reference in typesetting, unless the text was edited).
|
||||
pub fn xref_text(doc: &Document, x: &xref::CrossRef, index: Option<&XrefIndex>) -> String {
|
||||
// Edited text stays as typed; its page numbers still follow the destination, or show the
|
||||
// page last shown when the destination is gone (as in InDesign).
|
||||
if let Some(t) = &x.text {
|
||||
let live = index.filter(|_| doc.xref_values(x.target).is_some()).and_then(|i| i.page(x.target));
|
||||
return xref::expand_edited(t, live.or(x.page.as_deref()).unwrap_or("?"));
|
||||
}
|
||||
let Some(mut v) = doc.xref_values(x.target) else { return "??".into() };
|
||||
v.page = index.and_then(|i| i.page(x.target)).unwrap_or("?").to_string();
|
||||
let def = doc.xref_format(&x.format).map_or("<fullPara />", |f| f.definition.as_str());
|
||||
|
||||
@@ -112,8 +112,13 @@ pub fn find_same_asset(assets: &BTreeMap<AssetId, Arc<Asset>>, asset: &Asset) ->
|
||||
|
||||
/// Add `asset` to `assets` unless they already hold the same file; returns the id placements
|
||||
/// should reference. A new asset with the bytes of an existing one shares its buffer.
|
||||
///
|
||||
/// This scans `assets`; an importer adding many files uses an [`AssetIndex`] instead.
|
||||
pub fn insert_asset(assets: &mut BTreeMap<AssetId, Arc<Asset>>, mut asset: Asset) -> AssetId {
|
||||
if let Some(id) = find_same_asset(assets, &asset) {
|
||||
// An empty, unlinked asset never holds the same file as another: nothing to search for.
|
||||
if (!asset.data.is_empty() || asset.link.is_some())
|
||||
&& let Some(id) = find_same_asset(assets, &asset)
|
||||
{
|
||||
return id;
|
||||
}
|
||||
if !asset.data.is_empty()
|
||||
@@ -126,6 +131,51 @@ pub fn insert_asset(assets: &mut BTreeMap<AssetId, Arc<Asset>>, mut asset: Asset
|
||||
id
|
||||
}
|
||||
|
||||
/// A lookup of the assets added through it, by content, so that adding many files (an import)
|
||||
/// costs time in proportion to their number rather than its square. It only knows the assets
|
||||
/// it inserted: use one index per asset map, and add every asset through it.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct AssetIndex {
|
||||
fingerprint: std::collections::hash_map::RandomState,
|
||||
/// Candidates for [`Asset::same_file`]: (bytes fingerprint, type, page, link).
|
||||
files: HashMap<(u64, String, u32, Option<String>), Vec<AssetId>>,
|
||||
/// One asset per distinct non-empty byte content, for buffer sharing.
|
||||
bytes: HashMap<u64, Vec<AssetId>>,
|
||||
}
|
||||
|
||||
impl AssetIndex {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// [`insert_asset`], looking up candidates by fingerprint instead of scanning `assets`.
|
||||
/// A fingerprint only selects candidates; equality still compares the actual files.
|
||||
pub fn insert_asset(&mut self, assets: &mut BTreeMap<AssetId, Arc<Asset>>, mut asset: Asset) -> AssetId {
|
||||
// Empty, unlinked assets never compare equal: don't search for or index them.
|
||||
if asset.data.is_empty() && asset.link.is_none() {
|
||||
let id = asset.id;
|
||||
assets.insert(id, Arc::new(asset));
|
||||
return id;
|
||||
}
|
||||
let hash = self.fingerprint.hash_one(asset.data.as_slice());
|
||||
let key = (hash, asset.mime.clone(), asset.page, asset.link.clone());
|
||||
if let Some(id) = self.files.get(&key).and_then(|ids| ids.iter().copied().find(|id| assets.get(id).is_some_and(|a| a.same_file(&asset)))) {
|
||||
return id;
|
||||
}
|
||||
if !asset.data.is_empty() {
|
||||
let bucket = self.bytes.entry(hash).or_default();
|
||||
match bucket.iter().find_map(|id| assets.get(id).filter(|a| a.same_bytes(&asset))) {
|
||||
Some(a) => asset.data = Arc::clone(&a.data),
|
||||
None => bucket.push(asset.id),
|
||||
}
|
||||
}
|
||||
let id = asset.id;
|
||||
self.files.entry(key).or_default().push(id);
|
||||
assets.insert(id, Arc::new(asset));
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
/// Calls `f` with every asset reference in `it` and its children; false when nesting exceeded
|
||||
/// [`MAX_DEPTH`] (some references weren't visited).
|
||||
fn item_refs(it: &Item, f: &mut impl FnMut(AssetId), depth: usize) -> bool {
|
||||
@@ -463,6 +513,54 @@ mod tests {
|
||||
assert_ne!(m3, m4);
|
||||
}
|
||||
|
||||
/// The index dedupes exactly as the scan does, and skips empty unlinked assets.
|
||||
#[test]
|
||||
fn indexed_insert_matches_scanning_insert() {
|
||||
let linked = |id, path: &str, data: &[u8]| Asset { link: Some(path.into()), ..asset(id, data) };
|
||||
let cases = vec![
|
||||
asset(1, b"same"),
|
||||
asset(2, b"same"),
|
||||
asset(3, b"diff"),
|
||||
Asset { page: 1, ..asset(4, b"same") },
|
||||
linked(5, "/p/a.png", b"same"),
|
||||
linked(6, "/q/b.png", b"same"),
|
||||
linked(7, "/p/a.png", b"same"),
|
||||
linked(8, "/m/1.png", b""),
|
||||
linked(9, "/m/1.png", b""),
|
||||
asset(10, b""),
|
||||
asset(11, b""),
|
||||
];
|
||||
let mut scanned = BTreeMap::new();
|
||||
let mut indexed = BTreeMap::new();
|
||||
let mut index = AssetIndex::new();
|
||||
for a in cases {
|
||||
let s = insert_asset(&mut scanned, a.clone());
|
||||
let i = index.insert_asset(&mut indexed, a);
|
||||
assert_eq!(s, i);
|
||||
}
|
||||
assert_eq!(scanned.keys().collect::<Vec<_>>(), indexed.keys().collect::<Vec<_>>());
|
||||
assert!(Arc::ptr_eq(&indexed[&AssetId(5)].data, &indexed[&AssetId(6)].data), "identical bytes share one buffer");
|
||||
}
|
||||
|
||||
/// Many empty unlinked placements (graphics with neither bytes nor a link) insert without
|
||||
/// comparing against each other.
|
||||
#[test]
|
||||
fn many_empty_unlinked_assets_insert_quickly() {
|
||||
let mut assets = BTreeMap::new();
|
||||
let mut index = AssetIndex::new();
|
||||
for id in 1..=200_000u64 {
|
||||
assert_eq!(insert_asset(&mut assets, asset(id, b"")), AssetId(id));
|
||||
}
|
||||
assert_eq!(assets.len(), 200_000);
|
||||
let mut indexed = BTreeMap::new();
|
||||
for id in 1..=20_000u64 {
|
||||
let mut bytes = vec![7; 64];
|
||||
bytes[56..].copy_from_slice(&id.to_le_bytes());
|
||||
assert_eq!(index.insert_asset(&mut indexed, asset(id, &bytes)), AssetId(id));
|
||||
}
|
||||
assert_eq!(indexed.len(), 20_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_at_different_paths_stay_separate_assets() {
|
||||
let mut d = Document::new(&Default::default());
|
||||
|
||||
@@ -86,7 +86,7 @@ attr_set! {
|
||||
kenten_font: String = String::new(),
|
||||
kenten_font_style: String = String::new(),
|
||||
/// Mark size in points; `None` is half the text size.
|
||||
kenten_size: Option<f64> = None,
|
||||
kenten_size: Option<f64> = None => "deserialize_explicit_value",
|
||||
/// Horizontal / vertical scale of the mark, 1.0 = 100%.
|
||||
kenten_x_scale: f64 = 1.0,
|
||||
kenten_y_scale: f64 = 1.0,
|
||||
@@ -99,10 +99,10 @@ attr_set! {
|
||||
/// Mark fill and stroke swatches (empty = the text's), tints and stroke weight (`None` =
|
||||
/// the text's).
|
||||
kenten_fill: String = String::new(),
|
||||
kenten_fill_tint: Option<f32> = None,
|
||||
kenten_fill_tint: Option<f32> = None => "deserialize_explicit_value",
|
||||
kenten_stroke: String = String::new(),
|
||||
kenten_stroke_tint: Option<f32> = None,
|
||||
kenten_stroke_weight: Option<f64> = None,
|
||||
kenten_stroke_tint: Option<f32> = None => "deserialize_explicit_value",
|
||||
kenten_stroke_weight: Option<f64> = None => "deserialize_explicit_value",
|
||||
kenten_overprint_fill: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto,
|
||||
kenten_overprint_stroke: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto,
|
||||
// Shatai (斜体): the character is compressed across the direction at `shatai_angle`.
|
||||
@@ -126,7 +126,7 @@ attr_set! {
|
||||
ruby_font: String = String::new(),
|
||||
ruby_font_style: String = String::new(),
|
||||
/// Points; `None` is half the parent size.
|
||||
ruby_font_size: Option<f64> = None,
|
||||
ruby_font_size: Option<f64> = None => "deserialize_explicit_value",
|
||||
/// 1.0 = 100%.
|
||||
ruby_x_scale: f64 = 1.0,
|
||||
ruby_y_scale: f64 = 1.0,
|
||||
@@ -147,10 +147,10 @@ attr_set! {
|
||||
ruby_scaling_min: f64 = 0.66,
|
||||
/// Ruby colour: empty is the text's colour; `None` tint and weight follow the text.
|
||||
ruby_fill: String = String::new(),
|
||||
ruby_fill_tint: Option<f32> = None,
|
||||
ruby_fill_tint: Option<f32> = None => "deserialize_explicit_value",
|
||||
ruby_stroke: String = String::new(),
|
||||
ruby_stroke_tint: Option<f32> = None,
|
||||
ruby_stroke_weight: Option<f64> = None,
|
||||
ruby_stroke_tint: Option<f32> = None => "deserialize_explicit_value",
|
||||
ruby_stroke_weight: Option<f64> = None => "deserialize_explicit_value",
|
||||
ruby_overprint_fill: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto,
|
||||
ruby_overprint_stroke: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto,
|
||||
/// Digits (World-Ready): how 0–9 are drawn.
|
||||
|
||||
@@ -141,6 +141,9 @@ attr_set! {
|
||||
shading_on: bool = false,
|
||||
shading_color: String = "[Black]".into(),
|
||||
shading_tint: f32 = 0.2,
|
||||
/// Overprint (InDesign's Paragraph Shading › Overprint): stored and kept through IDML;
|
||||
/// PDF export doesn't overprint yet.
|
||||
shading_overprint: bool = false,
|
||||
shading_offsets: [f64; 4] = [0.0; 4],
|
||||
/// Paragraph border: stroke weights (top, left, bottom, right), colour, and offsets past the
|
||||
/// text (top, left, bottom, right).
|
||||
@@ -148,6 +151,8 @@ attr_set! {
|
||||
border_weights: [f64; 4] = [1.0; 4],
|
||||
border_color: String = "[Black]".into(),
|
||||
border_tint: f32 = 1.0,
|
||||
/// Overprint the border's stroke (stored and kept through IDML, as `ParagraphBorderOverprint`).
|
||||
border_overprint: bool = false,
|
||||
border_offsets: [f64; 4] = [0.0; 4],
|
||||
/// The border's stroke type (a document stroke style by name, or a built-in type), cap and
|
||||
/// join.
|
||||
@@ -167,6 +172,8 @@ attr_set! {
|
||||
/// The colour between the dashes, dots or stripes of a border that isn't solid.
|
||||
border_gap_color: String = "[None]".into(),
|
||||
border_gap_tint: f32 = 1.0,
|
||||
/// Overprint the border's gap colour (stored and kept through IDML).
|
||||
border_gap_overprint: bool = false,
|
||||
/// Shading corners: top left, top right, bottom right, bottom left.
|
||||
shading_corners: [designcraft_geom::corners::Corner; 4] = [designcraft_geom::corners::Corner::default(); 4],
|
||||
shading_width: BoxWidth = BoxWidth::Column,
|
||||
@@ -188,7 +195,7 @@ attr_set! {
|
||||
bullet_align: ListAlign = ListAlign::Left,
|
||||
number_align: ListAlign = ListAlign::Left,
|
||||
/// A tab stop for the text after the bullet or number, from the column's left edge.
|
||||
list_tab: Option<f64> = None,
|
||||
list_tab: Option<f64> = None => "deserialize_explicit_value",
|
||||
/// Restart Numbers at This Level After: a paragraph at a higher level (0 = any; otherwise
|
||||
/// that level) starts this level's numbering again.
|
||||
restart_numbers: bool = true,
|
||||
@@ -222,8 +229,11 @@ impl ParaAttrs {
|
||||
let serde_json::Value::Object(patch) = value else { return self.set_json(key, value) };
|
||||
let mut merged = serde_json::to_value(rule).map_err(|e| format!("{key}: {e}"))?;
|
||||
let serde_json::Value::Object(fields) = &mut merged else { return Err(format!("{key}: not an object")) };
|
||||
// Every field, those at their defaults (left out of the JSON) included.
|
||||
const RULE_FIELDS: [&str; 12] =
|
||||
["on", "weight", "color", "tint", "columnWidth", "offset", "leftIndent", "rightIndent", "type", "gapColor", "gapTint", "keepInFrame"];
|
||||
for (k, v) in patch {
|
||||
if !fields.contains_key(k) {
|
||||
if !RULE_FIELDS.contains(&k.as_str()) {
|
||||
return Err(format!("{key}: unknown rule field `{k}`"));
|
||||
}
|
||||
fields.insert(k.clone(), v.clone());
|
||||
|
||||
@@ -17,11 +17,46 @@ pub struct Rule {
|
||||
pub offset: f64,
|
||||
pub left_indent: f64,
|
||||
pub right_indent: f64,
|
||||
/// Type: the rule's stroke type (solid, dashed, wavy…, or a stroke style of the document).
|
||||
#[serde(rename = "type", skip_serializing_if = "rule_solid")]
|
||||
pub kind: crate::item::StrokeType,
|
||||
/// Gap Color (a swatch; [None]: none) and Gap Tint: under the gaps of a type that has them.
|
||||
#[serde(skip_serializing_if = "rule_no_gap")]
|
||||
pub gap_color: String,
|
||||
#[serde(skip_serializing_if = "rule_full_tint")]
|
||||
pub gap_tint: f32,
|
||||
/// Keep In Frame: a rule above the first line of a frame stays inside the frame.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub keep_in_frame: bool,
|
||||
}
|
||||
|
||||
// A rule's settings at their defaults are left out of the document (and of the attributes).
|
||||
fn rule_solid(k: &crate::item::StrokeType) -> bool {
|
||||
*k == crate::item::StrokeType::Solid
|
||||
}
|
||||
fn rule_no_gap(c: &str) -> bool {
|
||||
c == designcraft_color::swatch::NONE
|
||||
}
|
||||
fn rule_full_tint(t: &f32) -> bool {
|
||||
*t == 1.0
|
||||
}
|
||||
|
||||
impl Default for Rule {
|
||||
fn default() -> Self {
|
||||
Rule { on: false, weight: 1.0, color: TEXT_COLOR.into(), tint: 1.0, column_width: true, offset: 0.0, left_indent: 0.0, right_indent: 0.0 }
|
||||
Rule {
|
||||
on: false,
|
||||
weight: 1.0,
|
||||
color: TEXT_COLOR.into(),
|
||||
tint: 1.0,
|
||||
column_width: true,
|
||||
offset: 0.0,
|
||||
left_indent: 0.0,
|
||||
right_indent: 0.0,
|
||||
kind: crate::item::StrokeType::Solid,
|
||||
gap_color: designcraft_color::swatch::NONE.into(),
|
||||
gap_tint: 1.0,
|
||||
keep_in_frame: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-3
@@ -661,11 +661,18 @@ impl Document {
|
||||
}
|
||||
|
||||
/// Parent page (spread index, page index) applied to `page`: the page on its side of its
|
||||
/// parent spread.
|
||||
/// parent spread, or in its place for single-sided pages ([`Spread::parent_page_at`]).
|
||||
pub fn parent_page_of(&self, page: &Page) -> Option<(usize, usize)> {
|
||||
let pi = self.parent_index(page.parent?)?;
|
||||
// A parent spread can come without pages from a file.
|
||||
Some((pi, self.parents.get(pi)?.parent_page_for_side(page.side)?))
|
||||
let parent = self.parents.get(pi)?;
|
||||
// Only a single-sided page's position matters: look for it only then.
|
||||
let index = if page.side == PageSide::Single && parent.pages.len() >= 2 {
|
||||
self.spreads.iter().find_map(|s| s.pages.iter().position(|p| p.id == page.id)).unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
Some((pi, parent.parent_page_at(page.side, index)?))
|
||||
}
|
||||
|
||||
/// The parent page applied to `page`.
|
||||
@@ -777,7 +784,7 @@ impl Document {
|
||||
c
|
||||
}
|
||||
};
|
||||
chain.into_iter().filter_map(|i| Some((i, self.parents.get(i)?.parent_page_for_side(pg.side)?))).collect()
|
||||
chain.into_iter().filter_map(|i| Some((i, self.parents.get(i)?.parent_page_at(pg.side, page)?))).collect()
|
||||
}
|
||||
|
||||
/// Whether basing parent `pi` on `base` would make a `based_on` cycle (or names no parent).
|
||||
|
||||
@@ -385,6 +385,23 @@ mod tests {
|
||||
use super::*;
|
||||
use designcraft_geom::shapes;
|
||||
|
||||
/// Shadows and glows saved before they had a blend mode read Normal, as they were drawn;
|
||||
/// new ones start as InDesign's: Multiply for shadows, Screen for the outer glow.
|
||||
#[test]
|
||||
fn effect_blend_modes_default_as_indesign_and_keep_old_documents() {
|
||||
let old: DropShadow =
|
||||
serde_json::from_str(r#"{"on": true, "color": "[Black]", "opacity": 0.75, "angle": 120, "distance": 7, "size": 5, "spread": 0}"#)
|
||||
.unwrap();
|
||||
assert_eq!(old.mode, BlendMode::Normal);
|
||||
let old: OuterGlow = serde_json::from_str(r#"{"on": true, "color": "[Black]", "opacity": 0.75, "size": 7, "spread": 0}"#).unwrap();
|
||||
assert_eq!(old.mode, BlendMode::Normal);
|
||||
assert_eq!(DropShadow::default().mode, BlendMode::Multiply);
|
||||
assert_eq!(InnerShadow::default().mode, BlendMode::Multiply);
|
||||
assert_eq!(OuterGlow::default().mode, BlendMode::Screen);
|
||||
let saved = serde_json::to_value(DropShadow { mode: BlendMode::Screen, ..Default::default() }).unwrap();
|
||||
assert_eq!(serde_json::from_value::<DropShadow>(saved).unwrap().mode, BlendMode::Screen, "round trip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphics_without_a_clipping_path_load_and_save_as_before() {
|
||||
let g = Graphic {
|
||||
|
||||
@@ -13,12 +13,22 @@ pub struct DropShadow {
|
||||
pub distance: f64,
|
||||
pub size: f64,
|
||||
pub spread: f64,
|
||||
/// How the shadow blends with what's behind it (InDesign: Multiply). A document saved before
|
||||
/// effects had modes reads Normal, as they were drawn then.
|
||||
#[serde(default = "normal_blend")]
|
||||
pub mode: BlendMode,
|
||||
}
|
||||
|
||||
/// The blend of an effect saved before effects had one.
|
||||
fn normal_blend() -> BlendMode {
|
||||
BlendMode::Normal
|
||||
}
|
||||
|
||||
impl Default for DropShadow {
|
||||
fn default() -> Self {
|
||||
DropShadow {
|
||||
on: false,
|
||||
mode: BlendMode::Multiply,
|
||||
color: designcraft_color::swatch::BLACK.into(),
|
||||
opacity: 0.75,
|
||||
angle: 120.0,
|
||||
@@ -45,12 +55,16 @@ pub struct InnerShadow {
|
||||
pub size: f64,
|
||||
/// Percentage (0–100) of `size` that hardens the edge instead of blurring it.
|
||||
pub choke: f64,
|
||||
/// How the shadow blends with the object (InDesign: Multiply; Normal when saved before).
|
||||
#[serde(default = "normal_blend")]
|
||||
pub mode: BlendMode,
|
||||
}
|
||||
|
||||
impl Default for InnerShadow {
|
||||
fn default() -> Self {
|
||||
InnerShadow {
|
||||
on: false,
|
||||
mode: BlendMode::Multiply,
|
||||
color: designcraft_color::swatch::BLACK.into(),
|
||||
opacity: 0.75,
|
||||
angle: 120.0,
|
||||
@@ -72,11 +86,15 @@ pub struct OuterGlow {
|
||||
pub size: f64,
|
||||
/// Percentage (0–100) of `size` that grows the silhouette instead of blurring it.
|
||||
pub spread: f64,
|
||||
/// How the glow blends with what's behind it (InDesign: Screen; Normal when saved before).
|
||||
#[serde(default = "normal_blend")]
|
||||
pub mode: BlendMode,
|
||||
}
|
||||
|
||||
impl Default for OuterGlow {
|
||||
fn default() -> Self {
|
||||
OuterGlow { on: false, color: designcraft_color::swatch::BLACK.into(), opacity: 0.75, size: 7.0, spread: 0.0 }
|
||||
// InDesign starts a glow screened in white.
|
||||
OuterGlow { on: false, color: designcraft_color::swatch::PAPER.into(), opacity: 0.75, size: 7.0, spread: 0.0, mode: BlendMode::Screen }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+99
-1
@@ -44,7 +44,7 @@ use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use anchored::{AnchorPosition, AnchoredObject, OBJECT_MARK};
|
||||
pub use assets::{find_same_asset, insert_asset, remap_story_assets, story_asset_refs};
|
||||
pub use assets::{AssetIndex, find_same_asset, insert_asset, remap_story_assets, story_asset_refs};
|
||||
pub use attrs::*;
|
||||
pub use datamerge::{
|
||||
DataField, DataFieldKind, DataMerge, DataSource, Delimiter, Fingerprint, MergeOptions, Placeholder, PlaceholderAnchor, PlaceholderRole,
|
||||
@@ -831,6 +831,9 @@ pub struct Toc {
|
||||
/// Paragraph style of the title; `None` is the generated "TOC Title" style.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub title_style: Option<String>,
|
||||
/// Generate a nested PDF outline from the included paragraphs when the TOC is updated.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub create_bookmarks: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -841,6 +844,16 @@ pub struct Bookmark {
|
||||
pub page: usize,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub children: Vec<Bookmark>,
|
||||
/// Owned by the document's generated TOC, so updating it preserves manual bookmarks.
|
||||
///
|
||||
/// Stored only in native files. Bookmarks (and so this flag) are not written to or read
|
||||
/// from IDML yet, so after an IDML round trip the next TOC update builds the outline fresh.
|
||||
/// If IDML bookmark import is added, imported bookmarks arrive with this flag false and the
|
||||
/// next update would keep them as manual and add a second, duplicate outline. That import
|
||||
/// must set this flag on the bookmarks the TOC generated (for example by matching them to
|
||||
/// the TOC entries) when the TOC has `create_bookmarks` on.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub generated_by_toc: bool,
|
||||
}
|
||||
|
||||
impl Document {
|
||||
@@ -955,6 +968,91 @@ impl Document {
|
||||
designcraft_color::swatch::resolve(&self.swatches, swatch, tint)
|
||||
}
|
||||
|
||||
/// Give items that reuse an earlier item's id (spreads, then parents, then items anchored in
|
||||
/// stories) fresh ids, so a file holding such duplicates opens. The first item keeps the id and
|
||||
/// what refers to it by id; a renumbered text frame takes its place in its story's thread.
|
||||
pub fn renumber_duplicate_items(&mut self) {
|
||||
fn walk_mut(it: &mut Item, f: &mut impl FnMut(&mut Item)) {
|
||||
f(it);
|
||||
for c in it.children_mut().into_iter().flatten() {
|
||||
walk_mut(Arc::make_mut(c), f);
|
||||
}
|
||||
}
|
||||
fn story_items(st: &mut Story, f: &mut impl FnMut(&mut Item)) {
|
||||
for o in &mut st.objects {
|
||||
walk_mut(&mut Arc::make_mut(o).item, f);
|
||||
}
|
||||
for t in st.tables.values_mut() {
|
||||
for c in &mut Arc::make_mut(t).cells {
|
||||
story_items(&mut c.text, f);
|
||||
}
|
||||
}
|
||||
for n in st.notes.iter_mut().chain(st.endnotes.iter_mut()) {
|
||||
story_items(&mut Arc::make_mut(n).text, f);
|
||||
}
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut dups = std::collections::HashSet::new();
|
||||
let mut next = self.next_id;
|
||||
let mut note = |id: ItemId| {
|
||||
next = next.max(id.0);
|
||||
if !seen.insert(id) {
|
||||
dups.insert(id);
|
||||
}
|
||||
};
|
||||
for sp in self.spreads.iter().chain(self.parents.iter()) {
|
||||
for it in &sp.items {
|
||||
it.walk(&mut |i| note(i.id));
|
||||
}
|
||||
}
|
||||
for (_, i) in self.anchored_items() {
|
||||
note(i.id);
|
||||
}
|
||||
if dups.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
// (story, id in the file) → the ids its frames carry now, in walk order.
|
||||
let mut threads: std::collections::HashMap<(StoryId, ItemId), Vec<ItemId>> = std::collections::HashMap::new();
|
||||
let mut fix = |it: &mut Item| {
|
||||
let old = it.id;
|
||||
if !dups.contains(&old) {
|
||||
return;
|
||||
}
|
||||
if !seen.insert(old) {
|
||||
// An id at the top of the range has no successor: leave the duplicate for
|
||||
// `check()` to refuse rather than overflow.
|
||||
let Some(n) = next.checked_add(1) else { return };
|
||||
next = n;
|
||||
it.id = ItemId(next);
|
||||
}
|
||||
if let Some(t) = it.text_frame() {
|
||||
threads.entry((t.story, old)).or_default().push(it.id);
|
||||
}
|
||||
};
|
||||
for sp in self.spreads.iter_mut().chain(self.parents.iter_mut()) {
|
||||
for it in &mut Arc::make_mut(sp).items {
|
||||
walk_mut(Arc::make_mut(it), &mut fix);
|
||||
}
|
||||
}
|
||||
for st in self.stories.values_mut() {
|
||||
story_items(Arc::make_mut(st), &mut fix);
|
||||
}
|
||||
self.next_id = next;
|
||||
for ((sid, old), ids) in threads {
|
||||
let Some(st) = self.stories.get_mut(&sid) else { continue };
|
||||
let st = Arc::make_mut(st);
|
||||
let mut ids = ids.into_iter();
|
||||
for f in st.frames.iter_mut().filter(|f| **f == old) {
|
||||
match ids.next() {
|
||||
Some(id) => *f = id,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
st.frames.extend(ids);
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate structural invariants (ids unique, threads consistent, stories well-formed).
|
||||
pub fn check(&self) -> Result<()> {
|
||||
for f in &self.styles.composite_fonts {
|
||||
|
||||
@@ -452,6 +452,16 @@ impl Spread {
|
||||
PageSide::Single => Some(0),
|
||||
}
|
||||
}
|
||||
/// The parent page shown behind page `index` of a document spread, on `side`: by side for
|
||||
/// facing pages ([`Self::parent_page_for_side`]); by position for single-sided pages in a
|
||||
/// spread of several, as InDesign does, so the second page of a two-page spread shows the
|
||||
/// parent's second page (the last one, for spreads longer than the parent).
|
||||
pub fn parent_page_at(&self, side: PageSide, index: usize) -> Option<usize> {
|
||||
match side {
|
||||
PageSide::Single if self.pages.len() >= 2 => Some(index.min(self.pages.len() - 1)),
|
||||
_ => self.parent_page_for_side(side),
|
||||
}
|
||||
}
|
||||
/// Index of the page under spread x (nearest page for the pasteboard).
|
||||
pub fn page_at_x(&self, x: f64) -> Option<usize> {
|
||||
if self.pages.is_empty() {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::edit::SpreadRef;
|
||||
use crate::ids::{ItemId, StoryId};
|
||||
use crate::ids::{ItemId, SpreadId, StoryId};
|
||||
use crate::table::CellRange;
|
||||
|
||||
/// A selected ruler guide: index `index` in the guides of page `page` of spread `spread`.
|
||||
@@ -93,6 +93,9 @@ pub struct Selection {
|
||||
/// The page selected with the Page tool (absolute index).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub page: Option<usize>,
|
||||
/// Parent selected with the Page tool: stable spread id and page index within it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub parent_page: Option<(SpreadId, usize)>,
|
||||
/// Selected ruler guides. Never together with items, text or cells: selecting one clears
|
||||
/// the other.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
|
||||
+86
-5
@@ -39,6 +39,35 @@ pub const FORCED_LINE_BREAK: char = '\u{2028}';
|
||||
pub const ODD_PAGE_BREAK: char = '\u{E011}';
|
||||
pub const EVEN_PAGE_BREAK: char = '\u{E012}';
|
||||
|
||||
/// Encode a table-of-contents separator using InDesign's special-character notation.
|
||||
pub fn toc_separator_notation(text: &str) -> String {
|
||||
text.replace('^', "^^").replace('\t', "^t").replace(RIGHT_INDENT_TAB, "^y").replace('\u{2003}', "^m").replace('\u{2002}', "^>")
|
||||
}
|
||||
|
||||
/// Decode TOC notation, preserving unknown codes and an escaped literal caret.
|
||||
pub fn toc_separator_text(notation: &str) -> String {
|
||||
let mut text = String::new();
|
||||
let mut chars = notation.chars();
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch != '^' {
|
||||
text.push(ch);
|
||||
continue;
|
||||
}
|
||||
match chars.next() {
|
||||
Some('t') => text.push('\t'),
|
||||
Some('y') => text.push(RIGHT_INDENT_TAB),
|
||||
Some('m') => text.push('\u{2003}'),
|
||||
Some('>') => text.push('\u{2002}'),
|
||||
Some('^') | None => text.push('^'),
|
||||
Some(other) => {
|
||||
text.push('^');
|
||||
text.push(other);
|
||||
}
|
||||
}
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// A column, frame or page break character. It ends its paragraph: the story stores it as the
|
||||
/// paragraph's last character, followed by the `\n` separator (also at the end of the story), and
|
||||
/// the next paragraph starts in the next column, frame or page. A break inside a paragraph (from
|
||||
@@ -286,6 +315,24 @@ impl Story {
|
||||
v
|
||||
}
|
||||
|
||||
/// The paragraphs `r` touches (at least the one at `r.start`), as (index, byte range without
|
||||
/// its return), found from `r` itself rather than by listing every paragraph of the story.
|
||||
pub fn paras_touching(&self, r: std::ops::Range<usize>) -> Vec<(usize, std::ops::Range<usize>)> {
|
||||
let start = floor_char_boundary(&self.text, r.start.min(self.text.len()));
|
||||
let mut pi = self.para_at(start);
|
||||
let mut a = self.text[..start].rfind('\n').map_or(0, |i| i + 1);
|
||||
let mut out = Vec::new();
|
||||
loop {
|
||||
let b = self.text[a..].find('\n').map_or(self.text.len(), |i| a + i);
|
||||
out.push((pi, a..b));
|
||||
// The next paragraph starts after this one's return: is it still inside `r`?
|
||||
if b >= self.text.len() || b + 1 >= r.end {
|
||||
return out;
|
||||
}
|
||||
(a, pi) = (b + 1, pi + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Index of the paragraph containing byte `pos`.
|
||||
pub fn para_at(&self, pos: usize) -> usize {
|
||||
self.text[..floor_char_boundary(&self.text, pos)].bytes().filter(|b| *b == b'\n').count()
|
||||
@@ -385,11 +432,11 @@ impl Story {
|
||||
// Paragraph formats: each new '\n' splits the current paragraph; the new paragraphs copy it.
|
||||
let pi = self.para_at(pos);
|
||||
let newlines = text.bytes().filter(|b| *b == b'\n').count();
|
||||
if newlines > 0 {
|
||||
let f = self.paras[pi].clone();
|
||||
for _ in 0..newlines {
|
||||
self.paras.insert(pi + 1, f.clone());
|
||||
}
|
||||
if newlines > 0
|
||||
&& let Some(f) = self.paras.get(pi).cloned()
|
||||
{
|
||||
// All at once: an insert per return made many returns quadratic (#1588).
|
||||
self.paras.splice(pi + 1..pi + 1, std::iter::repeat_n(f, newlines));
|
||||
}
|
||||
self.text.insert_str(pos, text);
|
||||
// Character runs.
|
||||
@@ -639,6 +686,27 @@ mod tests {
|
||||
Story::with_text(StoryId(1), t, ParaFormat::default())
|
||||
}
|
||||
|
||||
/// `paras_touching` gives the paragraphs a range reaches into (returns included), as listing
|
||||
/// every paragraph would, for every range of a story with empty and multi-byte paragraphs.
|
||||
#[test]
|
||||
fn paras_touching_matches_the_full_listing() {
|
||||
let s = st("ab
|
||||
|
||||
çé
|
||||
last");
|
||||
let all = s.para_ranges();
|
||||
for a in 0..=s.len() {
|
||||
for b in a..=s.len() {
|
||||
if !s.text.is_char_boundary(a) || !s.text.is_char_boundary(b) {
|
||||
continue;
|
||||
}
|
||||
let want: Vec<(usize, std::ops::Range<usize>)> =
|
||||
all.iter().cloned().enumerate().filter(|(i, p)| *i == s.para_at(a) || (a < b && p.start < b && a <= p.end)).collect();
|
||||
assert_eq!(s.paras_touching(a..b), want, "{a}..{b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optical_margin_is_optional_in_saved_stories() {
|
||||
let plain = serde_json::to_value(st("Text")).unwrap();
|
||||
@@ -669,6 +737,19 @@ mod tests {
|
||||
assert_eq!(s.para_ranges(), vec![0..6]);
|
||||
}
|
||||
|
||||
/// Each return inserted its paragraph format on its own, quadratic in the returns (#1588).
|
||||
#[test]
|
||||
fn many_returns_split_a_paragraph_at_once() {
|
||||
let para = ParaFormat { style: "Body".into(), ..ParaFormat::default() };
|
||||
let started = std::time::Instant::now();
|
||||
let s = Story::with_text(StoryId(1), &"\n".repeat(20_000), para.clone());
|
||||
let took = started.elapsed();
|
||||
assert!(took < std::time::Duration::from_secs(1), "took {took:?}");
|
||||
assert_eq!(s.paras.len(), 20_001);
|
||||
assert!(s.paras.iter().all(|p| *p == para));
|
||||
s.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_formats_split_and_merge() {
|
||||
let mut s = st("hello world");
|
||||
|
||||
@@ -25,6 +25,32 @@ mod story;
|
||||
pub use cell::{AltFills, AltStrokes, Cell, CellStroke, Column, Row, RowHeightMode, RowKind, TableOptions};
|
||||
pub use grid::CellRange;
|
||||
|
||||
/// Most rows a table holds, as InDesign allows.
|
||||
pub const MAX_TABLE_ROWS: usize = 10_000;
|
||||
/// Most columns a table holds, as InDesign allows.
|
||||
pub const MAX_TABLE_COLS: usize = 1_000;
|
||||
/// Most cells a table holds, whatever its shape.
|
||||
///
|
||||
/// A [`Cell`] is about 1.4 KB (it carries a [`Story`]), so this is roughly 340 MB of cells at the
|
||||
/// limit. The row and column limits alone are not enough: 10,000 × 1,000 would be 13.6 GB, and an
|
||||
/// IDML story holding one `<Table>` with 5,000 `<Row/>` and 5,000 `<Column/>` -- about 1 KB
|
||||
/// zipped -- asked for 25M cells, some 34 GB. That allocation does not fail politely: it aborts
|
||||
/// the process through `handle_alloc_error`, which no `catch_unwind` can save, so every open
|
||||
/// document's unsaved work goes with it (#1583).
|
||||
pub const MAX_TABLE_CELLS: usize = 250_000;
|
||||
|
||||
/// The rows and columns a table of `rows × cols` is actually given, inside the limits above.
|
||||
///
|
||||
/// Columns are clamped first and rows take what is left of the cell budget, because a table is
|
||||
/// far more often long than wide: truncating a 5,000-column table to 1,000 columns and then its
|
||||
/// rows loses less of it than the other way round. Callers that read a file report the
|
||||
/// difference; the counts are a document's claim, not something to trust.
|
||||
pub fn clamped_size(rows: usize, cols: usize) -> (usize, usize) {
|
||||
let cols = cols.clamp(1, MAX_TABLE_COLS);
|
||||
let rows = rows.clamp(1, MAX_TABLE_ROWS.min(MAX_TABLE_CELLS / cols).max(1));
|
||||
(rows, cols)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Table {
|
||||
@@ -48,6 +74,48 @@ mod tests {
|
||||
Story::with_text(StoryId(1), t, ParaFormat::default())
|
||||
}
|
||||
|
||||
/// A table's shape comes out of IDML and DOCX files unchecked, and `Cell` is about 1.4 KB, so
|
||||
/// a file claiming 5,000 x 5,000 asked for 25M cells -- some 34 GB. That allocation aborts the
|
||||
/// process rather than failing, taking every open document's unsaved work with it (#1583).
|
||||
#[test]
|
||||
fn a_table_larger_than_the_limits_is_clamped_not_allocated() {
|
||||
// The reported shape: 5,000 rows and 5,000 columns.
|
||||
let t = Table::new(1, 5_000, 5_000, 0, 0, 500.0);
|
||||
assert_eq!(t.columns.len(), MAX_TABLE_COLS, "columns are clamped to InDesign's limit");
|
||||
assert!(t.cells.len() <= MAX_TABLE_CELLS, "{} cells is within the budget", t.cells.len());
|
||||
assert_eq!(t.rows.len() * t.columns.len(), t.cells.len(), "the grid stays rectangular");
|
||||
// What it would have been: 25,000,000 cells at 1,360 bytes each.
|
||||
assert!(25_000_000 * std::mem::size_of::<Cell>() > 30_000_000_000, "the size this test is about");
|
||||
// Freed before the next large table, so the peak stays at one of them.
|
||||
drop(t);
|
||||
|
||||
// A long table keeps its rows while it fits the budget (the budget itself is checked by
|
||||
// `clamped_size`, which allocates nothing).
|
||||
let long = Table::new(2, 10_000, 5, 0, 0, 500.0);
|
||||
assert_eq!((long.rows.len(), long.columns.len()), (10_000, 5), "a long thin table is untouched");
|
||||
assert_eq!(clamped_size(10_000, 25), (10_000, 25), "10,000 x 25 is inside the budget");
|
||||
drop(long);
|
||||
|
||||
// Past the row limit the rows are clamped too.
|
||||
let longer = Table::new(3, 50_000, 2, 0, 0, 100.0);
|
||||
assert_eq!(longer.rows.len(), MAX_TABLE_ROWS, "rows are clamped to InDesign's limit");
|
||||
drop(longer);
|
||||
|
||||
// Header, body and footer share the budget, and the table still has a body.
|
||||
let with_parts = Table::new(4, 5_000, 5_000, 3, 2, 500.0);
|
||||
assert!(with_parts.rows.len() <= MAX_TABLE_ROWS, "{} rows", with_parts.rows.len());
|
||||
assert!(with_parts.cells.len() <= MAX_TABLE_CELLS, "{} cells", with_parts.cells.len());
|
||||
assert!(with_parts.rows.iter().any(|r| r.kind == RowKind::Header), "the header survives");
|
||||
assert!(with_parts.rows.iter().any(|r| r.kind == RowKind::Footer), "the footer survives");
|
||||
assert!(with_parts.rows.iter().any(|r| r.kind == RowKind::Body), "and so does a body row");
|
||||
drop(with_parts);
|
||||
|
||||
// A header and footer bigger than the whole budget still leave a body row.
|
||||
let all_header = Table::new(6, 1, 1, MAX_TABLE_ROWS * 2, MAX_TABLE_ROWS * 2, 100.0);
|
||||
assert!(all_header.rows.len() <= MAX_TABLE_ROWS, "{} rows", all_header.rows.len());
|
||||
assert!(all_header.rows.iter().any(|r| r.kind == RowKind::Body), "a body row survives");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_row_keeps_header_and_footer_rows_in_place() {
|
||||
let mut t = Table::new(1, 3, 2, 1, 1, 120.0);
|
||||
@@ -88,6 +156,19 @@ mod tests {
|
||||
assert_eq!(right.selected_owners(CellRange::new(1, 0, 1, 1)), vec![(0, 1), (1, 0)], "owner order, not encounter order");
|
||||
}
|
||||
|
||||
/// A short last row's padding cells take the converted text's paragraph and character format,
|
||||
/// like the cells with text.
|
||||
#[test]
|
||||
fn from_strings_formats_the_padding_cells_too() {
|
||||
let data = vec![vec!["a".to_string(), "b".to_string()], vec!["c".to_string()]];
|
||||
let para = ParaFormat { style: "Body".into(), ..Default::default() };
|
||||
let fmt = CharFormat { style: "Strong".into(), ..Default::default() };
|
||||
let t = Table::from_strings(1, &data, 200.0, ¶, &fmt);
|
||||
let pad = &t.cell(1, 1).unwrap().text;
|
||||
assert_eq!((pad.paras[0].style.as_str(), pad.chars[0].format.style.as_str()), ("Body", "Strong"));
|
||||
assert_eq!(pad.text, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_table_gets_own_paragraph() {
|
||||
let mut s = story("before after");
|
||||
|
||||
@@ -25,8 +25,16 @@ impl CellRange {
|
||||
|
||||
impl Table {
|
||||
/// A `rows × cols` table (`header` + body + `footer` rows) whose columns share `width`.
|
||||
///
|
||||
/// The shape is clamped to [`clamped_size`](super::clamped_size): the counts reach this from
|
||||
/// IDML and DOCX files, where they are whatever the file says (#1583).
|
||||
pub fn new(id: u64, body_rows: usize, cols: usize, header: usize, footer: usize, width: f64) -> Table {
|
||||
let cols = cols.max(1);
|
||||
// Header, body and footer rows share one budget: the total is clamped first, then the
|
||||
// header and footer take what they can of it, and the body keeps at least one row.
|
||||
let (total, cols) = super::clamped_size(body_rows.max(1).saturating_add(header).saturating_add(footer), cols);
|
||||
let header = header.min(total.saturating_sub(1));
|
||||
let footer = footer.min(total.saturating_sub(header).saturating_sub(1));
|
||||
let body_rows = total.saturating_sub(header).saturating_sub(footer).max(1);
|
||||
let mut rows = Vec::new();
|
||||
rows.extend((0..header).map(|_| Row { kind: RowKind::Header, ..Default::default() }));
|
||||
rows.extend((0..body_rows.max(1)).map(|_| Row::default()));
|
||||
@@ -47,12 +55,15 @@ impl Table {
|
||||
pub fn from_strings(id: u64, data: &[Vec<String>], width: f64, para: &ParaFormat, fmt: &CharFormat) -> Table {
|
||||
let cols = data.iter().map(Vec::len).max().unwrap_or(1).max(1);
|
||||
let mut t = Table::new(id, data.len().max(1), cols, 0, 0, width);
|
||||
for (r, row) in data.iter().enumerate() {
|
||||
for (c, s) in row.iter().enumerate() {
|
||||
// Every cell takes the text's format, the padding of a short row too.
|
||||
for r in 0..t.nrows() {
|
||||
for c in 0..t.ncols() {
|
||||
let Some(cell) = t.cell_mut(r, c) else { continue };
|
||||
cell.text = Story::with_text(StoryId(0), "", ParaFormat { table: None, ..para.clone() });
|
||||
cell.text.chars[0].format = fmt.clone();
|
||||
cell.text.insert(0, s);
|
||||
if let Some(s) = data.get(r).and_then(|row| row.get(c)) {
|
||||
cell.text.insert(0, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
t
|
||||
@@ -172,14 +183,19 @@ impl Table {
|
||||
|
||||
/// Cell texts, rows separated by `\n`, cells by `\t` (Convert Table to Text, search, agents).
|
||||
pub fn plain_text(&self) -> String {
|
||||
self.text_with("\t", "\n")
|
||||
}
|
||||
|
||||
/// The cells' text, columns separated by `col` and rows by `row` (Convert Table to Text).
|
||||
pub fn text_with(&self, col: &str, row: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for r in 0..self.nrows() {
|
||||
if r > 0 {
|
||||
out.push('\n');
|
||||
out.push_str(row);
|
||||
}
|
||||
for c in 0..self.ncols() {
|
||||
if c > 0 {
|
||||
out.push('\t');
|
||||
out.push_str(col);
|
||||
}
|
||||
if let Some(cell) = self.cell(r, c) {
|
||||
out.push_str(&cell.text.text.replace('\n', " "));
|
||||
|
||||
@@ -175,6 +175,9 @@ pub fn parse_iso_date(s: &str) -> Option<i64> {
|
||||
let t = time.get(a..b)?;
|
||||
t.bytes().all(|c| c.is_ascii_digit()).then(|| t.parse().ok()).flatten()
|
||||
};
|
||||
if time.get(2..3) != Some(":") {
|
||||
return None;
|
||||
}
|
||||
let (h, mi) = (tnum(0, 2)?, tnum(3, 5)?);
|
||||
let mut i = 5;
|
||||
let mut sec = 0;
|
||||
@@ -290,6 +293,12 @@ impl Document {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn iso_date_requires_colon_separator() {
|
||||
assert_eq!(parse_iso_date("2019-12-16T09:48:00Z"), Some(1_576_489_680));
|
||||
assert_eq!(parse_iso_date("2019-12-16T09x48:00Z"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dates() {
|
||||
// 2026-10-01 14:05 UTC
|
||||
|
||||
+81
-5
@@ -29,13 +29,28 @@ pub struct TextAnchor {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CrossRef {
|
||||
/// The destination anchor (0 = unresolved, e.g. pasted without its destination).
|
||||
pub target: u64,
|
||||
/// Name of the format in [`Document::xref_formats`].
|
||||
pub format: String,
|
||||
/// Text typed over the generated text (InDesign keeps a cross-reference's edited text until
|
||||
/// it is updated): shown as is, except that each `<pageNum />` shows the destination's page.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub text: Option<String>,
|
||||
/// The page last shown (for edited text whose destination is gone).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub page: Option<String>,
|
||||
}
|
||||
|
||||
/// The `<pageNum />` building block, as it appears in [`CrossRef::text`].
|
||||
pub const PAGE_NUM_BLOCK: &str = "<pageNum />";
|
||||
|
||||
/// Edited cross-reference text with its page numbers filled in (quotes kept as typed).
|
||||
pub fn expand_edited(text: &str, page: &str) -> String {
|
||||
text.replace(PAGE_NUM_BLOCK, page)
|
||||
}
|
||||
|
||||
/// A cross-reference format: building blocks between literal text.
|
||||
@@ -84,7 +99,7 @@ pub fn expand(definition: &str, v: &XrefValues) -> String {
|
||||
let mut rest = definition;
|
||||
while let Some(i) = rest.find('<') {
|
||||
out.push_str(&rest[..i]);
|
||||
let Some(j) = rest[i..].find('>') else {
|
||||
let Some(j) = tag_end(&rest[i..]) else {
|
||||
out.push_str(&rest[i..]);
|
||||
return out;
|
||||
};
|
||||
@@ -120,6 +135,20 @@ pub fn expand(definition: &str, v: &XrefValues) -> String {
|
||||
typographic_quotes(&out)
|
||||
}
|
||||
|
||||
/// Byte index of the `>` that ends the tag starting at `s`, skipping quoted attribute values.
|
||||
/// An unterminated quote falls back to the first `>`, so only that malformed tag is affected.
|
||||
fn tag_end(s: &str) -> Option<usize> {
|
||||
let mut in_quote = false;
|
||||
for (k, c) in s.char_indices() {
|
||||
match c {
|
||||
'"' => in_quote = !in_quote,
|
||||
'>' if !in_quote => return Some(k),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
s.find('>')
|
||||
}
|
||||
|
||||
/// Straight double quotes in format text become typographer's quotes.
|
||||
fn typographic_quotes(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
@@ -198,7 +227,7 @@ impl Story {
|
||||
if n > 0 {
|
||||
let k = self.marks_before(XREF_MARK, pos);
|
||||
for i in 0..n {
|
||||
self.xrefs.insert(k + i, Arc::new(CrossRef { target: 0, format: String::new() }));
|
||||
self.xrefs.insert(k + i, Arc::new(CrossRef::default()));
|
||||
}
|
||||
}
|
||||
let n = text.matches(crate::index::INDEX_MARK).count();
|
||||
@@ -268,7 +297,7 @@ impl Story {
|
||||
let n = self.text.matches(XREF_MARK).count();
|
||||
self.xrefs.truncate(n);
|
||||
while self.xrefs.len() < n {
|
||||
self.xrefs.push(Arc::new(CrossRef { target: 0, format: String::new() }));
|
||||
self.xrefs.push(Arc::new(CrossRef::default()));
|
||||
}
|
||||
let n = self.text.matches(crate::index::INDEX_MARK).count();
|
||||
self.index_refs.truncate(n);
|
||||
@@ -310,6 +339,35 @@ impl Story {
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// The text a cross-reference to `target` in `format` generates, with each page number as
|
||||
/// [`PAGE_NUM_BLOCK`] (the form saved text takes); `None` when the destination is gone.
|
||||
pub fn generated_xref_text(&self, target: u64, format: &str) -> Option<String> {
|
||||
let mut v = self.xref_values(target)?;
|
||||
v.page = PAGE_NUM_BLOCK.into();
|
||||
Some(expand(self.xref_format(format).map_or("<fullPara />", |f| f.definition.as_str()), &v))
|
||||
}
|
||||
|
||||
/// Forget saved cross-reference text that is exactly what its format generates for its
|
||||
/// destination, so it is generated again (and follows edits to the destination). Any other
|
||||
/// saved text was typed over the generated text, or its destination is gone: it is kept as
|
||||
/// shown.
|
||||
pub fn drop_generated_xref_text(&mut self) {
|
||||
let generated: Vec<(StoryId, usize)> = self
|
||||
.stories
|
||||
.iter()
|
||||
.flat_map(|(sid, st)| st.xrefs.iter().enumerate().map(move |(k, x)| (*sid, k, x)))
|
||||
.filter(|(_, _, x)| x.text.as_ref().is_some_and(|t| self.generated_xref_text(x.target, &x.format).as_ref() == Some(t)))
|
||||
.map(|(sid, k, _)| (sid, k))
|
||||
.collect();
|
||||
for (sid, k) in generated {
|
||||
if let Some(x) = self.story_mut(sid).and_then(|st| st.xrefs.get_mut(k)) {
|
||||
let x = Arc::make_mut(x);
|
||||
x.text = None;
|
||||
x.page = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// (story, byte) of anchor `id`.
|
||||
pub fn find_anchor(&self, id: u64) -> Option<(StoryId, usize)> {
|
||||
if id == 0 {
|
||||
@@ -388,6 +446,24 @@ pub fn clean(s: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn gt_inside_quoted_attribute_does_not_end_tag() {
|
||||
let v = XrefValues { para: "Intro > the start".into(), ..Default::default() };
|
||||
assert_eq!(expand("<partialPara delim=\">\" includeDelim=\"false\" />|", &v), "Intro |");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unterminated_quote_only_affects_its_own_tag() {
|
||||
let v = XrefValues { para: "Intro > the start".into(), ..Default::default() };
|
||||
let out = expand("<x a=\"b> <fullPara />", &v);
|
||||
assert!(out.contains("Intro > the start"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edited_text_fills_in_its_page_numbers() {
|
||||
assert_eq!(expand_edited("p. <pageNum /> and <pageNum />", "4"), "p. 4 and 4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_expand() {
|
||||
let v =
|
||||
@@ -407,7 +483,7 @@ mod tests {
|
||||
let mut s = Story::with_text(StoryId(3), "Heading\nBody text.", Default::default());
|
||||
let a = s.insert_anchor(0, "Heading");
|
||||
assert_eq!(a >> 24, 3);
|
||||
s.insert_xref(13, CrossRef { target: a, format: "Page Number".into() });
|
||||
s.insert_xref(13, CrossRef { target: a, format: "Page Number".into(), ..Default::default() });
|
||||
s.check().unwrap();
|
||||
assert_eq!(s.anchor_pos(a), Some(0));
|
||||
s.insert(0, "New ");
|
||||
|
||||
@@ -66,6 +66,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
join_paths
|
||||
),
|
||||
cmd!(
|
||||
"path.deletePoints",
|
||||
"Delete Points",
|
||||
[],
|
||||
None,
|
||||
"{points?: [[id, subpath, index]]} — the Direct Selection tool's selected points by default, in one undo step (Properties › Quick Actions › Delete Point(s)); a subpath left with one point goes, and an object with none is removed → {deleted, removed: [id]}",
|
||||
has_doc,
|
||||
delete_points
|
||||
),
|
||||
cmd!(
|
||||
"path.convertPoints",
|
||||
"Convert Point",
|
||||
@@ -162,6 +171,66 @@ fn join_paths(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Delete Point(s): removes the given points, or the ones selected with Direct Selection.
|
||||
fn delete_points(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "path.deletePoints";
|
||||
let points: Vec<(ItemId, usize, usize)> = match p.get("points") {
|
||||
Some(v) => serde_json::from_value::<Vec<(u64, usize, usize)>>(v.clone())
|
||||
.map_err(|e| bad(ID, format!("points: [[id, subpath, index], …] ({e})")))?
|
||||
.into_iter()
|
||||
.map(|(id, si, ai)| (ItemId(id), si, ai))
|
||||
.collect(),
|
||||
None => s.doc()?.selection.anchors.clone(),
|
||||
};
|
||||
if points.is_empty() {
|
||||
return Err(bad(ID, "no points selected"));
|
||||
}
|
||||
// Each subpath's points, worked from the last subpath and the last point back, so the indices
|
||||
// still to delete keep their place.
|
||||
let mut groups: Vec<((ItemId, usize), Vec<usize>)> = Vec::new();
|
||||
for (id, si, ai) in points {
|
||||
match groups.iter_mut().find(|(k, _)| *k == (id, si)) {
|
||||
Some((_, v)) => v.push(ai),
|
||||
None => groups.push(((id, si), vec![ai])),
|
||||
}
|
||||
}
|
||||
groups.sort_by_key(|((id, si), _)| std::cmp::Reverse((id.0, *si)));
|
||||
s.edit(|d, sel| {
|
||||
let (mut deleted, mut removed) = (0usize, Vec::new());
|
||||
for ((id, si), mut ais) in groups {
|
||||
if d.item(id).is_none() {
|
||||
continue;
|
||||
}
|
||||
path_editable(d, id, ID)?;
|
||||
let Some(it) = d.item_mut(id) else { continue };
|
||||
let Some(sp) = it.path.subpaths.get_mut(si) else { continue };
|
||||
ais.sort_unstable();
|
||||
ais.dedup();
|
||||
for ai in ais.into_iter().rev() {
|
||||
if ai < sp.anchors.len() {
|
||||
sp.anchors.remove(ai);
|
||||
deleted += 1;
|
||||
}
|
||||
}
|
||||
// A subpath needs two points (three to stay closed).
|
||||
let (left, closed) = (sp.anchors.len(), sp.closed);
|
||||
if left < 2 {
|
||||
it.path.subpaths.remove(si);
|
||||
} else if closed && left < 3 {
|
||||
sp.closed = false;
|
||||
}
|
||||
as_path(&mut it.shape);
|
||||
if it.path.subpaths.is_empty() {
|
||||
d.remove_item(id)?;
|
||||
sel.items.retain(|i| *i != id);
|
||||
removed.push(id.0);
|
||||
}
|
||||
}
|
||||
sel.anchors.clear();
|
||||
Ok(json!({"deleted": deleted, "removed": removed}))
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert Point: the selected points (or every point of the selected paths) to a point type.
|
||||
fn convert_points(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let kind = super::str_param(p, "kind").unwrap_or("plain").to_string();
|
||||
@@ -232,7 +301,7 @@ fn erase_or_smooth(s: &mut Session, p: &Value, smooth: bool) -> Result<Value> {
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(|v| Some(Point::new(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?))).collect())
|
||||
.unwrap_or_default();
|
||||
s.edit(|d, _| {
|
||||
s.edit(|d, sel| {
|
||||
path_editable(d, id, cmd)?;
|
||||
let inv = to_inner(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
let scale = inv.determinant().abs().sqrt().max(1e-9);
|
||||
@@ -257,6 +326,8 @@ fn erase_or_smooth(s: &mut Session, p: &Value, smooth: bool) -> Result<Value> {
|
||||
return Err(bad(cmd, "the drag didn't touch the path"));
|
||||
};
|
||||
let sp = it.path.subpaths[si].clone();
|
||||
// The subpath list changes under the stored point indices: they would name other points.
|
||||
sel.anchors.retain(|a| a.0 != id);
|
||||
if smooth {
|
||||
it.path.subpaths[si] = designcraft_geom::edit_path::smooth(&sp, a, b, tol * scale);
|
||||
} else {
|
||||
@@ -299,7 +370,7 @@ fn add_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = super::id_param(p, "id").ok_or_else(|| bad("path.addAnchor", "missing id"))?;
|
||||
let at = point_param(p, "at").ok_or_else(|| bad("path.addAnchor", "missing at"))?;
|
||||
let tol = p.get("tolerance").and_then(Value::as_f64).unwrap_or(4.0);
|
||||
s.edit(|d, _| {
|
||||
s.edit(|d, sel| {
|
||||
path_editable(d, id, "path.addAnchor")?;
|
||||
let inv = to_inner(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
@@ -313,6 +384,10 @@ fn add_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = t.clamp(1e-4, 1.0 - 1e-4);
|
||||
let ai = it.path.subpaths[si].insert_anchor(seg, t);
|
||||
as_path(&mut it.shape);
|
||||
// Selected points after the new one move up an index, so they stay the same points.
|
||||
for a in sel.anchors.iter_mut().filter(|a| a.0 == id && a.1 == si && a.2 >= ai) {
|
||||
a.2 += 1;
|
||||
}
|
||||
Ok(json!({"subpath": si, "anchor": ai}))
|
||||
})
|
||||
}
|
||||
@@ -328,15 +403,27 @@ fn delete_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
sp.anchors.remove(ai);
|
||||
// A subpath needs two points (three to stay closed).
|
||||
if sp.anchors.len() < 2 {
|
||||
let removed_subpath = sp.anchors.len() < 2;
|
||||
if removed_subpath {
|
||||
it.path.subpaths.remove(si);
|
||||
} else if sp.closed && sp.anchors.len() < 3 {
|
||||
sp.closed = false;
|
||||
}
|
||||
as_path(&mut it.shape);
|
||||
// The deleted point leaves the selection; the selected points after it (or the subpaths
|
||||
// after a removed one) move down an index.
|
||||
sel.anchors.retain(|a| !(a.0 == id && a.1 == si && (a.2 == ai || removed_subpath)));
|
||||
for a in sel.anchors.iter_mut().filter(|a| a.0 == id) {
|
||||
if removed_subpath && a.1 > si {
|
||||
a.1 -= 1;
|
||||
} else if !removed_subpath && a.1 == si && a.2 > ai {
|
||||
a.2 -= 1;
|
||||
}
|
||||
}
|
||||
if it.path.subpaths.is_empty() {
|
||||
d.remove_item(id)?;
|
||||
sel.items.retain(|i| *i != id);
|
||||
sel.anchors.retain(|a| a.0 != id);
|
||||
return Ok(json!({"deleted": true}));
|
||||
}
|
||||
Ok(json!({"deleted": false}))
|
||||
@@ -426,6 +513,27 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Join Path closes one open path, and joins two open paths at their nearest ends.
|
||||
/// `path.moveAnchors` points may each carry their own movement (Direct Selection sends each
|
||||
/// point's in its own spread); without one a point takes the shared `dx`, `dy`.
|
||||
#[test]
|
||||
fn move_anchors_takes_a_movement_per_point() {
|
||||
let mut s = crate::Session::new();
|
||||
s.execute("file.new", &serde_json::json!({})).unwrap();
|
||||
let id = s.execute("frame.create", &serde_json::json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap();
|
||||
let at = |s: &crate::Session, i: usize| {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let it = d.item(designcraft_doc::ItemId(id)).unwrap();
|
||||
it.xf * it.path.subpaths[0].anchors[i].p
|
||||
};
|
||||
let (a0, a1) = (at(&s, 0), at(&s, 1));
|
||||
s.execute("path.moveAnchors", &serde_json::json!({"points": [[id, 0, 0, 5, 0], [id, 0, 1]], "dx": 0, "dy": 7})).unwrap();
|
||||
assert_eq!(at(&s, 0) - a0, designcraft_geom::Vec2::new(5.0, 0.0), "its own");
|
||||
assert_eq!(at(&s, 1) - a1, designcraft_geom::Vec2::new(0.0, 7.0), "the shared one");
|
||||
for bad in [serde_json::json!([[id, 0, 0, "x", 1]]), serde_json::json!([[id, 0, 0, 5]])] {
|
||||
assert!(s.execute("path.moveAnchors", &serde_json::json!({"points": bad})).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_path_closes_one_path_and_joins_two() {
|
||||
let mut s = Session::new();
|
||||
@@ -454,6 +562,63 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Convert Point turns the selected points (or a whole path's) plain, corner, smooth or symmetrical.
|
||||
/// Delete Point(s) removes the selected points of several paths in one step; a triangle left
|
||||
/// with two points opens, and Undo brings every point back.
|
||||
#[test]
|
||||
fn delete_points_removes_the_selected_points_of_several_paths() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let a = s.execute("frame.create", &json!({"rect": [72, 72, 172, 172]})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = s.execute("frame.create", &json!({"rect": [200, 72, 300, 172], "shape": "polygon", "sides": 3})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("selection.set", &json!({"anchors": [[a, 0, 0], [a, 0, 2], [b, 0, 1]]})).unwrap();
|
||||
let r = s.execute("path.deletePoints", &json!({})).unwrap();
|
||||
assert_eq!(r["deleted"], 3);
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let pa = &d.item(ItemId(a)).unwrap().path.subpaths[0];
|
||||
assert_eq!(pa.anchors.len(), 2, "two of the square's four points are left");
|
||||
assert!(!pa.closed, "two points can't stay closed");
|
||||
assert_eq!(d.item(ItemId(b)).unwrap().path.subpaths[0].anchors.len(), 2);
|
||||
assert!(s.doc().unwrap().selection.anchors.is_empty());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.item(ItemId(a)).unwrap().path.subpaths[0].anchors.len(), 4, "one undo step");
|
||||
assert_eq!(d.item(ItemId(b)).unwrap().path.subpaths[0].anchors.len(), 3);
|
||||
// A line's two points: the line goes.
|
||||
let l = s.execute("path.create", &json!({"anchors": [{"p": [72, 300]}, {"p": [172, 300]}]})).unwrap()["id"].as_u64().unwrap();
|
||||
let r = s.execute("path.deletePoints", &json!({"points": [[l, 0, 0]]})).unwrap();
|
||||
assert_eq!(r["removed"], json!([l]));
|
||||
assert!(s.doc().unwrap().doc.item(ItemId(l)).is_none());
|
||||
assert!(s.execute("path.deletePoints", &json!({"points": []})).is_err());
|
||||
// Named points need no selected object.
|
||||
s.execute("selection.set", &json!({"ids": []})).unwrap();
|
||||
assert!(s.doc().unwrap().selection.items.is_empty());
|
||||
let r = s.execute("path.deletePoints", &json!({"points": [[b, 0, 0]]})).unwrap();
|
||||
assert_eq!(r["deleted"], 1);
|
||||
// Edit › Clear with points selected deletes the points, not their paths.
|
||||
s.execute("selection.set", &json!({"anchors": [[a, 0, 0]]})).unwrap();
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.item(ItemId(a)).unwrap().path.subpaths[0].anchors.len(), 3, "one point gone, the frame stays");
|
||||
// Cut copies whole objects, so it removes whole objects.
|
||||
s.execute("selection.set", &json!({"anchors": [[a, 0, 0]]})).unwrap();
|
||||
s.execute("edit.cut", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.item(ItemId(a)).is_none(), "the cut object is gone");
|
||||
}
|
||||
|
||||
/// Shift-adding an object to a point selection drops the points, so Clear deletes the objects.
|
||||
#[test]
|
||||
fn clear_after_adding_an_object_to_a_point_selection_deletes_objects() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let a = s.execute("frame.create", &json!({"rect": [72, 72, 172, 172]})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = s.execute("frame.create", &json!({"rect": [200, 72, 300, 172]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("selection.set", &json!({"anchors": [[a, 0, 0]]})).unwrap();
|
||||
s.execute("selection.set", &json!({"ids": [b], "add": true})).unwrap();
|
||||
assert!(s.doc().unwrap().selection.anchors.is_empty());
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.item(ItemId(a)).is_none());
|
||||
assert!(s.doc().unwrap().doc.item(ItemId(b)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_point_kinds() {
|
||||
let mut s = Session::new();
|
||||
@@ -486,16 +651,45 @@ mod tests {
|
||||
let pts: Vec<Value> = (0..=20).map(|i| json!({"p": [100.0 + i as f64 * 20.0, if i % 2 == 0 { 300.0 } else { 306.0 }]})).collect();
|
||||
let id = s.execute("path.create", &json!({"anchors": pts})).unwrap()["id"].as_u64().unwrap();
|
||||
let n = s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.anchor_count();
|
||||
s.execute("selection.set", &json!({"anchors": [[id, 0, 20]]})).unwrap();
|
||||
let drag: Vec<Value> = (0..=12).map(|i| json!([150.0 + i as f64 * 20.0, 303.0])).collect();
|
||||
s.execute("path.smooth", &json!({"id": id, "points": drag})).unwrap();
|
||||
let after = s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.anchor_count();
|
||||
assert!(after < n, "{after} < {n}");
|
||||
assert!(s.doc().unwrap().selection.anchors.is_empty(), "stored point indices are stale after the path is rebuilt");
|
||||
let drag: Vec<Value> = (0..=5).map(|i| json!([200.0 + i as f64 * 20.0, 303.0])).collect();
|
||||
let r = s.execute("path.erase", &json!({"id": id, "points": drag})).unwrap();
|
||||
assert_eq!(r["subpaths"], 2, "a gap in the middle");
|
||||
assert!(s.execute("path.erase", &json!({"id": id, "points": [[100, 600]]})).is_err());
|
||||
}
|
||||
|
||||
/// The Direct Selection's selected points stay the same points when the path changes under
|
||||
/// them: a point added before them, one of them deleted, the path reversed.
|
||||
#[test]
|
||||
fn selected_anchors_follow_added_deleted_and_reversed_points() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
|
||||
let point = |s: &Session, a: (usize, usize)| s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.subpaths[a.0].anchors[a.1].p;
|
||||
s.execute("selection.set", &json!({"anchors": [[id, 0, 2], [id, 0, 3]]})).unwrap();
|
||||
let picked = |s: &Session| s.doc().unwrap().selection.anchors.iter().map(|a| (a.1, a.2)).collect::<Vec<_>>();
|
||||
let before: Vec<_> = picked(&s).into_iter().map(|a| point(&s, a)).collect();
|
||||
// A point added on the first side, before both.
|
||||
let first = point(&s, (0, 0));
|
||||
let second = point(&s, (0, 1));
|
||||
let mid = [(first.x + second.x) / 2.0, (first.y + second.y) / 2.0];
|
||||
s.execute("path.addAnchor", &json!({"id": id, "at": mid})).unwrap();
|
||||
assert_eq!(picked(&s).into_iter().map(|a| point(&s, a)).collect::<Vec<_>>(), before, "after an added point");
|
||||
// One of them deleted: the other stays selected, the same point.
|
||||
let (si, ai) = picked(&s)[0];
|
||||
s.execute("path.deleteAnchor", &json!({"id": id, "subpath": si, "anchor": ai})).unwrap();
|
||||
assert_eq!(picked(&s).len(), 1);
|
||||
assert_eq!(point(&s, picked(&s)[0]), before[1], "after a deleted point");
|
||||
// Reversed: still the same point.
|
||||
s.execute("path.reverse", &json!({"ids": [id]})).unwrap();
|
||||
assert_eq!(point(&s, picked(&s)[0]), before[1], "after reversing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_convert_delete_anchor_points() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -18,7 +18,7 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, cmd, has_doc, id_param, ok, str_param};
|
||||
use crate::{DocState, Result, Session};
|
||||
use fill::{FillReport, Maps, MissingImage, Sources, copy_item, fill_row, identity_map};
|
||||
use fill::{FillReport, ImageCache, Maps, MissingImage, Sources, copy_item, fill_row, identity_map};
|
||||
use parse::{
|
||||
Table, TileInput, extension, fingerprint_of, relative_between, select_records, status_of, table_from_bytes, table_from_grid, table_from_objects,
|
||||
tile,
|
||||
@@ -210,7 +210,8 @@ fn preview_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let row = source.rows.get((record as usize).saturating_sub(1)).cloned().unwrap_or_default();
|
||||
let data_dir = source_file(&source, st.path.as_deref().map(Path::new)).as_deref().and_then(Path::parent).map(Path::to_path_buf);
|
||||
let doc_dir = st.path.as_deref().and_then(parent_dir);
|
||||
let sources = Sources { options: &options, data_dir: data_dir.as_deref(), doc_dir: doc_dir.as_deref() };
|
||||
let images = ImageCache::default();
|
||||
let sources = Sources { options: &options, data_dir: data_dir.as_deref(), doc_dir: doc_dir.as_deref(), images: &images };
|
||||
fill_row(&mut doc, &source.fields, &row, record as u32, &maps, &sources);
|
||||
st.doc = Arc::new(doc);
|
||||
st.preview_record = Some(record as u32);
|
||||
@@ -246,7 +247,8 @@ fn merge_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
warnings.extend(table.warnings.iter().cloned());
|
||||
let mut merged = src.clone();
|
||||
let mut missing = Vec::new();
|
||||
let sources = Sources { options: &options, data_dir: data_dir.as_deref(), doc_dir: doc_dir.as_deref() };
|
||||
let images = ImageCache::default();
|
||||
let sources = Sources { options: &options, data_dir: data_dir.as_deref(), doc_dir: doc_dir.as_deref(), images: &images };
|
||||
let mut sink = Sink { warnings: &mut warnings, missing: &mut missing, parent: false };
|
||||
if options.per_page == "multiple" {
|
||||
tile_records(&mut merged, &src, &table, &selected, &sources, &mut sink)?;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! unfilled template. A placeholder whose frame or story is not in the map is left as it is.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use designcraft_doc::{
|
||||
@@ -134,6 +134,44 @@ pub struct Sources<'a> {
|
||||
pub options: &'a MergeOptions,
|
||||
pub data_dir: Option<&'a Path>,
|
||||
pub doc_dir: Option<&'a Path>,
|
||||
/// Images this merge has already read, by the path they resolved to.
|
||||
///
|
||||
/// Every record used to stat and read its image again, so a 100,000-row sheet naming the same
|
||||
/// file read it 100,000 times -- up to 256 MiB each, around 25 TB of I/O for one merge
|
||||
/// (#1595). Shared by `&`, so the cache is behind a `RefCell`.
|
||||
pub images: &'a ImageCache,
|
||||
}
|
||||
|
||||
/// Image files read during one merge: the bytes by path, and the total read so far.
|
||||
#[derive(Default)]
|
||||
pub struct ImageCache {
|
||||
inner: std::cell::RefCell<ImageCacheInner>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ImageCacheInner {
|
||||
files: std::collections::HashMap<PathBuf, std::sync::Arc<Vec<u8>>>,
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
/// Bytes of distinct image files one merge keeps. Past this the cache stops growing and files are
|
||||
/// read as before, so a merge over many large images is slow rather than out of memory.
|
||||
const MAX_CACHED_IMAGE_BYTES: usize = 256 << 20;
|
||||
|
||||
impl ImageCache {
|
||||
/// The bytes of `path`, reading it once per merge. `read` is called only on a miss.
|
||||
fn get_or_read(&self, path: &Path, read: impl FnOnce() -> Option<Vec<u8>>) -> Option<std::sync::Arc<Vec<u8>>> {
|
||||
if let Some(found) = self.inner.borrow().files.get(path) {
|
||||
return Some(found.clone());
|
||||
}
|
||||
let bytes = std::sync::Arc::new(read()?);
|
||||
let mut inner = self.inner.borrow_mut();
|
||||
if inner.bytes.saturating_add(bytes.len()) <= MAX_CACHED_IMAGE_BYTES {
|
||||
inner.bytes = inner.bytes.saturating_add(bytes.len());
|
||||
inner.files.insert(path.to_path_buf(), bytes.clone());
|
||||
}
|
||||
Some(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// Replacements and hyperlinks collected while walking the placeholders of one record.
|
||||
@@ -360,15 +398,20 @@ fn create_link(doc: &mut designcraft_doc::Document, anchor: PlaceholderAnchor, d
|
||||
}
|
||||
|
||||
fn place_image(doc: &mut designcraft_doc::Document, item: ItemId, field: &str, cell: &str, record: u32, sources: &Sources, report: &mut FillReport) {
|
||||
let Sources { options, data_dir, doc_dir } = *sources;
|
||||
let Sources { options, data_dir, doc_dir, images } = *sources;
|
||||
let cands = image_candidates(cell, data_dir, doc_dir);
|
||||
let found = cands.iter().find_map(|p| {
|
||||
// Refuse huge files before reading them; a too-large image counts as missing.
|
||||
let len = crate::io::metadata(p).ok()?.len();
|
||||
if len == 0 || len > super::MAX_IMAGE_FILE_BYTES {
|
||||
return None;
|
||||
}
|
||||
crate::io::read_file(p).ok().filter(|b| !b.is_empty()).map(|b| (p.clone(), b))
|
||||
// Read once per merge: many records naming one file is the normal case, and reading it
|
||||
// per record is the amplification (#1595).
|
||||
let bytes = images.get_or_read(p, || {
|
||||
// Refuse huge files before reading them; a too-large image counts as missing.
|
||||
let len = crate::io::metadata(p).ok()?.len();
|
||||
if len == 0 || len > super::MAX_IMAGE_FILE_BYTES {
|
||||
return None;
|
||||
}
|
||||
crate::io::read_file(p).ok().filter(|b| !b.is_empty())
|
||||
})?;
|
||||
Some((p.clone(), bytes))
|
||||
});
|
||||
let Some((path, bytes)) = found else {
|
||||
if let Some(it) = doc.item_mut(item) {
|
||||
@@ -430,7 +473,9 @@ fn place_image(doc: &mut designcraft_doc::Document, item: ItemId, field: &str, c
|
||||
name,
|
||||
mime: designcraft_render::image_mime(&bytes).to_string(),
|
||||
link,
|
||||
data: Arc::new(bytes),
|
||||
// The cached buffer is shared with every other record naming this file, rather than
|
||||
// copied per record (#1595).
|
||||
data: bytes,
|
||||
pixels: Some((pw, ph)),
|
||||
..Default::default()
|
||||
});
|
||||
@@ -523,20 +568,56 @@ mod link_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
#[cfg(test)]
|
||||
mod image_path_tests {
|
||||
use std::path::Path;
|
||||
|
||||
use super::image_candidates;
|
||||
|
||||
/// A merge image field names a file, and a field that names **another computer** must resolve
|
||||
/// to nothing: a UNC path is how a hostile data file turns a merge into an outbound SMB
|
||||
/// connection, and on Windows that means handing over NTLM credentials. This ran only on
|
||||
/// unix, so the refusal was unverified on the one platform where UNC means anything (#1601).
|
||||
#[test]
|
||||
fn image_cells_resolve_beside_the_data_and_never_to_other_computers() {
|
||||
fn an_image_cell_never_resolves_to_another_computer() {
|
||||
let (data, doc) = (Path::new("/work/data"), Path::new("/work/doc"));
|
||||
for cell in [r"\\192.0.2.1\share\x.png", "file://192.0.2.1/s/x.png", "//192.0.2.1/s/x.png", "https://example.com/x.png"] {
|
||||
assert!(image_candidates(cell, Some(data), Some(doc)).is_empty(), "{cell}");
|
||||
}
|
||||
let found = image_candidates("img/x.png", Some(data), Some(doc));
|
||||
assert_eq!(found, [data.join("img").join("x.png"), doc.join("img").join("x.png")]);
|
||||
// A field naming nothing resolves to nothing, whatever the platform.
|
||||
assert!(image_candidates("", Some(data), Some(doc)).is_empty(), "an empty cell");
|
||||
assert!(image_candidates("img/x.png", None, None).is_empty(), "not beside the working directory");
|
||||
}
|
||||
|
||||
/// The Windows shapes `craft-docpath` refuses, asserted where they have meaning: the extended
|
||||
/// UNC prefix, a device namespace path, and a legacy device name (with an extension, since a
|
||||
/// reserved name is reserved whatever follows it).
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_device_and_network_paths_are_refused() {
|
||||
let (data, doc) = (Path::new(r"C:\work\data"), Path::new(r"C:\work\doc"));
|
||||
for cell in [r"\\?\UNC\192.0.2.1\share\x.png", r"\\.\pipe\x", r"\\?\GLOBALROOT\Device\HarddiskVolume1\x", "CON", "NUL.png"] {
|
||||
assert!(image_candidates(cell, Some(data), Some(doc)).is_empty(), "{cell}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An extended-length path is not one of them: `\\?\C:\x` names a local file, the same one
|
||||
/// `C:\x` names, and `craft-docpath`'s own suite classifies it `PathKind::Local`. A data file
|
||||
/// could already give the plain spelling, so refusing this one would shut out long paths
|
||||
/// without shutting anything out (noted in review).
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn an_extended_length_drive_path_is_a_local_file() {
|
||||
let (data, doc) = (Path::new(r"C:\work\data"), Path::new(r"C:\work\doc"));
|
||||
assert!(!image_candidates(r"\\?\C:\secret\x.png", Some(data), Some(doc)).is_empty());
|
||||
}
|
||||
|
||||
/// Resolution beside the data and the document, with absolute paths this platform means.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn an_image_cell_resolves_beside_the_data_then_the_document() {
|
||||
let (data, doc) = (Path::new("/work/data"), Path::new("/work/doc"));
|
||||
let found = image_candidates("img/x.png", Some(data), Some(doc));
|
||||
assert_eq!(found, [data.join("img").join("x.png"), doc.join("img").join("x.png")]);
|
||||
}
|
||||
}
|
||||
@@ -210,6 +210,10 @@ fn select_range(range: &str, count: usize, idx: &mut Vec<usize>, seen: &mut std:
|
||||
}
|
||||
|
||||
fn parse_record_no(s: &str) -> Result<u32, String> {
|
||||
// `parse` alone would accept a leading '+'.
|
||||
if !s.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return Err(format!("bad record \"{s}\""));
|
||||
}
|
||||
s.parse::<u32>().map_err(|_| format!("bad record \"{s}\""))
|
||||
}
|
||||
|
||||
|
||||
@@ -519,6 +519,69 @@ fn typed_fields_keep_style_and_unknown_markers() {
|
||||
assert_eq!(story.format_after(at).over.size, Some(21.0));
|
||||
}
|
||||
|
||||
/// Every record stat'd and read its image again, so a sheet naming one file in many rows read it
|
||||
/// once per row -- up to 256 MiB each time (#1595). A merge reads a file once and the records
|
||||
/// share the bytes.
|
||||
#[test]
|
||||
fn one_image_named_by_many_records_is_read_once() {
|
||||
let dir = scratch("image-reads");
|
||||
let data = dir.join("data");
|
||||
std::fs::create_dir_all(&data).unwrap();
|
||||
let shared = dir.join("shared.png");
|
||||
write_png(&shared);
|
||||
// 25 records, all naming the same file.
|
||||
let rows: String = (0..25).map(|_| format!("{}\n", shared.display())).collect();
|
||||
write(&data.join("many.csv"), &format!("@Photo\n{rows}"));
|
||||
|
||||
let mut s = Session::new();
|
||||
new_doc(&mut s, 1);
|
||||
exec(&mut s, "data.source.select", json!({"path": data.join("many.csv")}));
|
||||
let frame = exec(&mut s, "frame.create", json!({"rect": [36.0, 36.0, 80.0, 80.0], "content": "graphic"}));
|
||||
exec(&mut s, "data.placeholder.add", json!({"field": "Photo", "item": frame["id"]}));
|
||||
let r = exec(&mut s, "data.merge", json!({}));
|
||||
assert_eq!(r["records"], 25);
|
||||
assert_eq!(r["missingImages"].as_array().map(Vec::len).unwrap_or(0), 0, "every record found the image");
|
||||
|
||||
// Each record has its graphic, and they share one buffer: the document holds the file's bytes
|
||||
// once however many records named it.
|
||||
let doc = &s.documents()[s.active_index().unwrap()].doc;
|
||||
let graphics: Vec<_> = doc
|
||||
.spreads
|
||||
.iter()
|
||||
.flat_map(|sp| &sp.items)
|
||||
.filter_map(|it| match &it.content {
|
||||
Content::Graphic(g) => Some(g.asset),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(graphics.len(), 25, "one graphic per record");
|
||||
}
|
||||
|
||||
/// The same merge, counting what the engine actually hands the file system: `io::touched` records
|
||||
/// every path read, so the reads are counted rather than inferred. The document already shares
|
||||
/// identical asset bytes, so memory alone does not show this -- the I/O is the bug (#1595).
|
||||
#[test]
|
||||
fn a_merge_reads_a_repeated_image_once_not_once_per_record() {
|
||||
let dir = scratch("image-read-count");
|
||||
let data = dir.join("data");
|
||||
std::fs::create_dir_all(&data).unwrap();
|
||||
let shared = dir.join("shared.png");
|
||||
write_png(&shared);
|
||||
let rows: String = (0..25).map(|_| format!("{}\n", shared.display())).collect();
|
||||
write(&data.join("many.csv"), &format!("@Photo\n{rows}"));
|
||||
|
||||
let mut s = Session::new();
|
||||
new_doc(&mut s, 1);
|
||||
exec(&mut s, "data.source.select", json!({"path": data.join("many.csv")}));
|
||||
let frame = exec(&mut s, "frame.create", json!({"rect": [36.0, 36.0, 80.0, 80.0], "content": "graphic"}));
|
||||
exec(&mut s, "data.placeholder.add", json!({"field": "Photo", "item": frame["id"]}));
|
||||
|
||||
let (r, seen) = crate::io::touched(|| exec(&mut s, "data.merge", json!({})));
|
||||
assert_eq!(r["records"], 25);
|
||||
let reads = seen.iter().filter(|p| p.ends_with("shared.png")).count();
|
||||
assert!(reads <= 2, "the image is read once for the merge, not once per record: {reads} reads for 25 records");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_resolve_in_order_and_roles_win() {
|
||||
let dir = scratch("images");
|
||||
@@ -1116,6 +1179,14 @@ fn select_records_dedupes_large_ranges_in_first_seen_order() {
|
||||
assert_eq!(&idx[..3], &[4, 0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_records_rejects_signed_record_numbers() {
|
||||
for (tok, bad) in [("+2", "+2"), ("1-+3", "+3"), ("+1,+4", "+1")] {
|
||||
let err = parse::select_records(5, "range", 1, tok, None).unwrap_err();
|
||||
assert!(err.contains(&format!("bad record \"{bad}\"")), "{tok}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_data_file_is_refused_before_reading() {
|
||||
let dir = scratch("oversized");
|
||||
|
||||
@@ -8,6 +8,21 @@ use serde_json::{Value, json};
|
||||
use super::{CommandSpec, bool_or, cmd, has_doc, has_selection, ids_param, ok};
|
||||
use crate::{HistoryEntry, Result, Session};
|
||||
|
||||
/// `anchors` of `selection.set`: the points that exist, once each.
|
||||
fn anchors_param(doc: &designcraft_doc::Document, p: &Value) -> Vec<(ItemId, usize, usize)> {
|
||||
let mut out: Vec<(ItemId, usize, usize)> = Vec::new();
|
||||
for v in p.get("anchors").and_then(Value::as_array).into_iter().flatten() {
|
||||
let n = |i: usize| v.get(i).and_then(Value::as_u64);
|
||||
let (Some(id), Some(si), Some(ai)) = (n(0), n(1), n(2)) else { continue };
|
||||
let (Ok(si), Ok(ai)) = (usize::try_from(si), usize::try_from(ai)) else { continue };
|
||||
let a = (ItemId(id), si, ai);
|
||||
if doc.item(a.0).is_some_and(|it| it.path.subpaths.get(si).is_some_and(|sp| ai < sp.anchors.len())) && !out.contains(&a) {
|
||||
out.push(a);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn can_undo(s: &Session) -> std::result::Result<(), String> {
|
||||
s.active().filter(|d| !d.history.undo.is_empty()).map(|_| ()).ok_or_else(|| "nothing to undo".into())
|
||||
}
|
||||
@@ -235,20 +250,36 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.typing_format = None;
|
||||
Ok(json!({"redone": e.label}))
|
||||
}),
|
||||
cmd!(noundo "selection.set", "Select", [], None, "{ids: [id], add?: bool, content?: bool}", has_doc, |s, p| {
|
||||
let ids = ids_param(p, "ids").unwrap_or_default();
|
||||
cmd!(noundo "selection.set", "Select", [], None, "{ids: [id], add?: bool, content?: bool, anchors?: [[id, subpath, index]] (Direct Selection: the selected anchor points; their paths are selected too)}", has_doc, |s, p| {
|
||||
let mut ids = ids_param(p, "ids").unwrap_or_default();
|
||||
let add = bool_or(p, "add", false);
|
||||
let content = bool_or(p, "content", false);
|
||||
let st = s.doc_mut()?;
|
||||
let anchors = anchors_param(&st.doc, p);
|
||||
for (id, _, _) in &anchors {
|
||||
if !ids.contains(id) {
|
||||
ids.push(*id);
|
||||
}
|
||||
}
|
||||
let ids: Vec<ItemId> = ids.into_iter().filter(|i| st.doc.item(*i).is_some()).collect();
|
||||
if add {
|
||||
// Adding objects (no points) leaves no live points: Delete would otherwise remove
|
||||
// the stale points instead of the objects.
|
||||
if anchors.is_empty() {
|
||||
st.selection.anchors.clear();
|
||||
}
|
||||
for i in ids {
|
||||
if !st.selection.items.contains(&i) {
|
||||
st.selection.items.push(i);
|
||||
}
|
||||
}
|
||||
for a in anchors {
|
||||
if !st.selection.anchors.contains(&a) {
|
||||
st.selection.anchors.push(a);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
st.selection = Selection { items: ids, content, ..Default::default() };
|
||||
st.selection = Selection { items: ids, content, anchors, ..Default::default() };
|
||||
}
|
||||
st.selection.text = None;
|
||||
st.selection.cells = None;
|
||||
@@ -262,12 +293,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
st.selection.cells = None;
|
||||
if let Some(i) = st.selection.items.iter().position(|x| *x == id) {
|
||||
st.selection.items.remove(i);
|
||||
// Its points go with it: arrow keys and Delete act on the selected points.
|
||||
st.selection.anchors.retain(|a| a.0 != id);
|
||||
// A key object needs another object to align to it.
|
||||
if st.selection.key == Some(id) || st.selection.items.len() < 2 {
|
||||
st.selection.key = None;
|
||||
}
|
||||
} else {
|
||||
st.selection.items.push(id);
|
||||
st.selection.anchors.clear();
|
||||
}
|
||||
st.bump_revision();
|
||||
ok()
|
||||
@@ -320,7 +354,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Clear",
|
||||
["Edit"],
|
||||
Some("Delete"),
|
||||
"{ids?} — delete selected items (or the selected text or ruler guides)",
|
||||
"{ids?} — delete selected items (or the selected text, points or ruler guides)",
|
||||
has_doc,
|
||||
|s, p| {
|
||||
if s.doc()?.selection.text.is_some() && p.get("ids").is_none() {
|
||||
@@ -329,6 +363,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
if p.get("ids").is_none() && s.doc()?.selection.items.is_empty() && !s.doc()?.selection.guides.is_empty() {
|
||||
return s.execute("guide.delete", &json!({}));
|
||||
}
|
||||
// Points selected with Direct Selection: they go, not their paths (as InDesign).
|
||||
if p.get("ids").is_none() && !s.doc()?.selection.anchors.is_empty() {
|
||||
return s.execute("path.deletePoints", &json!({}));
|
||||
}
|
||||
let ids = super::targets(s, p)?;
|
||||
// With a placed graphic itself selected (Direct Selection), only the graphic goes: its
|
||||
// frame stays, empty and selected (as in InDesign). Other items are deleted.
|
||||
@@ -362,6 +400,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
return Ok(json!({"text": text}));
|
||||
}
|
||||
copy_items(s)?;
|
||||
// The whole objects are on the clipboard, so the whole objects go, not just selected points.
|
||||
if p.get("ids").is_none() {
|
||||
let ids: Vec<u64> = s.doc()?.selection.items.iter().map(|i| i.0).collect();
|
||||
return s.execute("edit.clear", &json!({"ids": ids}));
|
||||
}
|
||||
s.execute("edit.clear", p)
|
||||
}),
|
||||
cmd!(
|
||||
|
||||
@@ -16,6 +16,7 @@ mod registry;
|
||||
use flatten::{rasterize_effects, shadow_coverage};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) use pdf::pdf_export;
|
||||
pub(crate) use pdf::{check_pdf_options, pdf_report};
|
||||
pub(crate) use pdf::{flatten_ppi, options, print_pdf};
|
||||
pub use registry::specs;
|
||||
|
||||
|
||||
@@ -110,6 +110,8 @@ pub(super) fn export_pptx(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if rasterized > 0 {
|
||||
r.warnings.push(format!("{rasterized} object(s) went in as images"));
|
||||
}
|
||||
// A slide has no room for the overset text either (#1465).
|
||||
r.warnings.extend(crate::cmd::preflight::overset_warnings(s, opts.pages.as_deref()));
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -87,8 +87,27 @@ fn first_page_ref(pdf: &[u8]) -> Option<usize> {
|
||||
s[i..].split_whitespace().next()?.parse().ok()
|
||||
}
|
||||
|
||||
/// Every page sharing a spread with one of `pages` (0-based), for an export that puts whole
|
||||
/// spreads on a sheet. The pages come back in document order.
|
||||
fn with_their_spreads(s: &Session, pages: &[usize]) -> Vec<usize> {
|
||||
let Some(st) = s.active() else { return pages.to_vec() };
|
||||
let d = &st.doc;
|
||||
let spreads: std::collections::BTreeSet<usize> = pages.iter().filter_map(|p| d.page_loc(*p)).map(|(si, _)| si).collect();
|
||||
if spreads.is_empty() {
|
||||
return pages.to_vec();
|
||||
}
|
||||
(0..d.page_count()).filter(|p| d.page_loc(*p).is_some_and(|(si, _)| spreads.contains(&si))).collect()
|
||||
}
|
||||
|
||||
pub(super) fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = pdf_export(s, p)?;
|
||||
let mut r = pdf_export(s, p)?;
|
||||
// Text the pages have no room for is not in the file: say so, as preflight does (#1465).
|
||||
// A spread export puts the whole spread on the sheet, so choosing page 2 exports page 3 as
|
||||
// well and page 3's overset is as absent from the file as page 2's would be. The selection
|
||||
// is widened to the spreads it touches before the warnings are collected (noted in review).
|
||||
let opts = options(p, s.doc()?.doc.page_count())?;
|
||||
let warn_pages = opts.pages.as_deref().map(|pages| if opts.spreads { with_their_spreads(s, pages) } else { pages.to_vec() });
|
||||
r.warnings.extend(crate::cmd::preflight::overset_warnings(s, warn_pages.as_deref()));
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
write_file(path, &r.bytes)?;
|
||||
@@ -169,41 +188,21 @@ pub(crate) fn print_pdf(
|
||||
/// The PDF that File › Export PDF writes for `p`, with its warnings: the print PDF
|
||||
/// ([`print_pdf`]) with its view options, timings and transitions. File › Print prints it.
|
||||
pub(crate) fn pdf_export(s: &Session, p: &Value) -> Result<designcraft_pdf::ExportReport> {
|
||||
let st = s.doc()?;
|
||||
let opts = options(p, st.doc.page_count())?;
|
||||
pdf_report(&s.doc()?.doc, &s.cache, p)
|
||||
}
|
||||
|
||||
/// [`pdf_export`] of `doc`: `file.package` writes its PDF of the packaged copy with it.
|
||||
pub(crate) fn pdf_report(doc: &designcraft_doc::Document, cache: &designcraft_compose::Cache, p: &Value) -> Result<designcraft_pdf::ExportReport> {
|
||||
let opts = options(p, doc.page_count())?;
|
||||
let ppi = flatten_ppi("file.exportPdf", p)?;
|
||||
let mut r = print_pdf(&st.doc, &s.cache, &opts, ppi)?;
|
||||
let doc: &designcraft_doc::Document = &st.doc;
|
||||
let (cat, dest) = view_options(p)?;
|
||||
let mut r = print_pdf(doc, cache, &opts, ppi)?;
|
||||
let spreads = designcraft_pdf::sheet_spreads(doc, &opts);
|
||||
// Interactive PDF view options.
|
||||
let mut cat = String::new();
|
||||
if p.get("fullScreen").and_then(Value::as_bool) == Some(true) {
|
||||
cat.push_str("/PageMode/FullScreen");
|
||||
} else if p.get("bookmarksPanel").and_then(Value::as_bool) == Some(true) {
|
||||
cat.push_str("/PageMode/UseOutlines");
|
||||
}
|
||||
match str_param(p, "pageLayout") {
|
||||
Some("single") => cat.push_str("/PageLayout/SinglePage"),
|
||||
Some("continuous") => cat.push_str("/PageLayout/OneColumn"),
|
||||
Some("twoUp") => cat.push_str("/PageLayout/TwoPageLeft"),
|
||||
Some("twoUpCover") => cat.push_str("/PageLayout/TwoPageRight"),
|
||||
Some("twoUpContinuous") => cat.push_str("/PageLayout/TwoColumnRight"),
|
||||
Some(o) => return Err(bad("file.exportPdf", format!("unknown pageLayout `{o}`"))),
|
||||
None => {}
|
||||
}
|
||||
if let Some(v) = str_param(p, "view") {
|
||||
// The first page opens with this view (pages are objects in order, so refer by index 0).
|
||||
let dest = match v {
|
||||
"fitPage" => "/Fit",
|
||||
"fitWidth" => "/FitH null",
|
||||
"actual" => "/XYZ null null 1",
|
||||
o => {
|
||||
return Err(bad("file.exportPdf", format!("unknown view `{o}` (fitPage, fitWidth, actual)")));
|
||||
}
|
||||
};
|
||||
if let Some(first) = first_page_ref(&r.bytes) {
|
||||
cat.push_str(&format!("/OpenAction[{first} 0 R{dest}]"));
|
||||
}
|
||||
let mut cat = cat;
|
||||
if let Some(dest) = dest
|
||||
&& let Some(first) = first_page_ref(&r.bytes)
|
||||
{
|
||||
cat.push_str(&format!("/OpenAction[{first} 0 R{dest}]"));
|
||||
}
|
||||
if !cat.is_empty() {
|
||||
match designcraft_pdf::add_catalog_entries(&r.bytes, &cat) {
|
||||
@@ -213,8 +212,7 @@ pub(crate) fn pdf_export(s: &Session, p: &Value) -> Result<designcraft_pdf::Expo
|
||||
}
|
||||
// Flip pages every N seconds (presentation).
|
||||
if let Some(secs) = p.get("advanceSeconds").and_then(Value::as_f64).filter(|s| *s > 0.0) {
|
||||
let n = designcraft_pdf::sheet_spreads(doc, &opts).len();
|
||||
let dur: Vec<Option<String>> = (0..n).map(|_| Some(format!("/Dur {secs:.2}"))).collect();
|
||||
let dur: Vec<Option<String>> = (0..spreads.len()).map(|_| Some(format!("/Dur {secs:.2}"))).collect();
|
||||
match designcraft_pdf::add_page_entries(&r.bytes, &dur) {
|
||||
Some(b) => r.bytes = b,
|
||||
None => r.warnings.push("page timings couldn't be set".into()),
|
||||
@@ -232,6 +230,44 @@ pub(crate) fn pdf_export(s: &Session, p: &Value) -> Result<designcraft_pdf::Expo
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// The catalog entries of the interactive view options in `p` (page mode and layout) and the
|
||||
/// destination the first page opens at (`view`); unknown values are refused.
|
||||
fn view_options(p: &Value) -> Result<(String, Option<&'static str>)> {
|
||||
let mut cat = String::new();
|
||||
if p.get("fullScreen").and_then(Value::as_bool) == Some(true) {
|
||||
cat.push_str("/PageMode/FullScreen");
|
||||
} else if p.get("bookmarksPanel").and_then(Value::as_bool) == Some(true) {
|
||||
cat.push_str("/PageMode/UseOutlines");
|
||||
}
|
||||
match str_param(p, "pageLayout") {
|
||||
Some("single") => cat.push_str("/PageLayout/SinglePage"),
|
||||
Some("continuous") => cat.push_str("/PageLayout/OneColumn"),
|
||||
Some("twoUp") => cat.push_str("/PageLayout/TwoPageLeft"),
|
||||
Some("twoUpCover") => cat.push_str("/PageLayout/TwoPageRight"),
|
||||
Some("twoUpContinuous") => cat.push_str("/PageLayout/TwoColumnRight"),
|
||||
Some(o) => return Err(bad("file.exportPdf", format!("unknown pageLayout `{o}`"))),
|
||||
None => {}
|
||||
}
|
||||
// The first page opens with this view (pages are objects in order, so refer by index 0).
|
||||
let dest = match str_param(p, "view") {
|
||||
None => None,
|
||||
Some("fitPage") => Some("/Fit"),
|
||||
Some("fitWidth") => Some("/FitH null"),
|
||||
Some("actual") => Some("/XYZ null null 1"),
|
||||
Some(o) => return Err(bad("file.exportPdf", format!("unknown view `{o}` (fitPage, fitWidth, actual)"))),
|
||||
};
|
||||
Ok((cat, dest))
|
||||
}
|
||||
|
||||
/// Refuses PDF options `p` that [`pdf_report`] would refuse for a document of `pages` pages,
|
||||
/// without exporting: `file.package` checks them before it writes anything.
|
||||
pub(crate) fn check_pdf_options(p: &Value, pages: usize) -> Result<()> {
|
||||
options(p, pages)?;
|
||||
flatten_ppi("file.exportPdf", p)?;
|
||||
view_options(p)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tagged_tests {
|
||||
use serde_json::json;
|
||||
@@ -461,3 +497,219 @@ mod page_list_tests {
|
||||
assert!(s.execute("file.exportPdf", &json!({"pages": []})).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod overset_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// A page-based export leaves out the text its frames have no room for, and said nothing
|
||||
/// about it: an imported 58-page form lost whole sentences — a data-consent clause, a
|
||||
/// statutory rule, the last row of two tables — with `warnings` empty while preflight
|
||||
/// reported 19 overset stories (#1465).
|
||||
#[test]
|
||||
fn an_export_warns_about_the_text_the_pages_had_no_room_for() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 2, "facingPages": false, "margins": 0})).unwrap();
|
||||
let text = "First line is visible.\nSecond paragraph does not fit: this sentence carries the legal notice and must not vanish from the PDF without a warning.";
|
||||
let frame = s.execute("frame.create", &json!({"rect": [20, 20, 280, 60], "content": "text", "text": text})).unwrap();
|
||||
let (id, sid) = (frame["id"].as_u64().unwrap(), frame["story"].clone());
|
||||
|
||||
// What preflight says about the same frame, for the warning to agree with.
|
||||
let issues = s.execute("preflight.run", &json!({})).unwrap();
|
||||
let overset = issues["issues"].as_array().unwrap().iter().find(|i| i["kind"] == "overset").expect("preflight reports it").clone();
|
||||
assert_eq!(overset["message"], "Overset text: 76 characters");
|
||||
|
||||
for command in ["file.exportPdf", "file.exportPptx"] {
|
||||
let r = s.execute(command, &json!({})).unwrap();
|
||||
let warnings = r["warnings"].as_array().expect("warnings").clone();
|
||||
let about: Vec<&str> = warnings.iter().filter_map(|w| w.as_str()).filter(|w| w.contains("not in the export")).collect();
|
||||
assert_eq!(about.len(), 1, "{command}: {warnings:?}");
|
||||
let w = about[0];
|
||||
assert!(w.contains("page 1"), "{command}: the page it is on: {w}");
|
||||
assert!(w.contains("76 character"), "{command}: the count preflight gives: {w}");
|
||||
assert!(w.contains(&format!("frame {id}")), "{command}: the frame: {w}");
|
||||
}
|
||||
|
||||
// The pages being exported are the ones reported on: page 2 has no overset frame.
|
||||
let r = s.execute("file.exportPdf", &json!({"pages": [2]})).unwrap();
|
||||
assert_eq!(r["warnings"], json!([]), "{r}");
|
||||
|
||||
// A reflowable export carries the whole story, so it has nothing to report.
|
||||
let r = s.execute("file.exportHtml", &json!({})).unwrap();
|
||||
assert_eq!(r["warnings"], json!([]), "{r}");
|
||||
assert!(r["text"].as_str().unwrap().contains("must not vanish"), "the HTML has the text the PDF left out");
|
||||
|
||||
// `overset` is the index where the text stops fitting; `oversetChars` is how much is left,
|
||||
// which is the number preflight reports. The two were read as the same thing.
|
||||
let story = s.execute("story.get", &json!({"story": sid})).unwrap();
|
||||
assert_eq!(story["overset"], json!(68), "{story}");
|
||||
assert_eq!(story["oversetChars"], json!(76), "{story}");
|
||||
|
||||
// A frame the text fits: nothing is reported anywhere.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 1, "facingPages": false, "margins": 0})).unwrap();
|
||||
let frame = s.execute("frame.create", &json!({"rect": [20, 20, 280, 180], "content": "text", "text": "Short."})).unwrap();
|
||||
for command in ["file.exportPdf", "file.exportPptx"] {
|
||||
assert_eq!(s.execute(command, &json!({})).unwrap()["warnings"], json!([]), "{command}");
|
||||
}
|
||||
let story = s.execute("story.get", &json!({"story": frame["story"].clone()})).unwrap();
|
||||
assert!(story["overset"].is_null() && story["oversetChars"].is_null(), "{story}");
|
||||
}
|
||||
|
||||
/// A sheet draws its spread's items without asking which page each one sits on, so a frame
|
||||
/// straddling two pages is drawn, in part, on both. Choosing the page by the frame's centre
|
||||
/// alone meant a frame centred on a page the export leaves out had its warning dropped while
|
||||
/// the part on an exported page went into the file (#1493 review).
|
||||
#[test]
|
||||
fn an_export_warns_about_a_frame_that_only_crosses_into_one_of_its_pages() {
|
||||
let mut s = Session::new();
|
||||
// Facing pages, so pages 2 and 3 are one spread and a frame can straddle their boundary.
|
||||
s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 4, "facingPages": true, "margins": 0})).unwrap();
|
||||
let text = "First line is visible.\nSecond paragraph does not fit: this sentence carries the legal notice and must not vanish from the PDF without a warning.";
|
||||
// Spread 1 is pages 2-3; its page 2 runs x 0..300 and page 3 runs x 300..600. The frame
|
||||
// is centred on page 3 and reaches back over the boundary into page 2.
|
||||
let frame = json!({"spread": 1, "rect": [260, 20, 420, 60], "content": "text", "text": text});
|
||||
let id = s.execute("frame.create", &frame).unwrap()["id"].as_u64().unwrap();
|
||||
|
||||
let about = |s: &mut Session, pages: serde_json::Value| {
|
||||
let params = if pages.is_null() { json!({}) } else { json!({"pages": pages}) };
|
||||
let r = s.execute("file.exportPdf", ¶ms).unwrap();
|
||||
let ws = r["warnings"].as_array().cloned().unwrap_or_default();
|
||||
ws.iter().filter_map(|w| w.as_str()).filter(|w| w.contains("not in the export")).map(str::to_owned).collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
// The whole document: reported once, at the page the frame is centred on.
|
||||
let all = about(&mut s, serde_json::Value::Null);
|
||||
assert_eq!(all.len(), 1, "{all:?}");
|
||||
assert!(all[0].contains("page 3") && all[0].contains(&format!("frame {id}")), "{}", all[0]);
|
||||
|
||||
// Exporting page 3 alone: the centre page, reported as before.
|
||||
let three = about(&mut s, json!([3]));
|
||||
assert_eq!(three.len(), 1, "{three:?}");
|
||||
assert!(three[0].contains("page 3"), "{}", three[0]);
|
||||
|
||||
// Exporting page 2 alone: the frame's centre is on page 3, which is not in the export --
|
||||
// but part of the frame is drawn on page 2, so it is still reported, and the page named is
|
||||
// one the export has.
|
||||
let two = about(&mut s, json!([2]));
|
||||
assert_eq!(two.len(), 1, "a frame crossing into page 2 is reported: {two:?}");
|
||||
assert!(two[0].contains("page 2"), "and the page it names is an exported one: {}", two[0]);
|
||||
assert!(two[0].contains(&format!("frame {id}")), "{}", two[0]);
|
||||
|
||||
// A page the frame does not reach at all: nothing to say.
|
||||
assert_eq!(about(&mut s, json!([4])), Vec::<String>::new());
|
||||
}
|
||||
|
||||
/// `overset` indexes `text` in UTF-8 bytes, not characters: a client that slices a JavaScript
|
||||
/// string at it cuts the wrong place once the text is not ASCII (noted in review).
|
||||
#[test]
|
||||
fn overset_is_a_byte_offset_into_the_text() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 1, "facingPages": false, "margins": 0})).unwrap();
|
||||
// Accented letters are two bytes each, so the byte offset runs ahead of the character index.
|
||||
let text = "Réservé à l'évaluation détaillée des éléments.\nCette phrase dépasse le cadre et n'est pas dans le PDF exporté du tout.";
|
||||
let frame = s.execute("frame.create", &json!({"rect": [20, 20, 280, 40], "content": "text", "text": text})).unwrap();
|
||||
let story = s.execute("story.get", &json!({"story": frame["story"].clone()})).unwrap();
|
||||
let at = story["overset"].as_u64().expect("it oversets") as usize;
|
||||
assert!(text.is_char_boundary(at), "a byte offset that splits no character: {at}");
|
||||
assert!(at > text[..at].chars().count(), "ahead of the character index: {at} bytes, {} characters", text[..at].chars().count());
|
||||
// And `oversetChars` counts characters, which is why the two numbers differ.
|
||||
assert_eq!(story["oversetChars"], json!(text[at..].chars().count()), "{story}");
|
||||
}
|
||||
|
||||
/// A parent page's frame is composed once per page that shows it: its page number marker is
|
||||
/// that page's, so the same frame fits on page 1 and oversets on page 10 -- and a parent page
|
||||
/// no exported page uses puts nothing in the file to be missing. Composing it once with no
|
||||
/// page reported neither: every selection got the same unpaged warning (noted in review).
|
||||
#[test]
|
||||
fn a_parent_frame_is_checked_on_the_pages_that_show_it() {
|
||||
let about = |r: &serde_json::Value| -> Vec<String> {
|
||||
r["warnings"]
|
||||
.as_array()
|
||||
.map(|w| w.iter().filter_map(|w| w.as_str()).filter(|w| w.contains("not in the export")).map(str::to_string).collect())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
// A parent page that only page 1 uses.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 2, "facingPages": false, "margins": 0})).unwrap();
|
||||
s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": null})).unwrap();
|
||||
let text = "First line is visible.\nSecond paragraph does not fit: this sentence carries the legal notice and must not vanish from the PDF without a warning.";
|
||||
let frame = s
|
||||
.execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [20, 20, 280, 60], "content": "text", "text": text}))
|
||||
.unwrap();
|
||||
let id = frame["id"].as_u64().unwrap();
|
||||
|
||||
let w = about(&s.execute("file.exportPdf", &json!({"pages": [1]})).unwrap());
|
||||
assert_eq!(w.len(), 1, "page 1 shows the parent: {w:?}");
|
||||
assert!(w[0].contains("page 1") && w[0].contains(&format!("frame {id}")) && w[0].contains("76 character"), "{}", w[0]);
|
||||
|
||||
// Page 2 has no parent, so nothing of that frame is in its export to be missing.
|
||||
let r = s.execute("file.exportPdf", &json!({"pages": [2]})).unwrap();
|
||||
assert!(about(&r).is_empty(), "page 2 does not use the parent: {r}");
|
||||
|
||||
// Page-dependent text: the marker is one digit up to page 9 and two from page 10, and the
|
||||
// wider line no longer fits. 112 pt is the width where that is the only difference.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 12, "facingPages": false, "margins": 0})).unwrap();
|
||||
let text = format!("Continued on page {}", designcraft_doc::story::PAGE_NUMBER);
|
||||
s.execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [20, 20, 132, 36], "content": "text", "text": text}))
|
||||
.unwrap();
|
||||
|
||||
for page in [1, 9] {
|
||||
let r = s.execute("file.exportPdf", &json!({"pages": [page]})).unwrap();
|
||||
assert!(about(&r).is_empty(), "page {page} fits one digit: {r}");
|
||||
}
|
||||
let w = about(&s.execute("file.exportPdf", &json!({"pages": [10]})).unwrap());
|
||||
assert_eq!(w.len(), 1, "two digits do not fit: {w:?}");
|
||||
assert!(w[0].contains("page 10"), "{}", w[0]);
|
||||
|
||||
// The whole document: one line naming every page it happens on, not one line per page.
|
||||
let w = about(&s.execute("file.exportPdf", &json!({})).unwrap());
|
||||
assert_eq!(w.len(), 1, "one line for the story: {w:?}");
|
||||
assert!(w[0].starts_with("pages 10, 11, 12:"), "{}", w[0]);
|
||||
}
|
||||
|
||||
/// A spread export puts the whole spread on the sheet, so choosing one page of it exports its
|
||||
/// partner too -- and that partner's overset is just as absent from the file. Reporting only
|
||||
/// the pages that were named left it unmentioned (noted in review of #1465).
|
||||
#[test]
|
||||
fn a_spread_export_warns_about_the_pages_it_takes_with_it() {
|
||||
let mut s = Session::new();
|
||||
// Facing pages: 2 and 3 share a spread, 1 is the cover on its own.
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200, "pages": 4, "facingPages": true, "margins": 0})).unwrap();
|
||||
let text = "First line is visible.\nSecond paragraph does not fit: this sentence carries the legal notice and must not vanish from the PDF without a warning.";
|
||||
// A frame on page 3, whose text does not fit it. `frame.create` takes a spread and
|
||||
// spread coordinates, so page 3's own bounds say where to put it.
|
||||
let (si, b) = {
|
||||
let d = &s.active().expect("a document").doc;
|
||||
let (si, pi) = d.page_loc(2).expect("page 3 is there");
|
||||
(si, d.spreads[si].pages[pi].bounds())
|
||||
};
|
||||
let rect = json!([b.x0 + 20.0, b.y0 + 20.0, b.x0 + 180.0, b.y0 + 60.0]);
|
||||
let frame = s.execute("frame.create", &json!({"spread": si, "rect": rect, "content": "text", "text": text})).unwrap();
|
||||
let id = frame["id"].as_u64().unwrap();
|
||||
let about = |r: &serde_json::Value| -> Vec<String> {
|
||||
r["warnings"]
|
||||
.as_array()
|
||||
.map(|w| w.iter().filter_map(|w| w.as_str()).filter(|w| w.contains("not in the export")).map(str::to_string).collect())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
// Page by page, choosing page 2 reports nothing: page 2 holds no overset frame.
|
||||
let r = s.execute("file.exportPdf", &json!({"pages": [2]})).unwrap();
|
||||
assert!(about(&r).is_empty(), "page 2 alone has nothing to report: {r}");
|
||||
|
||||
// As spreads, choosing page 2 exports page 3 with it, so page 3's overset is reported.
|
||||
let r = s.execute("file.exportPdf", &json!({"pages": [2], "spreads": true})).unwrap();
|
||||
let w = about(&r);
|
||||
assert_eq!(w.len(), 1, "the page that came along is reported too: {r}");
|
||||
assert!(w[0].contains("page 3") && w[0].contains(&format!("frame {id}")), "{}", w[0]);
|
||||
|
||||
// And a page on another spread entirely is still not reported.
|
||||
let r = s.execute("file.exportPdf", &json!({"pages": [1], "spreads": true})).unwrap();
|
||||
assert!(about(&r).is_empty(), "the cover's spread holds no overset frame: {r}");
|
||||
}
|
||||
}
|
||||
@@ -204,12 +204,12 @@ fn black_and_gold(pdf: &[u8]) -> Vec<(u8, u8)> {
|
||||
let mut out = Vec::new();
|
||||
for st in file.objects().into_iter().filter_map(|o| o.into_stream()) {
|
||||
let d = st.dict();
|
||||
if !d.get::<Name<'_>>(b"Subtype").is_some_and(|n| n.as_ref() == b"Image") || d.get::<i32>(b"BitsPerComponent") != Some(8) {
|
||||
if d.get::<Name<'_>>(b"Subtype").is_none_or(|n| n.as_ref() != b"Image") || d.get::<i32>(b"BitsPerComponent") != Some(8) {
|
||||
continue;
|
||||
}
|
||||
let Some(cs) = d.get::<Array<'_>>(b"ColorSpace") else { continue };
|
||||
let mut it = cs.iter::<Object<'_>>();
|
||||
if !it.next().and_then(|o| o.into_name()).is_some_and(|n| n.as_ref() == b"DeviceN") {
|
||||
if it.next().and_then(|o| o.into_name()).is_none_or(|n| n.as_ref() != b"DeviceN") {
|
||||
continue;
|
||||
}
|
||||
let Some(Object::Array(names)) = it.next() else { continue };
|
||||
|
||||
@@ -27,6 +27,16 @@ pub fn with_document_extension(path: &str) -> String {
|
||||
format!("{path}.{}", designcraft_format::EXTENSION)
|
||||
}
|
||||
|
||||
/// `name` without a trailing `.designcraft` (any case): the title of a document opened from bytes
|
||||
/// under its file name. Save, Save As and the File Name variable add the extension back, so a title
|
||||
/// that kept it would name the file `Brochure.designcraft.designcraft`. A bare `.designcraft` stays.
|
||||
pub(super) fn without_native_extension(name: &str) -> &str {
|
||||
match name.rsplit_once('.') {
|
||||
Some((stem, ext)) if !stem.is_empty() && ext.eq_ignore_ascii_case(designcraft_format::EXTENSION) => stem,
|
||||
_ => name,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `bytes` are a document `file.open` reads: a DesignCraft package or an IDML package.
|
||||
/// The first [`DOCUMENT_SNIFF_BYTES`] of a file are enough for packages that start with their
|
||||
/// `mimetype` entry, as both formats write them.
|
||||
|
||||
@@ -13,10 +13,11 @@ fn find_preset(s: &Session, name: &str) -> Option<NewDocument> {
|
||||
}
|
||||
|
||||
/// The settings `file.new` makes a document with for `p`: its preset, then the settings over it.
|
||||
/// Without a preset or `units`, it is in the default units (Preferences with no document).
|
||||
fn new_document_settings(s: &Session, p: &Value) -> Result<NewDocument> {
|
||||
let mut nd = match str_param(p, "preset") {
|
||||
Some(name) => find_preset(s, name).ok_or_else(|| bad("file.new", format!("unknown preset `{name}`")))?,
|
||||
None => NewDocument::default(),
|
||||
None => s.default_new_document(),
|
||||
};
|
||||
apply_settings(&mut nd, p);
|
||||
check_size("file.new", &nd)?;
|
||||
@@ -40,12 +41,19 @@ pub(super) fn file_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut nd = new_document_settings(s, p)?;
|
||||
s.untitled += 1;
|
||||
nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| format!("Untitled-{}", s.untitled));
|
||||
let d = new_document(&nd);
|
||||
let mut d = new_document(&nd);
|
||||
s.prefs.default_units.apply(&mut d.settings, p.get("preset").is_some_and(|v| !v.is_null()));
|
||||
let i = s.add_document(DocState::new(d, None));
|
||||
Ok(json!({"index": i}))
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// What New Document starts with: the factory settings, in the default units (Preferences
|
||||
/// with no document open).
|
||||
pub fn default_new_document(&self) -> NewDocument {
|
||||
NewDocument { units: self.prefs.default_units.horizontal_units, ..NewDocument::default() }
|
||||
}
|
||||
|
||||
/// New Document's Preview: a document made as `file.new` would make it with `p`, shown while
|
||||
/// the New Document window is open. It replaces the previous preview document in its tab and
|
||||
/// takes no Untitled number, no undo step and no recovery entry. Settings `file.new` refuses
|
||||
@@ -64,7 +72,9 @@ impl Session {
|
||||
self.untitled += 1;
|
||||
}
|
||||
nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| self.next_untitled_title());
|
||||
let st = DocState::new(new_document(&nd), None);
|
||||
let mut d = new_document(&nd);
|
||||
self.prefs.default_units.apply(&mut d.settings, p.get("preset").is_some_and(|v| !v.is_null()));
|
||||
let st = DocState::new(d, None);
|
||||
let uid = st.uid;
|
||||
match replace.and_then(|i| self.docs.get_mut(i).map(|d| (i, d))) {
|
||||
Some((i, d)) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#[cfg(test)]
|
||||
use super::bytes::{PREVIEW_PX, to_bytes};
|
||||
use super::bytes::{base64_decode, load_bytes};
|
||||
use super::bytes::{base64_decode, load_bytes, without_native_extension};
|
||||
use super::{Arc, DocState, Document, EngineError, Result, Session, Value, bad, json, str_param};
|
||||
|
||||
pub(super) fn has_recent(s: &Session) -> std::result::Result<(), String> {
|
||||
@@ -150,7 +150,8 @@ pub(super) fn file_open_bytes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (mut d, warnings) = load_bytes(&b)?;
|
||||
crate::cmd::datamerge::resolve_sources_on_open(&mut d, None);
|
||||
if let Some(n) = str_param(p, "name") {
|
||||
d.title = n.to_string();
|
||||
// Titled as an IDML file opened from bytes is: without its extension.
|
||||
d.title = without_native_extension(n).to_string();
|
||||
}
|
||||
let i = s.add_document(DocState::new(d, None));
|
||||
Ok(json!({"index": i, "warnings": warnings}))
|
||||
@@ -413,6 +414,66 @@ mod document_fonts_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod open_bytes_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn legacy_and_idml_bytes_named_as_indesign_files_open_without_conversion() {
|
||||
let mut maker = Session::new();
|
||||
maker.execute("file.new", &json!({})).unwrap();
|
||||
let idml = designcraft_idml::export_idml(&maker.doc().unwrap().doc);
|
||||
let base64 = super::super::bytes::base64_encode(&idml);
|
||||
let legacy = super::super::bytes::base64_encode(&serde_json::to_vec(&maker.doc().unwrap().doc).unwrap());
|
||||
for name in ["Brochure.indd", "Brochure.INDT"] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.openBytes", &json!({"name": name, "base64": legacy})).unwrap();
|
||||
assert_eq!(s.documents().len(), 1, "legacy: {name}");
|
||||
assert_eq!(s.doc().unwrap().doc.title, name, "legacy: {name}");
|
||||
}
|
||||
for command in ["file.openBytes", "file.openIdml"] {
|
||||
for name in ["Brochure.indd", "Brochure.INDT"] {
|
||||
let mut s = Session::new();
|
||||
s.execute(command, &json!({"name": name, "base64": base64})).unwrap();
|
||||
assert_eq!(s.documents().len(), 1, "{command}: {name}");
|
||||
assert_eq!(s.doc().unwrap().doc.title, "Brochure", "{command}: {name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `.designcraft` file opened from bytes under its file name (a browser or host passes the
|
||||
/// name it has) is titled without the extension, as an IDML file is, so saving it gives
|
||||
/// `Brochure.designcraft` and not `Brochure.designcraft.designcraft`.
|
||||
#[test]
|
||||
fn open_bytes_titles_a_native_document_without_its_extension() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let native = s.execute("file.serialize", &json!({})).unwrap();
|
||||
for (name, title) in [
|
||||
("Brochure.designcraft", "Brochure"),
|
||||
("Brochure.DesignCraft", "Brochure"),
|
||||
("Brochure", "Brochure"),
|
||||
("Brochure.v2", "Brochure.v2"),
|
||||
("Brochure.indd", "Brochure.indd"),
|
||||
("Brochure.INDT", "Brochure.INDT"),
|
||||
("Brochure.designcraft.designcraft", "Brochure.designcraft"),
|
||||
(".designcraft", ".designcraft"),
|
||||
// Names that aren't ASCII: cutting the extension's length off the end of the last one
|
||||
// would land inside its `é`.
|
||||
("Präsentation.designcraft", "Präsentation"),
|
||||
("日本.DESIGNCRAFT", "日本"),
|
||||
("é", "é"),
|
||||
("édesigncraft", "édesigncraft"),
|
||||
] {
|
||||
let r = s.execute("file.openBytes", &json!({"name": name, "base64": native["base64"]})).unwrap();
|
||||
let st = &s.documents()[r["index"].as_u64().unwrap() as usize];
|
||||
assert_eq!((st.doc.title.as_str(), st.title()), (title, title.to_string()), "{name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod recent_tests {
|
||||
use serde_json::json;
|
||||
|
||||
@@ -276,7 +276,7 @@ pub(super) fn place_drop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// Drag: fit proportionally; click: actual size at the point.
|
||||
let (r, k) = match rect {
|
||||
Some(r) if r.width() > 2.0 && r.height() > 2.0 => {
|
||||
Some(r) if r.width() > designcraft_tools::PLACE_DRAG_MIN && r.height() > designcraft_tools::PLACE_DRAG_MIN => {
|
||||
let k = (r.width() / nw).min(r.height() / nh);
|
||||
(Rect::new(r.x0, r.y0, r.x0 + nw * k, r.y0 + nh * k), k)
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
ok()
|
||||
}),
|
||||
cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"),
|
||||
"{path} — a DesignCraft or IDML file (recognised by its content, so the extension may be missing), or an InDesign .indd/.indt (recognised by its signature or extension, converted to IDML; its conversion warnings join `warnings`); the fonts in a `Document Fonts` folder beside it load first → {index, documentFonts: faces loaded, warnings: font files skipped, GREP styles whose pattern can't compile, Based on Parent links cleared and, for IDML and InDesign files, package parts missing}",
|
||||
"{path} — a DesignCraft or IDML file (recognised by its content, so the extension may be missing), or an InDesign .indd/.indt (recognised by its signature or extension, converted to IDML; its conversion warnings join `warnings`); the fonts in a `Document Fonts` folder beside it load first → {index, documentFonts: faces loaded, warnings: font files skipped, GREP styles whose pattern can't compile, Based on Parent links cleared and, for IDML and InDesign files, package parts missing; warningItems (IDML and InDesign files): per warning, the id of the frame it is about, or null}",
|
||||
always, file_open),
|
||||
cmd!(noundo "file.openBytes", "Open Bytes", [], None, "{name, base64} — DesignCraft JSON, an IDML package or an InDesign .indd/.indt → {index, warnings}", always, file_open_bytes),
|
||||
cmd!(noundo "file.openRecent", "Open Recent File", [], None,
|
||||
|
||||
+395
-28
@@ -1,12 +1,12 @@
|
||||
//! Edit → Find/Change: plain text and GREP (regular expressions) with InDesign's metacharacters,
|
||||
//! optional formatting in the change, scoped to the document, a story or the selection.
|
||||
//! a Find Format and a Change Format, scoped to the document, a story or the selection.
|
||||
|
||||
use std::ops::Range;
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
use designcraft_compose::grep::{Grep, GrepOptions, translate_replacement};
|
||||
use designcraft_doc::story::floor_char_boundary;
|
||||
use designcraft_doc::{CellAddr, Document, Selection, StoryId, TextSel};
|
||||
use designcraft_doc::{CellAddr, Document, Selection, Story, StoryId, Styles, TextSel};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, bool_or, cmd, has_doc, str_param};
|
||||
@@ -61,18 +61,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
),
|
||||
cmd!(query "find.find", "Find", ["Edit", "Find/Change"], None,
|
||||
"{find, grep?: bool, caseSensitive?: bool, wholeWord?: bool, scope?: document|story|selection, story?, change?|first?|attrs?: ignored (find.change takes them; one request serves all three Find/Change commands)} → matches [{story, start, end, text, cell?: {table, row, col}}] (document scope also searches table cells; start/end then index the cell's text)",
|
||||
"{find, grep?: bool, caseSensitive?: bool, wholeWord?: bool, scope?: document|story|selection, story?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), change?|first?|found?|attrs?|charStyle?|paraStyle?: ignored (find.change takes them; one request serves all three Find/Change commands)} → matches [{story, start, end, text, cell?: {table, row, col}}] (document scope also searches table cells; start/end then index the cell's text)",
|
||||
has_doc, find),
|
||||
cmd!(
|
||||
"find.change",
|
||||
"Change All",
|
||||
["Edit", "Find/Change"],
|
||||
None,
|
||||
"{find, change, grep?, caseSensitive?, wholeWord?, scope?, story?, first?: bool (change only the first match), attrs?: {character attributes applied to changed text}} → {count}",
|
||||
"{find, change, grep?, caseSensitive?, wholeWord?, scope?, story?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), first?: bool (change only the first match), found?: bool (Change: only the text Find Next found, while it is selected and still a match; it stays selected, and Find Next goes on after it in the same domain), attrs?: {character attributes applied to changed text}, charStyle?: a character style applied to it, paraStyle?: a paragraph style applied to its paragraphs} → {count} — attrs, charStyle and paraStyle are the Change Format: with one and an empty change, the found text stays and is only formatted; with no find, change is literal text",
|
||||
has_doc,
|
||||
change
|
||||
),
|
||||
cmd!(noundo "find.next", "Find Next", ["Edit"], Some("Cmd+Alt+F"), "{find, grep?, caseSensitive?, wholeWord?, scope?: document|story|selection, story?, change?|first?|attrs?: ignored (find.change takes them; one request serves all three Find/Change commands)} — Selection starts at its original beginning; later calls advance within the scope", has_doc, find_next),
|
||||
cmd!(noundo "find.next", "Find Next", ["Edit"], Some("Cmd+Alt+F"), "{find, grep?, caseSensitive?, wholeWord?, scope?: document|story|selection, story?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), change?|first?|found?|attrs?|charStyle?|paraStyle?: ignored (find.change takes them; one request serves all three Find/Change commands)} — Selection starts at its original beginning; later calls advance within the scope", has_doc, find_next),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -108,6 +108,227 @@ fn build(p: &Value) -> Result<Grep> {
|
||||
re.map_err(|e| bad("find", e))
|
||||
}
|
||||
|
||||
/// Find/Change's Find Format: the text a search finds must have it all through.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
struct FindFormat {
|
||||
char_style: Option<String>,
|
||||
para_style: Option<String>,
|
||||
/// Resolved character attributes, as `type.selectionAttrs` reports them.
|
||||
chars: serde_json::Map<String, Value>,
|
||||
}
|
||||
|
||||
impl FindFormat {
|
||||
fn from_params(p: &Value) -> Result<Option<Self>> {
|
||||
let Some(f) = p.get("findFormat").filter(|f| !f.is_null()) else { return Ok(None) };
|
||||
let o = f.as_object().ok_or_else(|| bad("find", "`findFormat` is an object"))?;
|
||||
// A style is named; a null asks for none. Any other value is refused rather than
|
||||
// searched without.
|
||||
let name = |k: &str| match o.get(k) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(n)) => Ok(Some(n.clone())),
|
||||
Some(v) => Err(bad("find", format!("findFormat: `{k}` is a style name, not {v}"))),
|
||||
};
|
||||
let chars = match o.get("chars") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::Object(m)) => Some(m),
|
||||
Some(v) => return Err(bad("find", format!("findFormat: `chars` is an object of character attributes, not {v}"))),
|
||||
};
|
||||
// Only attributes text has, kept as the document writes them: `font_style` and
|
||||
// `fontStyle`, or 12 and 12.0, then find the same text. A null asks for nothing.
|
||||
let mut known = designcraft_doc::CharAttrs::default();
|
||||
for (k, v) in chars.into_iter().flatten() {
|
||||
known.set_json(k, v).map_err(|e| bad("find", format!("findFormat: {e}")))?;
|
||||
}
|
||||
let chars = match serde_json::to_value(&known) {
|
||||
Ok(Value::Object(m)) => m,
|
||||
_ => serde_json::Map::new(),
|
||||
};
|
||||
let f = Self { char_style: name("charStyle")?, para_style: name("paraStyle")?, chars };
|
||||
Ok((f != Self::default()).then_some(f))
|
||||
}
|
||||
|
||||
/// Styles the document doesn't have are refused, as `find.change` refuses them.
|
||||
fn check(&self, styles: &Styles) -> Result<()> {
|
||||
if let Some(n) = self.char_style.as_ref().filter(|n| styles.char_style(n).is_none()) {
|
||||
return Err(bad("find", format!("no character style `{n}`")));
|
||||
}
|
||||
if let Some(n) = self.para_style.as_ref().filter(|n| styles.para(n).is_none()) {
|
||||
return Err(bad("find", format!("no paragraph style `{n}`")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The text in `r` of `story` that has the format, relative to `r.start`: the longest
|
||||
/// stretches, each paragraph's apart (a paragraph's `\n` isn't in one).
|
||||
fn spans(&self, styles: &Styles, story: &Story, r: &Range<usize>) -> Vec<Range<usize>> {
|
||||
let mut out: Vec<Range<usize>> = Vec::new();
|
||||
// Paragraphs and runs both go in text order: the runs before a paragraph are passed once.
|
||||
let runs: Vec<_> = story.runs().collect();
|
||||
let mut first = 0;
|
||||
for (pi, pr) in story.para_ranges().into_iter().enumerate() {
|
||||
let (a, b) = (pr.start.max(r.start), pr.end.min(r.end));
|
||||
let Some(pf) = story.paras.get(pi) else { continue };
|
||||
if a >= b || self.para_style.as_ref().is_some_and(|n| *n != pf.style) {
|
||||
continue;
|
||||
}
|
||||
while runs.get(first).is_some_and(|(run, _)| run.end <= a) {
|
||||
first += 1;
|
||||
}
|
||||
let base = (!self.chars.is_empty()).then(|| styles.resolve_para(pf).1);
|
||||
for (run, cf) in runs[first..].iter().take_while(|(run, _)| run.start < b) {
|
||||
let (x, y) = (run.start.max(a), run.end.min(b));
|
||||
if x >= y || self.char_style.as_ref().is_some_and(|n| *n != cf.style) {
|
||||
continue;
|
||||
}
|
||||
if let Some(base) = &base {
|
||||
let cp = serde_json::to_value(styles.resolve_char(base, cf)).unwrap_or_default();
|
||||
if !self.chars.iter().all(|(k, v)| cp.get(k).is_some_and(|c| same_value(c, v))) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
match out.last_mut() {
|
||||
Some(last) if last.end == x - r.start => last.end = y - r.start,
|
||||
_ => out.push(x - r.start..y - r.start),
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Attribute values alike: numbers to within rounding (inside objects and lists too), the rest
|
||||
/// exactly.
|
||||
fn same_value(a: &Value, b: &Value) -> bool {
|
||||
match (a, b) {
|
||||
(Value::Number(_), Value::Number(_)) => match (a.as_f64(), b.as_f64()) {
|
||||
(Some(x), Some(y)) => (x - y).abs() < 1e-6,
|
||||
_ => a == b,
|
||||
},
|
||||
(Value::Array(x), Value::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(a, b)| same_value(a, b)),
|
||||
(Value::Object(x), Value::Object(y)) => x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| same_value(v, w))),
|
||||
_ => a == b,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether match `m` lies in one of `spans`.
|
||||
fn within(spans: &[Range<usize>], m: &Range<usize>) -> bool {
|
||||
spans.iter().any(|s| s.start <= m.start && m.end <= s.end)
|
||||
}
|
||||
|
||||
/// What a search finds: the pattern's matches (with a Find Format, those whose text has it all
|
||||
/// through), or, with no pattern, the stretches of text with the Find Format.
|
||||
struct Search {
|
||||
re: Option<Grep>,
|
||||
format: Option<FindFormat>,
|
||||
}
|
||||
|
||||
/// The search of a Find/Change request: an empty `find` needs a Find Format.
|
||||
fn search(p: &Value) -> Result<Search> {
|
||||
let format = FindFormat::from_params(p)?;
|
||||
if format.is_some() && str_param(p, "find") == Some("") {
|
||||
return Ok(Search { re: None, format });
|
||||
}
|
||||
Ok(Search { re: Some(build(p)?), format })
|
||||
}
|
||||
|
||||
impl Search {
|
||||
fn check(&self, styles: &Styles) -> Result<()> {
|
||||
self.format.as_ref().map_or(Ok(()), |f| f.check(styles))
|
||||
}
|
||||
|
||||
/// Where the Find Format holds in target `r` of story `sid` (of its table cell `cell`, if any;
|
||||
/// `None`: no Find Format).
|
||||
fn spans(&self, d: &Document, sid: StoryId, cell: Option<CellAddr>, r: &Range<usize>) -> Option<Vec<Range<usize>>> {
|
||||
let f = self.format.as_ref()?;
|
||||
Some(d.text_story(sid, cell).map(|story| f.spans(&d.styles, story, r)).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Up to `limit` matches in `text`, target `r` of story `sid` (offsets in `text`).
|
||||
fn find_all(&self, d: &Document, sid: StoryId, cell: Option<CellAddr>, r: &Range<usize>, text: &str, limit: usize) -> Result<Vec<Range<usize>>> {
|
||||
let spans = self.spans(d, sid, cell, r);
|
||||
let Some(re) = &self.re else { return Ok(spans.unwrap_or_default().into_iter().take(limit).collect()) };
|
||||
let Some(spans) = spans else { return re.find_all(text, limit).map_err(|e| bad("find", e)) };
|
||||
Ok(re.find_all(text, usize::MAX).map_err(|e| bad("find", e))?.into_iter().filter(|m| within(&spans, m)).take(limit).collect())
|
||||
}
|
||||
|
||||
/// The first match in `text` (target `r` of story `sid`) at or after byte `from`.
|
||||
fn find_at(&self, d: &Document, sid: StoryId, cell: Option<CellAddr>, r: &Range<usize>, text: &str, from: usize) -> Result<Option<Range<usize>>> {
|
||||
let spans = self.spans(d, sid, cell, r);
|
||||
let Some(re) = &self.re else {
|
||||
// From inside a stretch, the rest of it.
|
||||
return Ok(spans.unwrap_or_default().into_iter().find(|s| s.end > from).map(|s| s.start.max(from)..s.end));
|
||||
};
|
||||
let mut at = from;
|
||||
loop {
|
||||
let Some(m) = re.find_at(text, at).map_err(|e| bad("find.next", e))? else { return Ok(None) };
|
||||
if spans.as_ref().is_none_or(|s| within(s, &m)) {
|
||||
return Ok(Some(m));
|
||||
}
|
||||
// On past it: its end, or a character on from an empty match.
|
||||
at = if m.is_empty() { text.get(m.end..).and_then(|t| t.chars().next()).map_or(text.len() + 1, |c| m.end + c.len_utf8()) } else { m.end };
|
||||
if at > text.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The edit Change makes when the selection `range` (offsets in `text`, target `r` of story
|
||||
/// `sid`) is what Find Next found there, so the two commands agree on what a match is. With
|
||||
/// no pattern: a selection inside one stretch with the Find Format (Find Next selects the rest
|
||||
/// of a stretch from inside it). With a pattern: the match it makes from the selection's start
|
||||
/// must be the selection, overlapping matches Find Next can select included.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn found_edit(
|
||||
&self,
|
||||
d: &Document,
|
||||
sid: StoryId,
|
||||
cell: Option<CellAddr>,
|
||||
r: &Range<usize>,
|
||||
text: &str,
|
||||
range: &Range<usize>,
|
||||
rep: &str,
|
||||
grep: bool,
|
||||
) -> Result<Option<(Range<usize>, String)>> {
|
||||
let spans = self.spans(d, sid, cell, r);
|
||||
let Some(re) = &self.re else {
|
||||
let inside = !range.is_empty() && spans.unwrap_or_default().iter().any(|s| s.start <= range.start && range.end <= s.end);
|
||||
return Ok(inside.then(|| (range.clone(), rep.to_string())));
|
||||
};
|
||||
let found =
|
||||
if grep { re.replacement_at(text, range.start, rep) } else { re.find_at(text, range.start).map(|m| m.map(|m| (m, rep.to_string()))) };
|
||||
let found = found.map_err(|e| bad("find.change", e))?;
|
||||
Ok(found.filter(|(m, _)| m == range && spans.as_ref().is_none_or(|s| within(s, m))))
|
||||
}
|
||||
|
||||
/// The edits Change All makes in `text` (target `r` of story `sid`): `rep` expanded for each
|
||||
/// GREP match, else `rep` itself.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn replacements(
|
||||
&self,
|
||||
d: &Document,
|
||||
sid: StoryId,
|
||||
cell: Option<CellAddr>,
|
||||
r: &Range<usize>,
|
||||
text: &str,
|
||||
rep: &str,
|
||||
grep: bool,
|
||||
first_only: bool,
|
||||
) -> Result<Vec<(Range<usize>, String)>> {
|
||||
let limit = if first_only { 1 } else { usize::MAX };
|
||||
match (&self.re, self.spans(d, sid, cell, r)) {
|
||||
(Some(re), None) if grep => re.replacements(text, rep, first_only).map_err(|e| bad("find.change", e)),
|
||||
(Some(re), Some(spans)) if grep => Ok(re
|
||||
.replacements(text, rep, false)
|
||||
.map_err(|e| bad("find.change", e))?
|
||||
.into_iter()
|
||||
.filter(|(m, _)| within(&spans, m))
|
||||
.take(limit)
|
||||
.collect()),
|
||||
_ => Ok(self.find_all(d, sid, cell, r, text, limit)?.into_iter().map(|m| (m, rep.to_string())).collect()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
enum Domain {
|
||||
Document,
|
||||
@@ -140,6 +361,7 @@ struct Matcher {
|
||||
grep: bool,
|
||||
case_sensitive: bool,
|
||||
whole_word: bool,
|
||||
format: Option<FindFormat>,
|
||||
}
|
||||
|
||||
impl Matcher {
|
||||
@@ -149,6 +371,7 @@ impl Matcher {
|
||||
grep: bool_or(p, "grep", false),
|
||||
case_sensitive: bool_or(p, "caseSensitive", false),
|
||||
whole_word: bool_or(p, "wholeWord", false),
|
||||
format: FindFormat::from_params(p).ok().flatten(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,6 +406,29 @@ impl FindContext {
|
||||
self.selection = st.selection.clone();
|
||||
}
|
||||
|
||||
/// After Change put `new_len` bytes in place of `old` in story `sid`: the domain keeps the
|
||||
/// changed text and the search goes on after it.
|
||||
fn after_change(&mut self, st: &DocState, sid: StoryId, cell: Option<CellAddr>, old: &Range<usize>, new_len: usize) {
|
||||
let moved = |x: usize| if x >= old.end { x - old.len() + new_len } else { x };
|
||||
for (tid, tcell, r) in &mut self.targets {
|
||||
if *tid == sid && *tcell == cell {
|
||||
*r = moved(r.start)..moved(r.end);
|
||||
}
|
||||
}
|
||||
self.offset = old.start + new_len;
|
||||
self.last_match_end = Some(self.offset);
|
||||
let at = self.offset;
|
||||
if let Some(i) = self
|
||||
.order
|
||||
.iter()
|
||||
.position(|i| self.targets.get(*i).is_some_and(|(tid, tcell, r)| *tid == sid && *tcell == cell && r.start <= at && at <= r.end))
|
||||
{
|
||||
self.target = i;
|
||||
}
|
||||
self.document = Arc::downgrade(&st.doc);
|
||||
self.acknowledge(st);
|
||||
}
|
||||
|
||||
fn restart(&mut self, st: &DocState) -> Result<()> {
|
||||
self.target = 0;
|
||||
self.last_match_end = None;
|
||||
@@ -299,13 +545,14 @@ fn scope(s: &Session, p: &Value) -> Result<FindContext> {
|
||||
const MAX_MATCHES: usize = 10_000;
|
||||
|
||||
fn find(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let re = build(p)?;
|
||||
let re = search(p)?;
|
||||
re.check(&s.doc()?.doc.styles)?;
|
||||
let context = scope(s, p)?;
|
||||
let st = s.doc()?;
|
||||
let mut out = Vec::new();
|
||||
for (sid, cell, r) in &context.targets {
|
||||
let text = target_text(&st.doc, *sid, *cell, r)?;
|
||||
for m in re.find_all(text, MAX_MATCHES.saturating_sub(out.len())).map_err(|e| bad("find", e))? {
|
||||
for m in re.find_all(&st.doc, *sid, *cell, r, text, MAX_MATCHES.saturating_sub(out.len()))? {
|
||||
let mut hit = json!({"story": sid.0, "start": r.start + m.start, "end": r.start + m.end, "text": text.get(m)});
|
||||
if let Some(c) = cell {
|
||||
hit["cell"] = json!({"table": c.table, "row": c.row, "col": c.col});
|
||||
@@ -349,51 +596,143 @@ fn char_overrides(attrs: Option<&Value>) -> Result<designcraft_doc::CharAttrs> {
|
||||
}
|
||||
|
||||
fn change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let re = build(p)?;
|
||||
let grep = bool_or(p, "grep", false);
|
||||
let rep = replacement(p);
|
||||
let re = search(p)?;
|
||||
re.check(&s.doc()?.doc.styles)?;
|
||||
// With no pattern (a Find Format alone), the change is literal text.
|
||||
let grep = bool_or(p, "grep", false) && re.re.is_some();
|
||||
let rep = if re.re.is_some() { replacement(p) } else { metachars(str_param(p, "change").unwrap_or("")) };
|
||||
let first_only = bool_or(p, "first", false);
|
||||
let attrs = p.get("attrs").cloned();
|
||||
let over = char_overrides(attrs.as_ref())?;
|
||||
// A null attribute clears that override on the found text.
|
||||
let cleared: Vec<String> = attrs
|
||||
.as_ref()
|
||||
.and_then(Value::as_object)
|
||||
.map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect())
|
||||
.unwrap_or_default();
|
||||
super::text::validate_char_fill(&s.doc()?.doc, &over, "find.change")?;
|
||||
let targets = scope(s, p)?.targets;
|
||||
s.edit(|d, sel| {
|
||||
let char_style = str_param(p, "charStyle").map(str::to_string);
|
||||
let para_style = str_param(p, "paraStyle").map(str::to_string);
|
||||
let styles = &s.doc()?.doc.styles;
|
||||
if let Some(n) = char_style.as_ref().filter(|n| styles.char_style(n).is_none()) {
|
||||
return Err(bad("find.change", format!("no character style `{n}`")));
|
||||
}
|
||||
if let Some(n) = para_style.as_ref().filter(|n| styles.para(n).is_none()) {
|
||||
return Err(bad("find.change", format!("no paragraph style `{n}`")));
|
||||
}
|
||||
// InDesign: with a Change Format and nothing in Change to, the found text is only formatted.
|
||||
let format_only =
|
||||
str_param(p, "change").unwrap_or("").is_empty() && (char_style.is_some() || para_style.is_some() || !over.is_empty() || !cleared.is_empty());
|
||||
let context = scope(s, p)?;
|
||||
let targets = context.targets.clone();
|
||||
// Change: only the text Find Next found, the selection, while it is still a match (as Find
|
||||
// Next defines one), with its replacement.
|
||||
let (found, found_out) = if bool_or(p, "found", false) {
|
||||
let st = s.doc()?;
|
||||
let selected = st.selection.text.filter(|t| st.selection.cells.is_none() && !t.is_caret()).map(|t| (t.story, t.cell, t.range()));
|
||||
let hit = selected.and_then(|(sid, cell, range)| {
|
||||
targets.iter().find_map(|(tid, tcell, r)| {
|
||||
if *tid != sid || *tcell != cell || range.start < r.start || r.end < range.end {
|
||||
return None;
|
||||
}
|
||||
let text = target_text(&st.doc, *tid, cell, r).ok()?;
|
||||
let local = range.start - r.start..range.end - r.start;
|
||||
let (m, out) = re.found_edit(&st.doc, *tid, cell, r, text, &local, &rep, grep).ok()??;
|
||||
Some(((sid, cell, r.start + m.start..r.start + m.end), out))
|
||||
})
|
||||
});
|
||||
match hit {
|
||||
Some((hit, out)) => (Some(hit), Some(out)),
|
||||
None => return Ok(json!({"count": 0})),
|
||||
}
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
let (count, changed) = s.edit(|d, sel| {
|
||||
let styles = d.styles.clone();
|
||||
let mut count = 0usize;
|
||||
let mut changed = None;
|
||||
for (sid, cell, r) in targets {
|
||||
if found.as_ref().is_some_and(|(at, in_cell, _)| *at != sid || *in_cell != cell) {
|
||||
continue;
|
||||
}
|
||||
let Some(story) = d.text_story(sid, cell) else { continue };
|
||||
// Collect matches first (positions refer to the original text), apply back to front.
|
||||
let text = story.text.get(r.clone()).ok_or_else(|| bad("find", "invalid selection bounds or UTF-8 boundary"))?;
|
||||
let edits: Vec<(std::ops::Range<usize>, String)> = if grep {
|
||||
re.replacements(text, &rep, first_only)
|
||||
} else {
|
||||
re.find_all(text, if first_only { 1 } else { usize::MAX }).map(|ms| ms.into_iter().map(|m| (m, rep.clone())).collect())
|
||||
}
|
||||
.map_err(|e| bad("find.change", e))?
|
||||
.into_iter()
|
||||
.map(|(m, out)| (r.start + m.start..r.start + m.end, out))
|
||||
.collect();
|
||||
let edits: Vec<(std::ops::Range<usize>, String)> = match (&found, &found_out) {
|
||||
// Change: the found text alone, in the target it lies in.
|
||||
(Some((_, _, range)), Some(out)) => {
|
||||
if r.start <= range.start && range.end <= r.end {
|
||||
vec![(range.clone(), out.clone())]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
_ => re
|
||||
.replacements(d, sid, cell, &r, text, &rep, grep, first_only)?
|
||||
.into_iter()
|
||||
.map(|(m, out)| (r.start + m.start..r.start + m.end, out))
|
||||
.collect(),
|
||||
};
|
||||
let Some(st) = d.text_story_mut(sid, cell) else { continue };
|
||||
for (range, new) in edits.iter().rev() {
|
||||
st.replace(range.clone(), new);
|
||||
if !over.is_empty() && !new.is_empty() {
|
||||
st.format_chars(range.start..range.start + new.len(), |f| f.over.merge(&over));
|
||||
let changed = if format_only {
|
||||
range.clone()
|
||||
} else {
|
||||
st.replace(range.clone(), new);
|
||||
range.start..range.start + new.len()
|
||||
};
|
||||
// A paragraph style goes to the match's paragraphs, an empty match's (`^`) too;
|
||||
// character formatting needs characters.
|
||||
if let Some(n) = ¶_style {
|
||||
super::style::apply_para_style(st, &styles, n, &changed, false);
|
||||
}
|
||||
if !changed.is_empty() {
|
||||
if let Some(n) = &char_style {
|
||||
super::style::apply_char_style(st, &styles, n, &changed);
|
||||
}
|
||||
if !over.is_empty() || !cleared.is_empty() {
|
||||
st.format_chars(changed, |f| {
|
||||
f.over.merge(&over);
|
||||
for k in &cleared {
|
||||
let _ = f.over.set_json(k, &Value::Null);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid && t.cell == cell) {
|
||||
repair_selection(t, st);
|
||||
}
|
||||
// Change leaves the changed text selected.
|
||||
if let Some((_, _, range)) = &found
|
||||
&& let Some((_, new)) = edits.first()
|
||||
&& let Some(t) = sel.text.as_mut()
|
||||
{
|
||||
let len = if format_only { range.len() } else { new.len() };
|
||||
(t.anchor, t.focus) = (range.start, range.start + len);
|
||||
changed = Some(len);
|
||||
}
|
||||
// `first` is the first match of the whole search, not of each story.
|
||||
if first_only && count > 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(json!({"count": count}))
|
||||
})
|
||||
Ok((count, changed))
|
||||
})?;
|
||||
// Change: Find Next goes on after the changed text, in the search's domain.
|
||||
if let (Some((sid, cell, old)), Some(len)) = (&found, changed) {
|
||||
let mut context = context;
|
||||
context.after_change(s.doc()?, *sid, *cell, old, len);
|
||||
s.find_context = Some(context);
|
||||
}
|
||||
Ok(json!({"count": count}))
|
||||
}
|
||||
|
||||
fn find_next(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let re = build(p)?;
|
||||
let re = search(p)?;
|
||||
re.check(&s.doc()?.doc.styles)?;
|
||||
let mut context = scope(s, p)?;
|
||||
let st = s.doc()?;
|
||||
let mut hit = None;
|
||||
@@ -403,7 +742,7 @@ fn find_next(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if text.get(from..).is_none() {
|
||||
return Err(bad("find", "invalid UTF-8 search cursor"));
|
||||
}
|
||||
if let Some(m) = re.find_at(text, from).map_err(|e| bad("find.next", e))? {
|
||||
if let Some(m) = re.find_at(&st.doc, sid, cell, &r, text, from)? {
|
||||
let (a, b) = (r.start + m.start, r.start + m.end);
|
||||
// Match find_iter/captures_iter: suppress an empty match adjacent to the
|
||||
// previous match's end, but keep adjacent nonempty matches.
|
||||
@@ -489,6 +828,34 @@ mod object_find_tests {
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// A GREP match that is empty (`^`, the start of each paragraph) gets the paragraph style:
|
||||
/// only character formatting needs characters.
|
||||
#[test]
|
||||
fn change_format_styles_paragraphs_of_empty_matches() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s
|
||||
.execute(
|
||||
"frame.create",
|
||||
&json!({"rect": [36, 36, 300, 300], "content": "text", "text": "One
|
||||
Two
|
||||
Three"}),
|
||||
)
|
||||
.unwrap();
|
||||
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
|
||||
s.execute("style.paragraph.create", &json!({"name": "Method"})).unwrap();
|
||||
let r = s.execute("find.change", &json!({"find": "^", "change": "", "grep": true, "paraStyle": "Method"})).unwrap();
|
||||
assert_eq!(r["count"], 3);
|
||||
let story = s.doc().unwrap().doc.story(sid).unwrap().clone();
|
||||
assert!(story.paras.iter().all(|p| p.style == "Method"), "{:?}", story.paras.iter().map(|p| &p.style).collect::<Vec<_>>());
|
||||
assert_eq!(
|
||||
story.text,
|
||||
"One
|
||||
Two
|
||||
Three"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_and_change_objects_by_attributes() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -621,6 +621,27 @@ fn document_scope_searches_and_changes_table_cells() {
|
||||
assert_eq!(s.execute("find.find", &json!({"find": "13.00", "scope": "document"})).unwrap().as_array().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
/// Change (`found`) on text Find Next found in a table cell changes it there, leaves the
|
||||
/// same text in the story alone, and Find Next goes on after it.
|
||||
#[test]
|
||||
fn change_changes_the_text_find_next_found_in_a_cell() {
|
||||
let (mut s, sid) = scope_session("red here");
|
||||
scope_select(&mut s, sid, 0, 0);
|
||||
let table = s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap()["table"].as_u64().unwrap();
|
||||
s.execute("table.setCell", &json!({"row": 0, "col": 1, "text": "red"})).unwrap();
|
||||
let p = json!({"find": "red", "change": "blue", "scope": "document"});
|
||||
let hit = s.execute("find.next", &p).unwrap();
|
||||
assert_eq!(hit["cell"], json!({"table": table, "row": 0, "col": 1}), "{hit}");
|
||||
let mut change = p.clone();
|
||||
change["found"] = json!(true);
|
||||
assert_eq!(s.execute("find.change", &change).unwrap()["count"], 1);
|
||||
let t = s.execute("table.get", &json!({"table": table})).unwrap();
|
||||
assert_eq!(t["cells"][1]["text"], "blue");
|
||||
assert!(s.doc().unwrap().doc.story(sid).unwrap().text.contains("red here"), "the story's red stays");
|
||||
let next = s.execute("find.next", &p).unwrap();
|
||||
assert!(next.get("cell").is_none() && next.get("start").is_some(), "on to the story: {next}");
|
||||
}
|
||||
|
||||
fn child_scope_session(note: bool, child: &str) -> (Session, designcraft_doc::StoryId, designcraft_doc::TextSel) {
|
||||
let (mut s, sid) = scope_session("red");
|
||||
scope_select(&mut s, sid, 3, 3);
|
||||
@@ -747,3 +768,194 @@ fn find_scope_revert_releases_context_with_same_document_uid() {
|
||||
assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red | red");
|
||||
assert!(s.execute("find.find", &p).is_err(), "revert's empty selection cannot inherit a previous range");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_format_styles_the_found_text_and_keeps_it() {
|
||||
// The course's chapter 22: every lowercase "eggs" gets the character style "eggs", with
|
||||
// Change to empty, so the words stay.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let text = "Poached Eggs\nBreak eggs; cook until eggs are set.";
|
||||
let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": text})).unwrap();
|
||||
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
|
||||
s.execute("style.character.create", &json!({"name": "eggs", "chars": {"fontStyle": "Bold", "fill": "C=15 M=100 Y=100 K=0"}})).unwrap();
|
||||
s.execute("style.paragraph.create", &json!({"name": "Method"})).unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let r = s.execute("find.change", &json!({"find": "eggs", "change": "", "caseSensitive": true, "charStyle": "eggs"})).unwrap();
|
||||
assert_eq!(r["count"], 2);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
let style_at = |s: &Session, at: usize| s.doc().unwrap().doc.story(sid).unwrap().format_after(at).style.clone();
|
||||
let story = s.doc().unwrap().doc.story(sid).unwrap().clone();
|
||||
assert_eq!(story.text, text, "the words stay");
|
||||
for at in [19, 36] {
|
||||
assert_eq!(&story.text[at..at + 4], "eggs");
|
||||
assert_eq!(style_at(&s, at), "eggs");
|
||||
}
|
||||
assert_eq!(style_at(&s, 8), designcraft_doc::NO_CHAR_STYLE, "case sensitive: not \"Eggs\"");
|
||||
assert_eq!(style_at(&s, 25), designcraft_doc::NO_CHAR_STYLE, "only the found text");
|
||||
// A paragraph style goes to the found text's paragraphs, with attributes over the text.
|
||||
s.execute("find.change", &json!({"find": "cook", "change": "", "paraStyle": "Method", "attrs": {"underline": true}})).unwrap();
|
||||
let story = s.doc().unwrap().doc.story(sid).unwrap().clone();
|
||||
assert_eq!((story.paras[0].style.as_str(), story.paras[1].style.as_str()), (designcraft_doc::BASIC_PARAGRAPH, "Method"));
|
||||
assert_eq!(story.format_after(25).over.underline, Some(true));
|
||||
assert_eq!(story.format_after(19).style, "eggs", "the other text keeps its character style");
|
||||
// Change to and a Change Format: the new text is formatted.
|
||||
s.execute("find.change", &json!({"find": "set", "change": "firm", "charStyle": "eggs"})).unwrap();
|
||||
assert_eq!(style_at(&s, s.doc().unwrap().doc.story(sid).unwrap().text.find("firm").unwrap()), "eggs");
|
||||
// No such style: refused, nothing changed.
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
assert!(s.execute("find.change", &json!({"find": "eggs", "change": "", "charStyle": "Nope"})).is_err());
|
||||
assert!(s.execute("find.change", &json!({"find": "eggs", "change": "", "paraStyle": "Nope"})).is_err());
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
// A null attribute is a Change Format too: the found text stays and loses that override.
|
||||
let before = s.doc().unwrap().doc.story(sid).unwrap().clone();
|
||||
s.execute("find.change", &json!({"find": "cook", "change": "", "attrs": {"underline": null}})).unwrap();
|
||||
let story = s.doc().unwrap().doc.story(sid).unwrap().clone();
|
||||
assert_eq!(story.text, before.text, "the words stay");
|
||||
assert_eq!(story.format_after(25).over.underline, None);
|
||||
// With no Change Format, an empty Change to still deletes the found text.
|
||||
s.execute("find.change", &json!({"find": "Break ", "change": ""})).unwrap();
|
||||
assert!(!s.doc().unwrap().doc.story(sid).unwrap().text.contains("Break"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_find_format_finds_only_text_with_it() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r =
|
||||
s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Break eggs; cook eggs.\nEggs and eggs"})).unwrap();
|
||||
let sid = r["story"].as_u64().unwrap();
|
||||
s.execute("style.character.create", &json!({"name": "eggs", "chars": {"fontStyle": "Bold"}})).unwrap();
|
||||
s.execute("style.paragraph.create", &json!({"name": "Method"})).unwrap();
|
||||
for (a, b) in [(6, 10), (12, 16)] {
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": a, "focus": b})).unwrap();
|
||||
s.execute("style.character.apply", &json!({"name": "eggs"})).unwrap();
|
||||
}
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 25, "focus": 25})).unwrap();
|
||||
s.execute("style.paragraph.apply", &json!({"name": "Method"})).unwrap();
|
||||
let found = |s: &mut Session, p: serde_json::Value| -> Vec<(u64, u64)> {
|
||||
let m = s.execute("find.find", &p).unwrap();
|
||||
m.as_array().unwrap().iter().map(|m| (m["start"].as_u64().unwrap(), m["end"].as_u64().unwrap())).collect()
|
||||
};
|
||||
// "eggs" where it has the character style: the first only.
|
||||
assert_eq!(found(&mut s, json!({"find": "eggs", "findFormat": {"charStyle": "eggs"}})), [(6, 10)]);
|
||||
// Nothing in Find what: each stretch of text in the style.
|
||||
assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"charStyle": "eggs"}})), [(6, 10), (12, 16)]);
|
||||
// A paragraph style, and resolved attributes.
|
||||
assert_eq!(found(&mut s, json!({"find": "eggs", "findFormat": {"paraStyle": "Method"}})), [(23, 27), (32, 36)]);
|
||||
assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"chars": {"fontStyle": "Bold"}}})), [(6, 10), (12, 16)]);
|
||||
assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"chars": {"size": 12}}})), [(0, 22), (23, 36)], "each paragraph apart");
|
||||
// Find Next walks the stretches.
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap();
|
||||
let next = json!({"find": "", "findFormat": {"charStyle": "eggs"}});
|
||||
assert_eq!(s.execute("find.next", &next).unwrap()["start"], 6);
|
||||
assert_eq!(s.execute("find.next", &next).unwrap()["start"], 12);
|
||||
// Equivalent spellings the attributes accept find the same text: a snake_case key, and a
|
||||
// nested number written as an integer (14 pt leading on the second paragraph).
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 23, "focus": 36})).unwrap();
|
||||
s.execute("type.char", &json!({"leading": {"kind": "points", "value": 14.0}})).unwrap();
|
||||
for (a, b) in [
|
||||
(json!({"fontStyle": "Bold"}), json!({"font_style": "Bold"})),
|
||||
(json!({"leading": {"kind": "points", "value": 14.0}}), json!({"leading": {"kind": "points", "value": 14}})),
|
||||
] {
|
||||
let (a, b) = (json!({"find": "", "findFormat": {"chars": a}}), json!({"find": "", "findFormat": {"chars": b}}));
|
||||
let all = found(&mut s, a.clone());
|
||||
assert!(!all.is_empty(), "{a}");
|
||||
assert_eq!(found(&mut s, b.clone()), all, "{b}");
|
||||
for p in [&a, &b] {
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap();
|
||||
assert_eq!(s.execute("find.next", p).unwrap()["start"].as_u64(), Some(all[0].0), "{p}");
|
||||
}
|
||||
}
|
||||
// A null attribute asks for nothing.
|
||||
assert_eq!(found(&mut s, json!({"find": "eggs", "findFormat": {"charStyle": "eggs", "chars": {"size": null}}})), [(6, 10)]);
|
||||
// With a Change Format: the text in the style goes back to [None], underlined.
|
||||
let r = s
|
||||
.execute(
|
||||
"find.change",
|
||||
&json!({"find": "", "change": "", "findFormat": {"charStyle": "eggs", "chars": {"font_style": "Bold"}}, "charStyle": "[None]", "attrs": {"underline": true}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["count"], 2, "a snake_case attribute changes the same text");
|
||||
assert!(found(&mut s, json!({"find": "", "findFormat": {"charStyle": "eggs"}})).is_empty());
|
||||
assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"chars": {"underline": true}}})), [(6, 10), (12, 16)]);
|
||||
// Unknown styles and attributes are refused; an empty search needs a Find Format.
|
||||
assert!(s.execute("find.find", &json!({"find": "", "findFormat": {"charStyle": "Nope"}})).is_err());
|
||||
assert!(s.execute("find.next", &json!({"find": "eggs", "findFormat": {"paraStyle": "Nope"}})).is_err());
|
||||
assert!(s.execute("find.find", &json!({"find": "eggs", "findFormat": {"chars": {"bogus": 1}}})).is_err());
|
||||
// Malformed fields are refused, not searched without.
|
||||
for f in [json!({"chars": [1]}), json!({"chars": 12}), json!({"charStyle": 3}), json!({"paraStyle": ["Body"]})] {
|
||||
assert!(s.execute("find.find", &json!({"find": "eggs", "findFormat": f})).is_err(), "{f}");
|
||||
}
|
||||
assert!(s.execute("find.find", &json!({"find": ""})).is_err());
|
||||
}
|
||||
|
||||
/// Change and Change/Find: only the text Find Next found changes; it stays selected and the
|
||||
/// search goes on after it in its domain (here the original selection). A selection that
|
||||
/// isn't a match changes nothing and makes no undo step.
|
||||
#[test]
|
||||
fn change_changes_the_found_text_and_the_search_goes_on() {
|
||||
let (mut s, sid) = scope_session("red red | red");
|
||||
scope_select(&mut s, sid, 0, 7);
|
||||
let p = json!({"find": "red", "change": "blue", "scope": "selection"});
|
||||
let mut change = p.clone();
|
||||
change["found"] = json!(true);
|
||||
scope_hit(&mut s, &p, sid, 0, 3);
|
||||
assert_eq!(s.execute("find.change", &change).unwrap()["count"], 1);
|
||||
let text = |s: &Session| s.doc().unwrap().doc.story(sid).unwrap().text.clone();
|
||||
assert_eq!(text(&s), "blue red | red");
|
||||
assert_eq!(s.doc().unwrap().selection.text.unwrap().range(), 0..4, "the changed text stays selected");
|
||||
scope_hit(&mut s, &p, sid, 5, 8);
|
||||
s.execute("find.change", &change).unwrap();
|
||||
assert_eq!(text(&s), "blue blue | red");
|
||||
assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null, "the last red is outside the selection searched");
|
||||
// Not a match: nothing changes, no undo step.
|
||||
scope_select(&mut s, sid, 0, 4);
|
||||
let history = s.doc().unwrap().history.undo.len();
|
||||
assert_eq!(s.execute("find.change", &json!({"find": "red", "change": "blue", "found": true})).unwrap()["count"], 0);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), history);
|
||||
// A Change Format with nothing in Change to formats the found text only.
|
||||
scope_select(&mut s, sid, 0, 0);
|
||||
let fmt = json!({"find": "red", "change": "", "attrs": {"underline": true}});
|
||||
let hit = s.execute("find.next", &fmt).unwrap();
|
||||
assert_eq!(hit["start"], 12);
|
||||
let mut found = fmt.clone();
|
||||
found["found"] = json!(true);
|
||||
s.execute("find.change", &found).unwrap();
|
||||
assert_eq!(text(&s), "blue blue | red");
|
||||
assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().format_after(12).over.underline, Some(true));
|
||||
assert_eq!(s.doc().unwrap().selection.text.unwrap().range(), 12..15);
|
||||
}
|
||||
|
||||
/// Change accepts what Find Next selected even when Change All wouldn't match there: the
|
||||
/// rest of a Find Format stretch from a caret inside it, and an overlapping match.
|
||||
#[test]
|
||||
fn change_takes_any_selection_find_next_made() {
|
||||
// A caret inside a bold stretch: Find Next with only a Find Format selects the rest of it.
|
||||
let (mut s, sid) = scope_session("plain bold text");
|
||||
s.execute("style.character.create", &json!({"name": "Strong", "chars": {"fontStyle": "Bold"}})).unwrap();
|
||||
scope_select(&mut s, sid, 6, 10);
|
||||
s.execute("style.character.apply", &json!({"name": "Strong"})).unwrap();
|
||||
scope_select(&mut s, sid, 8, 8);
|
||||
let p = json!({"find": "", "change": "", "findFormat": {"charStyle": "Strong"}, "attrs": {"underline": true}, "scope": "story"});
|
||||
let hit = s.execute("find.next", &p).unwrap();
|
||||
assert_eq!((hit["start"].as_u64(), hit["end"].as_u64()), (Some(8), Some(10)));
|
||||
let mut change = p.clone();
|
||||
change["found"] = json!(true);
|
||||
assert_eq!(s.execute("find.change", &change).unwrap()["count"], 1);
|
||||
let st = s.doc().unwrap().doc.story(sid).unwrap().clone();
|
||||
assert_eq!((st.format_after(7).over.underline, st.format_after(8).over.underline), (None, Some(true)), "only the selected rest");
|
||||
// "aa" in "aaa" from the second a: Find Next selects 1..3, which Change All never finds.
|
||||
for grep in [false, true] {
|
||||
let (mut s, sid) = scope_session("aaa");
|
||||
scope_select(&mut s, sid, 1, 1);
|
||||
let (find, change) = if grep { ("(a)a", "<$1>") } else { ("aa", "b") };
|
||||
let p = json!({"find": find, "change": change, "grep": grep, "scope": "story"});
|
||||
let hit = s.execute("find.next", &p).unwrap();
|
||||
assert_eq!((hit["start"].as_u64(), hit["end"].as_u64()), (Some(1), Some(3)));
|
||||
let mut found = p.clone();
|
||||
found["found"] = json!(true);
|
||||
assert_eq!(s.execute("find.change", &found).unwrap()["count"], 1, "grep: {grep}");
|
||||
assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, if grep { "a<a>" } else { "ab" });
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "font.list", "Fonts in Document", [], None, "{} → [{family, style, characters, missing, styleMissing, source: bundled|installed|document|added (null when missing), matchStatus: exact|styleSubstitute|missing, resolvedFamily, resolvedStyle, replacementFamily, replacementStyle}] (missing first)", has_doc, |s, _| {
|
||||
cmd!(query "font.list", "Fonts in Document", [], None, "{} → [{family, style, characters, missing, styleMissing, source: bundled|installed|document|added (null when missing), matchStatus: exact|styleSubstitute|missing, resolvedFamily, resolvedStyle, replacementFamily, replacementStyle, fsType, restrictedLicence, bitmapOnly, noSubsetting, editable, previewPrint (the OS/2 fsType embedding bits of the face that sets the text and what they mean; null when missing or the face has no OS/2 table)}] (missing first)", has_doc, |s, _| {
|
||||
Ok(Value::Array(list(&s.doc()?.doc)))
|
||||
}),
|
||||
cmd!(
|
||||
@@ -75,6 +75,19 @@ pub(super) struct UsedFont {
|
||||
pub resolved_style: String,
|
||||
pub replacement_family: String,
|
||||
pub replacement_style: String,
|
||||
/// The OS/2 `fsType` of the face that sets the text (`resolved_*`); None when missing or the
|
||||
/// face has no OS/2 table (as are the bits below).
|
||||
pub fs_type: Option<u16>,
|
||||
/// Restricted License embedding (bit 1 without bits 2–3).
|
||||
pub restricted_licence: Option<bool>,
|
||||
/// Bitmap embedding only (bit 9).
|
||||
pub bitmap_only: Option<bool>,
|
||||
/// No subsetting (bit 8): the font may only be embedded whole.
|
||||
pub no_subsetting: Option<bool>,
|
||||
/// Editable embedding (bit 3).
|
||||
pub editable: Option<bool>,
|
||||
/// Preview & Print embedding (bit 2).
|
||||
pub preview_print: Option<bool>,
|
||||
}
|
||||
|
||||
fn list(d: &Document) -> Vec<Value> {
|
||||
@@ -124,6 +137,8 @@ pub(super) fn used_fonts(d: &Document) -> Vec<UsedFont> {
|
||||
let resolved = db.face(&family, &style);
|
||||
let characters: Vec<char> = characters.into_iter().filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c)).collect();
|
||||
let replacement = db.replacement_face(&family, &style, &characters);
|
||||
let fs_type = (!missing).then(|| resolved.fs_type()).flatten();
|
||||
let bit = |mask: u16| fs_type.map(|t| t & mask != 0);
|
||||
let source = (!missing).then(|| match db.face(&family, &style).source {
|
||||
FontSource::Bundled => "bundled",
|
||||
FontSource::Installed(_) => "installed",
|
||||
@@ -148,6 +163,12 @@ pub(super) fn used_fonts(d: &Document) -> Vec<UsedFont> {
|
||||
resolved_style: resolved.style.clone(),
|
||||
replacement_family: replacement.family.clone(),
|
||||
replacement_style: replacement.style.clone(),
|
||||
fs_type,
|
||||
restricted_licence: fs_type.map(|_| resolved.restricted_licence()),
|
||||
bitmap_only: bit(0x0200),
|
||||
no_subsetting: bit(0x0100),
|
||||
editable: bit(0x0008),
|
||||
preview_print: bit(0x0004),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -297,6 +318,55 @@ mod tests {
|
||||
assert_eq!(before, s.execute("font.list", &json!({})).unwrap());
|
||||
}
|
||||
|
||||
/// `font.list` reports each face's OS/2 fsType and what its bits allow, so a caller can
|
||||
/// refuse a face whose licence doesn't allow the export it is about to make.
|
||||
#[test]
|
||||
fn font_list_reports_the_embedding_licence_bits() {
|
||||
use designcraft_fonts::testing::{font_with, with_fs_type, with_table_len};
|
||||
const FAMILY: &str = "DocFont List FsType";
|
||||
const FS_TYPES: [u16; 3] = [0x0002, 0x0108, 0x0204];
|
||||
for fs_type in FS_TYPES {
|
||||
designcraft_fonts::FontDb::global()
|
||||
.add_font(with_fs_type(font_with(&format!("{FAMILY} {fs_type:04x}"), &['W']).unwrap(), fs_type).unwrap());
|
||||
}
|
||||
// An OS/2 table cut to nothing can't be read: as if the font had none.
|
||||
designcraft_fonts::FontDb::global().add_font(with_table_len(font_with(&format!("{FAMILY} no OS/2"), &['W']).unwrap(), b"OS/2", 0).unwrap());
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
// "W W W W Plain Gone": each W in one of the fonts above, Plain in the default (bundled)
|
||||
// font, Gone in a family that isn't available.
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "W W W W Plain Gone"})).unwrap();
|
||||
let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
|
||||
let set = |s: &mut Session, at: usize, end: usize, family: &str| {
|
||||
s.execute("text.select", &json!({"story": sid.0, "anchor": at, "focus": end})).unwrap();
|
||||
s.execute("type.char", &json!({"fontFamily": family, "fontStyle": "Regular"})).unwrap();
|
||||
};
|
||||
for (i, fs_type) in FS_TYPES.into_iter().enumerate() {
|
||||
set(&mut s, i * 2, i * 2 + 1, &format!("{FAMILY} {fs_type:04x}"));
|
||||
}
|
||||
set(&mut s, 6, 7, &format!("{FAMILY} no OS/2"));
|
||||
set(&mut s, 14, 18, "Nonexistent Sans");
|
||||
let l = s.execute("font.list", &json!({})).unwrap();
|
||||
let rows = l.as_array().unwrap();
|
||||
let entry = |family: &str| rows.iter().find(|f| f["family"] == family).unwrap_or_else(|| panic!("{family}: {l}"));
|
||||
let bits = |e: &Value| ["fsType", "restrictedLicence", "bitmapOnly", "noSubsetting", "editable", "previewPrint"].map(|k| e[k].clone());
|
||||
let (t, f) = (json!(true), json!(false));
|
||||
assert_eq!(entry(&format!("{FAMILY} 0002"))["source"], "added", "{l}");
|
||||
assert_eq!(bits(entry(&format!("{FAMILY} 0002"))), [json!(0x0002), t.clone(), f.clone(), f.clone(), f.clone(), f.clone()], "{l}");
|
||||
assert_eq!(bits(entry(&format!("{FAMILY} 0108"))), [json!(0x0108), f.clone(), f.clone(), t.clone(), t.clone(), f.clone()], "{l}");
|
||||
assert_eq!(bits(entry(&format!("{FAMILY} 0204"))), [json!(0x0204), f.clone(), t.clone(), f.clone(), f.clone(), t.clone()], "{l}");
|
||||
// The bundled fonts are OFL: installable embedding, every bit clear.
|
||||
let bundled = rows.iter().find(|r| r["source"] == "bundled").unwrap_or_else(|| panic!("no bundled face: {l}"));
|
||||
assert_eq!(bits(bundled), [json!(0), f.clone(), f.clone(), f.clone(), f.clone(), f], "{l}");
|
||||
// A face without an OS/2 table doesn't say what its licence allows.
|
||||
assert_eq!(entry(&format!("{FAMILY} no OS/2"))["missing"], false, "{l}");
|
||||
assert_eq!(bits(entry(&format!("{FAMILY} no OS/2"))), [(); 6].map(|()| Value::Null), "{l}");
|
||||
// A missing face has no file to read.
|
||||
let missing = entry("Nonexistent Sans");
|
||||
assert_eq!(missing["missing"], true, "{l}");
|
||||
assert_eq!(bits(missing), [(); 6].map(|()| Value::Null), "{l}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_and_replace_font() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -6,6 +6,7 @@ use serde_json::{Value, json};
|
||||
use super::{CommandSpec, bad, cmd, has_doc, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// Register commands for editing hyperlinks and manual bookmarks.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
@@ -73,7 +74,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let name = str_param(p, "name").map(str::to_string);
|
||||
s.edit(|d, _| {
|
||||
let name = name.clone().unwrap_or_else(|| format!("Bookmark {}", d.bookmarks.len() + 1));
|
||||
d.bookmarks.push(Bookmark { name, page, children: vec![] });
|
||||
d.bookmarks.push(Bookmark { name, page, children: vec![], generated_by_toc: false });
|
||||
Ok(json!({"index": d.bookmarks.len() - 1}))
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -160,7 +160,7 @@ pub fn import(bytes: &[u8], package: Option<&std::path::Path>) -> Result<Documen
|
||||
|
||||
/// [`import`], with the problems that did not stop it (parts missing from the package, …).
|
||||
pub fn import_report(bytes: &[u8], package: Option<&std::path::Path>) -> Result<(Document, Vec<String>)> {
|
||||
import_report_with_previews(bytes, package, &std::collections::HashMap::new())
|
||||
import_report_with_previews(bytes, package, &std::collections::HashMap::new()).map(|(document, warnings, _)| (document, warnings))
|
||||
}
|
||||
|
||||
/// [`import_report`], showing `previews` (graphic `Self` → image) for linked files that can't be read.
|
||||
@@ -168,7 +168,7 @@ fn import_report_with_previews(
|
||||
bytes: &[u8],
|
||||
package: Option<&std::path::Path>,
|
||||
previews: &std::collections::HashMap<String, std::sync::Arc<Vec<u8>>>,
|
||||
) -> Result<(Document, Vec<String>)> {
|
||||
) -> Result<(Document, Vec<String>, std::collections::HashMap<String, designcraft_doc::StoryId>)> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let resolved = std::cell::RefCell::new(std::collections::HashMap::new());
|
||||
let read = |link: &str| -> Option<Vec<u8>> {
|
||||
@@ -184,7 +184,7 @@ fn import_report_with_previews(
|
||||
None
|
||||
}
|
||||
};
|
||||
let designcraft_idml::Imported { mut document, warnings } =
|
||||
let designcraft_idml::Imported { mut document, warnings, story_ids } =
|
||||
designcraft_idml::import_idml_report_with_previews(bytes, &read, previews).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
resolve_pdf_crops(&mut document);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -199,7 +199,7 @@ fn import_report_with_previews(
|
||||
resolve_packaged_links(&mut document, package);
|
||||
document
|
||||
};
|
||||
Ok((document, warnings))
|
||||
Ok((document, warnings, story_ids))
|
||||
}
|
||||
|
||||
/// Placed PDFs read from IDML name their crop (`PDFCrop`): find where each box sits on its page,
|
||||
@@ -422,7 +422,7 @@ pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
bytes
|
||||
};
|
||||
let (mut d, import_warnings) = import_report_with_previews(&bytes, package.as_deref(), &previews)?;
|
||||
let (mut d, import_warnings, story_ids) = import_report_with_previews(&bytes, package.as_deref(), &previews)?;
|
||||
warnings.extend(import_warnings);
|
||||
if !title.is_empty() {
|
||||
d.title = title;
|
||||
@@ -433,17 +433,33 @@ pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
warnings.extend(font_warnings);
|
||||
warnings.extend(super::style::grep_style_warnings(&d));
|
||||
let warning_items: Vec<Value> = warnings.iter().map(|w| warning_item(&d, &story_ids, w).map_or(Value::Null, |i| json!(i.0))).collect();
|
||||
// Never save over the .idml or .indd with the native format: the document starts unsaved.
|
||||
let mut st = DocState::new(d, None);
|
||||
st.fonts = fonts;
|
||||
let i = s.add_document(st);
|
||||
Ok(json!({"index": i, "documentFonts": faces, "warnings": warnings}))
|
||||
Ok(json!({"index": i, "documentFonts": faces, "warnings": warnings, "warningItems": warning_items}))
|
||||
}
|
||||
|
||||
/// The frame an import warning is about, so it can be shown: the first frame of the story a
|
||||
/// "story …: …" warning names, by the converter's INDD id (`story 30892`, which it writes to the
|
||||
/// IDML as `u78ac`) or by its IDML `Self` (`story u78ac`).
|
||||
fn warning_item(
|
||||
d: &Document,
|
||||
story_ids: &std::collections::HashMap<String, designcraft_doc::StoryId>,
|
||||
warning: &str,
|
||||
) -> Option<designcraft_doc::ItemId> {
|
||||
let name = warning.strip_prefix("story ")?.split([':', ' ']).next()?;
|
||||
let self_id = name.parse::<u32>().map_or_else(|_| name.to_string(), |uid| format!("u{uid:x}"));
|
||||
d.stories.get(story_ids.get(&self_id)?)?.frames.first().copied()
|
||||
}
|
||||
|
||||
/// An InDesign document or template (.indd, .indt): known by its signature, or by its name when
|
||||
/// the bytes are something else (the converter then says why it can't read them).
|
||||
/// the bytes are neither a recognized DesignCraft document nor IDML. The converter explains
|
||||
/// unrecognized data with an InDesign extension.
|
||||
pub(crate) fn is_indd(name: &str, bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(&indd::header::SIGNATURE) || has_indd_extension(name)
|
||||
bytes.starts_with(&indd::header::SIGNATURE)
|
||||
|| (has_indd_extension(name) && !super::file::is_document(bytes) && !bytes.trim_ascii_start().starts_with(b"{"))
|
||||
}
|
||||
|
||||
pub(crate) fn has_indd_extension(name: &str) -> bool {
|
||||
@@ -479,6 +495,24 @@ mod tests {
|
||||
Ok((bytes, png))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn story_warnings_point_at_the_story_frame() {
|
||||
let mut session = Session::new();
|
||||
session.execute("file.new", &json!({})).unwrap();
|
||||
session.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap();
|
||||
let idml = designcraft_idml::export_idml_with(&session.doc().unwrap().doc, &designcraft_idml::ExportOptions { embed_images: true });
|
||||
let (d, _, story_ids) = import_report_with_previews(&idml, None, &std::collections::HashMap::new()).unwrap();
|
||||
let (self_id, sid) = story_ids.iter().find(|(_, sid)| d.stories.get(*sid).is_some_and(|s| !s.frames.is_empty())).unwrap();
|
||||
let frame = d.stories[sid].frames[0];
|
||||
assert_eq!(warning_item(&d, &story_ids, &format!("story {self_id}: something")), Some(frame));
|
||||
// The INDD converter names stories by their number, which it writes as `u` + hex.
|
||||
if let Some(uid) = self_id.strip_prefix('u').and_then(|h| u32::from_str_radix(h, 16).ok()) {
|
||||
assert_eq!(warning_item(&d, &story_ids, &format!("story {uid}: text orientation of its frames is not known")), Some(frame));
|
||||
}
|
||||
assert_eq!(warning_item(&d, &story_ids, "story 999999999: unknown"), None);
|
||||
assert_eq!(warning_item(&d, &story_ids, "a table of 2 rows was read as 1"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opens_nested_packaged_links_from_a_different_computer() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-idml-nested-links-{}", std::process::id()));
|
||||
@@ -493,7 +527,7 @@ mod tests {
|
||||
assert_eq!(*asset.data, png, "the packaged graphic must render even when its author-machine path no longer exists");
|
||||
let resolved = std::path::absolute(&path).unwrap();
|
||||
assert_eq!(asset.link.as_deref(), resolved.to_str(), "remember the resolved link so update and preflight use the packaged file");
|
||||
assert_eq!(super::super::links::status(asset, None), "ok");
|
||||
assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "ok");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
@@ -510,7 +544,7 @@ mod tests {
|
||||
let asset = doc.assets.values().next().unwrap();
|
||||
assert_eq!(*asset.data, png);
|
||||
assert_eq!(asset.link.as_deref(), std::path::absolute(&path).unwrap().to_str());
|
||||
assert_eq!(super::super::links::status(asset, None), "ok");
|
||||
assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "ok");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
@@ -526,7 +560,7 @@ mod tests {
|
||||
let asset = doc.assets.values().next().unwrap();
|
||||
assert_eq!(*asset.data, png);
|
||||
assert_eq!(asset.link.as_deref(), std::path::absolute(&path).unwrap().to_str());
|
||||
assert_eq!(super::super::links::status(asset, None), "ok");
|
||||
assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "ok");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
@@ -546,7 +580,7 @@ mod tests {
|
||||
let asset = doc.assets.values().next().unwrap();
|
||||
assert_eq!(std::path::Path::new(asset.link.as_deref().unwrap()), original.as_path(), "an existing original stays authoritative");
|
||||
assert_eq!(*asset.data, png);
|
||||
assert_eq!(super::super::links::status(asset, None), "modified");
|
||||
assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "modified");
|
||||
|
||||
std::fs::remove_file(&original).unwrap();
|
||||
std::fs::write(©, b"modified packaged copy").unwrap();
|
||||
@@ -554,7 +588,7 @@ mod tests {
|
||||
let asset = doc.assets.values().next().unwrap();
|
||||
assert_eq!(asset.link.as_deref(), copy.to_str());
|
||||
assert_eq!(*asset.data, png, "rebinding must not replace embedded pixels");
|
||||
assert_eq!(super::super::links::status(asset, None), "modified");
|
||||
assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "modified");
|
||||
|
||||
std::fs::remove_file(©).unwrap();
|
||||
let packaged = package.join("x.idml");
|
||||
@@ -563,7 +597,7 @@ mod tests {
|
||||
let asset = doc.assets.values().next().unwrap();
|
||||
assert_eq!(std::path::Path::new(asset.link.as_deref().unwrap()), original.as_path(), "keep a missing link when there is no candidate");
|
||||
assert_eq!(*asset.data, png);
|
||||
assert_eq!(super::super::links::status(asset, None), "missing");
|
||||
assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "missing");
|
||||
}
|
||||
let oversized = "a/".repeat(MAX_LINK_PATH_BYTES);
|
||||
assert!(packaged_link_candidates(&oversized, Some(&packaged)).is_empty());
|
||||
@@ -611,7 +645,7 @@ mod tests {
|
||||
assert!(seen.iter().all(|p| p.starts_with(&dir) || itself.as_ref() == Some(p)), "{link}: {seen:?}");
|
||||
let asset = doc.assets.values().next().unwrap();
|
||||
assert!(asset.data.is_empty(), "{link}");
|
||||
assert_eq!(super::super::links::status(asset, None), "missing", "{link}");
|
||||
assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "missing", "{link}");
|
||||
}
|
||||
// An image elsewhere on this computer is read, and so is one beside the package.
|
||||
let away = outside.join("logo.png");
|
||||
@@ -620,7 +654,7 @@ mod tests {
|
||||
let doc = import(&idml, Some(&package)).unwrap();
|
||||
let asset = doc.assets.values().next().unwrap();
|
||||
assert_eq!((&*asset.data, asset.link.as_deref()), (&png, away.to_str()));
|
||||
assert_eq!(super::super::links::status(asset, None), "ok");
|
||||
assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "ok");
|
||||
std::fs::write(dir.join("Links").join("logo.png"), &png).unwrap();
|
||||
let (idml, _) = linked_idml("Links/logo.png", false).unwrap();
|
||||
let doc = import(&idml, Some(&package)).unwrap();
|
||||
|
||||
@@ -20,3 +20,7 @@ pub use sections::chapter_numbering;
|
||||
#[cfg(test)]
|
||||
#[path = "parent_delete_tests.rs"]
|
||||
mod parent_delete_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "parent_resize_tests.rs"]
|
||||
mod parent_resize_tests;
|
||||
@@ -61,84 +61,93 @@ pub(crate) fn resize_pages(d: &mut designcraft_doc::Document, pages: &[usize], w
|
||||
/// [`resize_pages`] keeping reference point `anchor` (0–8, row-major from the top-left) in place:
|
||||
/// the page grows away from it, so objects keep their place relative to it.
|
||||
pub(crate) fn resize_pages_from(d: &mut designcraft_doc::Document, pages: &[usize], w: Option<f64>, h: Option<f64>, anchor: usize) {
|
||||
for page in pages {
|
||||
let Some((si, pi)) = page.checked_sub(1).and_then(|i| d.page_loc(i)) else { continue };
|
||||
resize_spread_page(d, designcraft_doc::SpreadRef::Doc(si), pi, w.map(|v| v.max(1.0)), h.map(|v| v.max(1.0)), anchor);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn resize_spread_page(
|
||||
d: &mut designcraft_doc::Document,
|
||||
sr: designcraft_doc::SpreadRef,
|
||||
pi: usize,
|
||||
w: Option<f64>,
|
||||
h: Option<f64>,
|
||||
anchor: usize,
|
||||
) {
|
||||
let (fx, fy) = ((anchor % 3) as f64 / 2.0, (anchor / 3).min(2) as f64 / 2.0);
|
||||
{
|
||||
for page in pages {
|
||||
let Some((si, pi)) = d.page_loc(page - 1) else { continue };
|
||||
let sp = std::sync::Arc::make_mut(&mut d.spreads[si]);
|
||||
let old: Vec<f64> = sp.pages.iter().map(|x| x.x).collect();
|
||||
let old_bounds = sp.pages[pi].bounds();
|
||||
// The page's own objects (outer pages take the pasteboard beside them).
|
||||
let last = sp.pages.len() - 1;
|
||||
let (lo, hi) = (if pi == 0 { f64::NEG_INFINITY } else { old_bounds.x0 }, if pi == last { f64::INFINITY } else { old_bounds.x1 });
|
||||
let own: Vec<usize> = sp
|
||||
.items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, it)| {
|
||||
let c = it.bounds().center().x;
|
||||
c >= lo && c < hi
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
let pg = &mut sp.pages[pi];
|
||||
let dw = w.map_or(0.0, |w| w.max(1.0) - pg.width);
|
||||
let dh = h.map_or(0.0, |h| h.max(1.0) - pg.height);
|
||||
if let Some(w) = w {
|
||||
set_page_width(pg, w.max(1.0));
|
||||
}
|
||||
if let Some(h) = h {
|
||||
pg.height = h.max(1.0);
|
||||
}
|
||||
let left = pg.side == designcraft_doc::PageSide::Left;
|
||||
sp.relayout();
|
||||
// Keep the spine: shift the whole spread back when a left page grows.
|
||||
if left && dw != 0.0 {
|
||||
for q in &mut sp.pages {
|
||||
q.x -= dw;
|
||||
}
|
||||
}
|
||||
let new: Vec<f64> = sp.pages.iter().map(|x| x.x).collect();
|
||||
// Objects follow their page.
|
||||
let shifts: Vec<(f64, f64, f64)> =
|
||||
sp.pages.iter().enumerate().map(|(k, q)| (old[k], old[k] + q.width - if k == pi { dw } else { 0.0 }, new[k] - old[k])).collect();
|
||||
for it in &mut sp.items {
|
||||
let cx = it.bounds().center().x;
|
||||
if let Some((_, _, dx)) = shifts.iter().find(|(a, b, _)| cx >= *a && cx < *b)
|
||||
&& *dx != 0.0
|
||||
{
|
||||
let it = std::sync::Arc::make_mut(it);
|
||||
it.xf = designcraft_geom::Affine::translate((*dx, 0.0)) * it.xf;
|
||||
}
|
||||
}
|
||||
// Page guides move with their page.
|
||||
for (k, q) in sp.pages.iter_mut().enumerate() {
|
||||
let dx = new[k] - old[k];
|
||||
if dx != 0.0 {
|
||||
for g in q.guides.iter_mut().filter(|g| g.orientation == designcraft_doc::Orientation::Vertical) {
|
||||
g.position += dx;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Growing away from the anchor: the page's objects and guides keep their place
|
||||
// relative to it.
|
||||
let shift = designcraft_geom::Vec2::new(fx * dw, fy * dh);
|
||||
if shift != designcraft_geom::Vec2::ZERO {
|
||||
for i in &own {
|
||||
if let Some(it) = sp.items.get_mut(*i) {
|
||||
let it = std::sync::Arc::make_mut(it);
|
||||
it.xf = designcraft_geom::Affine::translate(shift) * it.xf;
|
||||
}
|
||||
}
|
||||
for g in &mut sp.pages[pi].guides {
|
||||
g.position += if g.orientation == designcraft_doc::Orientation::Vertical { shift.x } else { shift.y };
|
||||
}
|
||||
}
|
||||
// Liquid rules: objects were carried along with the page's left edge (and the anchor).
|
||||
let moved = old_bounds + designcraft_geom::Vec2::new(new[pi] - old[pi], 0.0) + shift;
|
||||
crate::cmd::liquid::apply(d, designcraft_doc::SpreadRef::Doc(si), pi, moved);
|
||||
let Some(sp) = d.spread_mut(sr) else { return };
|
||||
let origin = sp.bounds().x0;
|
||||
let old: Vec<f64> = sp.pages.iter().map(|x| x.x).collect();
|
||||
let old_bounds = sp.pages[pi].bounds();
|
||||
// The page's own objects (outer pages take the pasteboard beside them).
|
||||
let Some((leftmost, rightmost)) = sp.outer_page_indices() else { return };
|
||||
let (lo, hi) = (if pi == leftmost { f64::NEG_INFINITY } else { old_bounds.x0 }, if pi == rightmost { f64::INFINITY } else { old_bounds.x1 });
|
||||
let own: Vec<usize> = sp
|
||||
.items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, it)| {
|
||||
let c = it.bounds().center().x;
|
||||
c >= lo && c < hi
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
let pg = &mut sp.pages[pi];
|
||||
let dw = w.map_or(0.0, |w| w - pg.width);
|
||||
let dh = h.map_or(0.0, |h| h - pg.height);
|
||||
if let Some(w) = w {
|
||||
set_page_width(pg, w);
|
||||
}
|
||||
if let Some(h) = h {
|
||||
pg.height = h;
|
||||
}
|
||||
let left = pg.side == designcraft_doc::PageSide::Left;
|
||||
sp.relayout();
|
||||
// Relayout resets the origin; retain prior edits and grow left pages away from the spine.
|
||||
let offset = origin - if left { dw } else { 0.0 };
|
||||
for q in &mut sp.pages {
|
||||
q.x += offset;
|
||||
}
|
||||
let new: Vec<f64> = sp.pages.iter().map(|x| x.x).collect();
|
||||
// Objects follow their page.
|
||||
let shifts: Vec<(f64, f64, f64)> =
|
||||
sp.pages.iter().enumerate().map(|(k, q)| (old[k], old[k] + q.width - if k == pi { dw } else { 0.0 }, new[k] - old[k])).collect();
|
||||
for it in &mut sp.items {
|
||||
let cx = it.bounds().center().x;
|
||||
if let Some((_, _, dx)) = shifts.iter().find(|(a, b, _)| cx >= *a && cx < *b)
|
||||
&& *dx != 0.0
|
||||
{
|
||||
let it = std::sync::Arc::make_mut(it);
|
||||
it.xf = designcraft_geom::Affine::translate((*dx, 0.0)) * it.xf;
|
||||
}
|
||||
}
|
||||
// Page guides move with their page.
|
||||
for (k, q) in sp.pages.iter_mut().enumerate() {
|
||||
let dx = new[k] - old[k];
|
||||
if dx != 0.0 {
|
||||
for g in q.guides.iter_mut().filter(|g| g.orientation == designcraft_doc::Orientation::Vertical) {
|
||||
g.position += dx;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Growing away from the anchor: the page's objects and guides keep their place
|
||||
// relative to it.
|
||||
let shift = designcraft_geom::Vec2::new(fx * dw, fy * dh);
|
||||
if shift != designcraft_geom::Vec2::ZERO {
|
||||
for i in &own {
|
||||
if let Some(it) = sp.items.get_mut(*i) {
|
||||
let it = std::sync::Arc::make_mut(it);
|
||||
it.xf = designcraft_geom::Affine::translate(shift) * it.xf;
|
||||
}
|
||||
}
|
||||
for g in &mut sp.pages[pi].guides {
|
||||
g.position += if g.orientation == designcraft_doc::Orientation::Vertical { shift.x } else { shift.y };
|
||||
}
|
||||
}
|
||||
// Liquid rules: objects were carried along with the page's left edge (and the anchor).
|
||||
let moved = old_bounds + designcraft_geom::Vec2::new(new[pi] - old[pi], 0.0) + shift;
|
||||
crate::cmd::liquid::apply(d, sr, pi, moved);
|
||||
}
|
||||
|
||||
/// File › Document Setup values.
|
||||
@@ -322,6 +331,51 @@ mod setup_tests {
|
||||
mod page_size_tests {
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn document_page_resize_preserves_shifted_spread_objects_and_guides() {
|
||||
for binding in ["leftToRight", "rightToLeft"] {
|
||||
let mut s = crate::Session::new();
|
||||
s.execute("file.new", &json!({"pages": 3, "facingPages": true, "width": 400, "height": 400})).unwrap();
|
||||
s.execute("layout.documentSetup", &json!({"binding": binding})).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let (si, _) = before.page_loc(1).unwrap();
|
||||
let (left, right) = before.spreads[si].outer_page_indices().unwrap();
|
||||
let mut items = Vec::new();
|
||||
for pi in [left, right] {
|
||||
let x = before.spreads[si].pages[pi].x;
|
||||
let id = s.execute("frame.create", &json!({"spread": si, "rect": [x + 80.0, 80, x + 180.0, 180]})).unwrap()["id"].as_u64().unwrap();
|
||||
items.push(designcraft_doc::ItemId(id));
|
||||
s.execute("guide.add", &json!({"spread": si, "page": pi, "orientation": "vertical", "position": x + 120.0})).unwrap();
|
||||
}
|
||||
let first = before.first_page_of_spread(si) + 1;
|
||||
s.execute("layout.pageSize", &json!({"pages": [first + left], "width": 300})).unwrap();
|
||||
let shifted = s.doc().unwrap().doc.clone();
|
||||
let sp = &shifted.spreads[si];
|
||||
assert_eq!(sp.bounds().x0, 100.0);
|
||||
assert_eq!(sp.pages[right].x, 400.0);
|
||||
assert_eq!(sp.pages[left].guides[0].position, 220.0);
|
||||
assert_eq!(sp.pages[right].guides[0].position, 520.0);
|
||||
assert_eq!(shifted.item(items[0]).unwrap().bounds().x0, 180.0);
|
||||
assert_eq!(shifted.item(items[1]).unwrap().bounds().x0, 480.0);
|
||||
for width in [500, 450, 520] {
|
||||
s.execute("layout.pageSize", &json!({"pages": [first + right], "width": width})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let sp = &d.spreads[si];
|
||||
assert_eq!(sp.bounds().x0, 100.0, "{binding}, width {width}");
|
||||
assert_eq!(sp.pages[right].x, 400.0);
|
||||
for pi in [left, right] {
|
||||
assert_eq!(sp.pages[pi].guides, shifted.spreads[si].pages[pi].guides);
|
||||
}
|
||||
for id in &items {
|
||||
assert_eq!(d.item(*id).unwrap().bounds(), shifted.item(*id).unwrap().bounds());
|
||||
}
|
||||
assert_eq!(d.parents, before.parents);
|
||||
assert_eq!(d.settings, before.settings);
|
||||
d.check().unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_size_per_page_keeps_objects_on_their_page() {
|
||||
let mut s = crate::Session::new();
|
||||
|
||||
@@ -65,6 +65,43 @@ pub(super) fn parent_options(s: &mut crate::Session, p: &Value) -> Result<Value>
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn parent_page_target(d: &designcraft_doc::Document, p: &Value, cmd: &str) -> Result<(usize, usize)> {
|
||||
let parent = p.get("parent").and_then(Value::as_u64).map(SpreadId).ok_or_else(|| bad(cmd, "give a parent spread id"))?;
|
||||
let si = d.parent_index(parent).ok_or_else(|| bad(cmd, "no such parent"))?;
|
||||
let pi = p
|
||||
.get("page")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|n| usize::try_from(n).ok())
|
||||
.filter(|i| *i < d.parents[si].pages.len())
|
||||
.ok_or_else(|| bad(cmd, "no such parent page"))?;
|
||||
Ok((si, pi))
|
||||
}
|
||||
|
||||
pub(super) fn parent_page_size(s: &mut crate::Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "layout.parentPageSize";
|
||||
let (si, pi) = parent_page_target(&s.doc()?.doc, p, ID)?;
|
||||
let size = |key| {
|
||||
p.get(key)
|
||||
.map(|v| {
|
||||
v.as_f64().filter(|v| v.is_finite() && *v > 0.0 && *v <= 15552.0).ok_or_else(|| bad(ID, "page size out of range (0 < size ≤ 216 in)"))
|
||||
})
|
||||
.transpose()
|
||||
};
|
||||
let (w, h) = (size("width")?, size("height")?);
|
||||
if w.is_none() && h.is_none() {
|
||||
return Err(bad(ID, "give width or height"));
|
||||
}
|
||||
let anchor = match p.get("anchor") {
|
||||
None => 0,
|
||||
Some(v) => v.as_u64().filter(|n| *n <= 8).ok_or_else(|| bad(ID, "anchor: 0–8"))? as usize,
|
||||
};
|
||||
s.edit(|d, _| {
|
||||
super::pages::resize_spread_page(d, designcraft_doc::SpreadRef::Parent(si), pi, w, h, anchor);
|
||||
d.sync_parent_margins();
|
||||
Ok(json!({"parent": d.parents[si].id.0, "page": pi}))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod parent_margin_tests {
|
||||
use designcraft_doc::{Document, Margins};
|
||||
@@ -117,6 +154,52 @@ mod parent_margin_tests {
|
||||
assert!(s.execute("layout.marginsAndColumns", &json!({"parent": "Z", "margins": 10})).is_err());
|
||||
}
|
||||
|
||||
/// Insert Pages before a page, or at the start of the document (`before: 0`), as InDesign's
|
||||
/// dialog offers; `after` and `before` together are refused without an undo step.
|
||||
#[test]
|
||||
fn pages_go_in_before_a_page_or_at_the_start() {
|
||||
let mut s = Session::new();
|
||||
// Facing: [1] [2 3].
|
||||
s.execute("file.new", &json!({"pages": 3})).unwrap();
|
||||
let b = s.execute("layout.parents.new", &json!({"prefix": "B"})).unwrap()["id"].as_u64().unwrap();
|
||||
let ids = |s: &Session| -> Vec<u64> {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
(0..d.page_count()).map(|i| d.page(i).unwrap().id.0).collect()
|
||||
};
|
||||
let parent = |s: &Session, i: usize| s.doc().unwrap().doc.page(i).unwrap().parent.map(|p| p.0);
|
||||
let old = ids(&s);
|
||||
s.execute("layout.pages.insert", &json!({"before": 0, "parent": "B"})).unwrap();
|
||||
let now = ids(&s);
|
||||
assert_eq!(now.len(), 4);
|
||||
assert_eq!(now[1..], old[..], "the old pages follow the new first page");
|
||||
assert_eq!(parent(&s, 0), Some(b));
|
||||
// Before page 4 (index 3, the old page 3): two pages without a parent.
|
||||
s.execute("layout.pages.insert", &json!({"before": 3, "count": 2, "parent": null})).unwrap();
|
||||
let then = ids(&s);
|
||||
assert_eq!(then.len(), 6);
|
||||
assert_eq!(then[..3], now[..3]);
|
||||
assert_eq!(then[5], old[2]);
|
||||
assert_eq!((parent(&s, 3), parent(&s, 4)), (None, None));
|
||||
// Refused without a change or an undo step: both placements, even one of them null, and a
|
||||
// placement that isn't a page index.
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
for bad in [
|
||||
json!({"after": 0, "before": 1}),
|
||||
json!({"after": 0, "before": null}),
|
||||
json!({"after": null, "before": 1}),
|
||||
json!({"before": -1}),
|
||||
json!({"before": "0"}),
|
||||
json!({"before": null}),
|
||||
json!({"after": 1.5}),
|
||||
json!({"before": 6}),
|
||||
json!({"before": 99}),
|
||||
] {
|
||||
assert!(s.execute("layout.pages.insert", &bad).is_err(), "{bad}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{bad}");
|
||||
assert_eq!(ids(&s), then, "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applying_a_parent_gives_its_margins_and_local_margins_stay_local() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::layers::set_layer;
|
||||
use super::margins::{adjust_layout, column_guides_unlocked, margin_boxes, move_column_guide, set_margins_columns, set_page_width};
|
||||
use super::pages::{document_setup, page_options, page_size};
|
||||
use super::parents::{parent_options, parent_ref, parent_spread_index};
|
||||
use super::parents::{parent_options, parent_page_size, parent_page_target, parent_ref, parent_spread_index};
|
||||
use super::sections::{ChapterEdit, SectionEdit, chapter_numbering, start_number};
|
||||
use super::{CommandSpec, LAYER_COLORS, Layer, LayerId, Margins, SpreadId, Value, bad, cmd, has_doc, json, ok, str_param};
|
||||
|
||||
@@ -12,7 +12,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Insert Pages…",
|
||||
["Layout", "Pages"],
|
||||
Some("Cmd+Shift+P"),
|
||||
"{count?: 1 (1 to 9999), after?: 0-based page index (default: the last page), parent?: id|\"A-Parent\"|\"A\"|null}",
|
||||
"{count?: 1 (1 to 9999), after?: 0-based page index (default: the last page; past it: at the end), before?: 0-based page index (instead of `after`; 0: at the start), parent?: id|\"A-Parent\"|\"A\"|null}",
|
||||
has_doc,
|
||||
|s, p| {
|
||||
const ID: &str = "layout.pages.insert";
|
||||
@@ -27,12 +27,30 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
};
|
||||
let st = s.doc()?;
|
||||
let n = st.doc.page_count();
|
||||
// An `after` past the last page appends, which `insert_after_u64_max_appends_at_the_end`
|
||||
// pins deliberately.
|
||||
let after = p.get("after").and_then(Value::as_u64).map(|v| usize::try_from(v).unwrap_or(usize::MAX)).unwrap_or(n.saturating_sub(1));
|
||||
// A placement given (null included) must be a page index; only when neither is
|
||||
// given do the pages go after the last.
|
||||
if p.get("after").is_some() && p.get("before").is_some() {
|
||||
return Err(bad("layout.pages.insert", "give `after` or `before`, not both"));
|
||||
}
|
||||
// `before` names a page of the document; an `after` past the last page appends
|
||||
// (`insert_pages_bounds.rs` pins that).
|
||||
let index = |k: &str| match p.get(k) {
|
||||
None => Ok(None),
|
||||
Some(v) => v
|
||||
.as_u64()
|
||||
.map(|v| usize::try_from(v).unwrap_or(usize::MAX))
|
||||
.filter(|&i| k == "after" || i < n)
|
||||
.map(|i| Some(i.min(n.saturating_sub(1))))
|
||||
.ok_or_else(|| bad(ID, format!("`{k}` must be the index of one of the {n} pages, not {v}"))),
|
||||
};
|
||||
// The page the new ones follow; none: they go first.
|
||||
let after = match (index("after")?, index("before")?) {
|
||||
(_, Some(b)) => b.checked_sub(1),
|
||||
(a, None) => Some(a.unwrap_or(n.saturating_sub(1))),
|
||||
};
|
||||
let parent = parent_ref(ID, &st.doc, p.get("parent"))?.unwrap_or_else(|| st.doc.parents.first().map(|x| x.id));
|
||||
s.edit(|d, _| {
|
||||
let ids = d.insert_pages(Some(after), count, parent)?;
|
||||
let ids = d.insert_pages(after, count, parent)?;
|
||||
Ok(json!({"pages": ids.len(), "total": d.page_count()}))
|
||||
})
|
||||
}
|
||||
@@ -484,6 +502,30 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
page_size
|
||||
),
|
||||
cmd!(
|
||||
"layout.parentPageSize",
|
||||
"Parent Page Size",
|
||||
[],
|
||||
None,
|
||||
"{parent: spread id, page: 0-based parent page index, width?, height?, anchor?: 0–8} — resize only this parent page, keeping document page sizes independent",
|
||||
has_doc,
|
||||
parent_page_size
|
||||
),
|
||||
cmd!(
|
||||
noundo "layout.selectParentPage", "Select Parent Page", [], None,
|
||||
"{parent: spread id|null, page?: 0-based parent page index} — select a parent page with the Page tool; null clears the selection",
|
||||
has_doc, |s, p| {
|
||||
let target = match p.get("parent") {
|
||||
Some(Value::Null) => None,
|
||||
_ => Some(parent_page_target(&s.doc()?.doc, p, "layout.selectParentPage")?),
|
||||
};
|
||||
let st = s.doc_mut()?;
|
||||
st.selection = Default::default();
|
||||
st.selection.parent_page = target.map(|(si, pi)| (st.doc.parents[si].id, pi));
|
||||
st.bump_revision();
|
||||
ok()
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"layout.pageOptions",
|
||||
"Page Options",
|
||||
@@ -518,6 +560,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
st.selection = designcraft_doc::Selection::items(ids);
|
||||
}
|
||||
st.selection.page = page;
|
||||
st.selection.parent_page = None;
|
||||
st.bump_revision();
|
||||
Ok(json!({"page": page.map(|p| p + 1)}))
|
||||
}
|
||||
|
||||
@@ -27,8 +27,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(query "links.list", "Links", [], None, "{} → [{asset, name, path, status: ok|modified|missing|embedded, refused?: why a missing link isn't followed (a path on another computer, a device), pixels, pdfCrop (placed PDFs: the box shown), uses: [{id, page, ppi}]}], then linked text files: [{story, name, path, status (also tooLarge: unchanged size, too big to check), edited (the story was changed in the layout), uses: [{id, page}]}]", has_doc, |s, _| {
|
||||
// Taken out for the call so the document needn't be cloned on every poll.
|
||||
let mut checks = std::mem::take(&mut s.text_link_checks);
|
||||
let links = s.doc().map(|d| list(&d.doc, &s.cache, d.path.as_deref().map(Path::new), &mut checks));
|
||||
let mut images = std::mem::take(&mut s.image_link_checks);
|
||||
let links = s.doc().map(|d| list(&d.doc, &s.cache, d.path.as_deref().map(Path::new), &mut checks, &mut images));
|
||||
s.text_link_checks = checks;
|
||||
s.image_link_checks = images;
|
||||
Ok(Value::Array(links?))
|
||||
}),
|
||||
cmd!(
|
||||
@@ -137,7 +139,7 @@ pub fn refused(link: &str, document: Option<&Path>) -> Option<crate::io::Refused
|
||||
crate::io::document_path(link, document).err()
|
||||
}
|
||||
|
||||
/// Whether an asset's linked file is gone ([`status`] "missing" without reading the file). An
|
||||
/// Whether an asset's linked file is gone ([`status_cached`] "missing" without reading the file). An
|
||||
/// asset can hold data while its file is missing: the document's preview of it. `document` is
|
||||
/// the document's file, which relative links are resolved against.
|
||||
pub fn link_missing(a: &designcraft_doc::Asset, document: Option<&Path>) -> bool {
|
||||
@@ -153,9 +155,20 @@ pub fn link_missing(a: &designcraft_doc::Asset, document: Option<&Path>) -> bool
|
||||
}
|
||||
}
|
||||
|
||||
/// Link status of an asset in the document at `document`. Only a regular file is compared, so a
|
||||
/// link to a device (`/dev/zero`) or a FIFO is missing rather than read.
|
||||
pub fn status(a: &designcraft_doc::Asset, document: Option<&Path>) -> &'static str {
|
||||
/// Linked images checked this session: (asset, the identity of its bytes, the file its link
|
||||
/// resolves to) → (the file's size and time when it was read, whether it matched the bytes). The
|
||||
/// resolved file, not the link text, is the key: a relative link names another file once the
|
||||
/// document is saved elsewhere. The bytes' identity (their `Arc`'s address, as the render cache
|
||||
/// keys them) changes with every relink, update or undo, so a remembered answer can never
|
||||
/// describe other bytes.
|
||||
pub(crate) type ImageChecks = std::collections::HashMap<(AssetId, usize, std::path::PathBuf), (Fingerprint, bool)>;
|
||||
|
||||
/// Link status of a placed graphic (`ok`, `modified`, `missing`, `embedded`), reading the file
|
||||
/// only when its size is the asset's and its time isn't the one remembered from the last read
|
||||
/// (`checks`): the Links panel polls twice a second, and a document
|
||||
/// of press-size images would otherwise be read whole on every poll. A file changed in the last
|
||||
/// two seconds is never remembered (see [`settled`]).
|
||||
pub(crate) fn status_cached(a: &designcraft_doc::Asset, document: Option<&Path>, checks: &mut ImageChecks) -> &'static str {
|
||||
let Some(link) = &a.link else { return "embedded" };
|
||||
// Nothing was read from the file (a package's link that wasn't followed): missing, as in
|
||||
// Preflight.
|
||||
@@ -168,20 +181,32 @@ pub fn status(a: &designcraft_doc::Asset, document: Option<&Path>) -> &'static s
|
||||
if !crate::io::is_file(&path) {
|
||||
return "missing";
|
||||
}
|
||||
match crate::io::metadata(&path) {
|
||||
Err(_) => "missing",
|
||||
Ok(m) if m.len() as usize != a.data.len() => "modified",
|
||||
// Same size: compare the bytes (cheap for the sizes involved, and exact).
|
||||
Ok(_) => match crate::io::read_capped(&path, a.data.len() as u64) {
|
||||
Ok(b) if b == *a.data => "ok",
|
||||
Ok(_) => "modified",
|
||||
Err(_) => "missing",
|
||||
},
|
||||
let now = match crate::io::metadata(&path) {
|
||||
Err(_) => return "missing",
|
||||
Ok(m) => super::datamerge::fingerprint_from(&m),
|
||||
};
|
||||
if now.size != a.data.len() as u64 {
|
||||
return "modified";
|
||||
}
|
||||
let key = (a.id, Arc::as_ptr(&a.data) as usize, path.clone());
|
||||
if let Some((at, same)) = checks.get(&key)
|
||||
&& *at == now
|
||||
{
|
||||
return if *same { "ok" } else { "modified" };
|
||||
}
|
||||
let same = match crate::io::read_capped(&path, a.data.len() as u64) {
|
||||
Ok(b) => b == *a.data,
|
||||
Err(_) => return "missing",
|
||||
};
|
||||
// The time from before the read: a change during it is seen next time.
|
||||
if settled(&now) {
|
||||
checks.insert(key, (now, same));
|
||||
}
|
||||
if same { "ok" } else { "modified" }
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = (link, document);
|
||||
let _ = (link, document, checks);
|
||||
"ok"
|
||||
}
|
||||
}
|
||||
@@ -317,7 +342,9 @@ fn shown<'a>(d: &'a Document, cache: &Cache, aid: AssetId) -> Vec<Shown<'a>> {
|
||||
out
|
||||
}
|
||||
|
||||
fn list(d: &Document, cache: &Cache, document: Option<&Path>, checks: &mut TextChecks) -> Vec<Value> {
|
||||
/// `links.list`: every used placed graphic, then every linked text file, each with its status
|
||||
/// (`images` and `checks` remember what was read this session, so a poll reads nothing unchanged).
|
||||
fn list(d: &Document, cache: &Cache, document: Option<&Path>, checks: &mut TextChecks, images: &mut ImageChecks) -> Vec<Value> {
|
||||
let texts = d.stories.values().filter_map(|st| {
|
||||
let l = st.file_link.as_ref()?;
|
||||
let uses: Vec<Value> = st.frames.iter().map(|id| json!({"id": id.0, "page": d.page_of_item(*id).map(|p| d.page_name(p))})).collect();
|
||||
@@ -350,7 +377,7 @@ fn list(d: &Document, cache: &Cache, document: Option<&Path>, checks: &mut TextC
|
||||
.collect();
|
||||
let pdf_crop = (a.mime == "application/pdf").then(|| a.pdf_crop.name());
|
||||
let why = a.link.as_deref().and_then(|l| refused(l, document)).map(|r| r.to_string());
|
||||
json!({"asset": a.id.0, "name": a.name, "path": a.link, "status": status(a, document), "refused": why, "pixels": a.pixels, "pdfCrop": pdf_crop, "uses": uses})
|
||||
json!({"asset": a.id.0, "name": a.name, "path": a.link, "status": status_cached(a, document, images), "refused": why, "pixels": a.pixels, "pdfCrop": pdf_crop, "uses": uses})
|
||||
})
|
||||
.chain(texts)
|
||||
.collect()
|
||||
@@ -450,6 +477,7 @@ fn relinked_asset(a: &designcraft_doc::Asset, read: &Path, link: &str) -> Result
|
||||
Ok(Arc::new(a))
|
||||
}
|
||||
|
||||
/// `links.relink`: a placed graphic or linked text file takes its bytes from `path` from now on.
|
||||
fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("links.relink", "missing path"))?.to_string();
|
||||
if let Some(sid) = story_param(p) {
|
||||
@@ -464,13 +492,17 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let a = s.doc()?.doc.assets.get(&aid).ok_or_else(|| bad("links.relink", "no such graphic"))?;
|
||||
// Build the replacement before the edit: a file that cannot be read leaves the document as it was.
|
||||
let fresh = relinked_asset(a, Path::new(&path), &path)?;
|
||||
s.edit(|d, _| {
|
||||
let r = s.edit(|d, _| {
|
||||
let name = fresh.name.clone();
|
||||
d.assets.insert(aid, fresh);
|
||||
Ok(json!({"name": name}))
|
||||
})
|
||||
});
|
||||
s.image_link_checks.clear();
|
||||
r
|
||||
}
|
||||
|
||||
/// `links.relinkFolder`: relink graphics to same-named files in a folder and/or with another
|
||||
/// extension (the missing ones by default).
|
||||
fn relink_folder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let dir = str_param(p, "dir").map(std::path::PathBuf::from);
|
||||
if let Some(dir) = &dir {
|
||||
@@ -486,12 +518,13 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let missing_only = p.get("missingOnly").and_then(Value::as_bool).unwrap_or(dir.is_some() && ext.is_none());
|
||||
let only: Option<Vec<u64>> = p.get("assets").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect());
|
||||
let mut images = std::mem::take(&mut s.image_link_checks);
|
||||
let st = s.doc()?;
|
||||
let (d, document) = (&st.doc, st.path.as_deref().map(Path::new));
|
||||
let mut todo: Vec<(AssetId, std::path::PathBuf)> = Vec::new();
|
||||
let mut not_found = Vec::new();
|
||||
for a in d.assets.values() {
|
||||
if only.as_ref().is_some_and(|o| !o.contains(&a.id.0)) || (missing_only && status(a, document) != "missing") {
|
||||
if only.as_ref().is_some_and(|o| !o.contains(&a.id.0)) || (missing_only && status_cached(a, document, &mut images) != "missing") {
|
||||
continue;
|
||||
}
|
||||
let old = std::path::PathBuf::from(a.link.clone().unwrap_or_else(|| a.name.clone()));
|
||||
@@ -538,10 +571,13 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
images.clear();
|
||||
}
|
||||
s.image_link_checks = images;
|
||||
Ok(json!({"relinked": n, "notFound": not_found}))
|
||||
}
|
||||
|
||||
/// `links.update`: re-read the modified links (one, or every one) from where they resolve.
|
||||
fn update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (d, document) = {
|
||||
let st = s.doc()?;
|
||||
@@ -549,6 +585,7 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let document = document.as_deref().map(Path::new);
|
||||
let checks = &mut s.text_link_checks;
|
||||
let images = &mut s.image_link_checks;
|
||||
let (only_asset, only_story) = (p.get("asset").and_then(Value::as_u64), story_param(p));
|
||||
let all = only_asset.is_none() && only_story.is_none();
|
||||
// A link craft-docpath refuses is missing, so it is never read or refreshed.
|
||||
@@ -563,7 +600,7 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
.collect();
|
||||
let mut fresh = Vec::new();
|
||||
for a in d.assets.values().filter(|a| all || only_asset == Some(a.id.0)).filter(|a| status(a, document) == "modified") {
|
||||
for a in d.assets.values().filter(|a| all || only_asset == Some(a.id.0)).filter(|a| status_cached(a, document, images) == "modified") {
|
||||
// The bytes come from where the stored link resolves; the link itself stays as the
|
||||
// document stored it, so refreshing never rewrites a relative link to an absolute one.
|
||||
if let Some((link, at)) = a.link.as_deref().and_then(|l| resolved(l).map(|at| (l, at))) {
|
||||
@@ -579,7 +616,7 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if n == 0 {
|
||||
return Ok(json!({"updated": 0}));
|
||||
}
|
||||
s.edit(|d, _| {
|
||||
let r = s.edit(|d, _| {
|
||||
for (aid, a) in fresh {
|
||||
d.assets.insert(aid, a);
|
||||
}
|
||||
@@ -588,7 +625,9 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
warnings.extend(reimport(d, *sid, path, bytes, *before)?);
|
||||
}
|
||||
Ok(json!({"updated": n, "warnings": warnings}))
|
||||
})
|
||||
});
|
||||
s.image_link_checks.clear();
|
||||
r
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1096,6 +1135,16 @@ mod text_link_tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placed_plain_text_drops_story_markers() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let b64 = crate::cmd::base64_encode("a\u{E009}b\u{E000}c\u{E002}d".as_bytes());
|
||||
let r = s.execute("file.place", &json!({"base64": b64, "name": "n.txt", "rect": [72, 72, 300, 200]})).unwrap();
|
||||
let st = s.doc().unwrap().doc.story(StoryId(r["story"].as_u64().unwrap())).unwrap();
|
||||
assert_eq!(st.text, "abcd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_links_when_placing_text_files_preference() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-text-link-pref-{}", std::process::id()));
|
||||
|
||||
@@ -4,6 +4,78 @@ 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()
|
||||
}
|
||||
|
||||
/// The Links panel polls `links.list` twice a second: a linked image whose file keeps its size
|
||||
/// and time is not read again (press-size images would otherwise be read whole on every poll).
|
||||
/// A new time is read once; changed bytes show as modified; an update is remembered.
|
||||
#[cfg(all(unix, not(target_arch = "wasm32")))]
|
||||
#[test]
|
||||
fn unchanged_linked_images_are_not_read_again() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = std::env::temp_dir().join(format!("dc-image-link-poll-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("photo.png");
|
||||
// Two valid images of the same byte length (a uniform fill compresses to the same size
|
||||
// whatever its value), so a change shows only in the bytes.
|
||||
let a = png(40, 40);
|
||||
let b = designcraft_render::Rendered { width: 40, height: 40, pixels: vec![100u8; 40 * 40 * 4] }.to_png();
|
||||
assert_eq!(a.len(), b.len(), "the test needs two files of one size");
|
||||
assert_ne!(a, b);
|
||||
std::fs::write(&path, &a).unwrap();
|
||||
// Unreadable: a status that reads the file would say missing.
|
||||
let readable = |on: bool| std::fs::set_permissions(&path, std::fs::Permissions::from_mode(if on { 0o644 } else { 0o000 })).unwrap();
|
||||
// A time `secs` ago (a file changed in the last two seconds is always read).
|
||||
let touch = |secs: u64| {
|
||||
let f = std::fs::File::options().write(true).open(&path).unwrap();
|
||||
f.set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(secs)).unwrap();
|
||||
};
|
||||
touch(60);
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("file.place", &json!({"path": path.to_string_lossy(), "link": true})).unwrap();
|
||||
let status = |s: &mut Session| {
|
||||
let l = s.execute("links.list", &json!({})).unwrap();
|
||||
l.as_array().unwrap().iter().find(|r| r.get("asset").is_some()).unwrap()["status"].as_str().unwrap().to_string()
|
||||
};
|
||||
|
||||
// First poll reads and remembers; the second doesn't read.
|
||||
assert_eq!(status(&mut s), "ok");
|
||||
readable(false);
|
||||
if std::fs::read(&path).is_ok() {
|
||||
// Running as root: permissions don't stop reads, so this can't be observed.
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
return;
|
||||
}
|
||||
assert_eq!(status(&mut s), "ok", "same size and time: not read");
|
||||
// Saved again unchanged (a new time): read once, then not again.
|
||||
readable(true);
|
||||
touch(30);
|
||||
assert_eq!(status(&mut s), "ok");
|
||||
readable(false);
|
||||
assert_eq!(status(&mut s), "ok", "the second poll doesn't read it");
|
||||
// Changed, same size, new time: modified — and remembered, not re-read.
|
||||
readable(true);
|
||||
std::fs::write(&path, &b).unwrap();
|
||||
touch(20);
|
||||
assert_eq!(status(&mut s), "modified");
|
||||
readable(false);
|
||||
assert_eq!(status(&mut s), "modified", "a modified answer is remembered too");
|
||||
// Updated: the document's bytes are the file's, so the next poll reads once and says ok.
|
||||
readable(true);
|
||||
assert_eq!(s.execute("links.update", &json!({})).unwrap()["updated"], 1);
|
||||
assert_eq!(status(&mut s), "ok");
|
||||
readable(false);
|
||||
assert_eq!(status(&mut s), "ok", "remembered after the update");
|
||||
// A different size needs no read at all.
|
||||
readable(true);
|
||||
std::fs::write(&path, png(41, 41)).unwrap();
|
||||
touch(10);
|
||||
readable(false);
|
||||
assert_eq!(status(&mut s), "modified", "size differs: decided from metadata");
|
||||
readable(true);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A linked file's size is the document's choice, so a link is read under a cap (#1596).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
@@ -154,7 +226,7 @@ fn eps_status_tracks_source_bytes_across_save_update_relink_and_embed() {
|
||||
std::fs::remove_file(&replacement).unwrap();
|
||||
let embedded = designcraft_format::load(&designcraft_format::save(&s.doc().unwrap().doc).unwrap()).unwrap();
|
||||
// The asset is embedded, so no link is followed and the document is irrelevant.
|
||||
assert_eq!(status(&embedded.assets[&aid], None), "embedded");
|
||||
assert_eq!(status_cached(&embedded.assets[&aid], None, &mut Default::default()), "embedded");
|
||||
assert_eq!(embedded.assets[&aid].data.as_slice(), changed);
|
||||
assert!(designcraft_render::decode_pixmap(&embedded.assets[&aid].data).is_some());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
|
||||
@@ -70,8 +70,8 @@ pub(crate) fn apply(d: &mut Document, sr: SpreadRef, pi: usize, old: Rect) {
|
||||
let liquid_guides = |o: Orientation| pg.guides.iter().filter(|g| g.liquid && g.orientation == o).map(|g| g.position).collect::<Vec<_>>();
|
||||
let (vg, hg) = (liquid_guides(Orientation::Vertical), liquid_guides(Orientation::Horizontal));
|
||||
// The page's objects: centres within its old horizontal extent (outer pages take the pasteboard).
|
||||
let last = sp.pages.len() - 1;
|
||||
let (lo, hi) = (if pi == 0 { f64::NEG_INFINITY } else { old.x0 }, if pi == last { f64::INFINITY } else { old.x1 });
|
||||
let Some((leftmost, rightmost)) = sp.outer_page_indices() else { return };
|
||||
let (lo, hi) = (if pi == leftmost { f64::NEG_INFINITY } else { old.x0 }, if pi == rightmost { f64::INFINITY } else { old.x1 });
|
||||
let strokes = false;
|
||||
let Some(sp) = d.spread_mut(sr) else { return };
|
||||
for it in &mut sp.items {
|
||||
|
||||
@@ -35,6 +35,11 @@ fn math_item(d: &mut designcraft_doc::Document, latex: &str, size: f64, lid: des
|
||||
it
|
||||
}
|
||||
|
||||
/// The `size` parameter in points: 12 by default (also for non-finite values), clamped to 2..=400.
|
||||
fn size_param(p: &Value) -> f64 {
|
||||
p.get("size").and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(12.0).clamp(2.0, 400.0)
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
@@ -48,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!(query "math.svg", "Math Expression SVG", [], None, "{latex, size?} → {svg, width, height} — typeset without placing", has_doc, |_s, p| {
|
||||
let latex = str_param(p, "latex").ok_or_else(|| bad("math.svg", "`latex` required"))?;
|
||||
let (svg, (w, h)) = crate::math::to_svg(latex, p.get("size").and_then(Value::as_f64).unwrap_or(12.0));
|
||||
let (svg, (w, h)) = crate::math::to_svg(latex, size_param(p));
|
||||
Ok(json!({"svg": String::from_utf8_lossy(&svg), "width": w, "height": h}))
|
||||
}),
|
||||
]
|
||||
@@ -56,7 +61,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let latex = str_param(p, "latex").ok_or_else(|| bad("math.insert", "`latex` required"))?.to_string();
|
||||
let size = p.get("size").and_then(Value::as_f64).unwrap_or(12.0).clamp(2.0, 400.0);
|
||||
let size = size_param(p);
|
||||
let lid = s.doc()?.active_layer;
|
||||
// Edit an existing expression in place.
|
||||
if let Some(id) = super::id_param(p, "id") {
|
||||
@@ -102,6 +107,24 @@ mod tests {
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn math_svg_clamps_size_like_insert() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let dims = |s: &mut Session, size: f64| {
|
||||
let r = s.execute("math.svg", &json!({"latex": "x", "size": size})).unwrap();
|
||||
(r["width"].as_f64().unwrap(), r["height"].as_f64().unwrap())
|
||||
};
|
||||
let (w2, h2) = dims(&mut s, 2.0);
|
||||
let (w400, h400) = dims(&mut s, 400.0);
|
||||
for size in [0.0, -5.0, 1e-300] {
|
||||
assert_eq!(dims(&mut s, size), (w2, h2), "size {size}");
|
||||
}
|
||||
for size in [1e9, 1e300] {
|
||||
assert_eq!(dims(&mut s, size), (w400, h400), "size {size}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn math_places_inline_and_as_frames() {
|
||||
let mut s = Session::new();
|
||||
|
||||
+290
-54
@@ -382,9 +382,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Drop Shadow",
|
||||
["Object", "Effects"],
|
||||
Some("Cmd+Alt+M"),
|
||||
"{on?: bool, distance?, angle?, globalLight?: bool, opacity?, size?, spread? (%), color? (swatch), ids?}",
|
||||
"{on?: bool, distance?, angle?, globalLight?: bool, opacity?, size?, spread? (%), color? (swatch), mode?: blend mode (multiply…), ids?}",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let mode = blend_param(p, "object.dropShadow")?;
|
||||
let p2 = p.clone();
|
||||
set_effects(s, p, "object.dropShadow", move |fx| {
|
||||
let ds = &mut fx.drop_shadow;
|
||||
@@ -398,6 +399,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
if let Some(c) = str_param(&p2, "color") {
|
||||
ds.color = c.into();
|
||||
}
|
||||
if let Some(m) = mode {
|
||||
ds.mode = m;
|
||||
}
|
||||
})
|
||||
}
|
||||
),
|
||||
@@ -406,9 +410,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Inner Shadow",
|
||||
["Object", "Effects"],
|
||||
None,
|
||||
"{on?: bool, distance?, angle?, globalLight?: bool, opacity?, size?, choke? (%), color?, ids?}",
|
||||
"{on?: bool, distance?, angle?, globalLight?: bool, opacity?, size?, choke? (%), color?, mode?: blend mode, ids?}",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let mode = blend_param(p, "object.innerShadow")?;
|
||||
let p2 = p.clone();
|
||||
set_effects(s, p, "object.innerShadow", move |fx| {
|
||||
let e = &mut fx.inner_shadow;
|
||||
@@ -422,6 +427,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
if let Some(c) = str_param(&p2, "color") {
|
||||
e.color = c.into();
|
||||
}
|
||||
if let Some(m) = mode {
|
||||
e.mode = m;
|
||||
}
|
||||
})
|
||||
}
|
||||
),
|
||||
@@ -430,9 +438,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Outer Glow",
|
||||
["Object", "Effects"],
|
||||
None,
|
||||
"{on?: bool, opacity?, size?, spread? (%), color?, ids?}",
|
||||
"{on?: bool, opacity?, size?, spread? (%), color?, mode?: blend mode (screen…), ids?}",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let mode = blend_param(p, "object.outerGlow")?;
|
||||
let p2 = p.clone();
|
||||
set_effects(s, p, "object.outerGlow", move |fx| {
|
||||
let e = &mut fx.outer_glow;
|
||||
@@ -443,6 +452,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
if let Some(c) = str_param(&p2, "color") {
|
||||
e.color = c.into();
|
||||
}
|
||||
if let Some(m) = mode {
|
||||
e.mode = m;
|
||||
}
|
||||
})
|
||||
}
|
||||
),
|
||||
@@ -598,7 +610,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Text Frame Options…",
|
||||
["Object"],
|
||||
Some("Cmd+B"),
|
||||
"{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, columnsKind?: fixedNumber|fixedWidth, columnWidth? (pt, Fixed Width), firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin? (pt), autoSize?: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference?: topLeft|topCenter|topRight|leftCenter|center|rightCenter|bottomLeft|bottomCenter|bottomRight (the point that stays put), useMinimumHeight?: bool, minimumHeight? (pt), useMinimumWidth?: bool, minimumWidth? (pt), noLineBreaks?: bool, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), baselineGrid?: {start? (pt), increment? (pt), relativeTo?: page|margin|frame|inset} (the frame's custom baseline grid; fields not given keep the frame's, or come from the document grid; null turns it off), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}",
|
||||
"{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, paragraphSpacingLimit? (pt: Justify adds at most this between paragraphs, the rest between lines), columnsKind?: fixedNumber|fixedWidth, columnWidth? (pt, Fixed Width), firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin? (pt), autoSize?: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference?: topLeft|topCenter|topRight|leftCenter|center|rightCenter|bottomLeft|bottomCenter|bottomRight (the point that stays put), useMinimumHeight?: bool, minimumHeight? (pt), useMinimumWidth?: bool, minimumWidth? (pt), noLineBreaks?: bool, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), baselineGrid?: {start? (pt), increment? (pt), relativeTo?: page|margin|frame|inset} (the frame's custom baseline grid; fields not given keep the frame's, or come from the document grid; null turns it off), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}",
|
||||
has_selection,
|
||||
text_frame_options
|
||||
),
|
||||
@@ -607,7 +619,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Corner Options…",
|
||||
["Object"],
|
||||
None,
|
||||
"{shape: none|rounded|inverseRounded|inset|bevel|fancy, size, corners?: [{shape, size}] (4 entries in path order: top-left, top-right, bottom-right, bottom-left; replaces shape and size), ids?}",
|
||||
"{shape: none|rounded|inverseRounded|inset|bevel|fancy, size, corners?: [{shape, size} | null] (4 entries in path order: top-left, top-right, bottom-right, bottom-left; replaces shape and size; null keeps that corner of each object), ids?}",
|
||||
has_selection,
|
||||
corner_options
|
||||
),
|
||||
@@ -616,7 +628,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Text Wrap",
|
||||
["Window"],
|
||||
Some("Cmd+Alt+W"),
|
||||
"{mode?: none|boundingBox|contour|jumpObject|jumpToNextColumn (boundingBox when absent, unless side or contour is given: then unchanged), offset?: number|[t,l,b,r], invert?: bool, side?: bothSides|leftSide|rightSide|towardsSpine|awayFromSpine|largestArea, contour?: boundingBox|graphicFrame|sameAsClipping, content?: bool (the placed graphic instead of its frame; default: whether the selection is the frame's content), ids?}; offset or contour drops an imported custom wrap boundary",
|
||||
"{mode?: none|boundingBox|contour|jumpObject|jumpToNextColumn (boundingBox when absent, unless side or contour is given; null or those: each object's own, unchanged), offset?: number|[t,l,b,r] (a null side keeps each object's own), invert?: bool, side?: bothSides|leftSide|rightSide|towardsSpine|awayFromSpine|largestArea, contour?: boundingBox|graphicFrame|sameAsClipping, content?: bool (the placed graphic instead of its frame; default: whether the selection is the frame's content), ids?}; offset or contour drops an imported custom wrap boundary",
|
||||
has_selection,
|
||||
text_wrap
|
||||
),
|
||||
@@ -638,6 +650,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
fitting_options
|
||||
),
|
||||
cmd!(
|
||||
"object.clearFittingOptions",
|
||||
"Clear Frame Fitting Options",
|
||||
["Object", "Fitting"],
|
||||
None,
|
||||
"{ids?} — the frames' fitting options back to the defaults: no auto-fit, no crop amounts, the centre reference point; the content stays where it is",
|
||||
has_selection,
|
||||
clear_fitting_options
|
||||
),
|
||||
cmd!("object.rename", "Rename", [], None, "{id | ids?, name}", has_doc, |s, p| {
|
||||
let name = str_param(p, "name").unwrap_or("").to_string();
|
||||
set_flag(s, p, move |i| i.name = name.clone(), false)
|
||||
@@ -680,7 +701,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
path_reverse
|
||||
),
|
||||
cmd!("path.close", "Close Path", ["Object", "Paths"], None, "{id, subpath?: index (default every subpath)}", has_doc, path_close),
|
||||
cmd!("path.moveAnchors", "Move Anchors", [], None, "{id, anchors: [[subpath, index]], dx, dy, handle?: in|out}", has_doc, path_move_anchors),
|
||||
cmd!(
|
||||
"path.moveAnchors",
|
||||
"Move Anchors",
|
||||
[],
|
||||
None,
|
||||
"{id, anchors: [[subpath, index]], dx, dy, handle?: in|out} | {points: [[id, subpath, index, dx?, dy?]], dx?, dy?} (anchors of several paths, as Direct Selection drags them: one step; a point's own dx, dy win over the shared ones) — spread space",
|
||||
has_doc,
|
||||
path_move_anchors
|
||||
),
|
||||
cmd!(
|
||||
"object.align",
|
||||
"Align",
|
||||
@@ -2101,13 +2130,18 @@ fn text_wrap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => None,
|
||||
};
|
||||
let mode: Option<WrapMode> = match p.get("mode") {
|
||||
// Null keeps each object's own mode (an edit of the offsets or Invert alone).
|
||||
Some(Value::Null) => None,
|
||||
Some(v) => Some(serde_json::from_value(v.clone()).map_err(|_| bad("object.textWrap", "unknown mode"))?),
|
||||
None if side.is_some() || contour.is_some() => None,
|
||||
None => Some(WrapMode::BoundingBox),
|
||||
};
|
||||
let off = match p.get("offset") {
|
||||
Some(Value::Number(n)) => Some([n.as_f64().unwrap_or(0.0); 4]),
|
||||
Some(v) => serde_json::from_value(v.clone()).ok(),
|
||||
// Per side [top, left, bottom, right]; a null side keeps each object's own offset there, so
|
||||
// an edit of one side keeps the others of every object.
|
||||
// An array with no number in it changes no offset (and so keeps a custom boundary).
|
||||
let off: Option<[Option<f64>; 4]> = match p.get("offset") {
|
||||
Some(Value::Number(n)) => Some([n.as_f64(); 4]),
|
||||
Some(v) => serde_json::from_value::<[Option<f64>; 4]>(v.clone()).ok().filter(|o| o.iter().any(Option::is_some)),
|
||||
None => None,
|
||||
};
|
||||
let invert = p.get("invert").and_then(Value::as_bool);
|
||||
@@ -2127,7 +2161,11 @@ fn text_wrap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
w.mode = m;
|
||||
}
|
||||
if let Some(o) = off {
|
||||
w.offsets = o;
|
||||
for (w, v) in w.offsets.iter_mut().zip(o) {
|
||||
if let Some(v) = v {
|
||||
*w = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(v) = invert {
|
||||
w.invert = v;
|
||||
@@ -2152,19 +2190,31 @@ fn corner_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use designcraft_geom::corners::{Corner, CornerOptions, CornerShape};
|
||||
let opts = match p.get("corners") {
|
||||
Some(v) => {
|
||||
let corners: [Corner; 4] =
|
||||
serde_json::from_value(v.clone()).map_err(|e| bad("object.cornerOptions", format!("corners: four {{shape, size}} entries ({e})")))?;
|
||||
if corners.iter().any(|c| !c.size.is_finite() || c.size < 0.0) {
|
||||
let corners: [Option<Corner>; 4] = serde_json::from_value(v.clone())
|
||||
.map_err(|e| bad("object.cornerOptions", format!("corners: four {{shape, size}} or null entries ({e})")))?;
|
||||
if corners.iter().flatten().any(|c| !c.size.is_finite() || c.size < 0.0) {
|
||||
return Err(bad("object.cornerOptions", "corner sizes must be finite and not negative"));
|
||||
}
|
||||
CornerOptions { corners }
|
||||
corners
|
||||
}
|
||||
None => {
|
||||
let shape = p.get("shape").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or(CornerShape::Rounded);
|
||||
CornerOptions::uniform(shape, f64_or(p, "size", 12.0))
|
||||
CornerOptions::uniform(shape, f64_or(p, "size", 12.0)).corners.map(Some)
|
||||
}
|
||||
};
|
||||
set_flag(s, p, move |i| i.corners = opts, false)
|
||||
// A null corner keeps each object's own.
|
||||
set_flag(
|
||||
s,
|
||||
p,
|
||||
move |i| {
|
||||
for (c, new) in i.corners.corners.iter_mut().zip(opts) {
|
||||
if let Some(new) = new {
|
||||
*c = new;
|
||||
}
|
||||
}
|
||||
},
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
/// `object.matchAttributes`: the selected objects (or `ids`) take `from`'s appearance.
|
||||
@@ -2311,6 +2361,15 @@ fn effects_all(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
if let Some(patch) = &patch {
|
||||
let mut cur = serde_json::to_value(&it.effects).map_err(|e| bad(ID, e.to_string()))?;
|
||||
// Default effects are left out of the serialized form; a patch without `mode` would
|
||||
// then read the legacy Normal instead of the effect's current (Multiply, Screen) mode.
|
||||
for (key, now) in
|
||||
[("innerShadow", serde_json::to_value(&it.effects.inner_shadow)), ("outerGlow", serde_json::to_value(&it.effects.outer_glow))]
|
||||
{
|
||||
if let (Some(o), Ok(now)) = (cur.as_object_mut(), now) {
|
||||
o.entry(key).or_insert(now);
|
||||
}
|
||||
}
|
||||
merge_json(&mut cur, patch);
|
||||
let mut fx: designcraft_doc::Effects = serde_json::from_value(cur).map_err(|e| bad(ID, format!("effects: {e}")))?;
|
||||
sanitize_effects(&mut fx)?;
|
||||
@@ -2505,6 +2564,7 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let min_width = ranged("minimumWidth", 0.0, crate::autosize::MAX_SIDE, "a number of points")?;
|
||||
let column_width = ranged("columnWidth", 0.0, 15552.0, "a number of points")?;
|
||||
let first_baseline_min = ranged("firstBaselineMin", 0.0, 15552.0, "a number of points")?;
|
||||
let spacing_limit = ranged("paragraphSpacingLimit", 0.0, 15552.0, "a number of points")?;
|
||||
let reference = match p.get("autoSizeReference") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(
|
||||
@@ -2591,6 +2651,7 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = reference {
|
||||
o.auto_size_ref = v;
|
||||
}
|
||||
o.vj_paragraph_spacing_limit = spacing_limit.unwrap_or(o.vj_paragraph_spacing_limit);
|
||||
if let Some(v) = p.get("useMinimumHeight").and_then(Value::as_bool) {
|
||||
o.auto_size_use_min_height = v;
|
||||
}
|
||||
@@ -2938,7 +2999,9 @@ fn fitting_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else if fitting.is_some() && g.auto_fit != designcraft_doc::Fitting::None {
|
||||
g.auto_fit = mode;
|
||||
}
|
||||
if let Some(xf) = g.fitted(r, mode) {
|
||||
// Turning Auto-Fit off alone leaves the picture where the user put it.
|
||||
let turn_off_only = auto == Some(false) && fitting.is_none() && align.is_none() && crop.is_none();
|
||||
if !turn_off_only && let Some(xf) = g.fitted(r, mode) {
|
||||
g.xf = xf;
|
||||
}
|
||||
n += 1;
|
||||
@@ -2947,6 +3010,35 @@ fn fitting_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Clear Frame Fitting Options: auto-fit off, no crop, the centre reference point. The content
|
||||
/// stays where it is, as in InDesign.
|
||||
fn clear_fitting_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Locked frames (or frames on a locked layer) keep their options, as with Fit; frames whose
|
||||
// options are already clear are left out; with nothing to change, no edit (and no empty undo
|
||||
// step).
|
||||
let doc = &s.doc()?.doc;
|
||||
let set = |g: &designcraft_doc::Graphic| g.auto_fit != designcraft_doc::Fitting::None || g.crop != [0.0; 4] || g.fit_align != 4;
|
||||
let ids: Vec<ItemId> = targets(s, p)?
|
||||
.into_iter()
|
||||
.filter(|id| doc.item(*id).is_some_and(|it| unlocked(doc, it) && matches!(&it.content, Content::Graphic(g) if set(g))))
|
||||
.collect();
|
||||
if ids.is_empty() {
|
||||
return Ok(json!({"changed": 0}));
|
||||
}
|
||||
s.edit(|d, _| {
|
||||
let mut n = 0;
|
||||
for id in &ids {
|
||||
let Some(it) = d.item_mut(*id) else { continue };
|
||||
let Content::Graphic(g) = &mut it.content else { continue };
|
||||
g.auto_fit = designcraft_doc::Fitting::None;
|
||||
g.crop = [0.0; 4];
|
||||
g.fit_align = 4;
|
||||
n += 1;
|
||||
}
|
||||
Ok(json!({"changed": n}))
|
||||
})
|
||||
}
|
||||
|
||||
fn fit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let doc = &s.doc()?.doc;
|
||||
let ids: Vec<_> =
|
||||
@@ -3174,6 +3266,20 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// An effect's `mode` parameter: a blend mode by name ("multiply", "screen", or as saved).
|
||||
/// An effect's `mode`: `None` when omitted or null, an error when it names no blend mode.
|
||||
fn blend_param(p: &Value, cmd: &str) -> Result<Option<BlendMode>> {
|
||||
match p.get("mode") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v
|
||||
.as_str()
|
||||
.and_then(BlendMode::parse)
|
||||
.or_else(|| serde_json::from_value(v.clone()).ok())
|
||||
.map(Some)
|
||||
.ok_or_else(|| bad(cmd, format!("unknown blend mode {v}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
if ids.len() < 3 && p.get("spacing").is_none() {
|
||||
@@ -3266,29 +3372,36 @@ fn path_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn path_append(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = super::id_param(p, "id").ok_or_else(|| bad("path.appendAnchor", "missing id"))?;
|
||||
let a = p.get("anchor").and_then(anchor_from).ok_or_else(|| bad("path.appendAnchor", "missing anchor"))?;
|
||||
let subpath = p.get("subpath").and_then(Value::as_u64);
|
||||
// An omitted subpath is the last one; one given must name a subpath (never falls back).
|
||||
let subpath = match p.get("subpath") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(v.as_u64().ok_or_else(|| bad("path.appendAnchor", "`subpath` must be a subpath index"))?),
|
||||
};
|
||||
let at_start = match p.get("at") {
|
||||
None => false,
|
||||
Some(v) if v.as_str() == Some("end") => false,
|
||||
Some(v) if v.as_str() == Some("start") => true,
|
||||
Some(_) => return Err(bad("path.appendAnchor", "`at` must be `start` or `end`")),
|
||||
};
|
||||
s.edit(|d, _| {
|
||||
s.edit(|d, sel| {
|
||||
path_editable(d, id, "path.appendAnchor")?;
|
||||
let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
let inv = (d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default()).inverse();
|
||||
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
let sp = match subpath {
|
||||
Some(i) => {
|
||||
usize::try_from(i).ok().and_then(|i| it.path.subpaths.get_mut(i)).ok_or_else(|| bad("path.appendAnchor", "no such subpath"))?
|
||||
}
|
||||
None => it.path.subpaths.last_mut().ok_or_else(|| bad("path.appendAnchor", "empty path"))?,
|
||||
let si = match subpath {
|
||||
Some(i) => usize::try_from(i).ok().filter(|i| *i < it.path.subpaths.len()).ok_or_else(|| bad("path.appendAnchor", "no such subpath"))?,
|
||||
None => it.path.subpaths.len().checked_sub(1).ok_or_else(|| bad("path.appendAnchor", "empty path"))?,
|
||||
};
|
||||
let sp = &mut it.path.subpaths[si];
|
||||
if sp.closed {
|
||||
return Err(bad("path.appendAnchor", "the subpath is closed"));
|
||||
}
|
||||
if at_start {
|
||||
sp.anchors.insert(0, a.transform(inv));
|
||||
// The selected points of this sub-path move up one place with the others.
|
||||
for a in sel.anchors.iter_mut().filter(|a| a.0 == id && a.1 == si) {
|
||||
a.2 += 1;
|
||||
}
|
||||
} else {
|
||||
sp.anchors.push(a.transform(inv));
|
||||
}
|
||||
@@ -3367,7 +3480,7 @@ fn path_reverse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
path_editable(doc, *id, "path.reverse")?;
|
||||
}
|
||||
s.edit(|d, _| {
|
||||
s.edit(|d, sel| {
|
||||
let mut reversed = 0;
|
||||
for id in &editable {
|
||||
let Some(it) = d.item_mut(*id) else { continue };
|
||||
@@ -3378,6 +3491,11 @@ fn path_reverse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if only.is_some_and(|o| o != i) {
|
||||
continue;
|
||||
}
|
||||
// Selected points keep pointing at the same points, now counted from the other end.
|
||||
let n = sp.anchors.len();
|
||||
for a in sel.anchors.iter_mut().filter(|a| a.0 == *id && a.1 == i && a.2 < n) {
|
||||
a.2 = n - 1 - a.2;
|
||||
}
|
||||
sp.anchors.reverse();
|
||||
for a in &mut sp.anchors {
|
||||
std::mem::swap(&mut a.h_in, &mut a.h_out);
|
||||
@@ -3411,38 +3529,75 @@ fn path_close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn path_move_anchors(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = super::id_param(p, "id").ok_or_else(|| bad("path.moveAnchors", "missing id"))?;
|
||||
let which: Vec<(usize, usize)> = p.get("anchors").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
|
||||
// The anchors to move, by path: `points` (several paths) or `id` with `anchors`.
|
||||
// Each anchor with its own spread-space movement, when the points give one.
|
||||
let mut targets: Vec<(ItemId, Vec<(usize, usize, Option<Vec2>)>)> = Vec::new();
|
||||
// A `points` that isn't a list is an error, never the `id` form (which could move another path).
|
||||
let points = match p.get("points") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::Array(a)) => Some(a),
|
||||
Some(_) => return Err(bad("path.moveAnchors", "`points` must be a list of [id, subpath, index, dx?, dy?]")),
|
||||
};
|
||||
if let Some(points) = points {
|
||||
for v in points {
|
||||
let n = |i: usize| v.get(i).and_then(Value::as_u64).and_then(|x| usize::try_from(x).ok());
|
||||
let (Some(id), Some(si), Some(ai)) = (v.get(0).and_then(Value::as_u64), n(1), n(2)) else {
|
||||
return Err(bad("path.moveAnchors", "each point is [id, subpath, index] or [id, subpath, index, dx, dy]"));
|
||||
};
|
||||
let own = match (v.get(3), v.get(4)) {
|
||||
(None, None) => None,
|
||||
(Some(x), Some(y)) => match (x.as_f64(), y.as_f64()) {
|
||||
(Some(x), Some(y)) if x.is_finite() && y.is_finite() => Some(Vec2::new(x, y)),
|
||||
_ => return Err(bad("path.moveAnchors", "a point's dx and dy are finite numbers")),
|
||||
},
|
||||
_ => return Err(bad("path.moveAnchors", "a point gives both dx and dy, or neither")),
|
||||
};
|
||||
match targets.iter_mut().find(|(i, _)| i.0 == id) {
|
||||
Some((_, which)) => which.push((si, ai, own)),
|
||||
None => targets.push((ItemId(id), vec![(si, ai, own)])),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let id = super::id_param(p, "id").ok_or_else(|| bad("path.moveAnchors", "missing id"))?;
|
||||
let which: Vec<(usize, usize)> = p.get("anchors").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
|
||||
targets.push((id, which.into_iter().map(|(s, a)| (s, a, None)).collect()));
|
||||
}
|
||||
let d0 = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
|
||||
let handle = str_param(p, "handle").map(str::to_string);
|
||||
s.edit(|d, _| {
|
||||
path_editable(d, id, "path.moveAnchors")?;
|
||||
let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
let m = d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default();
|
||||
// Spread-space delta → inner-space delta (linear part only).
|
||||
let inv = m.inverse();
|
||||
let dv = (inv * Point::new(d0.x, d0.y)) - (inv * Point::ZERO);
|
||||
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
for (si, ai) in &which {
|
||||
let Some(a) = it.path.anchor_mut(*si, *ai) else { continue };
|
||||
match handle.as_deref() {
|
||||
Some("in") => {
|
||||
a.h_in += dv;
|
||||
if a.kind == designcraft_geom::AnchorKind::Smooth {
|
||||
a.h_out = a.p - (a.h_in - a.p);
|
||||
}
|
||||
}
|
||||
Some("out") => {
|
||||
a.h_out += dv;
|
||||
if a.kind == designcraft_geom::AnchorKind::Smooth {
|
||||
a.h_in = a.p - (a.h_out - a.p);
|
||||
}
|
||||
}
|
||||
_ => a.translate(dv),
|
||||
}
|
||||
for (id, _) in &targets {
|
||||
path_editable(d, *id, "path.moveAnchors")?;
|
||||
}
|
||||
if it.shape == Shape::Rectangle || it.shape == Shape::Oval || it.shape == Shape::Polygon {
|
||||
it.shape = Shape::Path;
|
||||
for (id, which) in &targets {
|
||||
let id = *id;
|
||||
let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
let m = d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default();
|
||||
// Spread-space delta → inner-space delta (linear part only).
|
||||
let inv = m.inverse();
|
||||
let inner = |d: Vec2| (inv * Point::new(d.x, d.y)) - (inv * Point::ZERO);
|
||||
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
for (si, ai, own) in which {
|
||||
let dv = inner(own.unwrap_or(d0));
|
||||
let Some(a) = it.path.anchor_mut(*si, *ai) else { continue };
|
||||
match handle.as_deref() {
|
||||
Some("in") => {
|
||||
a.h_in += dv;
|
||||
if a.kind == designcraft_geom::AnchorKind::Smooth {
|
||||
a.h_out = a.p - (a.h_in - a.p);
|
||||
}
|
||||
}
|
||||
Some("out") => {
|
||||
a.h_out += dv;
|
||||
if a.kind == designcraft_geom::AnchorKind::Smooth {
|
||||
a.h_in = a.p - (a.h_out - a.p);
|
||||
}
|
||||
}
|
||||
_ => a.translate(dv),
|
||||
}
|
||||
}
|
||||
if it.shape == Shape::Rectangle || it.shape == Shape::Oval || it.shape == Shape::Polygon {
|
||||
it.shape = Shape::Path;
|
||||
}
|
||||
}
|
||||
ok()
|
||||
})
|
||||
@@ -4143,6 +4298,31 @@ mod label_tests {
|
||||
mod light_tests {
|
||||
use super::*;
|
||||
|
||||
/// The shadow and glow commands take a blend mode by name.
|
||||
#[test]
|
||||
fn shadow_and_glow_commands_take_a_blend_mode() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = s.execute("frame.create", &json!({"rect": [72, 72, 172, 172]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("object.dropShadow", &json!({"on": true, "mode": "screen", "ids": [id]})).unwrap();
|
||||
s.execute("object.innerShadow", &json!({"on": true, "mode": "overlay", "ids": [id]})).unwrap();
|
||||
s.execute("object.outerGlow", &json!({"on": true, "mode": "normal", "ids": [id]})).unwrap();
|
||||
let fx = s.doc().unwrap().doc.item(ItemId(id)).unwrap().effects.clone();
|
||||
assert_eq!(fx.drop_shadow.mode, BlendMode::Screen);
|
||||
assert_eq!(fx.inner_shadow.mode, BlendMode::Overlay);
|
||||
assert_eq!(fx.outer_glow.mode, BlendMode::Normal);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
for cmd in ["object.dropShadow", "object.innerShadow", "object.outerGlow"] {
|
||||
for mode in [json!("multply"), json!(3)] {
|
||||
let e = s.execute(cmd, &json!({"on": true, "mode": mode, "ids": [id]})).unwrap_err();
|
||||
assert!(e.to_string().contains("unknown blend mode"), "{cmd}: {e}");
|
||||
}
|
||||
}
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "nothing changed");
|
||||
s.execute("object.dropShadow", &json!({"mode": null, "ids": [id]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.item(ItemId(id)).unwrap().effects.drop_shadow.mode, BlendMode::Screen, "null keeps the mode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_light_moves_shadows_that_use_it() {
|
||||
let mut s = Session::new();
|
||||
@@ -4352,6 +4532,60 @@ mod fitting_tests {
|
||||
let g = back.spreads.iter().flat_map(|sp| sp.items.iter()).find_map(|i| i.graphic().cloned()).unwrap();
|
||||
assert_eq!(g.auto_fit, designcraft_doc::Fitting::FillProportionally);
|
||||
}
|
||||
|
||||
/// Turning Auto-Fit off by itself keeps a moved picture where it is.
|
||||
#[test]
|
||||
fn auto_fit_off_alone_keeps_the_picture_position() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let f = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300]})).unwrap()["id"].as_u64().unwrap();
|
||||
let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png();
|
||||
s.execute("file.place", &json!({"base64": crate::cmd::base64_encode(&png), "name": "p.png", "frame": f})).unwrap();
|
||||
s.execute("object.fittingOptions", &json!({"ids": [f], "autoFit": true, "fitting": "fitProportionally"})).unwrap();
|
||||
let xf = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().graphic().unwrap().xf;
|
||||
s.edit(|d, _| {
|
||||
if let Some(Content::Graphic(g)) = d.item_mut(ItemId(f)).map(|it| &mut it.content) {
|
||||
g.xf = designcraft_geom::Affine::translate((7.0, 9.0)) * g.xf;
|
||||
}
|
||||
Ok(Value::Null)
|
||||
})
|
||||
.unwrap();
|
||||
let moved = xf(&s);
|
||||
s.execute("object.fittingOptions", &json!({"ids": [f], "autoFit": false})).unwrap();
|
||||
assert_eq!(xf(&s), moved);
|
||||
}
|
||||
|
||||
/// Clear Frame Fitting Options puts auto-fit, crop and the reference point back to their
|
||||
/// defaults and leaves the picture where it is.
|
||||
#[test]
|
||||
fn clear_fitting_options_resets_the_frames_options_and_keeps_the_picture() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let f = s.execute("frame.create", &json!({"rect": [72, 72, 272, 172]})).unwrap()["id"].as_u64().unwrap();
|
||||
let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png();
|
||||
s.execute("file.place", &json!({"base64": crate::cmd::base64_encode(&png), "name": "p.png", "frame": f})).unwrap();
|
||||
s.execute("object.fittingOptions", &json!({"ids": [f], "autoFit": true, "fitting": "fitProportionally", "crop": 10, "align": 0})).unwrap();
|
||||
let graphic = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().graphic().unwrap().clone();
|
||||
let before = graphic(&s);
|
||||
assert_ne!(before.auto_fit, designcraft_doc::Fitting::None);
|
||||
s.execute("object.clearFittingOptions", &json!({"ids": [f]})).unwrap();
|
||||
let g = graphic(&s);
|
||||
assert_eq!(g.auto_fit, designcraft_doc::Fitting::None);
|
||||
assert_eq!(g.crop, [0.0; 4]);
|
||||
assert_eq!(g.fit_align, 4, "the centre reference point");
|
||||
assert_eq!(g.xf, before.xf, "the picture stays where it is");
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let r = s.execute("object.clearFittingOptions", &json!({"ids": [f]})).unwrap();
|
||||
assert_eq!((r["changed"].as_u64(), s.doc().unwrap().history.undo.len()), (Some(0), undo), "already clear: no change, no step");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(graphic(&s).crop, [10.0; 4], "one undo step");
|
||||
s.execute("object.lock", &json!({"ids": [f]})).unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let r = s.execute("object.clearFittingOptions", &json!({"ids": [f]})).unwrap();
|
||||
assert_eq!(r["changed"], 0);
|
||||
assert_eq!(graphic(&s).crop, [10.0; 4], "a locked frame keeps its options");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no empty undo step");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -5538,12 +5772,14 @@ mod effects_command_tests {
|
||||
let it = st.doc.item(ItemId(id)).unwrap();
|
||||
let e = &it.effects;
|
||||
assert!(e.inner_shadow.on && e.inner_shadow.angle == 120.0 && e.inner_shadow.size == 7.0);
|
||||
assert_eq!(e.inner_shadow.mode, designcraft_color::BlendMode::Multiply, "a patch without a mode keeps the default");
|
||||
assert!(e.drop_shadow.on && e.drop_shadow.distance == 10.0 && e.drop_shadow.size == 9.0, "untouched fields stay");
|
||||
assert_eq!((e.feather, it.opacity, it.blend), (4.0, 0.5, designcraft_color::BlendMode::Multiply));
|
||||
// Out of range values are kept in range; garbage changes nothing.
|
||||
s.execute("object.effects", &json!({"ids": [id], "effects": {"outerGlow": {"on": true, "opacity": 3, "spread": 400, "size": -5}}})).unwrap();
|
||||
let og = s.doc().unwrap().doc.item(ItemId(id)).unwrap().effects.outer_glow.clone();
|
||||
assert_eq!((og.opacity, og.spread, og.size), (1.0, 100.0, 0.0));
|
||||
assert_eq!(og.mode, designcraft_color::BlendMode::Screen);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
for bad in [json!({"effects": {"dropShadow": {"distance": "far"}}}), json!({"effects": 5}), json!({"blend": "loud"}), json!({"opacity": "x"})]
|
||||
{
|
||||
|
||||
@@ -203,7 +203,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Package…",
|
||||
["File"],
|
||||
None,
|
||||
"{dir: folder to create, idml?: true, pdf?: false, instructions?: text} — the document (its placed files relinked to Links/), Links/, the font files that draw its text in Document Fonts/ (fallback fonts included; unless their licence restricts it), an IDML copy (linking the files in Links/, nothing embedded), an optional PDF and a report → {dir, files, report}",
|
||||
"{dir: folder to create, idml?: true, pdf?: false|true|{…options of file.exportPdf}, instructions?: text} — the document (its placed files relinked to Links/), Links/, the font files that draw its text in Document Fonts/ (fallback fonts included; unless their licence restricts it), an IDML copy (linking the files in Links/, nothing embedded), an optional PDF and a report → {dir, files, report}",
|
||||
has_doc,
|
||||
package
|
||||
),
|
||||
@@ -390,6 +390,17 @@ fn drawn_faces(cs: &ComposedStory, depth: usize, out: &mut Vec<FaceRef>) {
|
||||
fn package(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let dir = PathBuf::from(str_param(p, "dir").ok_or_else(|| bad("file.package", "missing `dir`"))?);
|
||||
let st = s.doc()?;
|
||||
// The PDF: with the default settings, or with the options of a PDF preset, made as
|
||||
// file.exportPdf makes it. Read first, so that bad options write nothing.
|
||||
let pdf = match p.get("pdf") {
|
||||
None | Some(Value::Null) | Some(Value::Bool(false)) => None,
|
||||
Some(Value::Bool(true)) => Some(json!({})),
|
||||
Some(o @ Value::Object(_)) => {
|
||||
super::export::check_pdf_options(o, st.doc.page_count())?;
|
||||
Some(o.clone())
|
||||
}
|
||||
Some(v) => return Err(bad("file.package", format!("`pdf` is true, false or PDF export options, not {v}"))),
|
||||
};
|
||||
let root = OutputRoot::new(dir.clone())?;
|
||||
let display_title = st.doc.title.clone();
|
||||
let title = safe_component(&display_title, "Untitled");
|
||||
@@ -441,18 +452,18 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
idml_layer_note = "\nPlaced PDF layers\nThe IDML copy links the placed PDFs, so it shows their file defaults. The DesignCraft document keeps each placement's Object Layer Options choice.\n";
|
||||
}
|
||||
}
|
||||
if p.get("pdf").and_then(Value::as_bool).unwrap_or(false) {
|
||||
if let Some(params) = pdf {
|
||||
let path = package_output_path(&dir, &title, "pdf")?;
|
||||
let bytes = designcraft_pdf::export_pdf(&super::interchange::with_pdf_boxes(&packed), &s.cache, &Default::default())
|
||||
.map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
root.write(&path, &bytes)?;
|
||||
root.write(&path, &super::export::pdf_report(&packed, &s.cache, ¶ms)?.bytes)?;
|
||||
files.push(path);
|
||||
}
|
||||
// The report: fonts (missing first), links, preflight, the user's instructions.
|
||||
let mut r = format!("Package report: {display_title}\n\n");
|
||||
// The printing instructions first, as the form gave them; then the report: fonts (missing
|
||||
// first), links, preflight.
|
||||
let mut r = String::new();
|
||||
if let Some(t) = str_param(p, "instructions").filter(|t| !t.trim().is_empty()) {
|
||||
r += &format!("Instructions\n{t}\n\n");
|
||||
}
|
||||
r += &format!("Package report: {display_title}\n\n");
|
||||
r += "Fonts\n";
|
||||
r += &font_lines.concat();
|
||||
if !fallback_lines.is_empty() {
|
||||
|
||||
@@ -25,6 +25,58 @@ fn relocated_packages_rebind_embedded_image_links_on_open() {
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// `pdf` takes file.exportPdf's options (a PDF preset's): a PDF/X-4 package PDF; `true` is the
|
||||
/// default PDF, and bad options are refused.
|
||||
#[test]
|
||||
fn package_pdf_takes_export_options() {
|
||||
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("dc-package-pdf-options-{}-{nonce}", std::process::id()));
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"title": "Options", "width": 72, "height": 72})).unwrap();
|
||||
let x4 = dir.join("x4");
|
||||
s.execute("file.package", &json!({"dir": x4.to_string_lossy(), "idml": false, "pdf": {"standard": "x4"}})).unwrap();
|
||||
let has = |path: &std::path::Path, what: &[u8]| std::fs::read(path).unwrap().windows(what.len()).any(|w| w == what);
|
||||
assert!(has(&x4.join("Options.pdf"), b"GTS_PDFX"));
|
||||
let plain = dir.join("plain");
|
||||
s.execute("file.package", &json!({"dir": plain.to_string_lossy(), "idml": false, "pdf": true})).unwrap();
|
||||
assert!(!has(&plain.join("Options.pdf"), b"GTS_PDFX"));
|
||||
// Refused before anything is written: bad options, a bad flattener or view option, and a
|
||||
// `pdf` that is neither a switch nor options.
|
||||
for bad in [
|
||||
json!({"standard": "nope"}),
|
||||
json!({"flatten": "extreme"}),
|
||||
json!({"pageLayout": "spiral"}),
|
||||
json!({"view": "zoomed"}),
|
||||
json!("print"),
|
||||
json!(3),
|
||||
] {
|
||||
let out = dir.join("bad");
|
||||
assert!(s.execute("file.package", &json!({"dir": out.to_string_lossy(), "pdf": bad})).is_err(), "{bad}");
|
||||
assert!(!out.exists(), "{bad}: bad options write nothing");
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// A preset that flattens transparency (Screen and Email's `flatten: low`) gives the packaged
|
||||
/// PDF the flattening `file.exportPdf` does.
|
||||
#[test]
|
||||
fn the_packaged_pdf_is_flattened_as_export_flattens_it() {
|
||||
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("dc-package-pdf-flatten-{}-{nonce}", std::process::id()));
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"title": "Flat", "width": 144, "height": 144})).unwrap();
|
||||
let id = s.execute("frame.create", &json!({"rect": [10, 10, 100, 100], "content": "unassigned"})).unwrap()["id"].clone();
|
||||
s.execute("object.fill", &json!({"ids": [id], "swatch": "[Black]"})).unwrap();
|
||||
s.execute("object.opacity", &json!({"ids": [id], "opacity": 0.5})).unwrap();
|
||||
let exported = super::super::base64_decode(s.execute("file.exportPdf", &json!({"flatten": "low"})).unwrap()["base64"].as_str().unwrap());
|
||||
s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false, "pdf": {"flatten": "low"}})).unwrap();
|
||||
let packaged = std::fs::read(dir.join("Flat.pdf")).unwrap();
|
||||
let images = |pdf: &[u8]| pdf.windows(b"/Subtype/Image".len()).filter(|w| *w == b"/Subtype/Image").count();
|
||||
assert!(images(&exported) > 0, "the export rasterises the transparent object");
|
||||
assert_eq!(images(&packaged), images(&exported), "so does the package");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// A per-placement PDF layer choice cannot travel in an IDML copy that links its files, so the
|
||||
/// report says the copies differ instead of leaving it to be discovered.
|
||||
#[test]
|
||||
@@ -169,6 +221,7 @@ fn package_writes_document_links_and_report() {
|
||||
assert!(out.join("Links").join("logo.svg").exists());
|
||||
let report = std::fs::read_to_string(out.join("Instructions.txt")).unwrap();
|
||||
assert!(report.contains("Print on matte.") && report.contains("logo.svg"), "{report}");
|
||||
assert!(report.find("Print on matte.") < report.find("Package report:"), "the instructions come first: {report}");
|
||||
assert!(report.contains("Preflight:"), "{report}");
|
||||
// The packaged document links to its Links folder.
|
||||
let doc_file = files.iter().find(|f| f.ends_with(".designcraft")).unwrap();
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use designcraft_doc::{Columns, LiquidRule, PageSide, SpreadId};
|
||||
use designcraft_geom::Rect;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn parent_id(s: &Session, index: usize) -> u64 {
|
||||
s.doc().unwrap().doc.parents[index].id.0
|
||||
}
|
||||
|
||||
fn parent_page(s: &Session, index: usize, page: usize) -> designcraft_doc::Page {
|
||||
s.doc().unwrap().doc.parents[index].pages[page].clone()
|
||||
}
|
||||
|
||||
fn parent_frame(s: &mut Session, x: f64) -> u64 {
|
||||
s.execute(
|
||||
"frame.create",
|
||||
&json!({
|
||||
"spread": {"kind": "parent", "index": 0},
|
||||
"rect": [x, 80.0, x + 100.0, 180.0]
|
||||
}),
|
||||
)
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn bounds(s: &Session, id: u64) -> Rect {
|
||||
s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds()
|
||||
}
|
||||
|
||||
fn guide_positions(s: &Session, page: usize) -> Vec<f64> {
|
||||
s.doc().unwrap().doc.parents[0].pages[page].guides.iter().map(|g| g.position).collect()
|
||||
}
|
||||
|
||||
fn prepare_parent_pages(s: &mut Session, binding: &str, rule: LiquidRule) -> (u64, u64, u64, usize, usize) {
|
||||
s.execute("file.new", &json!({"pages": 2, "facingPages": true, "width": 400.0, "height": 400.0})).unwrap();
|
||||
s.execute("layout.documentSetup", &json!({"binding": binding})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.settings.right_to_left_binding, binding == "rightToLeft");
|
||||
assert_eq!(s.doc().unwrap().doc.parents[0].pages[0].side, if binding == "rightToLeft" { PageSide::Right } else { PageSide::Left });
|
||||
let target = s.doc().unwrap().doc.parents[0].pages.iter().position(|p| p.side == PageSide::Right).unwrap();
|
||||
let other = 1 - target;
|
||||
let target_page = parent_page(s, 0, target);
|
||||
let other_page = parent_page(s, 0, other);
|
||||
let target_item = parent_frame(s, target_page.x + 80.0);
|
||||
let other_item = parent_frame(s, other_page.x + 80.0);
|
||||
s.execute(
|
||||
"guide.add",
|
||||
&json!({"spread": {"kind": "parent", "index": 0}, "page": target, "orientation": "vertical", "position": target_page.x + 120.0}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute(
|
||||
"guide.add",
|
||||
&json!({"spread": {"kind": "parent", "index": 0}, "page": target, "orientation": "horizontal", "position": 120.0, "at": target_page.x + 10.0}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute(
|
||||
"guide.add",
|
||||
&json!({"spread": {"kind": "parent", "index": 0}, "page": other, "orientation": "vertical", "position": other_page.x + 120.0}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute(
|
||||
"guide.add",
|
||||
&json!({"spread": {"kind": "parent", "index": 0}, "page": other, "orientation": "horizontal", "position": 120.0, "at": other_page.x + 10.0}),
|
||||
)
|
||||
.unwrap();
|
||||
s.edit(|d, _| {
|
||||
let sp = Arc::make_mut(&mut d.parents[0]);
|
||||
let positions = |page: &designcraft_doc::Page| {
|
||||
let w = page.margin_rect().width();
|
||||
vec![0.0, 100.0, 120.0, w]
|
||||
};
|
||||
for page in &mut sp.pages {
|
||||
page.columns = Columns { count: 2, gutter: 20.0, positions: Some(positions(page)) };
|
||||
}
|
||||
sp.pages[target].liquid = rule;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
(parent_id(s, 0), target_item, other_item, target, other)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_changes_only_the_target_page_in_ltr_and_rtl() {
|
||||
for binding in ["leftToRight", "rightToLeft"] {
|
||||
for rule in [
|
||||
LiquidRule::Off,
|
||||
LiquidRule::Scale,
|
||||
LiquidRule::ReCenter,
|
||||
LiquidRule::ObjectBased,
|
||||
LiquidRule::GuideBased,
|
||||
LiquidRule::ControlledByParent,
|
||||
] {
|
||||
let mut s = Session::new();
|
||||
let (parent, target_item, other_item, target, other) = prepare_parent_pages(&mut s, binding, rule);
|
||||
let before_target = parent_page(&s, 0, target);
|
||||
let before_other = parent_page(&s, 0, other);
|
||||
let before_target_item = bounds(&s, target_item);
|
||||
let before_other_item = bounds(&s, other_item);
|
||||
let before_target_guides = guide_positions(&s, target);
|
||||
let before_other_guides = guide_positions(&s, other);
|
||||
let before_target_columns = before_target.columns.positions.clone();
|
||||
let before_other_columns = before_other.columns.positions.clone();
|
||||
|
||||
s.execute("layout.selectParentPage", &json!({"parent": parent, "page": target})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.parent_page, Some((SpreadId(parent), target)));
|
||||
assert!(s.doc().unwrap().selection.items.is_empty());
|
||||
s.execute("layout.parentPageSize", &json!({"parent": parent, "page": target, "width": 500.0, "height": 500.0, "anchor": 8})).unwrap();
|
||||
|
||||
let after_target = parent_page(&s, 0, target);
|
||||
let after_other = parent_page(&s, 0, other);
|
||||
assert_eq!((after_target.width, after_target.height), (500.0, 500.0), "{binding} {rule:?}");
|
||||
assert_eq!((after_other.width, after_other.height), (before_other.width, before_other.height), "{binding} {rule:?}");
|
||||
assert_ne!(bounds(&s, target_item), before_target_item, "target object did not follow the resize: {binding} {rule:?}");
|
||||
assert_eq!(bounds(&s, other_item), before_other_item, "other page object moved: {binding} {rule:?}");
|
||||
assert_ne!(guide_positions(&s, target), before_target_guides, "target guides did not follow the resize: {binding} {rule:?}");
|
||||
assert_eq!(guide_positions(&s, other), before_other_guides, "other page guides moved: {binding} {rule:?}");
|
||||
assert_ne!(after_target.columns.positions, before_target_columns, "target columns did not scale: {binding} {rule:?}");
|
||||
assert_eq!(after_other.columns.positions, before_other_columns, "other page columns changed: {binding} {rule:?}");
|
||||
assert_eq!(s.doc().unwrap().doc.settings.page_width, 400.0);
|
||||
assert_eq!(s.doc().unwrap().doc.settings.page_height, 400.0);
|
||||
s.doc().unwrap().doc.check().unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_is_independent_of_applied_and_based_on_parents_and_undoable() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 1, "facingPages": false, "width": 400.0, "height": 400.0})).unwrap();
|
||||
let a = parent_id(&s, 0);
|
||||
let b = s.execute("layout.parents.new", &json!({"prefix": "B", "name": "Child"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("layout.parents.options", &json!({"parent": b, "basedOn": a})).unwrap();
|
||||
s.execute("layout.pages.applyParent", &json!({"pages": [0], "parent": b})).unwrap();
|
||||
let before = s.doc().unwrap().doc.as_ref().clone();
|
||||
let undo_before = s.doc().unwrap().history.undo.len();
|
||||
|
||||
s.execute("layout.parentPageSize", &json!({"parent": a, "page": 0, "width": 0.5, "anchor": 0})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.parents[0].pages[0].width, 0.5);
|
||||
assert_eq!(d.parents[1].pages[0].width, before.parents[1].pages[0].width);
|
||||
assert_eq!(d.spreads[0].pages[0].width, before.spreads[0].pages[0].width);
|
||||
assert_eq!(d.parents[1].parent.as_ref().unwrap().based_on, Some(SpreadId(a)));
|
||||
assert_eq!(d.settings.page_width, before.settings.page_width);
|
||||
d.check().unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo_before + 1);
|
||||
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.parents[0].pages[0].width, before.parents[0].pages[0].width);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.parents[0].pages[0].width, 0.5);
|
||||
s.doc().unwrap().doc.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_survives_native_and_idml_round_trips() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 1, "facingPages": false, "width": 400.0, "height": 400.0})).unwrap();
|
||||
let a = parent_id(&s, 0);
|
||||
s.execute("layout.parentPageSize", &json!({"parent": a, "page": 0, "width": 0.5, "height": 0.75})).unwrap();
|
||||
let d = s.doc().unwrap().doc.as_ref().clone();
|
||||
d.check().unwrap();
|
||||
|
||||
let native = crate::cmd::from_bytes(&crate::cmd::to_bytes(&d).unwrap()).unwrap();
|
||||
assert_eq!((native.parents[0].pages[0].width, native.parents[0].pages[0].height), (0.5, 0.75));
|
||||
native.check().unwrap();
|
||||
|
||||
let idml = designcraft_idml::export_idml(&d);
|
||||
let reopened = designcraft_idml::import_idml(&idml).unwrap();
|
||||
assert_eq!((reopened.parents[0].pages[0].width, reopened.parents[0].pages[0].height), (0.5, 0.75));
|
||||
reopened.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_rejects_invalid_inputs_atomically() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap();
|
||||
let parent = parent_id(&s, 0);
|
||||
let before = s.doc().unwrap().doc.as_ref().clone();
|
||||
let undo_before = s.doc().unwrap().history.undo.len();
|
||||
let invalid = [
|
||||
json!({"parent": parent, "page": 0, "width": null}),
|
||||
json!({"parent": parent, "page": 0, "width": "400"}),
|
||||
json!({"parent": parent, "page": 0, "width": "NaN"}),
|
||||
json!({"parent": parent, "page": 0, "width": "Infinity"}),
|
||||
json!({"parent": parent, "page": 0, "width": 0}),
|
||||
json!({"parent": parent, "page": 0, "width": -1}),
|
||||
json!({"parent": parent, "page": 0, "width": 15552.1}),
|
||||
json!({"parent": parent, "page": 0, "width": 400, "anchor": 9}),
|
||||
json!({"parent": parent, "page": 1, "width": 400}),
|
||||
json!({"parent": 999_999, "page": 0, "width": 400}),
|
||||
];
|
||||
for params in invalid {
|
||||
assert!(s.execute("layout.parentPageSize", ¶ms).is_err(), "accepted {params}");
|
||||
assert_eq!(s.doc().unwrap().doc.as_ref(), &before, "mutated after {params}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo_before, "added undo after {params}");
|
||||
}
|
||||
assert!(s.execute("layout.parentPageSize", &json!({"parent": parent, "page": 0})).is_err());
|
||||
assert_eq!(s.doc().unwrap().doc.as_ref(), &before);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo_before);
|
||||
s.doc().unwrap().doc.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_and_document_page_selection_validate_and_clear_each_other() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap();
|
||||
let parent = parent_id(&s, 0);
|
||||
let item = s.execute("frame.create", &json!({"rect": [40, 40, 100, 100]})).unwrap()["id"].as_u64().unwrap();
|
||||
assert!(!s.doc().unwrap().selection.items.is_empty());
|
||||
s.execute("layout.selectParentPage", &json!({"parent": parent, "page": 0})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.parent_page, Some((SpreadId(parent), 0)));
|
||||
assert!(s.doc().unwrap().selection.items.is_empty());
|
||||
assert!(s.execute("layout.selectParentPage", &json!({"parent": parent, "page": 1})).is_err());
|
||||
assert_eq!(s.doc().unwrap().selection.parent_page, Some((SpreadId(parent), 0)));
|
||||
s.execute("layout.selectPage", &json!({"page": 1, "ids": [item]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.page, Some(0));
|
||||
assert_eq!(s.doc().unwrap().selection.parent_page, None);
|
||||
s.execute("layout.selectParentPage", &json!({"parent": null})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection, Default::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_repeated_edits_keep_the_spine_and_other_page_objects() {
|
||||
for binding in ["leftToRight", "rightToLeft"] {
|
||||
for pi in 0..2 {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width":400,"height":600,"pages":3})).unwrap();
|
||||
s.execute("layout.documentSetup", &json!({"binding":binding})).unwrap();
|
||||
let id = parent_id(&s, 0);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let spine = before.parents[0].spine_x();
|
||||
let other = 1 - pi;
|
||||
let ox = before.parents[0].pages[other].x + 80.0;
|
||||
let object = parent_frame(&mut s, ox);
|
||||
let old_object = bounds(&s, object);
|
||||
for params in
|
||||
[json!({"parent":id,"page":pi,"width":500}), json!({"parent":id,"page":pi,"height":700}), json!({"parent":id,"page":pi,"width":550})]
|
||||
{
|
||||
s.execute("layout.parentPageSize", ¶ms).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.parents[0].spine_x(), spine, "spine moved: {binding}, page {pi}, {params}");
|
||||
assert_eq!(bounds(&s, object), old_object, "other-page object moved: {binding}, page {pi}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_keeps_margin_and_column_inheritance_without_resizing_applied_pages() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width":400,"height":600,"pages":3})).unwrap();
|
||||
let id = parent_id(&s, 0);
|
||||
s.edit(|d, _| {
|
||||
let pg = &mut Arc::make_mut(&mut d.parents[0]).pages[1];
|
||||
pg.columns = Columns { count: 2, gutter: 20.0, positions: Some(vec![0.0, 100.0, 120.0, 328.0]) };
|
||||
d.sync_parent_margins();
|
||||
Arc::make_mut(&mut d.spreads[1]).pages[1].local_margins = true;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
s.execute("layout.parentPageSize", &json!({"parent":id,"page":1,"width":500})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.page(0).unwrap().columns, d.parents[0].pages[1].columns);
|
||||
assert_eq!(d.page(0).unwrap().margins, d.parents[0].pages[1].margins);
|
||||
assert_eq!((d.page(0).unwrap().width, d.page(0).unwrap().height), (400.0, 600.0));
|
||||
assert_eq!(d.page(2).unwrap(), before.page(2).unwrap());
|
||||
d.check().unwrap();
|
||||
s.execute("layout.pages.applyParent", &json!({"pages":[0],"parent":null})).unwrap();
|
||||
s.execute("layout.pages.applyParent", &json!({"pages":[0],"parent":id})).unwrap();
|
||||
assert_eq!((s.doc().unwrap().doc.page(0).unwrap().width, s.doc().unwrap().doc.page(0).unwrap().height), (400.0, 600.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_facing_sizes_survive_idml_in_both_bindings() {
|
||||
for binding in ["leftToRight", "rightToLeft"] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width":400,"height":600,"pages":3,"facingPages":true})).unwrap();
|
||||
s.execute("layout.documentSetup", &json!({"binding":binding})).unwrap();
|
||||
let id = parent_id(&s, 0);
|
||||
for (pi, w, h) in [(0, 500, 700), (1, 300, 550)] {
|
||||
s.execute("layout.parentPageSize", &json!({"parent":id,"page":pi,"width":w,"height":h})).unwrap();
|
||||
}
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let reopened = designcraft_idml::import_idml(&designcraft_idml::export_idml(d)).unwrap();
|
||||
assert_eq!(reopened.settings.right_to_left_binding, d.settings.right_to_left_binding);
|
||||
for page in &d.parents[0].pages {
|
||||
let restored = reopened.parents[0].pages.iter().find(|p| p.side == page.side).unwrap();
|
||||
assert_eq!((restored.width, restored.height), (page.width, page.height), "{binding}, {:?}", page.side);
|
||||
}
|
||||
for abs in 0..d.page_count() {
|
||||
let (old, new) = (d.page(abs).unwrap(), reopened.page(abs).unwrap());
|
||||
assert_eq!((new.width, new.height), (old.width, old.height));
|
||||
}
|
||||
reopened.check().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
//! until the whole story fits (InDesign's Shift-click with a loaded text cursor).
|
||||
|
||||
use designcraft_doc::{
|
||||
CharacterStyle, Content, Document, ParaFormat, ParagraphStyle, SpreadRef, Story, StoryId, TextDirection, TextFileLink, TextSel,
|
||||
CharacterStyle, Content, Document, ParaFormat, ParagraphStyle, SpreadRef, StartParagraph, Story, StoryId, TextDirection, TextFileLink, TextSel,
|
||||
};
|
||||
use designcraft_geom::Rect;
|
||||
use serde_json::{Value, json};
|
||||
@@ -197,21 +197,60 @@ pub(crate) fn reimport(
|
||||
Ok(imp.warnings)
|
||||
}
|
||||
|
||||
/// Is the story overset in its frames, and how much text do its full frames hold (bytes, frames)?
|
||||
/// Is the story overset in its frames, how much text do its full frames hold (bytes, frames), and
|
||||
/// how many frames does the overset text start on its own (breaks, Start Paragraph options)?
|
||||
/// A frame a column, frame or page break ends early is left out: it says nothing about how much
|
||||
/// a frame holds.
|
||||
fn fit(d: &Document, sid: StoryId) -> (Option<usize>, usize, usize) {
|
||||
/// a frame holds. So is one a Start Paragraph option ends early (#1209).
|
||||
fn fit(d: &Document, sid: StoryId) -> (Option<usize>, usize, usize, usize) {
|
||||
let cs = designcraft_compose::compose_story(d, sid, &Default::default());
|
||||
let text = d.story(sid).map_or("", |s| s.text.as_str());
|
||||
let Some(story) = d.story(sid) else { return (cs.overset_at, 0, 0, 0) };
|
||||
let text = story.text.as_str();
|
||||
let ranges = story.para_ranges();
|
||||
let deleted = designcraft_compose::deleted_paras(story, &ranges);
|
||||
let placed = |p: usize| !deleted.get(p).copied().unwrap_or(false);
|
||||
let starts = |p: usize| placed(p) && story.paras.get(p).is_some_and(|pf| d.styles.resolve_para(pf).0.start_paragraph != StartParagraph::Anywhere);
|
||||
// A frame's range stops before the paragraph's return; composing skips deleted paragraphs.
|
||||
let moved_on = |end: usize| {
|
||||
let next = if text.as_bytes().get(end) == Some(&b'\n') { end.saturating_add(1) } else { end };
|
||||
next.checked_sub(1).is_some_and(|i| text.as_bytes().get(i) == Some(&b'\n'))
|
||||
&& (story.para_at(next)..story.paras.len()).find(|&p| placed(p)).is_some_and(starts)
|
||||
};
|
||||
let (mut held, mut full) = (0, 0);
|
||||
for f in &cs.frames {
|
||||
let Some(shown) = text.get(f.range.clone()) else { continue };
|
||||
if !shown.is_empty() && !shown.chars().any(designcraft_doc::is_break_char) {
|
||||
if !shown.is_empty() && !shown.chars().any(designcraft_doc::is_break_char) && !moved_on(f.range.end) {
|
||||
held += shown.len();
|
||||
full += 1;
|
||||
}
|
||||
}
|
||||
(cs.overset_at, held, full)
|
||||
let forced = cs.overset_at.map_or(0, |at| {
|
||||
let first = story.para_at(at);
|
||||
// The paragraph the overset text starts with counts when it starts there.
|
||||
let from = if at.checked_sub(1).is_none_or(|i| text.as_bytes().get(i) == Some(&b'\n')) { first } else { first.saturating_add(1) };
|
||||
// Once per paragraph: after a break, its start option only fixes odd or even page parity.
|
||||
let sent = (from..ranges.len())
|
||||
.filter(|&p| starts(p) || (placed(p) && designcraft_compose::previous_visible_break(d, story, &ranges, &deleted, p).is_some()))
|
||||
.count();
|
||||
// A break inside a paragraph moves the rest of it.
|
||||
let inside: usize = (first..ranges.len())
|
||||
.filter(|&p| placed(p))
|
||||
.filter_map(|p| Some((p, ranges.get(p)?.clone())))
|
||||
.map(|(p, r)| {
|
||||
let Some(t) = text.get(r.clone()) else { return 0 };
|
||||
let last = t.char_indices().next_back().map(|(i, _)| i);
|
||||
t.char_indices()
|
||||
.filter(|&(i, c)| {
|
||||
designcraft_doc::is_break_char(c)
|
||||
&& Some(i) != last
|
||||
&& r.start.saturating_add(i) >= at
|
||||
&& designcraft_compose::visible_break(d, story, p, r.start.saturating_add(i)).is_some()
|
||||
})
|
||||
.count()
|
||||
})
|
||||
.sum();
|
||||
sent.saturating_add(inside)
|
||||
});
|
||||
(cs.overset_at, held, full, forced)
|
||||
}
|
||||
|
||||
/// The frames autoflow sets on a page: one per column inside the margins, in the order a story
|
||||
@@ -256,12 +295,13 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s
|
||||
// The next page of the document autoflow may set frames on; it only moves forward.
|
||||
let mut following = 0;
|
||||
loop {
|
||||
let (overset, held, full) = fit(d, sid);
|
||||
let (overset, held, full, forced) = fit(d, sid);
|
||||
let Some(at) = overset else { break };
|
||||
let len = d.story(sid).map_or(0, |s| s.len());
|
||||
// Frames still needed at the rate the full ones are filled (at least one); with no full
|
||||
// frame to go by yet, one more.
|
||||
let want = held.checked_div(full).map_or(1, |per_frame| len.saturating_sub(at).div_ceil(per_frame.max(1))).max(1);
|
||||
// Frames still needed at the rate the full ones are filled, plus one per frame the text
|
||||
// starts on its own (at least one); with neither to go by yet, one more.
|
||||
let rate = held.checked_div(full).map_or(0, |per_frame| len.saturating_sub(at).div_ceil(per_frame.max(1)));
|
||||
let want = rate.saturating_add(forced).max(1);
|
||||
let last = *d.story(sid).and_then(|s| s.frames.last()).ok_or_else(|| bad(cmd, "the story has no frame"))?;
|
||||
let page = d.page_of_item(last).unwrap_or(d.page_count().saturating_sub(1));
|
||||
let layer = d.default_layer();
|
||||
|
||||
@@ -307,3 +307,117 @@ fn autoflow_adds_the_pages_odd_and_even_page_breaks_ask_for() {
|
||||
assert_eq!(designcraft_compose::compose_story(&d, sid, &Default::default()).overset_at, None, "{brk:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autoflow_sizes_no_frame_by_one_a_start_option_ends_early() {
|
||||
// Regression (#1209): the title frame a start option ends early isn't a frame's worth of text.
|
||||
use std::io::Write;
|
||||
const W: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#;
|
||||
let para = |style: &str, text: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{style}"/></w:pPr><w:r><w:t>{text}</w:t></w:r></w:p>"#);
|
||||
let body = [para("Title", "Front"), para("Chapter", "Chapter one"), para("Body", "lorem ipsum dolor sit amet ".repeat(60).trim())].concat();
|
||||
let styles: String = ["Title", "Chapter", "Body"]
|
||||
.iter()
|
||||
.map(|s| format!(r#"<w:style w:type="paragraph" w:styleId="{s}"><w:name w:val="{s}"/></w:style>"#))
|
||||
.collect();
|
||||
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default();
|
||||
for (n, c) in [
|
||||
("word/document.xml", format!(r#"<w:document {W}><w:body>{body}</w:body></w:document>"#)),
|
||||
("word/styles.xml", format!(r#"<w:styles {W}>{styles}</w:styles>"#)),
|
||||
] {
|
||||
w.start_file(n, o).unwrap();
|
||||
w.write_all(c.as_bytes()).unwrap();
|
||||
}
|
||||
let docx = w.finish().unwrap().into_inner();
|
||||
for (start, pages) in [("nextOddPage", 5), ("nextEvenPage", 4)] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 3, "facingPages": true})).unwrap();
|
||||
s.execute("style.paragraph.create", &json!({"name": "Chapter", "para": {"startParagraph": start}})).unwrap();
|
||||
let ids = s.doc().unwrap().doc.next_id;
|
||||
let r = s
|
||||
.execute("file.place", &json!({"base64": super::super::file::base64_encode(&docx), "name": "front.docx", "page": 3, "autoflow": true}))
|
||||
.unwrap();
|
||||
assert_eq!(r["overset"], false, "{start}: {r}");
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.page_count(), pages, "{start}: {r}");
|
||||
// A few ids per page it keeps, not hundreds for pages it builds and removes.
|
||||
assert!(d.next_id - ids < 100, "{start}: {} ids", d.next_id - ids);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autoflow_looks_past_a_deleted_paragraph_for_the_start_option() {
|
||||
// The title frame ends before a tracked deletion; the chapter after it starts on an odd page.
|
||||
use designcraft_doc::{ChangeMark, build::NewDocument};
|
||||
let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, primary_text_frame: true, ..Default::default() });
|
||||
let sid = d.settings.primary_story.unwrap();
|
||||
let st = d.story_mut(sid).unwrap();
|
||||
st.insert(0, &format!("Front\nGone\nChapter one\n{}", "lorem ipsum dolor sit amet ".repeat(400).trim()));
|
||||
st.paras[2].para.start_paragraph = Some(StartParagraph::NextOddPage);
|
||||
st.format_chars(6..11, |f| f.over.change = Some(ChangeMark::Deleted));
|
||||
let ids = d.next_id;
|
||||
assert_eq!(autoflow(&mut d, sid, 2000, "file.place", false).unwrap(), 4);
|
||||
assert_eq!(designcraft_compose::compose_story(&d, sid, &Default::default()).overset_at, None);
|
||||
assert!(d.next_id - ids < 100, "{} ids", d.next_id - ids);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autoflow_asks_for_every_frame_the_start_options_ask_for_at_once() {
|
||||
// A paragraph per page: no frame sets a fill rate, yet one round shouldn't add one frame.
|
||||
use designcraft_doc::build::NewDocument;
|
||||
let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, primary_text_frame: true, ..Default::default() });
|
||||
let sid = d.settings.primary_story.unwrap();
|
||||
let st = d.story_mut(sid).unwrap();
|
||||
st.insert(0, (0..200).map(|i| format!("Chapter {i}\n")).collect::<String>().trim_end());
|
||||
for p in &mut st.paras {
|
||||
p.para.start_paragraph = Some(StartParagraph::NextPage);
|
||||
}
|
||||
let ids = d.next_id;
|
||||
assert_eq!(autoflow(&mut d, sid, 2000, "file.place", false).unwrap(), 199);
|
||||
assert_eq!(designcraft_compose::compose_story(&d, sid, &Default::default()).overset_at, None);
|
||||
assert!(d.next_id - ids < 2000, "{} ids", d.next_id - ids);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autoflow_asks_no_frame_for_a_deleted_or_hidden_break() {
|
||||
// Composing skips breaks in the overset text that tracked changes delete or a condition hides.
|
||||
use designcraft_doc::{ChangeMark, Condition, PAGE_BREAK, build::NewDocument};
|
||||
for hide in [false, true] {
|
||||
let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, primary_text_frame: true, ..Default::default() });
|
||||
d.conditions.push(Condition { name: "Hidden".into(), color: [0, 0, 0], visible: false });
|
||||
let sid = d.settings.primary_story.unwrap();
|
||||
let st = d.story_mut(sid).unwrap();
|
||||
let body = "lorem ipsum dolor sit amet ".repeat(300);
|
||||
let gone = format!("x{PAGE_BREAK}\n").repeat(200);
|
||||
st.insert(0, &format!("{}\n{gone}{}", body.trim(), body.trim()));
|
||||
let at = body.trim().len() + 1;
|
||||
if hide {
|
||||
for (i, _) in gone.match_indices(PAGE_BREAK) {
|
||||
st.format_chars(at + i..at + i + PAGE_BREAK.len_utf8(), |f| f.over.conditions = Some(vec!["Hidden".into()]));
|
||||
}
|
||||
} else {
|
||||
st.format_chars(at..at + gone.len(), |f| f.over.change = Some(ChangeMark::Deleted));
|
||||
}
|
||||
let ids = d.next_id;
|
||||
autoflow(&mut d, sid, 2000, "file.place", false).unwrap();
|
||||
assert_eq!(designcraft_compose::compose_story(&d, sid, &Default::default()).overset_at, None, "hide {hide}");
|
||||
assert!(d.next_id - ids < 100, "hide {hide}: {} pages, {} ids", d.page_count(), d.next_id - ids);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autoflow_counts_a_break_and_the_start_option_it_meets_once() {
|
||||
// Each chapter ends in a page break and starts on the next page: one new page each, not two.
|
||||
use designcraft_doc::{PAGE_BREAK, build::NewDocument};
|
||||
let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, primary_text_frame: true, ..Default::default() });
|
||||
let sid = d.settings.primary_story.unwrap();
|
||||
let st = d.story_mut(sid).unwrap();
|
||||
st.insert(0, (0..200).map(|i| format!("Chapter {i}{PAGE_BREAK}\n")).collect::<String>().trim_end());
|
||||
for p in &mut st.paras {
|
||||
p.para.start_paragraph = Some(StartParagraph::NextPage);
|
||||
}
|
||||
let ids = d.next_id;
|
||||
assert_eq!(autoflow(&mut d, sid, 2000, "file.place", false).unwrap(), 199);
|
||||
assert_eq!(designcraft_compose::compose_story(&d, sid, &Default::default()).overset_at, None);
|
||||
assert!(d.next_id - ids < 2000, "{} ids", d.next_id - ids);
|
||||
}
|
||||
@@ -38,6 +38,25 @@ fn page_of(d: &Document, sr: SpreadRef, it: &Item, parent: Affine) -> Option<usi
|
||||
Some(d.first_page_of_spread(si) + sp.page_at_x(bounds.center().x).unwrap_or(0))
|
||||
}
|
||||
|
||||
/// Every page of the spread the frame reaches, not just the one its centre is on.
|
||||
///
|
||||
/// A sheet draws the items of its spread without testing which page they sit on, so a frame that
|
||||
/// straddles two pages is drawn, in part, on both. Choosing by the centre alone dropped the
|
||||
/// warning for a frame whose centre is on a page the export leaves out while the part on an
|
||||
/// exported page went into the file (noted in review). Empty when the frame is on no document
|
||||
/// spread, which is the same answer `page_of` gives.
|
||||
fn pages_of(d: &Document, sr: SpreadRef, it: &Item, parent: Affine) -> Vec<usize> {
|
||||
let SpreadRef::Doc(si) = sr else { return vec![] };
|
||||
let Some(sp) = d.spreads.get(si) else { return vec![] };
|
||||
let bounds = parent.transform_rect_bbox(it.bounds());
|
||||
let first = d.first_page_of_spread(si);
|
||||
let touched: Vec<usize> =
|
||||
sp.pages.iter().enumerate().filter(|(_, pg)| bounds.x1 > pg.x && bounds.x0 < pg.x + pg.width).map(|(i, _)| first + i).collect();
|
||||
// A frame wholly on the pasteboard touches no page's span; it still belongs to the page
|
||||
// `page_at_x` nominates, so the two functions agree about where it is.
|
||||
if touched.is_empty() { page_of(d, sr, it, parent).into_iter().collect() } else { touched }
|
||||
}
|
||||
|
||||
/// Characters a missing-glyph report names before "and N more".
|
||||
const MISSING_GLYPHS_LISTED: usize = 20;
|
||||
|
||||
@@ -74,11 +93,19 @@ fn missing_glyphs(
|
||||
}
|
||||
}
|
||||
|
||||
/// Locate an embedded frame at the outer page frame that actually renders it.
|
||||
fn overset_location(s: &Session, target: designcraft_doc::ItemId) -> Option<(designcraft_doc::ItemId, Option<usize>)> {
|
||||
/// Locate an embedded frame at the outer page frame that actually renders it: the frame, the page
|
||||
/// its centre is on, and every page it reaches (see [`pages_of`]).
|
||||
type Located = (designcraft_doc::ItemId, Option<usize>, Vec<usize>);
|
||||
|
||||
fn overset_location(s: &Session, target: designcraft_doc::ItemId) -> Option<Located> {
|
||||
let d = &s.active()?.doc;
|
||||
let at = |id: designcraft_doc::ItemId, loc: &designcraft_doc::ItemLoc| {
|
||||
let it = d.item(id);
|
||||
let xf = d.parent_xf(loc);
|
||||
(id, it.and_then(|it| page_of(d, loc.spread, it, xf)), it.map(|it| pages_of(d, loc.spread, it, xf)).unwrap_or_default())
|
||||
};
|
||||
if let Some(loc) = d.find(target) {
|
||||
return Some((target, d.item(target).and_then(|it| page_of(d, loc.spread, it, d.parent_xf(&loc)))));
|
||||
return Some(at(target, &loc));
|
||||
}
|
||||
fn contains(root: &designcraft_compose::FrameText, target: designcraft_doc::ItemId) -> bool {
|
||||
let mut frames = vec![root];
|
||||
@@ -107,12 +134,131 @@ fn overset_location(s: &Session, target: designcraft_doc::ItemId) -> Option<(des
|
||||
let frame = cs.frames.iter().find(|f| contains(f, current))?;
|
||||
current = frame.frame;
|
||||
if let Some(loc) = d.find(current) {
|
||||
return Some((current, d.item(current).and_then(|it| page_of(d, loc.spread, it, d.parent_xf(&loc)))));
|
||||
return Some(at(current, &loc));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Text that is in the document but not in a page-based export (PDF, PowerPoint): the overset
|
||||
/// part of a story, and a story with no frame at all. The export used to write the file and
|
||||
/// report nothing, so whole sentences left an imported document without a hint anywhere (#1465).
|
||||
///
|
||||
/// One line per story, naming the page and the frame as the preflight issue does. `pages` are the
|
||||
/// 0-based pages being exported; a story whose overset is on a page that is not being exported is
|
||||
/// not reported, and one with no page of its own always is. A reflowable export (EPUB, HTML) or a
|
||||
/// text export carries the whole story, so neither calls this.
|
||||
/// Pages a single warning names before "and N more".
|
||||
const MAX_WARNED_PAGES: usize = 10;
|
||||
|
||||
/// How the pages one warning covers are named: "page 3", "pages 3, 4", "pages 3, 4 and 6 more".
|
||||
fn page_list(pages: &[usize]) -> String {
|
||||
let shown: Vec<String> = pages.iter().take(MAX_WARNED_PAGES).map(|p| (p + 1).to_string()).collect();
|
||||
let rest = pages.len() - shown.len();
|
||||
let list = shown.join(", ");
|
||||
match (pages.len(), rest) {
|
||||
(1, _) => format!("page {list}"),
|
||||
(_, 0) => format!("pages {list}"),
|
||||
_ => format!("pages {list} and {rest} more"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The parent spread and the page of it a frame sits on, or `None` for a frame on a document page.
|
||||
fn parent_page_of(d: &Document, target: designcraft_doc::ItemId) -> Option<(usize, usize)> {
|
||||
let loc = d.find(target)?;
|
||||
let SpreadRef::Parent(pi) = loc.spread else { return None };
|
||||
let bounds = d.parent_xf(&loc).transform_rect_bbox(d.item(target)?.bounds());
|
||||
Some((pi, d.spread(loc.spread)?.page_at_x(bounds.center().x).unwrap_or(0)))
|
||||
}
|
||||
|
||||
/// Document pages that draw parent page `pp` of parent spread `pi`, in document order.
|
||||
fn pages_showing(d: &Document, (pi, pp): (usize, usize)) -> Vec<usize> {
|
||||
(0..d.page_count())
|
||||
.filter(|p| d.page_loc(*p).is_some_and(|(si, pgi)| d.shown_parent_pages(SpreadRef::Doc(si), pgi).contains(&(pi, pp))))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Characters of `story` that `cs` had no room for.
|
||||
fn overset_chars(story: &designcraft_doc::Story, cs: &ComposedStory) -> usize {
|
||||
story.text.get(cs.overset_at.unwrap_or(0)..).unwrap_or("").chars().count()
|
||||
}
|
||||
|
||||
pub fn overset_warnings(s: &Session, pages: Option<&[usize]>) -> Vec<String> {
|
||||
let Some(st) = s.active() else { return vec![] };
|
||||
let d = &st.doc;
|
||||
let mut out = Vec::new();
|
||||
for story in d.stories.values() {
|
||||
let last = story.frames.last().copied();
|
||||
// A story on a parent page is composed once per page that shows it: its page number
|
||||
// markers and variables are that page's, so the same frame can fit on one page and
|
||||
// overset on another, and a parent page no exported page uses puts nothing in the file
|
||||
// to be missing. Composing it once with no page reported neither (noted in review).
|
||||
if let Some(parent) = last.and_then(|f| parent_page_of(d, f)) {
|
||||
let mut by_count: std::collections::BTreeMap<usize, Vec<usize>> = std::collections::BTreeMap::new();
|
||||
for page in pages_showing(d, parent) {
|
||||
if pages.is_some_and(|sel| !sel.contains(&page)) {
|
||||
continue;
|
||||
}
|
||||
let cs = s.cache.get(d, story.id, Some(page));
|
||||
let n = overset_chars(story, &cs);
|
||||
if cs.is_overset() && n > 0 {
|
||||
by_count.entry(n).or_default().push(page);
|
||||
}
|
||||
}
|
||||
let item = last.map_or(0, |f| f.0);
|
||||
out.extend(
|
||||
by_count.into_iter().map(|(n, ps)| {
|
||||
format!("{}: {n} character(s) of the story in frame {item} do not fit it and are not in the export", page_list(&ps))
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let cs = s.cache.get(d, story.id, None);
|
||||
if !cs.is_overset() {
|
||||
continue;
|
||||
}
|
||||
let n = overset_chars(story, &cs);
|
||||
if n == 0 {
|
||||
continue;
|
||||
}
|
||||
if story.frames.is_empty() {
|
||||
out.push(format!("a story with no text frame: {n} character(s) of it are not in the export"));
|
||||
continue;
|
||||
}
|
||||
let location = last.and_then(|f| overset_location(s, f));
|
||||
let centre = location.as_ref().and_then(|(_, page, _)| *page);
|
||||
let touched: &[usize] = location.as_ref().map(|(_, _, ps)| ps.as_slice()).unwrap_or_default();
|
||||
// A sheet draws its spread's items without asking which page they are on, so a frame
|
||||
// straddling two pages is drawn in part on both. Testing the centre page alone dropped
|
||||
// the warning for a frame centred on a page the export leaves out while the part on an
|
||||
// exported page went into the file (noted in review).
|
||||
//
|
||||
// The page named is one the export has: the centre page when that is exported, so an
|
||||
// ordinary frame reads as before, otherwise the first exported page the frame reaches.
|
||||
let page = match pages {
|
||||
None => centre,
|
||||
Some(sel) => {
|
||||
let exported: Vec<usize> = touched.iter().copied().filter(|p| sel.contains(p)).collect();
|
||||
match centre.filter(|p| sel.contains(p)).or_else(|| exported.first().copied()) {
|
||||
Some(p) => Some(p),
|
||||
// Nothing of this frame is on an exported page.
|
||||
None if centre.is_some() || !touched.is_empty() => continue,
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
};
|
||||
let item = location.as_ref().map(|(item, _, _)| *item).or(last);
|
||||
out.push(match (page, item) {
|
||||
(Some(page), Some(item)) => {
|
||||
format!("page {}: {n} character(s) of the story in frame {} do not fit it and are not in the export", page + 1, item.0)
|
||||
}
|
||||
(None, Some(item)) => format!("the story in frame {}: {n} character(s) do not fit and are not in the export", item.0),
|
||||
_ => format!("{n} character(s) of a story do not fit their frame and are not in the export"),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> {
|
||||
let Some(st) = s.active() else { return vec![] };
|
||||
let d = &st.doc;
|
||||
@@ -138,8 +284,8 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> {
|
||||
} else {
|
||||
format!("Overset text: {n} characters")
|
||||
},
|
||||
item: location.map(|(item, _)| item.0).or(last.map(|i| i.0)),
|
||||
page: location.and_then(|(_, page)| page),
|
||||
item: location.as_ref().map(|(item, _, _)| item.0).or(last.map(|i| i.0)),
|
||||
page: location.as_ref().and_then(|(_, page, _)| *page),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,12 +50,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Preferences",
|
||||
[],
|
||||
None,
|
||||
"{showHiddenCharacters?, typographersQuotes?, polygonSides?, starInset?, scaleStrokes?, dimensionsIncludeStroke?, transformationsAreTotals?, absolutePageNumbers?, highlightHj?, highlightKeeps?, highlightCustomTracking?, highlightSubstitutedFonts?, richBlackOutput?, favoriteFonts?: [family], recentFonts?: [family], showFontNamesInEnglish?, smartTextReflow?, autocorrect?, autocorrectList?: [[word, correction]], showAddedText?, showNoteAnchors?, cjkFeatures?: bool|null (CJK-only menu items and dialogs shown; null follows the interface language), linkTextFiles? (Create Links When Placing Text Files), recoveryMinutes?, recentFilesMax?: 0–100} → all application preferences",
|
||||
"{showHiddenCharacters?, typographersQuotes?, polygonSides?, starInset?, scaleStrokes?, dimensionsIncludeStroke?, transformationsAreTotals?, absolutePageNumbers?, highlightHj?, highlightKeeps?, highlightCustomTracking?, highlightSubstitutedFonts?, richBlackOutput?, favoriteFonts?: [family], recentFonts?: [family], showFontNamesInEnglish?, smartTextReflow?, autocorrect?, autocorrectList?: [[word, correction]], showAddedText?, showNoteAnchors?, cjkFeatures?: bool|null (CJK-only menu items and dialogs shown; null follows the interface language), linkTextFiles? (Create Links When Placing Text Files), recoveryMinutes?, recentFilesMax?: 0–100, defaultUnits?: {horizontalUnits?, verticalUnits?, rulerOrigin?, keyboardIncrement? (pt), kerningIncrement? (1/1000 em)} (Units & Increments of new documents)} → all application preferences",
|
||||
super::always,
|
||||
|s, p| {
|
||||
let cur = serde_json::to_value(&s.prefs).map_err(|e| bad("prefs.set", e.to_string()))?;
|
||||
let new = merge("prefs.set", cur, p, None)?;
|
||||
s.prefs = serde_json::from_value(new).map_err(|e| bad("prefs.set", e.to_string()))?;
|
||||
let prefs: crate::Prefs = serde_json::from_value(new).map_err(|e| bad("prefs.set", e.to_string()))?;
|
||||
prefs.default_units.check().map_err(|e| bad("prefs.set", e))?;
|
||||
s.prefs = prefs;
|
||||
s.prefs.trim_recent_files();
|
||||
serde_json::to_value(&s.prefs).map_err(|e| bad("prefs.set", e.to_string()))
|
||||
}
|
||||
@@ -100,6 +102,9 @@ fn merge(cmd: &str, mut cur: Value, p: &Value, allowed: Option<&[&str]>) -> Resu
|
||||
match (c.get_mut(k), v) {
|
||||
(Some(Value::Object(old)), Value::Object(v)) => {
|
||||
for (k2, v2) in v {
|
||||
if !old.is_empty() && !old.contains_key(k2) {
|
||||
return Err(bad(cmd, format!("unknown preference {k}.{k2}")));
|
||||
}
|
||||
old.insert(k2.clone(), v2.clone());
|
||||
}
|
||||
}
|
||||
@@ -127,6 +132,7 @@ mod tests {
|
||||
s.execute("prefs.set", &json!({"showFontNamesInEnglish": true})).unwrap();
|
||||
assert!(s.prefs.show_font_names_in_english);
|
||||
assert!(s.execute("prefs.set", &json!({"nope": 1})).is_err());
|
||||
assert!(s.execute("prefs.set", &json!({"defaultUnits": {"horizontalUnit": "inches"}})).is_err());
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s
|
||||
.execute("document.preferences", &json!({"horizontalUnits": "millimeters", "keyboardIncrement": 2.5, "grid": {"subdivisions": 4}}))
|
||||
@@ -141,6 +147,53 @@ mod tests {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.settings.horizontal_units, designcraft_geom::Unit::Picas);
|
||||
}
|
||||
|
||||
/// Units & Increments set with no document open are what new documents start with, as in
|
||||
/// InDesign: a reader who works in inches sets them once, not in every file.
|
||||
#[test]
|
||||
fn default_units_are_what_new_documents_start_with() {
|
||||
use designcraft_geom::Unit;
|
||||
let mut s = Session::new();
|
||||
let r = s
|
||||
.execute(
|
||||
"prefs.set",
|
||||
&json!({"defaultUnits": {"horizontalUnits": "inches", "verticalUnits": "inches", "keyboardIncrement": 0.5, "rulerOrigin": "page"}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["defaultUnits"]["horizontalUnits"], "inches");
|
||||
assert_eq!(r["defaultUnits"]["kerningIncrement"], 20.0, "the rest kept");
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let st = s.doc().unwrap().doc.settings.clone();
|
||||
assert_eq!((st.horizontal_units, st.vertical_units), (Unit::Inches, Unit::Inches));
|
||||
assert_eq!((st.keyboard_increment, st.ruler_origin), (0.5, designcraft_doc::RulerOrigin::Page));
|
||||
assert!(!s.doc().unwrap().is_dirty(), "made so, not edited");
|
||||
// Inches across, millimetres down: New Document's units (the horizontal ones) keep the
|
||||
// vertical preference; other units picked there set both rulers.
|
||||
s.execute("prefs.set", &json!({"defaultUnits": {"verticalUnits": "millimeters"}})).unwrap();
|
||||
s.execute("file.new", &json!({"units": "inches"})).unwrap();
|
||||
let st = s.doc().unwrap().doc.settings.clone();
|
||||
assert_eq!((st.horizontal_units, st.vertical_units), (Unit::Inches, Unit::Millimeters));
|
||||
s.execute("file.new", &json!({"units": "points"})).unwrap();
|
||||
let st = s.doc().unwrap().doc.settings.clone();
|
||||
assert_eq!((st.horizontal_units, st.vertical_units), (Unit::Points, Unit::Points));
|
||||
// A preset keeps its own units, both of them, even when the default horizontal units are
|
||||
// the preset's.
|
||||
s.execute("prefs.set", &json!({"defaultUnits": {"horizontalUnits": "millimeters", "verticalUnits": "inches"}})).unwrap();
|
||||
s.execute("file.new", &json!({"preset": "A4"})).unwrap();
|
||||
let st = s.doc().unwrap().doc.settings.clone();
|
||||
assert_eq!((st.horizontal_units, st.vertical_units), (Unit::Millimeters, Unit::Millimeters));
|
||||
assert_eq!(st.keyboard_increment, 0.5, "the increments still apply");
|
||||
s.execute("prefs.set", &json!({"defaultUnits": {"horizontalUnits": "inches", "verticalUnits": "millimeters"}})).unwrap();
|
||||
// New Document opens with them; bad values are refused and change nothing.
|
||||
assert_eq!(s.default_new_document().units, Unit::Inches);
|
||||
for bad in [json!({"horizontalUnits": "furlongs"}), json!({"keyboardIncrement": 0}), json!({"kerningIncrement": -5})] {
|
||||
assert!(s.execute("prefs.set", &json!({"defaultUnits": bad})).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(s.prefs.default_units.keyboard_increment, 0.5);
|
||||
// Kept with the application preferences.
|
||||
let saved: crate::Prefs = serde_json::from_slice(&serde_json::to_vec(&s.prefs).unwrap()).unwrap();
|
||||
assert_eq!(saved.default_units, s.prefs.default_units);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -86,7 +86,15 @@ mod tests {
|
||||
} else {
|
||||
assert_eq!(
|
||||
command,
|
||||
["xps-print", "printer=Office", "copies=2", "collate=true", "duplex=(queue default)", "scaling=shrink-to-fit", "color=color"]
|
||||
[
|
||||
"xps-print",
|
||||
"printer=Office",
|
||||
"copies=2",
|
||||
"collate=true",
|
||||
"duplex=(queue default)",
|
||||
"scaling=shrink-to-fit",
|
||||
"color=(driver default)"
|
||||
]
|
||||
);
|
||||
}
|
||||
assert!(r["bytes"].as_u64().unwrap() > 500);
|
||||
@@ -108,6 +116,7 @@ mod tests {
|
||||
assert_eq!(job.printer.as_deref(), Some("Office (east)"));
|
||||
assert_eq!(job.copies, 2);
|
||||
assert_eq!(job.duplex, None);
|
||||
assert!(!job.grayscale);
|
||||
assert_eq!(job.scaling, craft_print::Scaling::ShrinkToFit);
|
||||
assert!(!job.title.is_empty());
|
||||
Ok("request id is Office-1".into())
|
||||
|
||||
@@ -81,13 +81,91 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Common words the hyphenation word list (from the early 1990s) lacks: everyday short words
|
||||
/// such as "we", "us" and "if", titles, and words that came with computers and the web. Sorted,
|
||||
/// lowercase, base forms (the inflections below reach "emails", "downloaded", "bloggers").
|
||||
const COMMON: &[&str] = &[
|
||||
"ah",
|
||||
"bitmap",
|
||||
"blog",
|
||||
"bluetooth",
|
||||
"chatbot",
|
||||
"checkbox",
|
||||
"cybersecurity",
|
||||
"database",
|
||||
"dataset",
|
||||
"desktop",
|
||||
"download",
|
||||
"dr",
|
||||
"dropdown",
|
||||
"ebook",
|
||||
"ecommerce",
|
||||
"eh",
|
||||
"email",
|
||||
"emoji",
|
||||
"firewall",
|
||||
"gigabyte",
|
||||
"hashtag",
|
||||
"hm",
|
||||
"hmm",
|
||||
"homepage",
|
||||
"hyperlink",
|
||||
"if",
|
||||
"inbox",
|
||||
"internet",
|
||||
"jr",
|
||||
"kilobyte",
|
||||
"laptop",
|
||||
"livestream",
|
||||
"login",
|
||||
"logout",
|
||||
"malware",
|
||||
"megabyte",
|
||||
"metadata",
|
||||
"mr",
|
||||
"offline",
|
||||
"ok",
|
||||
"online",
|
||||
"ow",
|
||||
"pm",
|
||||
"podcast",
|
||||
"popup",
|
||||
"printout",
|
||||
"screenshot",
|
||||
"selfie",
|
||||
"smartphone",
|
||||
"smartwatch",
|
||||
"spreadsheet",
|
||||
"sr",
|
||||
"startup",
|
||||
"terabyte",
|
||||
"touchscreen",
|
||||
"um",
|
||||
"upload",
|
||||
"us",
|
||||
"username",
|
||||
"voicemail",
|
||||
"vs",
|
||||
"we",
|
||||
"webinar",
|
||||
"webmaster",
|
||||
"webpage",
|
||||
"website",
|
||||
"wifi",
|
||||
"workflow",
|
||||
"yay",
|
||||
];
|
||||
|
||||
/// Is `w` (lowercase) a known word, allowing common inflections of dictionary stems?
|
||||
pub fn known(dict: &Dictionary, user: &[String], w: &str) -> bool {
|
||||
if w.len() <= 1 || dict.get(w).is_some() || user.iter().any(|u| u == w) {
|
||||
// A typographer's apostrophe (don’t, we’re) is the word list's straight one.
|
||||
let w = &w.replace('’', "'");
|
||||
let has = |w: &str| dict.get(w).is_some() || COMMON.binary_search(&w).is_ok();
|
||||
if w.len() <= 1 || has(w) || user.iter().any(|u| u.replace('’', "'") == *w) {
|
||||
return true;
|
||||
}
|
||||
let w = w.trim_end_matches("'s").trim_end_matches("’s");
|
||||
if dict.get(w).is_some() {
|
||||
let w = w.trim_end_matches("'s");
|
||||
if has(w) {
|
||||
return true;
|
||||
}
|
||||
let stems: [(&str, &[&str]); 9] = [
|
||||
@@ -105,14 +183,14 @@ pub fn known(dict: &Dictionary, user: &[String], w: &str) -> bool {
|
||||
if let Some(base) = w.strip_suffix(suf) {
|
||||
for add in adds {
|
||||
let cand = format!("{base}{add}");
|
||||
if cand.len() > 1 && dict.get(&cand).is_some() {
|
||||
if cand.len() > 1 && has(&cand) {
|
||||
return true;
|
||||
}
|
||||
// Doubled consonant: "stopped" → "stop".
|
||||
if let Some(c) = base.chars().last()
|
||||
&& base.len() > 2
|
||||
&& base.ends_with(&format!("{c}{c}"))
|
||||
&& dict.get(&base[..base.len() - c.len_utf8()]).is_some()
|
||||
&& has(&base[..base.len() - c.len_utf8()])
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -128,7 +206,7 @@ pub fn suggest(dict: &Dictionary, w: &str) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut push = |c: Vec<char>| {
|
||||
let c: String = c.into_iter().collect();
|
||||
if c != w && !out.contains(&c) && dict.get(&c).is_some() {
|
||||
if c != w && !out.contains(&c) && (dict.get(&c).is_some() || COMMON.binary_search(&c.as_str()).is_ok()) {
|
||||
out.push(c);
|
||||
}
|
||||
};
|
||||
@@ -238,6 +316,7 @@ mod tests {
|
||||
}
|
||||
assert!(!known(d, &[], "typograpy"));
|
||||
assert!(suggest(d, "typograpy").contains(&"typography".to_string()));
|
||||
assert!(suggest(d, "emial").contains(&"email".to_string()), "words in COMMON are suggested too");
|
||||
assert!(known(d, &["designcraft".into()], "designcraft"));
|
||||
}
|
||||
|
||||
@@ -253,6 +332,53 @@ mod tests {
|
||||
assert_eq!(bad, vec![6..10]);
|
||||
}
|
||||
|
||||
/// Everyday words the hyphenation list lacks aren't misspelled ("we", "us", "if", "email"…),
|
||||
/// with their inflections, and neither are contractions typed with a typographer's
|
||||
/// apostrophe, which Use Typographer's Quotes turns ' into.
|
||||
#[test]
|
||||
fn common_words_and_curly_apostrophes_are_known() {
|
||||
let d = Dictionary::en_us();
|
||||
if d.is_empty() {
|
||||
return; // data not bundled in this build
|
||||
}
|
||||
assert!(COMMON.is_sorted(), "COMMON is binary-searched");
|
||||
for w in [
|
||||
"we",
|
||||
"us",
|
||||
"if",
|
||||
"ok",
|
||||
"mr",
|
||||
"email",
|
||||
"emails",
|
||||
"emailed",
|
||||
"online",
|
||||
"website",
|
||||
"websites",
|
||||
"downloaded",
|
||||
"database",
|
||||
"databases",
|
||||
"blogger",
|
||||
"don’t",
|
||||
"can’t",
|
||||
"i’m",
|
||||
"we’re",
|
||||
"they’ve",
|
||||
"it’s",
|
||||
"don't",
|
||||
] {
|
||||
assert!(known(d, &[], w), "{w}");
|
||||
}
|
||||
for w in ["dont", "wee’re", "emial"] {
|
||||
assert!(!known(d, &[], w), "{w}");
|
||||
}
|
||||
// A user word added with a typographer's apostrophe matches either form.
|
||||
assert!(known(d, &["zorbo’s".into()], "zorbo's") && known(d, &["zorbo's".into()], "zorbo’s"));
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "We don’t email us if we’re online."})).unwrap();
|
||||
assert_eq!(s.execute("spelling.check", &json!({})).unwrap(), json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_finds_misspellings() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -17,7 +17,7 @@ use object::{apply_object, clear_object_overrides, create_object, edit_object};
|
||||
use paragraph::{
|
||||
apply_char, apply_para, create_char, create_para, delete_char, delete_para, edit_char, edit_para, export_tag_key, group_styles, redefine,
|
||||
};
|
||||
pub(crate) use paragraph::{check_grep_styles, grep_style_warnings};
|
||||
pub(crate) use paragraph::{apply_char_style, apply_para_style, check_grep_styles, grep_style_warnings};
|
||||
pub use swatches::parse_color;
|
||||
use swatches::{add_to_swatches, create_swatch, load_swatches, new_swatch, save_swatches, swatch_options};
|
||||
|
||||
@@ -134,7 +134,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Paragraph Style Options…",
|
||||
[],
|
||||
None,
|
||||
"{name, rename?, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, spaceBefore, ignoreOpticalMargin, …; ruleAbove?/ruleBelow?: only the named rule fields change; startAt?: null = continue numbering; kinsoku?: set | null (default rules); a grepStyles pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}}",
|
||||
"{name, rename?, basedOn?, nextStyle?: style | null ([Same style]), resetToBase?: bool (Reset To Base: the style sets nothing of its own; `para`/`chars` then apply over its Based On), para?: {paragraph attributes, as type.para names them: align, leftIndent, spaceBefore, ignoreOpticalMargin, …; ruleAbove?/ruleBelow?: only the named rule fields change; startAt?: null = continue numbering; kinsoku?: set | null (default rules); a grepStyles pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}}",
|
||||
has_doc,
|
||||
edit_para
|
||||
),
|
||||
|
||||
@@ -328,7 +328,7 @@ fn apply_para_in_story(
|
||||
f.chars.retain_differences(style_chars);
|
||||
}
|
||||
});
|
||||
for (pi, range) in st.para_ranges().into_iter().enumerate().take(last.saturating_add(1)).skip(first) {
|
||||
for (pi, range) in st.paras_touching(r.clone()).into_iter().filter(|(pi, _)| (first..=last).contains(pi)) {
|
||||
let Some(pf) = st.paras.get(pi) else { continue };
|
||||
let (_, base) = styles.resolve_para(pf);
|
||||
let end = range.end.saturating_add(1).min(st.len());
|
||||
@@ -345,6 +345,13 @@ fn apply_para_in_story(
|
||||
}
|
||||
}
|
||||
|
||||
/// Paragraph style `name` applied to the paragraphs of `r` in `st`: the paragraphs' and their
|
||||
/// text's overrides that the style now gives go (all of them with `clear`).
|
||||
pub(crate) fn apply_para_style(st: &mut designcraft_doc::Story, styles: &Styles, name: &str, r: &std::ops::Range<usize>, clear: bool) {
|
||||
let (style_para, style_chars) = styles.resolve_para_style(name);
|
||||
apply_para_in_story(st, r, name, clear, (&style_para, &style_chars), styles);
|
||||
}
|
||||
|
||||
pub(super) fn apply_para(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.apply", "missing name"))?.to_string();
|
||||
let clear = p.get("clearOverrides").and_then(Value::as_bool).unwrap_or(false);
|
||||
@@ -374,25 +381,31 @@ pub(super) fn apply_char(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let styles = d.styles.clone();
|
||||
for t in &targets {
|
||||
if let Some(st) = d.text_story_mut(t.story, t.cell) {
|
||||
for (pi, range) in st.para_ranges().into_iter().enumerate() {
|
||||
let range = range.start.max(t.range.start)..range.end.saturating_add(1).min(st.len()).min(t.range.end);
|
||||
if range.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(pf) = st.paras.get(pi) else { continue };
|
||||
let (_, mut inherited) = styles.resolve_para(pf);
|
||||
inherited.apply(&styles.char_style_attrs(&name));
|
||||
st.format_chars(range, |f| {
|
||||
f.style = name.clone();
|
||||
f.over.retain_differences(&inherited);
|
||||
});
|
||||
}
|
||||
apply_char_style(st, &styles, &name, &t.range);
|
||||
}
|
||||
}
|
||||
ok()
|
||||
})
|
||||
}
|
||||
|
||||
/// Character style `name` applied to `r` in `st`: the text's overrides that the style (over its
|
||||
/// paragraph) now gives go.
|
||||
pub(crate) fn apply_char_style(st: &mut designcraft_doc::Story, styles: &Styles, name: &str, r: &std::ops::Range<usize>) {
|
||||
for (pi, range) in st.paras_touching(r.clone()) {
|
||||
let range = range.start.max(r.start)..range.end.saturating_add(1).min(st.len()).min(r.end);
|
||||
if range.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(pf) = st.paras.get(pi) else { continue };
|
||||
let (_, mut inherited) = styles.resolve_para(pf);
|
||||
inherited.apply(&styles.char_style_attrs(name));
|
||||
st.format_chars(range, |f| {
|
||||
f.style = name.to_string();
|
||||
f.over.retain_differences(&inherited);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// In a style, `startAt: null` is Continue from Previous Number: it is stored as an explicit
|
||||
/// "continue" so it also overrides a Start At the style would inherit.
|
||||
fn continue_numbering(p: Option<&Value>, para: &mut ParaAttrs) {
|
||||
@@ -403,8 +416,19 @@ fn continue_numbering(p: Option<&Value>, para: &mut ParaAttrs) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The style `p[key]` names: none when absent or null; anything but a name is refused.
|
||||
fn style_name_param(cmd: &str, p: &Value, key: &str) -> Result<Option<String>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(n)) => Ok(Some(n.clone())),
|
||||
Some(_) => Err(bad(cmd, format!("`{key}` is a style name or null"))),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn create_para(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let base = str_param(p, "name").unwrap_or("Paragraph Style 1").to_string();
|
||||
let based_on = style_name_param("style.paragraph.create", p, "basedOn")?;
|
||||
let next = style_name_param("style.paragraph.create", p, "nextStyle")?;
|
||||
let mut para: ParaAttrs =
|
||||
attrs("style.paragraph.create", "para", p, |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, &designcraft_doc::ParaProps::default()))?;
|
||||
continue_numbering(p.get("para"), &mut para);
|
||||
@@ -428,8 +452,6 @@ pub(super) fn create_para(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
chars = full;
|
||||
}
|
||||
}
|
||||
let based_on = str_param(p, "basedOn").map(str::to_string);
|
||||
let next = str_param(p, "nextStyle").map(str::to_string);
|
||||
s.edit(|d, _| {
|
||||
let name = Styles::unique_name(|n| d.styles.para(n).is_some(), &base);
|
||||
if let Some(b) = &based_on
|
||||
@@ -437,6 +459,13 @@ pub(super) fn create_para(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
return Err(bad("style.paragraph.create", format!("no style `{b}`")));
|
||||
}
|
||||
// Next Style: a style of the document, or this one.
|
||||
if let Some(n) = &next
|
||||
&& *n != name
|
||||
&& d.styles.para(n).is_none()
|
||||
{
|
||||
return Err(bad("style.paragraph.create", format!("no style `{n}`")));
|
||||
}
|
||||
d.styles_mut().paragraph.push(ParagraphStyle { name: name.clone(), based_on, next_style: next, para, chars, shortcut: String::new() });
|
||||
Ok(json!({"name": name}))
|
||||
})
|
||||
@@ -499,11 +528,41 @@ pub(super) fn edit_para(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
crate::cmd::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.edit")?;
|
||||
let rename = str_param(p, "rename").map(str::trim).filter(|n| *n != name).map(str::to_string);
|
||||
let based = p.get("basedOn").cloned();
|
||||
let next = p.get("nextStyle").cloned();
|
||||
let reset = p.get("resetToBase").and_then(Value::as_bool).unwrap_or(false);
|
||||
for (key, v) in [("basedOn", &based), ("nextStyle", &next)] {
|
||||
if v.as_ref().is_some_and(|v| !v.is_string() && !v.is_null()) {
|
||||
return Err(bad("style.paragraph.edit", format!("`{key}` must be a style name or null")));
|
||||
}
|
||||
}
|
||||
s.edit(|d, _| {
|
||||
if let Some(Value::String(b)) = &based
|
||||
&& d.styles.para_based_on_cycles(&name, b)
|
||||
if let Some(Value::String(b)) = &based {
|
||||
if d.styles.para(b).is_none() {
|
||||
return Err(bad("style.paragraph.edit", format!("no style `{b}`")));
|
||||
}
|
||||
if d.styles.para_based_on_cycles(&name, b) {
|
||||
return Err(bad("style.paragraph.edit", "based-on would create a cycle"));
|
||||
}
|
||||
}
|
||||
if let Some(Value::String(n)) = &next
|
||||
&& d.styles.para(n).is_none()
|
||||
{
|
||||
return Err(bad("style.paragraph.edit", "based-on would create a cycle"));
|
||||
return Err(bad("style.paragraph.edit", format!("no style `{n}`")));
|
||||
}
|
||||
// Reset To Base: what the style set of its own goes before the edits apply.
|
||||
if reset {
|
||||
let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?;
|
||||
st.para = ParaAttrs::default();
|
||||
st.chars = CharAttrs::default();
|
||||
}
|
||||
// A new Based On first, so the fields below resolve over the new parent.
|
||||
if based.is_some() {
|
||||
let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?;
|
||||
match &based {
|
||||
Some(Value::String(b)) => st.based_on = Some(b.clone()),
|
||||
Some(Value::Null) => st.based_on = None,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(n) = &rename {
|
||||
if n.is_empty() || n.starts_with('[') {
|
||||
@@ -522,9 +581,9 @@ pub(super) fn edit_para(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?;
|
||||
st.para.merge(¶);
|
||||
st.chars.merge(&chars);
|
||||
match based {
|
||||
Some(Value::String(b)) => st.based_on = Some(b),
|
||||
Some(Value::Null) => st.based_on = None,
|
||||
match next {
|
||||
Some(Value::String(n)) => st.next_style = Some(n),
|
||||
Some(Value::Null) => st.next_style = None,
|
||||
_ => {}
|
||||
}
|
||||
if let Some(n) = rename.clone() {
|
||||
@@ -781,7 +840,7 @@ pub(super) fn create_char(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
chars = captured;
|
||||
}
|
||||
// Based on [None] is based on nothing.
|
||||
let based_on = str_param(p, "basedOn").filter(|b| *b != designcraft_doc::NO_CHAR_STYLE).map(str::to_string);
|
||||
let based_on = style_name_param("style.character.create", p, "basedOn")?.filter(|b| b != designcraft_doc::NO_CHAR_STYLE);
|
||||
s.edit(|d, _| {
|
||||
if let Some(b) = &based_on
|
||||
&& d.styles.char_style(b).is_none()
|
||||
@@ -1141,6 +1200,7 @@ mod delete_tests {
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
}
|
||||
|
||||
/// Deleting a paragraph style also redirects TOC style references to its replacement.
|
||||
#[test]
|
||||
fn deleting_a_paragraph_style_points_every_use_at_the_replacement() {
|
||||
let mut s = Session::new();
|
||||
@@ -1162,6 +1222,7 @@ mod delete_tests {
|
||||
entries: vec![TocEntry { entry_style: Some("Table Body".into()), ..TocEntry::new("Table Body", 1) }],
|
||||
page_numbers: false,
|
||||
title_style: Some("Table Body".into()),
|
||||
create_bookmarks: false,
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
|
||||
@@ -28,4 +28,5 @@ mod styles;
|
||||
mod target;
|
||||
|
||||
pub use registry::specs;
|
||||
pub(crate) use selection::step_cell;
|
||||
pub(crate) use selection::{one_selected_cell_to_text, step_cell};
|
||||
pub use structure::{MAX_BODY_ROWS, MAX_COLUMNS, MAX_CONVERTED_CELLS, MAX_HEADER_FOOTER_ROWS};
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::cell::{get_cell_stroke, set_cell};
|
||||
use super::content::{drop_cells, place_graphic, sort_rows};
|
||||
use super::options::{get, options};
|
||||
use super::selection::{move_rc, select, step_cell};
|
||||
use super::selection::{move_rc, select, step_cell, toggle_selection};
|
||||
use super::sizing::{distribute_cols, distribute_rows, resize_border, set_col_width, set_row_height};
|
||||
use super::structure::{
|
||||
can_convert_rows, convert_from_text, convert_rows, convert_to_text, delete_cols, delete_rows, delete_table, insert, insert_col, insert_row,
|
||||
@@ -82,8 +82,9 @@ fn table_specs() -> Vec<CommandSpec> {
|
||||
"table.insert",
|
||||
"Create Table",
|
||||
[],
|
||||
Some("Cmd+Alt+Shift+T"),
|
||||
"{rows?: body rows (4), cols? | columns? (4), headerRows? (0), footerRows? (0), width?} — at the text insertion point",
|
||||
// InDesign's Cmd+Alt+Shift+T opens the Insert Table dialog (`app.insertTableDialog`).
|
||||
None,
|
||||
"{rows?: body rows (4, at most 500), cols? | columns? (4, at most 200), headerRows? (0), footerRows? (0, each at most 100), tableStyle?: name, width?} — at the text insertion point, styled in the same step",
|
||||
has_text,
|
||||
insert
|
||||
),
|
||||
@@ -92,7 +93,7 @@ fn table_specs() -> Vec<CommandSpec> {
|
||||
"Convert Text to Table",
|
||||
["Table"],
|
||||
None,
|
||||
"{columnSeparator?: tab|comma, width?: table width (default: the column)} — selected paragraphs become rows",
|
||||
"{columnSeparator?: tab|comma|paragraph|text (tab), rowSeparator?: paragraph|tab|comma|text (paragraph), columns?: number of columns when both separators are the same (1, at most 200), tableStyle?: name, width?: table width (default: the column)} — the selected paragraphs become a table, as InDesign's Convert Text to Table; the words tab, comma and paragraph name those separators, any other text is the separator itself",
|
||||
has_text,
|
||||
convert_from_text
|
||||
),
|
||||
@@ -101,7 +102,7 @@ fn table_specs() -> Vec<CommandSpec> {
|
||||
"Convert Table to Text",
|
||||
["Table"],
|
||||
None,
|
||||
"{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — cells separated by tabs, rows by paragraphs",
|
||||
"{columnSeparator?: tab|comma|paragraph|text (tab), rowSeparator?: paragraph|tab|comma|text (paragraph), story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — the table becomes its cells' text, as InDesign's Convert Table to Text",
|
||||
in_table,
|
||||
convert_to_text
|
||||
),
|
||||
@@ -207,7 +208,7 @@ fn table_specs() -> Vec<CommandSpec> {
|
||||
cmd!(
|
||||
"table.placeGraphic",
|
||||
"Convert Cell to Graphic Cell",
|
||||
["Table", "Convert Cell Type"],
|
||||
["Table"],
|
||||
None,
|
||||
"{path? | base64?, name?: with base64, fit?: proportional|fill (default proportional), story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — an image in the target cell (its text is kept but hidden)",
|
||||
in_table,
|
||||
@@ -216,7 +217,7 @@ fn table_specs() -> Vec<CommandSpec> {
|
||||
cmd!(
|
||||
"table.textCell",
|
||||
"Convert Cell to Text Cell",
|
||||
["Table", "Convert Cell Type"],
|
||||
["Table"],
|
||||
None,
|
||||
"{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — drop the target cells' graphics",
|
||||
in_table,
|
||||
@@ -397,6 +398,15 @@ fn table_specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
cmd!(noundo "table.nextCell", "Next Cell", [], None, "{}", in_cell, |s, _| step_cell(s, true)),
|
||||
cmd!(noundo "table.prevCell", "Previous Cell", [], None, "{}", in_cell, |s, _| step_cell(s, false)),
|
||||
cmd!(
|
||||
noundo "table.toggleSelection",
|
||||
"Toggle Text and Cell Selection",
|
||||
[],
|
||||
None,
|
||||
"{} — Esc in a table, as InDesign: the insertion point or text in a cell selects that cell; selected cells select the text of the first one",
|
||||
in_cell,
|
||||
|s, _| toggle_selection(s)
|
||||
),
|
||||
cmd!(query "table.get", "Get Table", [], None, "{story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?} → rows, columns, cells (text, spans, fill), options", in_table_or_ids, get),
|
||||
]
|
||||
}
|
||||
@@ -66,6 +66,55 @@ pub(crate) fn step_cell(s: &mut Session, forward: bool) -> Result<Value> {
|
||||
Ok(json!({"row": row, "col": col}))
|
||||
}
|
||||
|
||||
/// With one cell selected (a merged one counts as one), select its whole text instead, so what is
|
||||
/// typed next replaces it, as InDesign does. Other selections are left as they are.
|
||||
pub(crate) fn one_selected_cell_to_text(s: &mut Session) -> Result<()> {
|
||||
let st = s.doc_mut()?;
|
||||
let (Some(cs), Some(t)) = (st.selection.cells, st.selection.text) else { return Ok(()) };
|
||||
let Some(tb) = st.doc.story(cs.story).and_then(|x| x.tables.get(&cs.table)) else { return Ok(()) };
|
||||
let (row, col) = tb.owner(cs.range.r0, cs.range.c0);
|
||||
if tb.owner(cs.range.r1, cs.range.c1) != (row, col) {
|
||||
return Ok(());
|
||||
}
|
||||
let cell = CellAddr { table: cs.table, row, col };
|
||||
let len = st.doc.text_story(cs.story, Some(cell)).map_or(0, |x| x.len());
|
||||
st.selection = Selection::text(TextSel { story: cs.story, anchor: 0, focus: len, cell: Some(cell), ..t });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Esc in a table: text in a cell becomes that cell's selection, and selected cells the text of
|
||||
/// the first one (InDesign toggles between the two).
|
||||
pub(super) fn toggle_selection(s: &mut Session) -> Result<Value> {
|
||||
const ID: &str = "table.toggleSelection";
|
||||
let st = s.doc_mut()?;
|
||||
let t = st.selection.text.ok_or_else(|| bad(ID, "no caret"))?;
|
||||
let c = t.cell.filter(|c| c.footnote_id().is_none()).ok_or_else(|| bad(ID, "not in a cell"))?;
|
||||
let cells = match st.selection.cells {
|
||||
Some(cs) => {
|
||||
// A merged cell's text belongs to the cell at its top left.
|
||||
let (row, col) = st
|
||||
.doc
|
||||
.story(cs.story)
|
||||
.and_then(|x| x.tables.get(&cs.table))
|
||||
.map_or((cs.range.r0, cs.range.c0), |tb| tb.owner(cs.range.r0, cs.range.c0));
|
||||
let cell = CellAddr { table: cs.table, row, col };
|
||||
let len = st.doc.text_story(cs.story, Some(cell)).map_or(0, |x| x.len());
|
||||
st.selection = Selection::text(TextSel { story: cs.story, anchor: 0, focus: len, cell: Some(cell), ..t });
|
||||
false
|
||||
}
|
||||
None => {
|
||||
st.selection = Selection {
|
||||
cells: Some(TableSel { story: t.story, table: c.table, range: CellRange::cell(c.row, c.col) }),
|
||||
text: Some(TextSel { anchor: 0, focus: 0, ..t }),
|
||||
..Default::default()
|
||||
};
|
||||
true
|
||||
}
|
||||
};
|
||||
st.bump_revision();
|
||||
Ok(json!({"cells": cells}))
|
||||
}
|
||||
|
||||
/// The table cell under spread point `pt` in text frame `frame`: (story, table, row, col).
|
||||
pub(super) fn cell_at(s: &Session, frame: designcraft_doc::ItemId, pt: designcraft_geom::Point) -> Option<(StoryId, u64, usize, usize)> {
|
||||
let st = s.active()?;
|
||||
|
||||
@@ -320,3 +320,49 @@ mod resize_border_tests {
|
||||
assert!(s.execute("table.resizeBorder", &json!({"frame": f, "table": t, "axis": "column", "index": 0, "delta": 5})).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod insert_size_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// A table size that is given but is not one a table can have used to be corrected in
|
||||
/// silence: `{"rows": 0}` made a one-row table, `-1` and `1.5` made the default four, and
|
||||
/// 10000 was capped at 500 — so a caller's mistake became a table nobody asked for.
|
||||
#[test]
|
||||
fn a_table_size_insert_cannot_mean_is_refused() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 1})).unwrap();
|
||||
let frame = s.execute("frame.create", &json!({"rect": [20, 20, 400, 300], "content": "text", "text": ""})).unwrap();
|
||||
let at = |s: &mut Session| {
|
||||
s.execute("text.select", &json!({"story": frame["story"].clone(), "anchor": 0, "focus": 0})).unwrap();
|
||||
};
|
||||
for (params, names) in [
|
||||
(json!({"rows": 0}), "rows 0: 1 to 500"),
|
||||
(json!({"rows": -1}), "rows -1: 1 to 500"),
|
||||
(json!({"rows": 1.5}), "rows 1.5: 1 to 500"),
|
||||
(json!({"rows": 10000}), "rows 10000: 1 to 500"),
|
||||
(json!({"cols": 0}), "cols 0: 1 to 200"),
|
||||
(json!({"columns": 201}), "cols 201: 1 to 200"),
|
||||
(json!({"headerRows": 101}), "headerRows 101: 0 to 100"),
|
||||
(json!({"footerRows": -2}), "footerRows -2: 0 to 100"),
|
||||
] {
|
||||
at(&mut s);
|
||||
let e = s.execute("table.insert", ¶ms).expect_err("refused").to_string();
|
||||
assert!(e.contains(names), "{params}: {e}");
|
||||
}
|
||||
// Nothing was inserted by any of those.
|
||||
at(&mut s);
|
||||
assert!(s.execute("table.get", &json!({})).is_err(), "no table yet");
|
||||
// The sizes a table can have still work, and absent means the default four by four.
|
||||
at(&mut s);
|
||||
s.execute("table.insert", &json!({})).unwrap();
|
||||
let t = s.execute("table.get", &json!({})).unwrap();
|
||||
assert_eq!(t["cells"].as_array().unwrap().len(), 16, "four by four");
|
||||
at(&mut s);
|
||||
s.execute("table.insert", &json!({"rows": 1, "cols": 2, "headerRows": 1})).unwrap();
|
||||
let t = s.execute("table.get", &json!({})).unwrap();
|
||||
assert_eq!(t["cells"].as_array().unwrap().len(), 4, "one body row and one header row, two columns");
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,55 @@
|
||||
use super::target::{column_width, count, edit_table, in_cell, para_direction, target};
|
||||
use super::{CellAddr, Result, RowKind, Selection, Session, Table, TextSel, Value, bad, json, ok, str_param};
|
||||
|
||||
/// The most body rows, columns, and header or footer rows `table.insert` makes (the Insert Table
|
||||
/// dialog's limits too).
|
||||
pub const MAX_BODY_ROWS: u64 = 500;
|
||||
pub const MAX_COLUMNS: u64 = 200;
|
||||
pub const MAX_HEADER_FOOTER_ROWS: u64 = 100;
|
||||
/// The most cells `table.convertFromText` makes: as many as the largest table `table.insert`
|
||||
/// makes, however the rows and columns divide them (a long list can make a long table).
|
||||
pub const MAX_CONVERTED_CELLS: u64 = MAX_BODY_ROWS * MAX_COLUMNS;
|
||||
|
||||
/// A command's `tableStyle`: none for an absent one or [Basic Table] (a new table's look), else the
|
||||
/// resolved style and the cell styles it names. Anything but an existing style's name is an error.
|
||||
fn table_style_param(s: &Session, p: &Value, cmd: &str) -> Result<Option<(designcraft_doc::TableStyle, Vec<designcraft_doc::CellStyle>)>> {
|
||||
let name = match p.get("tableStyle") {
|
||||
None | Some(Value::Null) => return Ok(None),
|
||||
Some(Value::String(n)) if n == designcraft_doc::BASIC_TABLE => return Ok(None),
|
||||
Some(Value::String(n)) => n,
|
||||
Some(_) => return Err(bad(cmd, "`tableStyle` must be a table style's name")),
|
||||
};
|
||||
let styles = &s.doc()?.doc.styles;
|
||||
if !styles.table.iter().any(|x| x.name == *name) {
|
||||
return Err(bad(cmd, format!("no table style `{name}`")));
|
||||
}
|
||||
Ok(Some((styles.resolve_table_style(name), styles.cell.clone())))
|
||||
}
|
||||
|
||||
pub(super) fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = s.doc()?.selection.text.ok_or_else(|| bad("table.insert", "no insertion point"))?;
|
||||
if t.cell.is_some() {
|
||||
return Err(bad("table.insert", "tables can't be nested in cells"));
|
||||
}
|
||||
let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(4).clamp(1, 500) as usize;
|
||||
let cols = p.get("cols").or_else(|| p.get("columns")).and_then(Value::as_u64).unwrap_or(4).clamp(1, 200) as usize;
|
||||
let header = p.get("headerRows").and_then(Value::as_u64).unwrap_or(0).min(100) as usize;
|
||||
let footer = p.get("footerRows").and_then(Value::as_u64).unwrap_or(0).min(100) as usize;
|
||||
// A size that is given but is not one a table can have used to be corrected in silence:
|
||||
// `{"rows": 0}` made a table with one row, `-1` and `1.5` made the default four, and 10000
|
||||
// was capped at 500. Named `given` rather than `count`, which this module already imports
|
||||
// for the repeat count of a row or column insert.
|
||||
let given = |key: &str, alt: Option<&str>, default: u64, range: std::ops::RangeInclusive<u64>| -> Result<usize> {
|
||||
let v = p.get(key).or_else(|| alt.and_then(|a| p.get(a))).filter(|v| !v.is_null());
|
||||
match v {
|
||||
None => Ok(default as usize),
|
||||
Some(v) => match v.as_u64().filter(|n| range.contains(n)) {
|
||||
Some(n) => Ok(n as usize),
|
||||
None => Err(bad("table.insert", format!("{key} {v}: {} to {}", range.start(), range.end()))),
|
||||
},
|
||||
}
|
||||
};
|
||||
let rows = given("rows", None, 4, 1..=MAX_BODY_ROWS)?;
|
||||
let cols = given("cols", Some("columns"), 4, 1..=MAX_COLUMNS)?;
|
||||
let header = given("headerRows", None, 0, 0..=MAX_HEADER_FOOTER_ROWS)?;
|
||||
let footer = given("footerRows", None, 0, 0..=MAX_HEADER_FOOTER_ROWS)?;
|
||||
let style = table_style_param(s, p, "table.insert")?;
|
||||
let width = p.get("width").and_then(Value::as_f64).unwrap_or_else(|| column_width(s, &t));
|
||||
s.edit(|d, sel| {
|
||||
let id = d.alloc();
|
||||
@@ -23,6 +63,9 @@ pub(super) fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
c.text.paras[0] = designcraft_doc::ParaFormat { table: None, ..pf.clone() };
|
||||
c.text.chars[0].format = cf.clone();
|
||||
}
|
||||
if let Some((ts, cells)) = &style {
|
||||
ts.apply_to(&mut table, cells);
|
||||
}
|
||||
let r = t.range();
|
||||
st.delete(r.clone());
|
||||
st.insert_table(r.start, table);
|
||||
@@ -31,12 +74,41 @@ pub(super) fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn convert_from_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = s.doc()?.selection.text.ok_or_else(|| bad("table.convertFromText", "no text selected"))?;
|
||||
if t.cell.is_some() {
|
||||
return Err(bad("table.convertFromText", "select text outside tables"));
|
||||
/// A Convert Text to Table / Convert Table to Text separator: `tab`, `comma`, `paragraph` or the
|
||||
/// separator's own text (InDesign's fields take any characters).
|
||||
fn separator(p: &Value, key: &str, default: &str, cmd: &str) -> Result<String> {
|
||||
let sep = match p.get(key) {
|
||||
None | Some(Value::Null) => return Ok(default.to_string()),
|
||||
Some(Value::String(s)) => match s.as_str() {
|
||||
"tab" => "\t",
|
||||
"comma" => ",",
|
||||
"paragraph" => "\n",
|
||||
other => other,
|
||||
},
|
||||
Some(_) => "",
|
||||
};
|
||||
if sep.is_empty() {
|
||||
return Err(bad(cmd, format!("`{key}` must be tab, comma, paragraph or the separator's characters")));
|
||||
}
|
||||
let sep = if str_param(p, "columnSeparator") == Some("comma") { ',' } else { '\t' };
|
||||
Ok(sep.to_string())
|
||||
}
|
||||
|
||||
pub(super) fn convert_from_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "table.convertFromText";
|
||||
let t = s.doc()?.selection.text.ok_or_else(|| bad(ID, "no text selected"))?;
|
||||
if t.cell.is_some() {
|
||||
return Err(bad(ID, "select text outside tables"));
|
||||
}
|
||||
let (col_sep, row_sep) = (separator(p, "columnSeparator", "\t", ID)?, separator(p, "rowSeparator", "\n", ID)?);
|
||||
// With one separator for both, InDesign fills rows of Number of Columns cells.
|
||||
let per_row = match p.get("columns") {
|
||||
None | Some(Value::Null) => 1,
|
||||
Some(v) => v
|
||||
.as_u64()
|
||||
.filter(|n| (1..=MAX_COLUMNS).contains(n))
|
||||
.ok_or_else(|| bad(ID, format!("`columns` must be a whole number from 1 to {MAX_COLUMNS}")))? as usize,
|
||||
};
|
||||
let style = table_style_param(s, p, ID)?;
|
||||
let width = p.get("width").and_then(Value::as_f64).unwrap_or_else(|| column_width(s, &t));
|
||||
s.edit(|d, sel| {
|
||||
let id = d.alloc();
|
||||
@@ -48,11 +120,33 @@ pub(super) fn convert_from_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad("table.convertFromText", "the selection contains a table"));
|
||||
}
|
||||
let (a, b) = (ranges[pa].start, ranges[pb].end);
|
||||
let data: Vec<Vec<String>> = st.text[a..b].split('\n').map(|line| line.split(sep).map(|c| c.trim().to_string()).collect()).collect();
|
||||
let text = &st.text[a..b];
|
||||
let data: Vec<Vec<String>> = if col_sep == row_sep {
|
||||
let cells: Vec<String> = text.split(col_sep.as_str()).map(|c| c.trim().to_string()).collect();
|
||||
cells.chunks(per_row).map(<[String]>::to_vec).collect()
|
||||
} else {
|
||||
text.split(row_sep.as_str()).map(|row| row.split(col_sep.as_str()).map(|c| c.trim().to_string()).collect()).collect()
|
||||
};
|
||||
// The table is as wide as its widest row; refuse what no table here could hold.
|
||||
let cols = data.iter().map(Vec::len).max().unwrap_or(1) as u64;
|
||||
if cols > MAX_COLUMNS {
|
||||
return Err(bad(ID, format!("the text makes {cols} columns; a table has at most {MAX_COLUMNS}")));
|
||||
}
|
||||
if cols * data.len() as u64 > MAX_CONVERTED_CELLS {
|
||||
return Err(bad(ID, format!("the text makes more than {MAX_CONVERTED_CELLS} cells")));
|
||||
}
|
||||
// `Table::new` would clamp the shape and drop the rows past it, with their text.
|
||||
let (rows, _) = designcraft_doc::clamped_size(data.len(), cols as usize);
|
||||
if rows < data.len() {
|
||||
return Err(bad(ID, format!("the text makes {} rows; a table of {cols} columns has at most {rows}", data.len())));
|
||||
}
|
||||
let pf = st.paras[pa].clone();
|
||||
let cf = st.format_after(a).clone();
|
||||
let mut table = Table::from_strings(id, &data, width, &pf, &cf);
|
||||
table.options.direction = direction;
|
||||
if let Some((ts, cells)) = &style {
|
||||
ts.apply_to(&mut table, cells);
|
||||
}
|
||||
st.delete(a..b);
|
||||
let anchor = st.insert_table(a, table);
|
||||
*sel = Selection::text(TextSel::caret(t.story, anchor));
|
||||
@@ -62,9 +156,11 @@ pub(super) fn convert_from_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
pub(super) fn convert_to_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let g = target(s, p, "table.convertToText")?;
|
||||
let col_sep = separator(p, "columnSeparator", "\t", "table.convertToText")?;
|
||||
let row_sep = separator(p, "rowSeparator", "\n", "table.convertToText")?;
|
||||
s.edit(|d, sel| {
|
||||
let st = d.story_mut(g.story).ok_or_else(|| bad("table.convertToText", "no story"))?;
|
||||
let text = st.tables.get(&g.table).map(|t| t.plain_text()).unwrap_or_default();
|
||||
let text = st.tables.get(&g.table).map(|t| t.text_with(&col_sep, &row_sep)).unwrap_or_default();
|
||||
let a = st.table_anchor(g.table).ok_or_else(|| bad("table.convertToText", "no anchor"))?;
|
||||
st.replace(a..a + designcraft_doc::TABLE_ANCHOR.len_utf8(), &text);
|
||||
*sel = Selection::text(TextSel::caret(g.story, a + text.len()));
|
||||
|
||||
@@ -25,7 +25,13 @@ pub(super) fn in_table_or_ids(s: &Session) -> std::result::Result<(), String> {
|
||||
|
||||
pub(super) fn in_cell(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
if s.active().is_some_and(|d| d.selection.text.is_some_and(|t| t.cell.is_some())) { Ok(()) } else { Err("not in a table cell".into()) }
|
||||
// Footnote text is addressed as a cell too, but isn't in a table.
|
||||
let in_table = |c: CellAddr| c.footnote_id().is_none();
|
||||
if s.active().is_some_and(|d| d.selection.text.is_some_and(|t| t.cell.is_some_and(in_table))) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("not in a table cell".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// The table a command acts on and its target cell range.
|
||||
|
||||
Loaded 100 of 286 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user