diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 72bac7ef7..30a02804c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,10 +1,7 @@
-# The CLAUDE.md quality gate (`cargo xtask ci`: fmt, clippy -D warnings, tests, assets, layers,
-# wasm) on every pull request and every push to main. Today only path-filtered workflows run on
-# pull requests, so most PRs land with no test run at all.
-#
-# Ported from wordcraft#800. Linux only; standard GitHub-hosted runner, no secrets.
name: CI
+# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
+# suite's shared gate workflow in craft-common-actions.
on:
push:
branches: [main]
@@ -14,54 +11,19 @@ on:
permissions:
contents: read
+# A newer push to a PR branch cancels its older run; every push to main runs to the end.
concurrency:
- group: ci-${{ github.ref }}
+ group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
-env:
- # Same craft-fonts commit as release.yml, so the font tests see what a release build embeds.
- CRAFT_FONTS_REF: abb83316d96aa59c1cf64784289e378fe9fa5695
-
jobs:
gate:
- name: cargo xtask ci
- runs-on: ubuntu-24.04
- timeout-minutes: 90
- env:
- CARGO_TERM_COLOR: always
- CARGO_INCREMENTAL: "0"
- RUST_BACKTRACE: "1"
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
-
- - name: Check out craft-fonts
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- repository: storytold/craft-fonts
- ref: ${{ env.CRAFT_FONTS_REF }}
- path: craft-fonts
- persist-credentials: false
-
- - name: Install Linux build dependencies
- run: |
- sudo apt-get update
- sudo apt-get install -y --no-install-recommends \
- pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev \
- libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
-
- - name: Install Rust (stable + clippy, rustfmt, wasm32)
- run: |
- rustup toolchain install stable --profile minimal --component clippy,rustfmt \
- --target wasm32-unknown-unknown
- rustup default stable
-
- - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- with:
- save-if: ${{ github.ref == 'refs/heads/main' }}
-
- - name: Quality gate
- env:
- CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
- run: cargo xtask ci
+ # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
+ uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
+ with:
+ # Same craft-fonts commit as release.yml.
+ craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
+ timeout-minutes: 90
+ packages: >-
+ pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
+ libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml
index e60c2bdf3..8e562f8ba 100644
--- a/.github/workflows/freebsd.yml
+++ b/.github/workflows/freebsd.yml
@@ -2,16 +2,17 @@
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
name: FreeBSD
+# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
+# request: a drifted dependency still shows up within a day, and always before a release ships.
+# PRs that change the FreeBSD workflow itself still run it.
on:
push:
- branches: [main]
+ branches: [release]
pull_request:
paths:
- - Cargo.toml
- - Cargo.lock
- - "**/Cargo.toml"
- - "crates/fonts/**"
- .github/workflows/freebsd.yml
+ schedule:
+ - cron: "27 2 * * *"
workflow_dispatch:
concurrency:
diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml
index ecee45a6d..e78fe1ea3 100644
--- a/.github/workflows/sonarcloud.yml
+++ b/.github/workflows/sonarcloud.yml
@@ -11,7 +11,8 @@ permissions:
concurrency:
group: sonarcloud-${{ github.ref }}
- cancel-in-progress: true
+ # A push to main is the run that publishes coverage; superseded runs there would never finish.
+ cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
sonarcloud:
diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml
index ed7060b99..85856800b 100644
--- a/.github/workflows/windows-arm64.yml
+++ b/.github/workflows/windows-arm64.yml
@@ -4,19 +4,17 @@
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
name: Windows ARM64
+# Nightly on main and on pushes to `release` (dependency and runner-image drift shows up within a
+# day, and always before a release ships), and on PRs that change the Windows packaging itself.
on:
+ schedule:
+ - cron: "3 5 * * *"
push:
- branches: [main]
+ branches: [release]
pull_request:
paths:
- - Cargo.toml
- - Cargo.lock
- - "**/Cargo.toml"
- - "**/build.rs"
- .github/workflows/windows-arm64.yml
- packaging/windows/**
- schedule:
- - cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
workflow_dispatch:
concurrency:
diff --git a/ASSETS.md b/ASSETS.md
index 40e8a97b4..407a26c92 100644
--- a/ASSETS.md
+++ b/ASSETS.md
@@ -40,12 +40,12 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| `docs/images/page-4.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI |
| `docs/images/page-cover.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI |
| `docs/images/ui-spread.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI |
-| `crates/render/tests/golden/drop-shadow-spread.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
-| `crates/render/tests/golden/drop-shadow-text.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
-| `crates/render/tests/golden/drop-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
-| `crates/render/tests/golden/feather.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
-| `crates/render/tests/golden/inner-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
-| `crates/render/tests/golden/outer-glow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
+| `crates/render/tests/golden/drop-shadow-spread.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
+| `crates/render/tests/golden/drop-shadow-text.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
+| `crates/render/tests/golden/drop-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
+| `crates/render/tests/golden/feather.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
+| `crates/render/tests/golden/inner-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
+| `crates/render/tests/golden/outer-glow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `assets/app-icon/LICENSE.txt` | (licence text) | this project | — | Licence for the app icon |
| `assets/app-icon/designcraft-1024.png` | DesignCraft project owner (drawn in ArtCraft) | rendered from `assets/app-icon/designcraft.svg` by `packaging/icons.sh` | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon: calico cat in a polka-dot scarf (see `assets/app-icon/README.md`) |
| `assets/app-icon/designcraft-macos-512.png` | DesignCraft project owner (drawn in ArtCraft) | rendered from `assets/app-icon/designcraft.svg` by `packaging/icons.sh` | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon: calico cat in a polka-dot scarf (see `assets/app-icon/README.md`) |
diff --git a/Cargo.lock b/Cargo.lock
index 0b022ecfe..e75e8d5a5 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -942,7 +942,7 @@ dependencies = [
[[package]]
name = "designcraft"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"arboard",
"designcraft-engine",
@@ -964,7 +964,7 @@ dependencies = [
[[package]]
name = "designcraft-cli"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -980,7 +980,7 @@ dependencies = [
[[package]]
name = "designcraft-color"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"craft-cms",
"kurbo",
@@ -992,7 +992,7 @@ dependencies = [
[[package]]
name = "designcraft-compose"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"designcraft-color",
"designcraft-doc",
@@ -1012,7 +1012,7 @@ dependencies = [
[[package]]
name = "designcraft-doc"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"chrono",
"designcraft-color",
@@ -1025,7 +1025,7 @@ dependencies = [
[[package]]
name = "designcraft-engine"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"caseless",
"craft-atomic",
@@ -1064,7 +1064,7 @@ dependencies = [
[[package]]
name = "designcraft-epub"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"chrono",
"designcraft-color",
@@ -1074,7 +1074,7 @@ dependencies = [
[[package]]
name = "designcraft-fonts"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"core-text",
"harfrust 0.13.3",
@@ -1087,7 +1087,7 @@ dependencies = [
[[package]]
name = "designcraft-format"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"designcraft-doc",
"designcraft-images",
@@ -1099,7 +1099,7 @@ dependencies = [
[[package]]
name = "designcraft-geom"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"flo_curves",
"kurbo",
@@ -1112,7 +1112,7 @@ dependencies = [
[[package]]
name = "designcraft-idml"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1127,7 +1127,7 @@ dependencies = [
[[package]]
name = "designcraft-images"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"craft-cms",
"designcraft-color",
@@ -1142,7 +1142,7 @@ dependencies = [
[[package]]
name = "designcraft-mcp"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"designcraft-engine",
"designcraft-geom",
@@ -1155,7 +1155,7 @@ dependencies = [
[[package]]
name = "designcraft-pdf"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"chrono",
"craft-pdf",
@@ -1178,7 +1178,7 @@ dependencies = [
[[package]]
name = "designcraft-pptx"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"chrono",
"designcraft-color",
@@ -1196,7 +1196,7 @@ dependencies = [
[[package]]
name = "designcraft-render"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"craft-pdf",
"designcraft-color",
@@ -1215,7 +1215,7 @@ dependencies = [
[[package]]
name = "designcraft-textimport"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"designcraft-doc",
"quick-xml",
@@ -1224,7 +1224,7 @@ dependencies = [
[[package]]
name = "designcraft-tools"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1236,7 +1236,7 @@ dependencies = [
[[package]]
name = "designcraft-ui-egui"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"craft-ui",
"designcraft-color",
@@ -1263,7 +1263,7 @@ dependencies = [
[[package]]
name = "designcraft-web"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"designcraft-engine",
"designcraft-format",
@@ -5814,7 +5814,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"serde_json",
]
diff --git a/Cargo.toml b/Cargo.toml
index 0e728e87f..8a83e0463 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"]
[workspace.package]
-version = "0.11.0"
+version = "0.12.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -103,11 +103,16 @@ todo = "warn"
too_many_arguments = "allow"
type_complexity = "allow"
+# Line tables keep file and line in panics and backtraces; full debug info
+# for every test binary and dependency would multiply the target dir size.
+# The test profile inherits both settings.
[profile.dev.package."*"]
opt-level = 2
+debug = false
[profile.dev]
opt-level = 1
+debug = "line-tables-only"
[profile.release]
opt-level = 3
diff --git a/ROADMAP.md b/ROADMAP.md
index b3cc76a63..e4d94421d 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -42,7 +42,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Graphics backend chosen before the window exists (DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, OpenGL as the fallback; `WGPU_BACKEND` overrides) with a start-up fallback: a backend whose start never showed a frame is skipped at the next start and the status bar says so (`gpu.json`). Found on an AMD hybrid-graphics laptop whose Vulkan driver faulted at the first present.
- Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool).
- Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`.
-- Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`).
+- Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow threads frames on the pages that follow, then adds pages at the end of the document until the text fits; Remove Styles option (`designcraft-textimport`).
- Gradient Feather (⇧G tool, `object.gradientFeather`): objects fade along a linear or radial opacity gradient, on screen and as a soft mask in PDF.
- Script Label (`object.label`, `object.findByLabel`), alt text (`object.altText`), Isolate Blending / Knockout Group (`object.transparencyGroup`; on screen, PDF isolates), Table ▸ Sort (`table.sortRows`, numeric-aware, header/footer fixed).
- File ▸ Export Text (`file.exportText`): a story as Text Only or RTF (fonts, styles, alignment, indents, tables as tab-separated rows); Export EPUB now asks for a path in the UI.
@@ -301,6 +301,8 @@ measured); Ready estimated.
## Languages
Interface strings translated, of 1,724 measured. Detail: [docs/localization-parity.md](docs/localization-parity.md).
+A string without a translation shows in English and fails no check; `cargo xtask i18n-report`
+(also a `cargo xtask ci` step) lists the missing strings per language.
| Language | Code | Status | Translated |
|---|---|---|---|
@@ -348,7 +350,10 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
+- **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-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-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change.
- **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change.
- **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change.
diff --git a/apps/designcraft-cli/tests/cli.rs b/apps/designcraft-cli/tests/it/cli.rs
similarity index 100%
rename from apps/designcraft-cli/tests/cli.rs
rename to apps/designcraft-cli/tests/it/cli.rs
diff --git a/apps/designcraft-cli/tests/it/main.rs b/apps/designcraft-cli/tests/it/main.rs
new file mode 100644
index 000000000..26710c101
--- /dev/null
+++ b/apps/designcraft-cli/tests/it/main.rs
@@ -0,0 +1 @@
+mod cli;
diff --git a/apps/designcraft-web/index.html b/apps/designcraft-web/index.html
index b05be3936..da7ccc5da 100644
--- a/apps/designcraft-web/index.html
+++ b/apps/designcraft-web/index.html
@@ -2,12 +2,12 @@
-
+
DesignCraft
diff --git a/apps/designcraft/src/gpu/backend.rs b/apps/designcraft/src/gpu/backend.rs
index 0a3cda618..6aa250dd4 100644
--- a/apps/designcraft/src/gpu/backend.rs
+++ b/apps/designcraft/src/gpu/backend.rs
@@ -231,7 +231,21 @@ impl Fallback {
choice.backend.name(),
choice.record.failed
),
- None => log::info!("graphics: {} (WGPU_BACKEND overrides)", choice.backend.name()),
+ // Not a switch from an unfinished start. `begin` has already returned when
+ // `WGPU_BACKEND` names a backend, so wgpu is not being told which to use here -- an
+ // empty or unrecognised value is no choice and reaches this line like an unset one.
+ // The message used to say the environment had been asked, which reads in a log as
+ // though the user had chosen the backend (#1343). What this start did choose is
+ // either the first candidate or the first one that has not failed before, so it says
+ // which of the two it is rather than claiming the first choice.
+ None if choice.record.failed.is_empty() => {
+ log::info!("graphics: {} (nothing has failed before; set WGPU_BACKEND to choose another)", choice.backend.name())
+ }
+ None => log::info!(
+ "graphics: {} (the first that hasn't failed before, after {:?}; set WGPU_BACKEND to choose another)",
+ choice.backend.name(),
+ choice.record.failed
+ ),
}
let instance = instance_backends(choice.backend, &choice.record.failed);
Some(Fallback { backend: choice.backend, switched_from: choice.switched_from, instance, path, record: choice.record })
diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs
index 13416e4e0..e694fe84f 100644
--- a/apps/designcraft/src/main.rs
+++ b/apps/designcraft/src/main.rs
@@ -277,12 +277,27 @@ fn picked_save_path(picked: std::path::PathBuf, ext: Option<&str>) -> std::path:
s.into()
}
+/// The folder a save dialog starts in and the name it suggests. A suggestion with a folder in
+/// front (the document's, or the last one used) starts there while that folder still exists;
+/// otherwise the system picks, as it did before.
+fn save_dialog_start(suggested: &str) -> (Option, String) {
+ let path = std::path::Path::new(suggested);
+ let name = path.file_name().map_or_else(|| suggested.to_string(), |n| n.to_string_lossy().to_string());
+ let folder = path.parent().filter(|f| !f.as_os_str().is_empty() && f.is_dir()).map(std::path::Path::to_path_buf);
+ (folder, name)
+}
+
/// The save dialog: the suggested name's extension as its filter, and the extension added to a
/// name typed without it. Adding it names another file than the one the dialog checked, so
/// replacing an existing one asks again.
-fn pick_save(name: &str) -> Option {
+fn pick_save(suggested: &str) -> Option {
+ let (folder, name) = save_dialog_start(suggested);
+ let name = name.as_str();
let ext = suggested_extension(name);
let mut dialog = rfd::FileDialog::new().set_file_name(name);
+ if let Some(folder) = folder {
+ dialog = dialog.set_directory(folder);
+ }
if let Some(ext) = ext {
dialog = dialog.add_filter(ext.to_ascii_uppercase(), &[ext]);
}
@@ -606,6 +621,17 @@ mod tests {
assert_eq!(save("/d/Brochure.txt", "Untitled.rtf"), PathBuf::from("/d/Brochure.txt"), "another export format is kept");
assert_eq!(save("/d/Package", "Untitled 1.2 Folder"), PathBuf::from("/d/Package"), "a folder has no extension");
}
+
+ /// #1653: the save dialog opens in the folder the suggestion names, unless it is gone.
+ #[test]
+ fn save_dialog_starts_in_the_suggested_folder_while_it_exists() {
+ let folder = std::env::temp_dir();
+ let suggested = folder.join("Brochure.pdf");
+ assert_eq!(super::save_dialog_start(&suggested.to_string_lossy()), (Some(folder.clone()), "Brochure.pdf".to_string()));
+ let gone = folder.join(format!("designcraft-gone-{}", std::process::id())).join("Brochure.pdf");
+ assert_eq!(super::save_dialog_start(&gone.to_string_lossy()), (None, "Brochure.pdf".to_string()), "a folder deleted since: the system's own");
+ assert_eq!(super::save_dialog_start("Untitled 1.2 Folder"), (None, "Untitled 1.2 Folder".to_string()), "a bare name: the system's own");
+ }
}
#[cfg(all(test, unix))]
diff --git a/crates/color/src/ase.rs b/crates/color/src/ase.rs
index 6cfd72034..f44e3dba8 100644
--- a/crates/color/src/ase.rs
+++ b/crates/color/src/ase.rs
@@ -128,8 +128,14 @@ pub fn write(swatches: &[Swatch]) -> Vec {
continue;
}
let mut b = Vec::new();
- let units: Vec = s.name.encode_utf16().chain(std::iter::once(0)).collect();
- b.extend((units.len() as u16).to_be_bytes());
+ // The length field is a u16 that counts the terminating NUL: truncate longer names.
+ let mut units: Vec = s.name.encode_utf16().take(usize::from(u16::MAX) - 1).collect();
+ // Do not leave half of a surrogate pair at the cut.
+ if units.last().is_some_and(|u| (0xD800..0xDC00).contains(u)) {
+ units.pop();
+ }
+ units.push(0);
+ b.extend(u16::try_from(units.len()).unwrap_or(u16::MAX).to_be_bytes());
for u in units {
b.extend(u.to_be_bytes());
}
@@ -233,6 +239,21 @@ mod tests {
assert_eq!(read(&write(&sw[1..])).map(|w| w.len()), Ok(MAX_COLORS), "up to the limit");
}
+ #[test]
+ fn over_long_name_is_truncated_to_fit() {
+ let sw = vec![Swatch {
+ name: "x".repeat(70_000),
+ value: SwatchValue::Color { color: Color::Gray { k: 0.5 }, color_type: ColorType::Process },
+ locked: false,
+ named: true,
+ hidden: false,
+ }];
+ let back = read(&write(&sw)).unwrap();
+ assert_eq!(back.len(), 1, "the swatch must survive the round trip");
+ assert_eq!(back[0].name, "x".repeat(usize::from(u16::MAX) - 1));
+ assert_eq!(back[0].value, sw[0].value);
+ }
+
#[test]
fn out_of_range_components_are_clamped() {
let mut f = Vec::new();
diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs
index 89ea89bd3..ebf1e377b 100644
--- a/crates/compose/src/lib.rs
+++ b/crates/compose/src/lib.rs
@@ -165,6 +165,20 @@ pub struct PlacedGlyph {
}
impl PlacedGlyph {
+ /// Whether an underline or a strikethrough runs across this glyph.
+ ///
+ /// A tab is not drawn, but it is a character with an advance, and the rule runs across its
+ /// whole width as InDesign draws it: forms rule their fill-in lines with underlined tabs, and
+ /// without this they lost every writing line (#1466). The spaces a line break trimmed off the
+ /// end, the break characters themselves and the markers' carriers are not part of the line's
+ /// text and are not spanned.
+ pub fn spans_rules(&self) -> bool {
+ if self.visible {
+ return true;
+ }
+ self.adv > 0.0 && matches!(self.rendered_char, '\t' | designcraft_doc::story::RIGHT_INDENT_TAB)
+ }
+
/// In a vertical frame, the turn that sets this glyph upright, applied after drawing it at `x`
/// on `baseline`: it hangs from its vertical origin ([`designcraft_fonts::FontFace::v_origin`],
/// centred across) at `x` on the line's centre, the middle of the font's em box; a tate-chu-yoko
@@ -679,10 +693,12 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec {
let abs = doc.first_page_of_spread(si) + pi;
(Some(doc.page_name(abs)), Some(abs), sp.pages.get(pi).is_some_and(|p| p.side == designcraft_doc::PageSide::Left))
}
- (Some(designcraft_doc::SpreadRef::Parent(pi)), Some(sp)) => {
+ (Some(designcraft_doc::SpreadRef::Parent(_)), Some(sp)) => {
let prefix = sp.parent.as_ref().map(|p| p.prefix.clone()).unwrap_or_else(|| "A".into());
- let _ = pi;
- (Some(prefix), None, false)
+ // The side of the parent page under the frame, so a folio there aligns to the spine of its own page.
+ let under = sp.page_at_x(item.bounds().center().x).and_then(|pi| sp.pages.get(pi));
+ let left = under.is_some_and(|p| p.side == designcraft_doc::PageSide::Left);
+ (Some(prefix), None, left)
}
_ => (None, None, false),
};
@@ -1674,10 +1690,16 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
}
// A drop cap sits at the start-side indent; the first-line indent doesn't move it.
let beside = drop_cap.as_ref().filter(|dc| line_no < dc.lines && col_first_line == 0).map(DropCap::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),
Some(w) => (pp.left_indent + w, pp.right_indent),
- None => (pp.left_indent + if line_no == 0 { pp.first_line_indent } else { 0.0 }, pp.right_indent),
+ // The first-line indent is at the paragraph's start side, which is the right
+ // one in a right-to-left paragraph: applied on the left it moved the line the
+ // wrong way, out of the frame for the hanging indent a numbered list uses
+ // (#1448).
+ None if rtl => (pp.left_indent, pp.right_indent + first_line),
+ None => (pp.left_indent + first_line, pp.right_indent),
};
let lx0 = x0 + ind_l;
let lx1 = x1 - ind_r;
diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs
index 15c412ada..b73806d27 100644
--- a/crates/compose/src/tests.rs
+++ b/crates/compose/src/tests.rs
@@ -7310,3 +7310,58 @@ fn rule_above_offset_is_measured_from_the_baseline() {
assert!((rule.height() - 1.5).abs() < 1e-6);
assert!(rule.y0 > first.baseline, "the rule stays below the row above: {rule:?}, its baseline {}", first.baseline);
}
+
+#[test]
+fn parent_page_frames_know_which_side_of_the_spine_they_are_on() {
+ // Folios on a facing-pages parent: towards/away from spine must differ between its left and right page.
+ let mut d = Document::new(&NewDocument::default());
+ let lid = d.default_layer();
+ let w = d.parents[0].pages[0].width;
+ let para = ParaFormat { para: ParaAttrs { align: Some(Align::TowardsSpine), ..Default::default() }, ..Default::default() };
+ let (_, left_story) = d.add_text_frame(SpreadRef::Parent(0), Rect::new(36.0, 700.0, w - 36.0, 730.0), lid, "7", para.clone()).unwrap();
+ let (_, right_story) = d.add_text_frame(SpreadRef::Parent(0), Rect::new(w + 36.0, 700.0, 2.0 * w - 36.0, 730.0), lid, "8", para).unwrap();
+ assert!(frame_specs(&d, left_story)[0].left_page);
+ assert!(!frame_specs(&d, right_story)[0].left_page);
+}
+
+/// A right-to-left paragraph's first-line indent belongs at its start side, the right. Applied on
+/// the left it moved the first line the wrong way — out of the frame for the hanging indent a
+/// numbered list uses (right indent 10 mm, first line −10 mm), while the wrapped lines sat where
+/// the first line should have been (#1448).
+#[test]
+fn an_rtl_first_line_indent_is_at_the_right_edge() {
+ const MM: f64 = 72.0 / 25.4;
+ // Latin text in a right-to-left paragraph: the direction is the paragraph's, and it is the
+ // placement that is being measured, so no Arabic font has to be present.
+ let text = LOREM;
+ let rect = Rect::new(0.0, 0.0, 200.0, 300.0);
+ let para = |dir, align, first, left, right| ParaAttrs {
+ direction: Some(dir),
+ align: Some(align),
+ first_line_indent: Some(first),
+ left_indent: Some(left),
+ right_indent: Some(right),
+ ..Default::default()
+ };
+ // The hanging indent of a numbered list, right to left.
+ let (d, sid, _) = doc_with(text, rect, para(designcraft_doc::TextDirection::RightToLeft, Align::Right, -10.0 * MM, 0.0, 10.0 * MM));
+ let cs = compose_story(&d, sid, &ComposeOptions::default());
+ let lines = all_lines(&cs);
+ assert!(lines.len() >= 2, "the text wraps: {}", lines.len());
+ let right_edge = |l: &Line| l.glyphs.iter().filter(|g| g.visible).map(|g| g.x + g.adv).fold(f64::MIN, f64::max);
+ let left_edge = |l: &Line| l.glyphs.iter().filter(|g| g.visible).map(|g| g.x).fold(f64::MAX, f64::min);
+ // The first line reaches the frame's right edge; the wrapped lines stop 10 mm short of it.
+ assert!((right_edge(lines[0]) - rect.x1).abs() < 1.0, "first line at the edge: {}", right_edge(lines[0]));
+ assert!((right_edge(lines[1]) - (rect.x1 - 10.0 * MM)).abs() < 1.0, "wrapped line indented: {}", right_edge(lines[1]));
+ // And nothing leaves the frame on the left, which is where the first line used to go.
+ for (i, l) in lines.iter().enumerate() {
+ assert!(left_edge(l) >= rect.x0 - 0.01, "line {i} starts inside the frame: {}", left_edge(l));
+ }
+
+ // The same paragraph left to right is unchanged: the first line hangs on the left.
+ let (d, sid, _) = doc_with(text, rect, para(designcraft_doc::TextDirection::LeftToRight, Align::Left, -10.0 * MM, 10.0 * MM, 0.0));
+ let cs = compose_story(&d, sid, &ComposeOptions::default());
+ let lines = all_lines(&cs);
+ assert!((left_edge(lines[0]) - rect.x0).abs() < 1.0, "left to right, first line at the edge: {}", left_edge(lines[0]));
+ assert!((left_edge(lines[1]) - (rect.x0 + 10.0 * MM)).abs() < 1.0, "left to right, wrapped line indented: {}", left_edge(lines[1]));
+}
diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs
index fb8848a90..a10e9ae2f 100644
--- a/crates/engine/src/cmd/edit.rs
+++ b/crates/engine/src/cmd/edit.rs
@@ -262,12 +262,29 @@ pub fn specs() -> Vec {
st.selection.cells = None;
if let Some(i) = st.selection.items.iter().position(|x| *x == id) {
st.selection.items.remove(i);
+ // 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.bump_revision();
ok()
}),
+ cmd!(noundo "selection.key", "Key Object", [], None, "{id} — make a selected item the key object for Align; the current key object drops the key", has_doc, |s, p| {
+ let id = super::id_param(p, "id").ok_or_else(|| super::bad("selection.key", "missing id"))?;
+ let st = s.doc_mut()?;
+ if !st.selection.items.contains(&id) {
+ return Err(super::bad("selection.key", "the key object must be selected"));
+ }
+ if st.selection.items.len() < 2 {
+ return Err(super::bad("selection.key", "a key object needs at least two selected objects"));
+ }
+ st.selection.key = if st.selection.key == Some(id) { None } else { Some(id) };
+ st.bump_revision();
+ ok()
+ }),
cmd!(noundo "edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{} — all items on the active spreads, or all text in the story", has_doc, |s, _| {
let st = s.doc_mut()?;
if let Some(t) = st.selection.text {
diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs
index 36fd3d52a..551dd7484 100644
--- a/crates/engine/src/cmd/export.rs
+++ b/crates/engine/src/cmd/export.rs
@@ -10,7 +10,7 @@ use crate::{EngineError, Result, Session};
pub fn specs() -> Vec {
vec![
cmd!(noundo "file.exportPdf", "Export PDF…", ["File"], None,
- "{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), flatten?: high|medium|low|ppi (Transparency Flattener: spreads with transparency are rasterised), spreads?: bool, fullScreen?: bool, bookmarksPanel?: bool, pageLayout?: single|continuous|twoUp|twoUpCover|twoUpContinuous, view?: fitPage|fitWidth|actual, advanceSeconds?: number (interactive PDF), bleed?: bool (document bleed), marks?: bool | {crop?, bleed?, pageInfo?, weight?, offset?}, standard?: \"none\"|\"x4\"|\"a2b\", compressImages?: bool, tagged?: bool (structure tree: stories as paragraphs, figures with alt text), media?: bool (interactive: embed placed video and sound), title?, author?} → {path, bytes, pages, warnings} (no path: {base64, …})",
+ "{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), flatten?: high|medium|low|ppi (Transparency Flattener: spreads with transparency are rasterised), spreads?: bool, fullScreen?: bool, bookmarksPanel?: bool, pageLayout?: single|continuous|twoUp|twoUpCover|twoUpContinuous, view?: fitPage|fitWidth|actual, advanceSeconds?: number (interactive PDF), bleed?: bool (document bleed), marks?: bool | {crop?, bleed?, pageInfo?, weight?, offset?}, standard?: \"none\"|\"x4\"|\"a2b\", compressImages?: bool, tagged?: bool (structure tree: stories as paragraphs, figures with alt text), media?: bool (interactive: embed placed video and sound), title?, author?, created?: int (creation date in Unix seconds; default now, for reproducible files)} → {path, bytes, pages, warnings} (no path: {base64, …})",
has_doc, export_pdf),
cmd!(noundo "file.exportEpub", "Export EPUB (Reflowable)…", ["File"], None,
"{path?, title?, author?, language?: \"en\", identifier?: str (dc:identifier; a stable urn:uuid by default), coverImage?: path (a PNG or JPEG as the cover), tocTitle?: str (the table of contents' title; from the language by default), cover?: bool (the first page as the cover image), embedFonts?: bool (package the stylesheet's fonts whose licence allows it, whole files), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes, warnings} (no path: {base64, bytes, warnings}); warnings name images without alt text, exported as decorative, and fonts that could not be embedded",
@@ -331,7 +331,12 @@ fn pages_param(id: &str, p: &Value, page_count: usize) -> Result= 1 && *n <= page_count)
.ok_or_else(|| bad(id, format!("bad page {x}")))?;
- v.push(n - 1);
+ // A page named twice is one page, as `"2,2"` already is: the list says which
+ // pages to export, not how many times. `[2, 2]` exported two copies of page 2
+ // while `"2,2"` exported one.
+ if !v.contains(&(n - 1)) {
+ v.push(n - 1);
+ }
}
Some(v)
}
@@ -371,6 +376,10 @@ pub(crate) fn options(p: &Value, page_count: usize) -> Result {
Some(s) => Standard::parse(s).ok_or_else(|| bad(ID, format!("unknown standard `{s}` (none, x4, a2b)")))?,
None => Standard::None,
};
+ let created = match p.get("created") {
+ None | Some(Value::Null) => None,
+ Some(v) => Some(v.as_i64().ok_or_else(|| bad(ID, "`created`: Unix seconds, an integer"))?),
+ };
Ok(PdfOptions {
pages,
spreads,
@@ -382,6 +391,7 @@ pub(crate) fn options(p: &Value, page_count: usize) -> Result {
author: str_param(p, "author").map(str::to_string),
tagged: p.get("tagged").and_then(Value::as_bool).unwrap_or(false),
media: p.get("media").and_then(Value::as_bool).unwrap_or(false),
+ created,
..PdfOptions::default()
})
}
@@ -1326,3 +1336,52 @@ mod pptx_tests {
assert!(x.contains("") && !x.contains("prst=\"ellipse\""), "{x}");
}
}
+
+#[cfg(test)]
+mod page_list_tests {
+ use serde_json::{Value, json};
+
+ use crate::Session;
+
+ fn doc(pages: u64) -> Session {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({"width": 200, "height": 200, "pages": pages, "facingPages": false, "margins": 0})).unwrap();
+ s
+ }
+
+ /// The same selection written two ways exported different files: `"2,2"` is one page, because
+ /// `parse_page_range` keeps the first of a repeat, while `[2, 2]` was two copies of page 2.
+ /// The list says which pages to export, not how many times.
+ #[test]
+ fn a_page_named_twice_is_exported_once_either_way() {
+ let mut s = doc(4);
+ for (pages, want) in [
+ (json!("2,2"), 1),
+ (json!([2, 2]), 1),
+ (json!([2, 2, 2]), 1),
+ (json!("2-2,2"), 1),
+ (json!([1, 2, 1]), 2),
+ (json!("1,2,1"), 2),
+ (json!([4, 1]), 2),
+ (json!("4,1"), 2),
+ (json!("all"), 4),
+ ] {
+ let r = s.execute("file.exportPdf", &json!({"pages": pages.clone()})).unwrap();
+ assert_eq!(r["pages"].as_u64(), Some(want), "pdf {pages}");
+ let r = s.execute("file.exportPptx", &json!({"pages": pages.clone()})).unwrap();
+ assert_eq!(r["slides"].as_u64(), Some(want), "pptx {pages}");
+ }
+ // The two forms now agree byte for byte, and the pages come out in the document's order
+ // whichever order the list names them in (which is how the export already behaved).
+ let mut file = |pages: Value| {
+ let r = s.execute("file.exportPdf", &json!({"pages": pages})).unwrap();
+ r["base64"].as_str().unwrap_or_default().to_string()
+ };
+ assert_eq!(file(json!([4, 1])), file(json!("4,1")), "the array and the string agree");
+ assert_eq!(file(json!([4, 1])), file(json!([1, 4])), "pages 1 and 4, in the document's order");
+ assert_eq!(file(json!([2, 2])), file(json!("2")), "a repeat is the single page");
+ // A page that does not exist is still refused, and an empty list still exports nothing.
+ assert!(s.execute("file.exportPdf", &json!({"pages": [5]})).is_err());
+ assert!(s.execute("file.exportPdf", &json!({"pages": []})).is_err());
+ }
+}
diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs
index 3ba6970a1..a40e8da51 100644
--- a/crates/engine/src/cmd/find.rs
+++ b/crates/engine/src/cmd/find.rs
@@ -6,7 +6,7 @@ use std::sync::{Arc, Weak};
use designcraft_compose::grep::{Grep, GrepOptions, translate_replacement};
use designcraft_doc::story::floor_char_boundary;
-use designcraft_doc::{Document, Selection, StoryId, TextSel};
+use designcraft_doc::{CellAddr, Document, Selection, StoryId, TextSel};
use serde_json::{Value, json};
use super::{CommandSpec, bad, bool_or, cmd, has_doc, str_param};
@@ -61,7 +61,7 @@ pub fn specs() -> Vec {
}
),
cmd!(query "find.find", "Find", ["Edit", "Find/Change"], None,
- "{find, grep?: bool, caseSensitive?: bool, wholeWord?: bool, scope?: document|story|selection, story?, change?|first?|attrs?: ignored (find.change takes them; one request serves all three Find/Change commands)} → matches [{story, start, end, text}]",
+ "{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)",
has_doc, find),
cmd!(
"find.change",
@@ -153,6 +153,9 @@ impl Matcher {
}
}
+/// A searched text: a story, or one table cell of it, and the byte range inside.
+type Target = (StoryId, Option, Range);
+
/// A search owns its original domain, not the selection used to paint the latest hit.
/// Weak identity avoids retaining an old document merely for this transient cache.
#[derive(Clone)]
@@ -163,7 +166,7 @@ pub(crate) struct FindContext {
selection: Selection,
domain: Domain,
matcher: Matcher,
- targets: Vec<(StoryId, Range)>,
+ targets: Vec,
order: Vec,
target: usize,
offset: usize,
@@ -184,21 +187,25 @@ impl FindContext {
self.target = 0;
self.last_match_end = None;
self.order = (0..self.targets.len()).collect();
- let caret = st.selection.text.filter(|t| t.cell.is_none() && st.selection.cells.is_none());
- if self.domain == Domain::Document
- && let Some(t) = st.selection.text
- && let Some(i) = self.targets.iter().position(|(sid, _)| *sid == t.story)
- {
+ let caret = st.selection.text.filter(|_| st.selection.cells.is_none());
+ // A caret in a searched cell starts there; one in a footnote starts at its story.
+ let start = st.selection.text.and_then(|t| {
+ let at = |want: Option| self.targets.iter().position(|(sid, cell, _)| *sid == t.story && *cell == want);
+ at(t.cell).or_else(|| at(None))
+ });
+ if let Some(i) = start.filter(|_| self.domain == Domain::Document) {
self.order.rotate_left(i);
}
let first = self.order.first().and_then(|i| self.targets.get(*i));
self.offset = match first {
- Some((sid, r)) if self.domain != Domain::Selection => caret.filter(|t| t.story == *sid).map_or(r.start, |t| t.range().end.min(r.end)),
- Some((_, r)) => r.start,
+ Some((sid, cell, r)) if self.domain != Domain::Selection => {
+ caret.filter(|t| t.story == *sid && t.cell == *cell).map_or(r.start, |t| t.range().end.min(r.end))
+ }
+ Some((_, _, r)) => r.start,
None => 0,
};
- if let Some((sid, r)) = first {
- let text = target_text(&st.doc, *sid, r)?;
+ if let Some((sid, cell, r)) = first {
+ let text = target_text(&st.doc, *sid, *cell, r)?;
if text.get(self.offset.saturating_sub(r.start)..).is_none() {
return Err(bad("find", "invalid UTF-8 search cursor"));
}
@@ -209,7 +216,7 @@ impl FindContext {
fn advance_target(&mut self) {
self.target = self.target.saturating_add(1);
self.last_match_end = None;
- self.offset = self.order.get(self.target).and_then(|i| self.targets.get(*i)).map_or(0, |(_, r)| r.start);
+ self.offset = self.order.get(self.target).and_then(|i| self.targets.get(*i)).map_or(0, |(_, _, r)| r.start);
}
fn advance_empty(&mut self, text: &str, relative_end: usize, end: usize) {
@@ -222,8 +229,8 @@ impl FindContext {
}
}
-fn target_text<'a>(d: &'a Document, sid: StoryId, r: &Range) -> Result<&'a str> {
- d.story(sid)
+fn target_text<'a>(d: &'a Document, sid: StoryId, cell: Option, r: &Range) -> Result<&'a str> {
+ d.text_story(sid, cell)
.ok_or_else(|| bad("find", "no such story"))?
.text
.get(r.clone())
@@ -246,7 +253,7 @@ fn scope(s: &Session, p: &Value) -> Result {
let targets = match domain {
Domain::Explicit(sid) => {
let len = d.story(sid).ok_or_else(|| bad("find", "no such story"))?.len();
- vec![(sid, 0..len)]
+ vec![(sid, None, 0..len)]
}
Domain::Story | Domain::Selection => {
if st.selection.cells.is_some() || st.selection.text.is_some_and(|t| t.cell.is_some()) {
@@ -255,10 +262,21 @@ fn scope(s: &Session, p: &Value) -> Result {
let t = st.selection.text.ok_or_else(|| bad("find", "no text selected"))?;
let story = d.story(t.story).ok_or_else(|| bad("find", "no such story"))?;
let r = if domain == Domain::Selection { t.range() } else { 0..story.len() };
- target_text(d, t.story, &r)?;
- vec![(t.story, r)]
+ target_text(d, t.story, None, &r)?;
+ vec![(t.story, None, r)]
}
- Domain::Document => d.stories.values().map(|x| (x.id, 0..x.len())).collect(),
+ // Table cells hold their own text outside `stories`; search them right after the story that anchors the table.
+ Domain::Document => d
+ .stories
+ .values()
+ .flat_map(|x| {
+ let cells = x.tables.iter().flat_map(move |(id, t)| {
+ let cols = t.ncols().max(1);
+ t.cells.iter().enumerate().map(move |(i, c)| (x.id, Some(CellAddr { table: *id, row: i / cols, col: i % cols }), 0..c.text.len()))
+ });
+ std::iter::once((x.id, None, 0..x.len())).chain(cells)
+ })
+ .collect(),
};
let mut context = FindContext {
document: Arc::downgrade(d),
@@ -285,10 +303,14 @@ fn find(s: &mut Session, p: &Value) -> Result {
let context = scope(s, p)?;
let st = s.doc()?;
let mut out = Vec::new();
- for (sid, r) in &context.targets {
- let text = target_text(&st.doc, *sid, r)?;
+ 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))? {
- out.push(json!({"story": sid.0, "start": r.start + m.start, "end": r.start + m.end, "text": text.get(m)}));
+ 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});
+ }
+ out.push(hit);
}
}
s.find_context = Some(context);
@@ -317,8 +339,8 @@ fn change(s: &mut Session, p: &Value) -> Result {
let targets = scope(s, p)?.targets;
s.edit(|d, sel| {
let mut count = 0usize;
- for (sid, r) in targets {
- let Some(story) = d.story(sid) else { continue };
+ for (sid, cell, r) in targets {
+ let Some(story) = d.text_story(sid, cell) else { continue };
// Collect matches first (positions refer to the original text), apply back to front.
let text = story.text.get(r.clone()).ok_or_else(|| bad("find", "invalid selection bounds or UTF-8 boundary"))?;
let edits: Vec<(std::ops::Range, String)> = if grep {
@@ -330,7 +352,7 @@ fn change(s: &mut Session, p: &Value) -> Result {
.into_iter()
.map(|(m, out)| (r.start + m.start..r.start + m.end, out))
.collect();
- let Some(st) = d.story_mut(sid) else { continue };
+ 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() {
@@ -338,7 +360,7 @@ fn change(s: &mut Session, p: &Value) -> Result {
}
count += 1;
}
- if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid && t.cell.is_none()) {
+ if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid && t.cell == cell) {
// Repair byte validity once against the final story, without remapping valid offsets.
let range = t.range();
let start = floor_char_boundary(&st.text, range.start);
@@ -365,8 +387,8 @@ fn find_next(s: &mut Session, p: &Value) -> Result {
let mut context = scope(s, p)?;
let st = s.doc()?;
let mut hit = None;
- while let Some((sid, r)) = context.order.get(context.target).and_then(|i| context.targets.get(*i)).cloned() {
- let text = target_text(&st.doc, sid, &r)?;
+ while let Some((sid, cell, r)) = context.order.get(context.target).and_then(|i| context.targets.get(*i)).cloned() {
+ let text = target_text(&st.doc, sid, cell, &r)?;
let from = context.offset.checked_sub(r.start).ok_or_else(|| bad("find", "invalid search cursor"))?;
if text.get(from..).is_none() {
return Err(bad("find", "invalid UTF-8 search cursor"));
@@ -379,7 +401,7 @@ fn find_next(s: &mut Session, p: &Value) -> Result {
context.advance_empty(text, m.end, b);
continue;
}
- hit = Some((sid, a, b));
+ hit = Some((sid, cell, a, b));
context.last_match_end = Some(b);
if m.is_empty() {
context.advance_empty(text, m.end, b);
@@ -390,17 +412,22 @@ fn find_next(s: &mut Session, p: &Value) -> Result {
}
context.advance_target();
}
- let Some((sid, a, b)) = hit else {
+ let Some((sid, cell, a, b)) = hit else {
s.find_context = Some(context);
return Ok(Value::Null);
};
let frame = st.doc.story(sid).and_then(|x| x.frames.first().copied());
+ let cell_json = cell.map(|c| json!({"table": c.table, "row": c.row, "col": c.col}));
let stm = s.doc_mut()?;
- stm.selection = Selection::text(TextSel { story: sid, anchor: a, focus: b, frame, cell: None });
+ stm.selection = Selection::text(TextSel { story: sid, anchor: a, focus: b, frame, cell });
stm.bump_revision();
context.acknowledge(stm);
s.find_context = Some(context);
- Ok(json!({"story": sid.0, "start": a, "end": b}))
+ let mut out = json!({"story": sid.0, "start": a, "end": b});
+ if let Some(c) = cell_json {
+ out["cell"] = c;
+ }
+ Ok(out)
}
#[cfg(test)]
@@ -1007,6 +1034,31 @@ mod tests {
assert_eq!(s.doc().unwrap().history.undo.len(), history);
}
+ #[test]
+ fn document_scope_searches_and_changes_table_cells() {
+ let (mut s, sid) = scope_session("Ore 12.15 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": "Ore 12.15"})).unwrap();
+ // The caret sits in the first cell, so Find Next goes to the second cell, then the story.
+ let p = json!({"find": "Ore 12.15", "scope": "document"});
+ let cell = designcraft_doc::CellAddr { table, row: 0, col: 1 };
+ assert_eq!(s.execute("find.next", &p).unwrap(), json!({"story": sid.0, "start": 0, "end": 9, "cell": {"table": table, "row": 0, "col": 1}}));
+ assert_eq!(s.doc().unwrap().selection.text.map(|t| (t.story, t.range(), t.cell)), Some((sid, 0..9, Some(cell))));
+ let next = s.execute("find.next", &p).unwrap();
+ assert!(next.get("start").is_some() && next.get("cell").is_none(), "{next}");
+ let hits = s.execute("find.find", &p).unwrap();
+ let hits = hits.as_array().unwrap();
+ assert_eq!(hits.len(), 2);
+ let in_cell: Vec<_> = hits.iter().filter(|h| h.get("cell").is_some()).collect();
+ assert_eq!(in_cell.len(), 1);
+ assert_eq!((in_cell[0]["cell"]["row"].as_u64(), in_cell[0]["cell"]["col"].as_u64()), (Some(0), Some(1)));
+ let n = s.execute("find.change", &json!({"find": "Ore 12.15", "change": "Ore 13.00", "scope": "document"})).unwrap();
+ assert_eq!(n["count"], 2);
+ assert!(s.execute("find.find", &json!({"find": "12.15", "scope": "document"})).unwrap().as_array().unwrap().is_empty());
+ assert_eq!(s.execute("find.find", &json!({"find": "13.00", "scope": "document"})).unwrap().as_array().unwrap().len(), 2);
+ }
+
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);
@@ -1051,12 +1103,15 @@ mod tests {
p["story"] = json!(sid.0);
p["scope"] = json!("selection");
}
+ // Document scope also covers table cells (#1624); an explicit story and footnotes stay top-level.
+ let in_cell = if explicit || note { 0 } else { child.matches("red").count() };
+ let changed = if in_cell > 0 { child.replace("red", "blue") } else { child.to_string() };
s.doc_mut().unwrap().selection = selection.clone();
- assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1);
+ assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1 + in_cell);
scope_hit(&mut s, &p, sid, 0, 3);
s.doc_mut().unwrap().selection = selection.clone();
- assert_eq!(s.execute("find.change", &p).unwrap()["count"], 1);
- assert_eq!(s.doc().unwrap().doc.text_story(sid, selected.cell).unwrap().text, child);
+ assert_eq!(s.execute("find.change", &p).unwrap()["count"], 1 + in_cell);
+ assert_eq!(s.doc().unwrap().doc.text_story(sid, selected.cell).unwrap().text, changed);
assert_eq!(s.doc().unwrap().selection, selection, "parent replacement does not clamp child offsets");
p["find"] = json!("blue");
p["change"] = json!("red");
diff --git a/crates/engine/src/cmd/index.rs b/crates/engine/src/cmd/index.rs
index bb6f09917..38a6bc49d 100644
--- a/crates/engine/src/cmd/index.rs
+++ b/crates/engine/src/cmd/index.rs
@@ -72,7 +72,9 @@ fn add_reference(s: &mut Session, p: &Value) -> Result {
"currentPage" => IndexRange::CurrentPage,
"toEndOfStory" => IndexRange::ToEndOfStory,
"suppressPageRange" => IndexRange::SuppressPageRange,
- "nextParagraphs" => IndexRange::NextParagraphs(p.get("paragraphs").and_then(Value::as_u64).unwrap_or(1) as u32),
+ "nextParagraphs" => {
+ IndexRange::NextParagraphs(p.get("paragraphs").and_then(Value::as_u64).map_or(1, |x| u32::try_from(x).unwrap_or(u32::MAX)))
+ }
"see" => IndexRange::See(target()),
"seeAlso" => IndexRange::SeeAlso(target()),
r => return Err(bad("index.addReference", format!("unknown range `{r}`"))),
@@ -103,8 +105,9 @@ fn resolve(s: &Session, d: &Document, skip: Option) -> Vec<(StoryId, us
IndexRange::ToEndOfStory => page(st.len().saturating_sub(1)),
IndexRange::NextParagraphs(n) => {
let ranges = st.para_ranges();
- let pi = (st.para_at(pos) + *n as usize).min(ranges.len() - 1);
- page(ranges[pi].end.saturating_sub(1).max(ranges[pi].start))
+ let n = usize::try_from(*n).unwrap_or(usize::MAX);
+ let pi = st.para_at(pos).saturating_add(n).min(ranges.len().saturating_sub(1));
+ ranges.get(pi).and_then(|r| page(r.end.saturating_sub(1).max(r.start)))
}
_ => start,
};
diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs
index 047bd90ba..d67f469f0 100644
--- a/crates/engine/src/cmd/layout.rs
+++ b/crates/engine/src/cmd/layout.rs
@@ -161,14 +161,25 @@ pub fn specs() -> Vec {
"Insert Pages…",
["Layout", "Pages"],
Some("Cmd+Shift+P"),
- "{count?: 1, after?: page index (default: last), parent?: id|\"A-Parent\"|\"A\"|null}",
+ "{count?: 1 (1 to 9999), after?: 0-based page index (default: the last page), parent?: id|\"A-Parent\"|\"A\"|null}",
has_doc,
|s, p| {
- let count = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 9999) as usize;
+ const ID: &str = "layout.pages.insert";
+ // A count that is given but is not a number of pages used to insert one page:
+ // `0`, `-1` and `1.5` all came out as 1, and 10000 was quietly capped.
+ let count = match p.get("count").filter(|v| !v.is_null()) {
+ None => 1,
+ Some(v) => match v.as_u64().filter(|n| (1..=9999).contains(n)).map(|n| n as usize) {
+ Some(n) => n,
+ None => return Err(bad(ID, format!("count {v}: 1 to 9999 pages"))),
+ },
+ };
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));
- let parent = parent_ref("layout.pages.insert", &st.doc, p.get("parent"))?.unwrap_or_else(|| st.doc.parents.first().map(|x| x.id));
+ 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)?;
Ok(json!({"pages": ids.len(), "total": d.page_count()}))
@@ -2497,3 +2508,34 @@ mod parent_base_tests {
#[cfg(test)]
#[path = "parent_delete_tests.rs"]
mod parent_delete_tests;
+
+#[cfg(test)]
+mod insert_count_tests {
+ use serde_json::json;
+
+ use crate::Session;
+
+ /// A count that is given but is not a number of pages used to insert one page anyway:
+ /// `{"count": 0}` added a page, `-1` and `1.5` added one each, and `10000` was quietly capped
+ /// to 9999. A position past the last page appends, which is deliberate.
+ #[test]
+ fn a_page_count_insert_pages_cannot_mean_is_refused() {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap();
+ let total = |s: &mut Session| s.execute("layout.pages.insert", &json!({"count": 1})).unwrap()["total"].as_u64().unwrap();
+ assert_eq!(total(&mut s), 3, "one page added");
+
+ for params in [json!({"count": 0}), json!({"count": -1}), json!({"count": 1.5}), json!({"count": 10000}), json!({"count": "two"})] {
+ let e = s.execute("layout.pages.insert", ¶ms).expect_err("refused").to_string();
+ assert!(e.contains("1 to 9999 pages"), "{params}: {e}");
+ }
+ // Nothing was inserted by any of those.
+ assert_eq!(s.doc().unwrap().doc.page_count(), 3);
+ // A position past the last page still appends, which `insert_pages_bounds.rs` pins.
+ let r = s.execute("layout.pages.insert", &json!({"after": 99})).unwrap();
+ assert_eq!((r["pages"].as_u64(), r["total"].as_u64()), (Some(1), Some(4)));
+ // The positions it does have still work: after the first page of three.
+ let r = s.execute("layout.pages.insert", &json!({"count": 2, "after": 0})).unwrap();
+ assert_eq!((r["pages"].as_u64(), r["total"].as_u64()), (Some(2), Some(6)));
+ }
+}
diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs
index 4ccb5c823..87f601604 100644
--- a/crates/engine/src/cmd/object.rs
+++ b/crates/engine/src/cmd/object.rs
@@ -686,7 +686,7 @@ pub fn specs() -> Vec {
"Align",
["Window", "Object & Layout", "Align"],
None,
- "{edge: left|hcenter|right|top|vcenter|bottom, to?: selection|keyObject|margins|page|spread, ids?}",
+ "{edge: left|hcenter|right|top|vcenter|bottom, to?: selection|keyObject|margins|page|spread (default: keyObject while a key object is set), ids?}",
has_selection,
align
),
@@ -708,7 +708,7 @@ pub fn specs() -> Vec {
super::always,
|s, p| {
if let Some(n) = p.get("sides").and_then(Value::as_u64) {
- s.prefs.polygon_sides = (n as u32).clamp(3, 100);
+ s.prefs.polygon_sides = n.clamp(3, 100) as u32;
}
if let Some(v) = p.get("starInset").and_then(Value::as_f64) {
s.prefs.star_inset = (v / 100.0).clamp(0.0, 1.0);
@@ -972,7 +972,7 @@ fn frame_create(s: &mut Session, p: &Value) -> Result {
let text = super::text_param(p, "text");
let caret = bool_or(p, "caret", content == "text");
let vertical = bool_or(p, "vertical", false);
- let sides = p.get("sides").and_then(Value::as_u64).map_or(s.prefs.polygon_sides, |v| v as u32).clamp(3, 100);
+ let sides = p.get("sides").and_then(Value::as_u64).unwrap_or(u64::from(s.prefs.polygon_sides)).clamp(3, 100) as u32;
let inset = p.get("starInset").and_then(Value::as_f64).map_or(s.prefs.star_inset, |v| v / 100.0).clamp(0.0, 1.0);
let rect = Rect::new(rect.x0, rect.y0, rect.x1.max(rect.x0 + 0.5), rect.y1.max(rect.y0 + 0.5));
s.edit(|d, sel| {
@@ -2517,7 +2517,7 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result {
let Some(tf) = d.item_mut(*id).and_then(Item::text_frame_mut) else { continue };
let o = &mut tf.options;
if let Some(v) = p.get("columns").and_then(Value::as_u64) {
- o.columns = (v as u32).clamp(1, 40);
+ o.columns = v.clamp(1, 40) as u32;
}
if let Some(v) = p.get("gutter").and_then(Value::as_f64) {
o.gutter = v.max(0.0);
@@ -3074,8 +3074,18 @@ fn align_reference(d: &Document, ids: &[ItemId], to: &str, key: Option)
fn align(s: &mut Session, p: &Value) -> Result {
let ids = targets(s, p)?;
let edge = str_param(p, "edge").unwrap_or("left").to_string();
- let to = str_param(p, "to").map(str::to_string).unwrap_or_else(|| if ids.len() > 1 { "selection".into() } else { "page".into() });
- let key = s.doc()?.selection.key;
+ // A key object counts only while it is one of several items aligned. With one, InDesign's
+ // Align To switches to Key Object, so it is also the default target.
+ let key = s.doc()?.selection.key.filter(|k| ids.len() > 1 && ids.contains(k));
+ let to = str_param(p, "to").map(str::to_string).unwrap_or_else(|| {
+ if key.is_some() {
+ "keyObject".into()
+ } else if ids.len() > 1 {
+ "selection".into()
+ } else {
+ "page".into()
+ }
+ });
s.edit(|d, _| {
let r = align_reference(d, &ids, &to, key).ok_or_else(|| bad("object.align", "nothing to align"))?;
for id in &ids {
@@ -3788,6 +3798,21 @@ mod polygon_tests {
.unwrap();
assert_eq!(s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.subpaths[0].anchors.len(), 3);
}
+
+ #[test]
+ fn polygon_sides_clamp_before_narrowing() {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({})).unwrap();
+ let r = s.execute("tool.polygonSettings", &json!({"sides": 4294967301u64})).unwrap();
+ assert_eq!(r["sides"], 100);
+ let r = s.execute("tool.polygonSettings", &json!({"sides": 4294967297u64})).unwrap();
+ assert_eq!(r["sides"], 100);
+ let id =
+ s.execute("frame.create", &json!({"rect": [0, 0, 50, 50], "shape": "polygon", "sides": 4294967301u64, "starInset": 0})).unwrap()["id"]
+ .as_u64()
+ .unwrap();
+ assert_eq!(s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.subpaths[0].anchors.len(), 100);
+ }
}
#[cfg(test)]
@@ -4498,6 +4523,21 @@ mod column_rule_tests {
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(opts(&s), before);
}
+
+ /// A column count past `u32::MAX` was cast to `u32` before the clamp, so
+ /// 2^32 + 1 wrapped to 1 column instead of clamping to the maximum, 40.
+ #[test]
+ fn huge_column_count_clamps_to_the_maximum() {
+ 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"})).unwrap();
+ let id = ItemId(r["id"].as_u64().unwrap());
+ let columns = |s: &Session| s.doc().unwrap().doc.item(id).unwrap().text_frame().unwrap().options.columns;
+ for (given, want) in [(4_294_967_297_u64, 40), (u64::MAX, 40), (0, 1)] {
+ s.execute("object.textFrameOptions", &json!({"ids": [id.0], "columns": given})).unwrap();
+ assert_eq!(columns(&s), want, "columns {given}");
+ }
+ }
}
#[cfg(test)]
diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs
index f2d33ccfa..d0ed58b03 100644
--- a/crates/engine/src/cmd/package.rs
+++ b/crates/engine/src/cmd/package.rs
@@ -135,8 +135,9 @@ impl OutputRoot {
{
use std::os::unix::fs::PermissionsExt;
- // Match fs::write for a new output: 0666 restricted by the caller's umask.
- builder.permissions(std::fs::Permissions::from_mode(0o666));
+ // Like fs::write for a new output: request 0666 and let the process umask decide the
+ // effective mode (0644 with the usual 022, 0664 with 002).
+ builder.permissions(std::fs::Permissions::from_mode(0o666)); // NOSONAR
}
let file = builder.tempfile_in(parent).map_err(|e| path_error(path, e))?;
#[cfg(unix)]
diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs
index c54b55084..1053fafc0 100644
--- a/crates/engine/src/cmd/place_text.rs
+++ b/crates/engine/src/cmd/place_text.rs
@@ -233,11 +233,12 @@ fn same_rect(a: Rect, b: Rect) -> bool {
}
/// Thread new frames until the story fits: first into the columns after the last frame's column
-/// on its page (when it sits in one), then one frame per column on each page it adds. With
-/// `primary` (Smart Text Reflow of the primary text frame), each added page gets one frame on the
-/// live area with the page's columns instead. Empty frames it made at the end of the thread are
-/// removed again, except the first on a page it didn't add; a page it added that holds only such
-/// a frame goes with it. Returns pages added.
+/// on its page (when it sits in one), then one frame per column on each page that follows it,
+/// whatever else the page holds, and on each page it adds at the end of the document, as InDesign
+/// does. With `primary` (Smart Text Reflow of the primary text frame), pages are added right after
+/// the last frame's page, each with one frame on the live area with the page's columns instead.
+/// Empty frames it made at the end of the thread are removed again; a page it added that holds
+/// only such a frame goes with it. Returns pages added.
/// A story on a parent spread doesn't autoflow: its frames thread only on that parent. Errors
/// name `cmd`, the command that autoflows.
pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &str, primary: bool) -> Result {
@@ -252,6 +253,8 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s
let mut made = Vec::new();
let mut new_pages = Vec::new();
let mut first = true;
+ // 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 Some(at) = overset else { break };
@@ -267,16 +270,33 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s
let rects = flow_rects(d, dir, vertical, page);
let next = d.item(last).and_then(|it| rects.iter().position(|r| same_rect(*r, it.bounds()))).map_or(rects.len(), |k| k + 1);
if let (Some(rest), Some((si, _))) = (rects.get(next..).filter(|r| !r.is_empty()), d.page_loc(page)) {
- for r in rest.iter().take(want) {
+ // Whole pages: an estimate that stops inside one would leave its later columns out
+ // of the thread for good. The cleanup below takes the frames the text doesn't reach.
+ for r in rest {
prev = add_flow_frame(d, si, *r, layer, prev)?;
made.push(prev);
}
continue;
}
}
+ following = following.max(page + 1);
+ if !primary && following < d.page_count() {
+ let mut left = want;
+ while left > 0 && following < d.page_count() {
+ let Some((si, _)) = d.page_loc(following) else { break };
+ for r in flow_rects(d, dir, vertical, following) {
+ prev = add_flow_frame(d, si, r, layer, prev)?;
+ made.push(prev);
+ left = left.saturating_sub(1);
+ }
+ following += 1;
+ }
+ continue;
+ }
if added >= max_pages {
return Err(bad(cmd, format!("autoflow stopped after {max_pages} pages")));
}
+ // Past the following pages, `page` is the document's last: autoflow adds pages at its end.
let per_page = if vertical { 1 } else { d.page(page).map_or(1, |p| p.column_rects().len().max(1)) };
let pages = want.div_ceil(per_page).clamp(1, max_pages - added);
let parent = d.page_loc(page).and_then(|(si, pi)| d.spreads[si].pages[pi].parent);
@@ -304,16 +324,23 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s
}
let page = d.page_of_item(f);
if d.page_of_item(*before) != page {
- // The first frame on its page: the estimate added more pages than the text needs.
- // Drop the page too when autoflow added it and the frame is all it holds.
- let Some(pg) = page.filter(|pg| d.page(*pg).is_some_and(|p| new_pages.contains(&p.id))) else { break };
- let alone = d.spreads.iter().flat_map(|sp| sp.items.iter()).all(|it| it.id == f || d.page_of_item(it.id) != Some(pg));
- if !alone {
- break;
+ // The first frame on its page: the estimate asked for more pages than the text needs.
+ // Drop the page too when autoflow added it and the frame is all it holds; a page the
+ // document had keeps whatever else is on it.
+ match page.filter(|pg| d.page(*pg).is_some_and(|p| new_pages.contains(&p.id))) {
+ Some(pg) => {
+ let alone = d.spreads.iter().flat_map(|sp| sp.items.iter()).all(|it| it.id == f || d.page_of_item(it.id) != Some(pg));
+ if !alone {
+ break;
+ }
+ d.remove_item(f)?;
+ d.delete_pages(&[pg])?;
+ added = added.saturating_sub(1);
+ }
+ None => {
+ d.remove_item(f)?;
+ }
}
- d.remove_item(f)?;
- d.delete_pages(&[pg])?;
- added = added.saturating_sub(1);
} else {
d.remove_item(f)?;
}
@@ -568,6 +595,141 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
+ /// A facing-pages document with two columns, its inside margin wider than the outside one, so
+ /// left and right pages have their columns in different places. A graphic frame sits on the
+ /// page `marker` (0-based). Returns the session and the marker's id.
+ fn facing_document(pages: usize, marker: usize) -> (Session, u64) {
+ let mut s = Session::new();
+ let margins = json!({"top": 54, "bottom": 54, "inside": 72, "outside": 36});
+ s.execute("file.new", &json!({"preset": "Letter", "pages": pages, "facingPages": true, "columns": 2, "gutter": 18, "margins": margins}))
+ .unwrap();
+ let d = &s.doc().unwrap().doc;
+ let (si, _) = d.page_loc(marker).unwrap();
+ let m = d.page(marker).unwrap().margin_rect();
+ let rect = [m.x0 + 20.0, m.y0 + 20.0, m.x0 + 120.0, m.y0 + 120.0];
+ let id = s.execute("frame.create", &json!({"rect": rect, "spread": si, "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
+ // Selected, an empty frame would take the placed text.
+ s.execute("edit.deselectAll", &json!({})).unwrap();
+ (s, id)
+ }
+
+ /// Every frame of the story on the column of its page, the story running through the pages in
+ /// order, two frames a page.
+ fn assert_frames_in_columns(d: &Document, frames: &[designcraft_doc::ItemId]) {
+ for (i, f) in frames.iter().enumerate() {
+ let page = d.page_of_item(*f).unwrap();
+ assert_eq!(page, i / 2, "frame {i}");
+ assert_eq!(d.item(*f).unwrap().bounds(), d.page(page).unwrap().column_rects()[i % 2], "frame {i}");
+ }
+ }
+
+ #[test]
+ fn autoflow_fills_the_pages_that_follow_before_adding_any() {
+ // Gap 26: autoflow inserted pages after the first frame's page, in front of the empty
+ // pages the document already had. InDesign threads frames on those first.
+ let dir = std::env::temp_dir().join(format!("dc-place-following-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("long.txt");
+ std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 24].join("\n")).unwrap();
+ let (mut s, marker) = facing_document(6, 2);
+ let ids: Vec<_> = (0..6).map(|i| s.doc().unwrap().doc.page(i).unwrap().id).collect();
+ let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "page": 1, "autoflow": true})).unwrap();
+ assert_eq!(r["overset"], false, "{r}");
+ assert_eq!(r["pagesAdded"], 0, "{r}");
+ let d = &s.doc().unwrap().doc;
+ assert_eq!((0..d.page_count()).map(|i| d.page(i).unwrap().id).collect::>(), ids);
+ let sid = StoryId(r["story"].as_u64().unwrap());
+ let frames = d.story(sid).unwrap().frames.clone();
+ // Past page 3, which holds the marker: frames go on a page whatever else it holds.
+ assert!(d.page_of_item(*frames.last().unwrap()).unwrap() > 2, "{} frames", frames.len());
+ assert_frames_in_columns(d, &frames);
+ // The text ends before the document does: no empty frame after it.
+ let cs = designcraft_compose::compose_story(d, sid, &Default::default());
+ assert!(frames.iter().all(|f| cs.frame(*f).is_some_and(|ft| !ft.range.is_empty())));
+ assert!(d.page_of_item(*frames.last().unwrap()).unwrap() < 5);
+ assert_eq!(d.page_of_item(designcraft_doc::ItemId(marker)), Some(2));
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn autoflow_adds_pages_at_the_end_of_the_document() {
+ let dir = std::env::temp_dir().join(format!("dc-place-end-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("long.txt");
+ std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 60].join("\n")).unwrap();
+ let (mut s, marker) = facing_document(3, 2);
+ let ids: Vec<_> = (0..3).map(|i| s.doc().unwrap().doc.page(i).unwrap().id).collect();
+ let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "page": 1, "autoflow": true})).unwrap();
+ assert_eq!(r["overset"], false, "{r}");
+ let d = &s.doc().unwrap().doc;
+ assert!(d.page_count() > 4, "{r}");
+ assert_eq!(r["pagesAdded"].as_u64(), Some(d.page_count() as u64 - 3), "{r}");
+ // The document's own pages come first, the added ones after the last of them.
+ assert_eq!((0..3).map(|i| d.page(i).unwrap().id).collect::>(), ids);
+ assert_eq!(d.page_of_item(designcraft_doc::ItemId(marker)), Some(2));
+ let frames = d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames.clone();
+ assert_frames_in_columns(d, &frames);
+ assert_eq!(d.page_of_item(*frames.last().unwrap()), Some(d.page_count() - 1));
+ // One undo step takes the frames, the pages and the story away.
+ s.execute("edit.undo", &json!({})).unwrap();
+ let d = &s.doc().unwrap().doc;
+ assert_eq!((0..d.page_count()).map(|i| d.page(i).unwrap().id).collect::>(), ids);
+ assert!(d.story(StoryId(r["story"].as_u64().unwrap())).is_none());
+ assert_eq!(d.spreads.iter().map(|sp| sp.items.len()).sum::(), 1);
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn autoflow_fills_every_column_of_a_following_page() {
+ // A first frame across the whole live area holds twice what a column does, so autoflow
+ // asks for half the frames it needs. Asked page by page, it left the later columns of a
+ // page out of the thread and went on to the next page.
+ let dir = std::env::temp_dir().join(format!("dc-place-under-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("long.txt");
+ std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 30].join("\n")).unwrap();
+ let mut s = Session::new();
+ s.execute("file.new", &json!({"preset": "Letter", "pages": 6, "facingPages": false, "columns": 2, "gutter": 18, "margins": 54})).unwrap();
+ let m = s.doc().unwrap().doc.page(0).unwrap().margin_rect();
+ let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "rect": [m.x0, m.y0, m.x1, m.y1], "autoflow": true})).unwrap();
+ assert_eq!(r["overset"], false, "{r}");
+ assert_eq!(r["pagesAdded"], 0, "{r}");
+ let d = &s.doc().unwrap().doc;
+ let frames = &d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames;
+ assert_eq!(d.item(frames[0]).unwrap().bounds(), m);
+ assert!(d.page_of_item(*frames.last().unwrap()).unwrap() >= 3, "{} frames", frames.len());
+ for (k, f) in frames[1..].iter().enumerate() {
+ let page = d.page_of_item(*f).unwrap();
+ assert_eq!(page, 1 + k / 2, "frame {}", k + 1);
+ assert_eq!(d.item(*f).unwrap().bounds(), d.page(page).unwrap().column_rects()[k % 2], "frame {}", k + 1);
+ }
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn autoflow_leaves_no_empty_frame_on_the_pages_that_follow() {
+ // A small first frame makes autoflow ask for more frames than the columns need; the ones
+ // it set on the following pages past the end of the text go again, the pages stay.
+ let dir = std::env::temp_dir().join(format!("dc-place-small-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("long.txt");
+ std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 12].join("\n")).unwrap();
+ let mut s = Session::new();
+ s.execute("file.new", &json!({"preset": "Letter", "pages": 8, "facingPages": false, "margins": 54})).unwrap();
+ let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "rect": [100, 100, 200, 130], "autoflow": true})).unwrap();
+ assert_eq!(r["overset"], false, "{r}");
+ assert_eq!(r["pagesAdded"], 0, "{r}");
+ let d = &s.doc().unwrap().doc;
+ assert_eq!(d.page_count(), 8);
+ let sid = StoryId(r["story"].as_u64().unwrap());
+ let frames = &d.story(sid).unwrap().frames;
+ assert!(frames.len() >= 2, "{r}");
+ let cs = designcraft_compose::compose_story(d, sid, &Default::default());
+ assert!(frames.iter().all(|f| cs.frame(*f).is_some_and(|ft| !ft.range.is_empty())), "{} frames", frames.len());
+ assert_eq!(d.spreads.iter().map(|sp| sp.items.len()).sum::(), frames.len());
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
#[test]
fn autoflow_adds_only_the_pages_breaks_ask_for() {
// Regression (#262): a frame a break ends early made autoflow take it for a frame that
diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs
index 4f79f13ad..0bb280780 100644
--- a/crates/engine/src/cmd/print.rs
+++ b/crates/engine/src/cmd/print.rs
@@ -17,7 +17,7 @@ pub fn specs() -> Vec {
"Print…",
["File"],
Some("Cmd+P"),
- "{printer?, copies?: 1, pages?: \"1-3,5\", spreads?, marks?, bleed?, dryRun?: bool (export and preview submission without printing)} → {printer, copies, pages, command, bytes}",
+ "{printer?, copies?: 1, pages?: \"1-3,5\", spreads?, marks?, bleed?, dryRun?: bool (export and preview submission without printing), created?: int (Unix seconds, as file.exportPdf)} → {printer, copies, pages, command, bytes}",
has_doc,
print
),
@@ -95,7 +95,8 @@ mod tests {
fn print_submits_the_selected_export_and_reports_refusal() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 4})).unwrap();
- let params = json!({"printer": "Office (east)", "copies": 2, "pages": "2-3", "marks": true, "bleed": true});
+ // A fixed creation date: both exports would otherwise take the clock's second.
+ let params = json!({"printer": "Office (east)", "copies": 2, "pages": "2-3", "marks": true, "bleed": true, "created": 1_700_000_000});
let expected = {
let d = &s.doc().unwrap().doc;
let options = super::super::export::options(¶ms, d.page_count()).unwrap();
@@ -118,5 +119,6 @@ mod tests {
assert!(refusal.to_string().contains("printer offline"));
print_with(&mut s, &json!({"dryRun": true}), |_, _| panic!("dry runs must not spool")).unwrap();
assert!(print_with(&mut s, &json!({"pages": "9"}), |_, _| panic!("invalid ranges must not spool")).is_err());
+ assert!(print_with(&mut s, &json!({"created": "today"}), |_, _| panic!("an invalid date must not spool")).is_err());
}
}
diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs
index 7049c2f39..063be89ac 100644
--- a/crates/engine/src/cmd/table.rs
+++ b/crates/engine/src/cmd/table.rs
@@ -297,7 +297,7 @@ fn table_specs() -> Vec {
"Cell Options",
[],
None,
- "{fill?: swatch, tint?: 0..1, insets?: n | [t,l,b,r], vj? | verticalJustification?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|fixed|emboxHeight, firstBaselineMin?: pt, text?, stroke?: {weight?, color?: swatch, tint?: 0..1, type?: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed | {kind:…}, gapColor?: swatch, gapTint?: 0..1, edges?: name | [names] (all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical; default all)}, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — top/left/bottom/right are the target range's outer sides, innerHorizontal/innerVertical the lines between its rows/columns",
+ "{fill?: swatch, tint?: 0..1, insets?: n | [t,l,b,r], vj? | verticalJustification?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|fixed|emboxHeight, firstBaselineMin?: pt, rotation?: 0|90|180|270 (Cell Options › Text › Rotation; kept and written back to IDML, composed horizontally for now), text?, stroke?: {weight?, color?: swatch, tint?: 0..1, type?: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed | {kind:…}, gapColor?: swatch, gapTint?: 0..1, edges?: name | [names] (all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical; default all)}, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — top/left/bottom/right are the target range's outer sides, innerHorizontal/innerVertical the lines between its rows/columns",
in_table,
set_cell
),
@@ -971,6 +971,15 @@ fn set_cell(s: &mut Session, p: &Value) -> Result {
None => None,
};
let (first_baseline, first_baseline_min) = first_baseline_params(p, ID)?;
+ // Cell Options › Text › Rotation. The value is kept and written back to IDML; only 0 is
+ // composed so far, which the IDML import reports (#1468).
+ let rotation = match p.get("rotation").filter(|v| !v.is_null()) {
+ Some(v) => match v.as_f64().map(|d| d.rem_euclid(360.0)) {
+ Some(d) if d == 0.0 || d == 90.0 || d == 180.0 || d == 270.0 => Some(d),
+ _ => return Err(bad(ID, "rotation: 0, 90, 180 or 270 degrees")),
+ },
+ None => None,
+ };
let text = str_param(p, "text").map(|_| super::text_param(p, "text"));
let stroke = match p.get("stroke").filter(|v| !v.is_null()) {
Some(sv) => Some((StrokeEdit::parse(doc, sv, ID)?, EdgeSel::parse(sv.get("edges"), ID)?)),
@@ -1021,6 +1030,9 @@ fn set_cell(s: &mut Session, p: &Value) -> Result {
if let Some(v) = first_baseline_min {
cell.first_baseline_min = v;
}
+ if let Some(v) = rotation {
+ cell.rotation = v;
+ }
if let Some(tx) = &text {
let len = cell.text.len();
cell.text.replace(0..len, tx);
@@ -1393,14 +1405,14 @@ fn parse_alt(v: &Value) -> Option {
}
let d = AltFills::default();
Some(AltFills {
- first: v.get("first").and_then(Value::as_u64).map_or(d.first, |x| x as u32),
+ first: v.get("first").and_then(Value::as_u64).map_or(d.first, |x| u32::try_from(x).unwrap_or(u32::MAX)),
first_color: color_param(v, "firstColor").unwrap_or(d.first_color),
first_tint: v.get("firstTint").and_then(Value::as_f64).map_or(d.first_tint, |x| x as f32),
- next: v.get("next").and_then(Value::as_u64).map_or(d.next, |x| x as u32),
+ next: v.get("next").and_then(Value::as_u64).map_or(d.next, |x| u32::try_from(x).unwrap_or(u32::MAX)),
next_color: color_param(v, "nextColor").unwrap_or(d.next_color),
next_tint: v.get("nextTint").and_then(Value::as_f64).map_or(d.next_tint, |x| x as f32),
- skip_first: v.get("skipFirst").and_then(Value::as_u64).map_or(0, |x| x as u32),
- skip_last: v.get("skipLast").and_then(Value::as_u64).map_or(0, |x| x as u32),
+ skip_first: v.get("skipFirst").and_then(Value::as_u64).map_or(0, |x| u32::try_from(x).unwrap_or(u32::MAX)),
+ skip_last: v.get("skipLast").and_then(Value::as_u64).map_or(0, |x| u32::try_from(x).unwrap_or(u32::MAX)),
})
}
@@ -1548,7 +1560,7 @@ fn get(s: &mut Session, p: &Value) -> Result {
.filter_map(|(r, c)| {
let cell = t.cell(r, c)?;
Some(json!({"row": r, "col": c, "rowSpan": cell.row_span, "colSpan": cell.col_span, "text": cell.text.text, "fill": cell.fill,
- "insets": cell.insets, "vj": cell.vj, "firstBaseline": cell.first_baseline, "firstBaselineMin": cell.first_baseline_min, "style": cell.style}))
+ "insets": cell.insets, "vj": cell.vj, "firstBaseline": cell.first_baseline, "firstBaselineMin": cell.first_baseline_min, "rotation": cell.rotation, "style": cell.style}))
})
.collect();
Ok(json!({
@@ -1832,6 +1844,16 @@ mod style_tests {
let cell = got["cells"].as_array().unwrap().iter().find(|c| c["row"] == 0 && c["col"] == 0).unwrap().clone();
assert_eq!((cell["firstBaseline"].as_str(), cell["firstBaselineMin"].as_f64()), (Some("capHeight"), Some(3.0)));
assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaseline": "middle"})).is_err());
+ // Cell Options › Text › Rotation: carried and written back, composed horizontally so far,
+ // which the IDML import reports (#1468).
+ for (given, want) in [(90, 90.0), (270, 270.0), (0, 0.0), (-90, 270.0), (450, 90.0)] {
+ s.execute("table.setCell", &json!({"row": 0, "col": 0, "rotation": given})).unwrap_or_else(|e| panic!("{given}: {e}"));
+ let cell = s.execute("table.get", &json!({})).unwrap()["cells"][0].clone();
+ assert_eq!(cell["rotation"].as_f64(), Some(want), "{given}");
+ }
+ for bad in [45, 1, 100] {
+ assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "rotation": bad})).is_err(), "{bad}");
+ }
assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaselineMin": -1})).is_err());
s.execute("style.cell.create", &json!({"name": "Leading", "firstBaseline": "leading"})).unwrap();
s.execute("style.cell.apply", &json!({"name": "Leading", "row": 1, "col": 1})).unwrap();
diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs
index 12bd540b7..dbfbcfe15 100644
--- a/crates/engine/src/cmd/text.rs
+++ b/crates/engine/src/cmd/text.rs
@@ -1502,12 +1502,29 @@ fn ruby_cmd(s: &mut Session, p: &Value) -> Result {
/// A character fill must name a swatch of the document.
pub(crate) fn validate_char_fill(document: &designcraft_doc::Document, attrs: &CharAttrs, command_id: &str) -> Result<()> {
+ validate_char_size(attrs, command_id)?;
match &attrs.fill {
Some(fill) => super::object::known_swatch(document, fill, command_id, "fill"),
None => Ok(()),
}
}
+/// Type size is 0.1 to 1296 pt, the range this engine already applies to a ruby annotation's
+/// size. Nothing checked it for ordinary text, so `{"size": 0}` set type with no size, a negative
+/// size was accepted as well, and 1e9 pt asked the composer for a line a million times the page.
+pub(crate) fn validate_char_size(attrs: &CharAttrs, command_id: &str) -> Result<()> {
+ match attrs.size {
+ Some(size) if !(MIN_TYPE_SIZE..=MAX_TYPE_SIZE).contains(&size) => {
+ Err(bad(command_id, format!("size {size}: {MIN_TYPE_SIZE} to {MAX_TYPE_SIZE} pt")))
+ }
+ _ => Ok(()),
+ }
+}
+
+/// Type size limits (InDesign's, and the ones `type.ruby` already uses).
+pub(crate) const MIN_TYPE_SIZE: f64 = 0.1;
+pub(crate) const MAX_TYPE_SIZE: f64 = 1296.0;
+
/// Byte `pos` is the hidden paragraph mark of an *empty* paragraph: the only character on that
/// line, and so the one a line-affecting attribute like leading can land on right away instead of
/// waiting for the next typed character (otherwise an empty line could never get its own leading).
@@ -3911,3 +3928,42 @@ mod pick_up_format_tests {
let _ = ItemId(rect);
}
}
+
+#[cfg(test)]
+mod size_bounds_tests {
+ use serde_json::json;
+
+ use crate::Session;
+
+ /// Type size was not checked anywhere: `{"size": 0}` set type with no size at all, a negative
+ /// size was accepted the same way, and 1e9 pt asked the composer for a line a million times
+ /// the height of the page. The range is the one `type.ruby` already applies, 0.1 to 1296 pt.
+ #[test]
+ fn a_type_size_outside_the_range_is_refused() {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({"pages": 1})).unwrap();
+ let f = s.execute("frame.create", &json!({"rect": [20, 20, 400, 300], "content": "text", "text": "Hello world"})).unwrap();
+ let select = |s: &mut Session| {
+ s.execute("text.select", &json!({"story": f["story"].clone(), "anchor": 0, "focus": 5})).unwrap();
+ };
+ for size in [0.0, -12.0, 0.05, 1297.0, 1e9] {
+ select(&mut s);
+ let e = s.execute("type.char", &json!({"size": size})).expect_err("refused").to_string();
+ assert!(e.contains("0.1 to 1296 pt"), "{size}: {e}");
+ }
+ // The sizes in range still apply, including the ends.
+ for size in [0.1, 12.0, 1296.0] {
+ select(&mut s);
+ s.execute("type.char", &json!({"size": size})).unwrap_or_else(|e| panic!("{size}: {e}"));
+ assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"].as_f64(), Some(size));
+ }
+ // And the styles that carry character attributes are checked the same way.
+ for (command, params) in [
+ ("style.character.create", json!({"name": "Tiny", "chars": {"size": 0}})),
+ ("style.paragraph.create", json!({"name": "Huge", "chars": {"size": 2000}})),
+ ] {
+ let e = s.execute(command, ¶ms).expect_err("refused").to_string();
+ assert!(e.contains("0.1 to 1296 pt"), "{command}: {e}");
+ }
+ }
+}
diff --git a/crates/engine/src/cmd/thread.rs b/crates/engine/src/cmd/thread.rs
index d5056d463..51bab42db 100644
--- a/crates/engine/src/cmd/thread.rs
+++ b/crates/engine/src/cmd/thread.rs
@@ -18,7 +18,7 @@ pub fn specs() -> Vec {
"Thread Text Frames",
[],
None,
- "{from?, to?, rect?: [x0,y0,x1,y1] (spread coords), spread?, autoflow?: bool} — threads frame `to` right after frame `from` (between `from` and the frame that followed it, if any). `to` is an empty frame or a text frame nothing threads into yet; its text joins the end of the story. Give `rect` in place of `from` or `to` to thread a new text frame made there on `spread`: `{from, rect}` adds it after `from`, `{rect, to}` in front of `to`. autoflow: then add pages with threaded frames until the story fits. Selects the frame that joined the thread. → {story, from, to, overset, pagesAdded}",
+ "{from?, to?, rect?: [x0,y0,x1,y1] (spread coords), spread?, autoflow?: bool} — threads frame `to` right after frame `from` (between `from` and the frame that followed it, if any). `to` is an empty frame or a text frame nothing threads into yet; its text joins the end of the story. Give `rect` in place of `from` or `to` to thread a new text frame made there on `spread`: `{from, rect}` adds it after `from`, `{rect, to}` in front of `to`. autoflow: then thread a frame per column on the pages that follow, adding pages at the end of the document until the story fits. Selects the frame that joined the thread. → {story, from, to, overset, pagesAdded}",
has_doc,
thread
),
diff --git a/crates/engine/src/cmd/xref.rs b/crates/engine/src/cmd/xref.rs
index d462f3d1b..446148095 100644
--- a/crates/engine/src/cmd/xref.rs
+++ b/crates/engine/src/cmd/xref.rs
@@ -22,7 +22,7 @@ pub fn specs() -> Vec {
"New Hyperlink Destination…",
["Type", "Hyperlinks & Cross-References"],
None,
- "{name} — a text anchor at the insertion point",
+ "{name} — a text anchor at the insertion point; the name is the document's, so it must not be one another anchor already has",
in_story_text,
create_anchor
),
@@ -103,6 +103,14 @@ fn insert(s: &mut Session, p: &Value) -> Result {
fn create_anchor(s: &mut Session, p: &Value) -> Result {
let name = str_param(p, "name").ok_or_else(|| bad("anchor.create", "missing name"))?.to_string();
+ if name.trim().is_empty() {
+ return Err(bad("anchor.create", "a text anchor needs a name (a cross-reference and a hyperlink find it by that name)"));
+ }
+ // Two anchors of the same name made every reference to it ambiguous: `xref.insert` and
+ // `hyperlink.create` take the first they find, and a file writes both under one name.
+ if s.doc()?.doc.stories.values().any(|st| st.anchors.iter().any(|a| a.name == name)) {
+ return Err(bad("anchor.create", format!("a text anchor named `{name}` is already in this document (anchor.list names them)")));
+ }
s.edit(|d, sel| {
let t = sel.text.ok_or_else(|| bad("anchor.create", "no insertion point"))?;
let st = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
@@ -215,6 +223,44 @@ mod tests {
assert_eq!(s.execute("xref.list", &json!({})).unwrap()[0]["text"], "(p. 3)");
}
+ /// Two text anchors of the same name made every reference to it ambiguous: `xref.insert
+ /// {"anchor": "A"}` and `hyperlink.create {"anchor": "A"}` take the first they find, and an
+ /// exported file writes both destinations under one name.
+ #[test]
+ fn a_text_anchor_name_is_the_documents_so_it_is_not_reused() {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap();
+ let a = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Photo one here"})).unwrap();
+ let select = |s: &mut Session, story: serde_json::Value, from: usize| {
+ s.execute("text.select", &json!({"story": story, "anchor": from, "focus": from})).unwrap();
+ };
+ select(&mut s, a["story"].clone(), 0);
+ s.execute("anchor.create", &json!({"name": "Photo 1"})).unwrap();
+
+ // The same name again, in the same story and in another one.
+ select(&mut s, a["story"].clone(), 10);
+ let e = s.execute("anchor.create", &json!({"name": "Photo 1"})).expect_err("refused").to_string();
+ assert!(e.contains("already in this document"), "{e}");
+ let sp = s.doc().unwrap().doc.page_loc(1).unwrap().0;
+ let b = s.execute("frame.create", &json!({"spread": sp, "rect": [72, 72, 400, 200], "content": "text", "text": "Photo two"})).unwrap();
+ select(&mut s, b["story"].clone(), 0);
+ assert!(s.execute("anchor.create", &json!({"name": "Photo 1"})).is_err(), "not in another story either");
+ // A name of spaces is not a name.
+ assert!(s.execute("anchor.create", &json!({"name": " "})).is_err());
+ assert!(s.execute("anchor.create", &json!({"name": ""})).is_err());
+
+ // One anchor, so a reference by name can only mean that one.
+ let anchors = s.execute("anchor.list", &json!({})).unwrap();
+ assert_eq!(anchors.as_array().unwrap().len(), 1, "{anchors}");
+ // Another name is fine.
+ s.execute("anchor.create", &json!({"name": "Photo 2"})).unwrap();
+ assert_eq!(s.execute("anchor.list", &json!({})).unwrap().as_array().unwrap().len(), 2);
+ // The check is on the name, not on where it is: a name that differs only in case is a
+ // different name, as it is everywhere else in the document.
+ select(&mut s, b["story"].clone(), 0);
+ s.execute("anchor.create", &json!({"name": "photo 1"})).expect("a different name");
+ }
+
#[test]
fn cross_reference_into_a_story_with_a_running_header_composes() {
let mut s = Session::new();
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index 42f57b3a1..b1792485d 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -1651,3 +1651,108 @@ fn a_line_fits_when_its_baseline_does() {
assert!(!overset(&mut s, 78.5), "the line is set; its descenders hang below the frame");
assert!(overset(&mut s, 50.0), "a baseline below the frame still oversets the line");
}
+
+/// Issue #1637: with several objects selected, a click on one of them (no drag) makes it the key
+/// object, and Align to Key Object leaves it in place. A second click on it drops the key.
+#[test]
+fn a_click_in_the_selection_makes_the_key_object() {
+ use designcraft_tools::{PointerEvent, PointerKind};
+ let mut s = session();
+ let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
+ let b = s.execute("frame.create", &json!({"rect": [200.0, 120.0, 300.0, 170.0], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
+ s.execute("selection.set", &json!({"ids": [a, b]})).unwrap();
+ s.set_tool("selection");
+ let v = ViewInfo::at_zoom(1.0);
+ // On the part of the key object that the aligned object never covers, away from the handles.
+ let click = |s: &mut Session| {
+ s.pointer(&PointerEvent::new(PointerKind::Down, 280.0, 160.0), v).unwrap();
+ s.pointer(&PointerEvent::new(PointerKind::Up, 280.0, 160.0), v).unwrap();
+ };
+ click(&mut s);
+ assert_eq!(s.doc().unwrap().selection.items.len(), 2, "the click keeps both selected");
+ // The key is the right-hand object, so aligning left edges to the selection would move it.
+ s.execute("object.align", &json!({"edge": "left", "to": "keyObject"})).unwrap();
+ let x0 = |s: &Session, id: u64| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds().x0;
+ assert!((x0(&s, b) - 200.0).abs() < 1e-6, "the key object moved to {}", x0(&s, b));
+ assert!((x0(&s, a) - 200.0).abs() < 1e-6, "the other object is at {}", x0(&s, a));
+ // The Align panel and the Control panel send no target: the key object is the default one.
+ s.execute("object.align", &json!({"edge": "top"})).unwrap();
+ let y0 = |s: &Session, id: u64| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds().y0;
+ assert!((y0(&s, b) - 120.0).abs() < 1e-6, "the key object moved to {}", y0(&s, b));
+ assert!((y0(&s, a) - 120.0).abs() < 1e-6, "the other object is at {}", y0(&s, a));
+ click(&mut s);
+ assert_eq!(s.doc().unwrap().selection.key, None, "a click on the key object drops the key");
+}
+
+/// Dragging one of several selected objects moves them all and makes no key object.
+#[test]
+fn dragging_the_selection_makes_no_key_object() {
+ use designcraft_tools::{PointerEvent, PointerKind};
+ let mut s = session();
+ let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
+ let b = s.execute("frame.create", &json!({"rect": [200.0, 100.0, 260.0, 150.0], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
+ s.execute("selection.set", &json!({"ids": [a, b]})).unwrap();
+ s.set_tool("selection");
+ let v = ViewInfo::at_zoom(1.0);
+ s.pointer(&PointerEvent::new(PointerKind::Down, 230.0, 125.0), v).unwrap();
+ s.pointer(&PointerEvent::new(PointerKind::Drag, 250.0, 135.0), v).unwrap();
+ s.pointer(&PointerEvent::new(PointerKind::Up, 250.0, 135.0), v).unwrap();
+ assert_eq!(s.doc().unwrap().selection.key, None);
+}
+
+/// Shift-clicking the key object out of the selection drops the key.
+#[test]
+fn deselecting_the_key_object_drops_the_key() {
+ let mut s = session();
+ let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0]})).unwrap()["id"].as_u64().unwrap();
+ let b = s.execute("frame.create", &json!({"rect": [200.0, 100.0, 260.0, 150.0]})).unwrap()["id"].as_u64().unwrap();
+ s.execute("selection.set", &json!({"ids": [a, b]})).unwrap();
+ s.execute("selection.key", &json!({"id": b})).unwrap();
+ assert_eq!(s.doc().unwrap().selection.key, Some(designcraft_doc::ItemId(b)));
+ s.execute("selection.toggle", &json!({"id": b})).unwrap();
+ assert_eq!(s.doc().unwrap().selection.key, None);
+}
+
+/// A key object needs another object to align to: with one object selected there is nothing to key
+/// against, and the stored key would turn that object into the target as soon as a second one joins.
+#[test]
+fn a_single_selected_object_cannot_be_the_key_object() {
+ let mut s = session();
+ let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0]})).unwrap()["id"].as_u64().unwrap();
+ let b = s.execute("frame.create", &json!({"rect": [200.0, 120.0, 300.0, 170.0]})).unwrap()["id"].as_u64().unwrap();
+ s.execute("selection.set", &json!({"ids": [a]})).unwrap();
+ assert!(s.execute("selection.key", &json!({"id": a})).is_err());
+ assert_eq!(s.doc().unwrap().selection.key, None);
+ s.execute("selection.toggle", &json!({"id": b})).unwrap();
+ assert_eq!(s.doc().unwrap().selection.key, None, "the second object joining must not find a key already set");
+}
+
+/// With the selection down to the key object alone, Align no longer has a key object: a single
+/// object aligns to the page, as before.
+#[test]
+fn a_lone_key_object_aligns_to_the_page() {
+ let mut s = session();
+ let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0]})).unwrap()["id"].as_u64().unwrap();
+ let b = s.execute("frame.create", &json!({"rect": [200.0, 120.0, 300.0, 170.0]})).unwrap()["id"].as_u64().unwrap();
+ s.execute("selection.set", &json!({"ids": [a, b]})).unwrap();
+ s.execute("selection.key", &json!({"id": b})).unwrap();
+ s.execute("selection.toggle", &json!({"id": a})).unwrap();
+ assert_eq!(s.doc().unwrap().selection.key, None);
+ s.execute("object.align", &json!({"edge": "left"})).unwrap();
+ let x0 = s.doc().unwrap().doc.item(designcraft_doc::ItemId(b)).unwrap().bounds().x0;
+ assert!(x0.abs() < 1e-6, "the object is at {x0}, not at the page's left edge");
+}
+
+/// Aligning one object alone (`ids`) while it is the key object of a larger selection also takes
+/// the page.
+#[test]
+fn aligning_the_key_object_alone_takes_the_page() {
+ let mut s = session();
+ let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0]})).unwrap()["id"].as_u64().unwrap();
+ let b = s.execute("frame.create", &json!({"rect": [200.0, 120.0, 300.0, 170.0]})).unwrap()["id"].as_u64().unwrap();
+ s.execute("selection.set", &json!({"ids": [a, b]})).unwrap();
+ s.execute("selection.key", &json!({"id": b})).unwrap();
+ s.execute("object.align", &json!({"edge": "left", "ids": [b]})).unwrap();
+ let x0 = s.doc().unwrap().doc.item(designcraft_doc::ItemId(b)).unwrap().bounds().x0;
+ assert!(x0.abs() < 1e-6, "the object is at {x0}, not at the page's left edge");
+}
diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests_table.rs
index 59d2f267b..2fc4fd338 100644
--- a/crates/engine/src/tests_table.rs
+++ b/crates/engine/src/tests_table.rs
@@ -1097,3 +1097,26 @@ fn convert_rows_keeps_merged_cells_on_one_side() {
assert!(!convert_enabled(&s, "Body"));
check(&s);
}
+
+#[test]
+fn alternating_fill_counts_saturate_instead_of_wrapping() {
+ let (mut s, _, _) = session_with_frame();
+ s.execute("table.insert", &json!({"rows": 3, "cols": 2})).unwrap();
+ s.execute("table.options", &json!({"altRows": {"first": 4294967297u64, "next": 1, "skipFirst": 4294967296u64, "skipLast": 9999999999u64}}))
+ .unwrap();
+ let t = s.execute("table.get", &json!({})).unwrap();
+ let a = &t["options"]["altRows"];
+ assert_eq!(a["first"], u32::MAX);
+ assert_eq!(a["skipFirst"], u32::MAX);
+ assert_eq!(a["skipLast"], u32::MAX);
+ assert_eq!(a["next"], 1);
+}
+
+#[test]
+fn index_next_paragraphs_saturates_instead_of_wrapping() {
+ let (mut s, _, _) = session_with_frame();
+ s.execute("index.addReference", &json!({"topic": "Intro", "range": "nextParagraphs", "paragraphs": 4294967296u64})).unwrap();
+ let l = s.execute("index.list", &json!({})).unwrap();
+ assert_eq!(l[0]["range"]["type"], "nextParagraphs");
+ assert_eq!(l[0]["range"]["value"], u32::MAX);
+}
diff --git a/crates/engine/tests/book_safety.rs b/crates/engine/tests/it/book_safety.rs
similarity index 99%
rename from crates/engine/tests/book_safety.rs
rename to crates/engine/tests/it/book_safety.rs
index 74c96815a..01c527be5 100644
--- a/crates/engine/tests/book_safety.rs
+++ b/crates/engine/tests/it/book_safety.rs
@@ -1,5 +1,3 @@
-#![cfg(not(target_arch = "wasm32"))]
-
use designcraft_engine::Session;
use serde_json::json;
use std::path::Path;
diff --git a/crates/engine/tests/browser_print.rs b/crates/engine/tests/it/browser_print.rs
similarity index 97%
rename from crates/engine/tests/browser_print.rs
rename to crates/engine/tests/it/browser_print.rs
index fda224b90..208a6cde8 100644
--- a/crates/engine/tests/browser_print.rs
+++ b/crates/engine/tests/it/browser_print.rs
@@ -1,5 +1,4 @@
//! Browser Print must reject native spooler operations without trapping the editor.
-#![cfg(target_arch = "wasm32")]
use designcraft_engine::Session;
use serde_json::json;
diff --git a/crates/engine/tests/character_style_from_selection.rs b/crates/engine/tests/it/character_style_from_selection.rs
similarity index 100%
rename from crates/engine/tests/character_style_from_selection.rs
rename to crates/engine/tests/it/character_style_from_selection.rs
diff --git a/crates/engine/tests/close_retention.rs b/crates/engine/tests/it/close_retention.rs
similarity index 100%
rename from crates/engine/tests/close_retention.rs
rename to crates/engine/tests/it/close_retention.rs
diff --git a/crates/engine/tests/collection_state_atomicity.rs b/crates/engine/tests/it/collection_state_atomicity.rs
similarity index 98%
rename from crates/engine/tests/collection_state_atomicity.rs
rename to crates/engine/tests/it/collection_state_atomicity.rs
index b73eefa7e..cb6a0db94 100644
--- a/crates/engine/tests/collection_state_atomicity.rs
+++ b/crates/engine/tests/it/collection_state_atomicity.rs
@@ -1,5 +1,3 @@
-#![cfg(not(target_arch = "wasm32"))]
-
use designcraft_engine::Session;
use serde_json::json;
diff --git a/crates/engine/tests/combined_close_glyph.rs b/crates/engine/tests/it/combined_close_glyph.rs
similarity index 100%
rename from crates/engine/tests/combined_close_glyph.rs
rename to crates/engine/tests/it/combined_close_glyph.rs
diff --git a/crates/engine/tests/distribute_locks.rs b/crates/engine/tests/it/distribute_locks.rs
similarity index 100%
rename from crates/engine/tests/distribute_locks.rs
rename to crates/engine/tests/it/distribute_locks.rs
diff --git a/crates/engine/tests/export_page_input.rs b/crates/engine/tests/it/export_page_input.rs
similarity index 100%
rename from crates/engine/tests/export_page_input.rs
rename to crates/engine/tests/it/export_page_input.rs
diff --git a/crates/engine/tests/insert_pages_bounds.rs b/crates/engine/tests/it/insert_pages_bounds.rs
similarity index 100%
rename from crates/engine/tests/insert_pages_bounds.rs
rename to crates/engine/tests/it/insert_pages_bounds.rs
diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs
new file mode 100644
index 000000000..14555d3ee
--- /dev/null
+++ b/crates/engine/tests/it/main.rs
@@ -0,0 +1,23 @@
+#[cfg(not(target_arch = "wasm32"))]
+mod book_safety;
+#[cfg(target_arch = "wasm32")]
+mod browser_print;
+mod character_style_from_selection;
+mod close_retention;
+#[cfg(not(target_arch = "wasm32"))]
+mod collection_state_atomicity;
+mod combined_close_glyph;
+mod distribute_locks;
+mod export_page_input;
+mod insert_pages_bounds;
+mod param_keys;
+mod pdf_backend;
+mod preflight_resolution;
+mod primary_frame_columns;
+#[cfg(not(target_arch = "wasm32"))]
+mod save_state_atomicity;
+mod stroke_tint_range;
+mod text_decorations;
+mod text_insert_memory;
+mod text_thread_regressions;
+mod thread_color_safety;
diff --git a/crates/engine/tests/param_keys.rs b/crates/engine/tests/it/param_keys.rs
similarity index 100%
rename from crates/engine/tests/param_keys.rs
rename to crates/engine/tests/it/param_keys.rs
diff --git a/crates/engine/tests/pdf_backend.rs b/crates/engine/tests/it/pdf_backend.rs
similarity index 100%
rename from crates/engine/tests/pdf_backend.rs
rename to crates/engine/tests/it/pdf_backend.rs
diff --git a/crates/engine/tests/preflight_resolution.rs b/crates/engine/tests/it/preflight_resolution.rs
similarity index 100%
rename from crates/engine/tests/preflight_resolution.rs
rename to crates/engine/tests/it/preflight_resolution.rs
diff --git a/crates/engine/tests/primary_frame_columns.rs b/crates/engine/tests/it/primary_frame_columns.rs
similarity index 100%
rename from crates/engine/tests/primary_frame_columns.rs
rename to crates/engine/tests/it/primary_frame_columns.rs
diff --git a/crates/engine/tests/save_state_atomicity.rs b/crates/engine/tests/it/save_state_atomicity.rs
similarity index 98%
rename from crates/engine/tests/save_state_atomicity.rs
rename to crates/engine/tests/it/save_state_atomicity.rs
index ac0f30f58..2ced20ef0 100644
--- a/crates/engine/tests/save_state_atomicity.rs
+++ b/crates/engine/tests/it/save_state_atomicity.rs
@@ -1,5 +1,3 @@
-#![cfg(not(target_arch = "wasm32"))]
-
use std::path::Path;
use std::sync::Arc;
diff --git a/crates/engine/tests/stroke_tint_range.rs b/crates/engine/tests/it/stroke_tint_range.rs
similarity index 100%
rename from crates/engine/tests/stroke_tint_range.rs
rename to crates/engine/tests/it/stroke_tint_range.rs
diff --git a/crates/engine/tests/text_decorations.rs b/crates/engine/tests/it/text_decorations.rs
similarity index 100%
rename from crates/engine/tests/text_decorations.rs
rename to crates/engine/tests/it/text_decorations.rs
diff --git a/crates/engine/tests/text_insert_memory.rs b/crates/engine/tests/it/text_insert_memory.rs
similarity index 100%
rename from crates/engine/tests/text_insert_memory.rs
rename to crates/engine/tests/it/text_insert_memory.rs
diff --git a/crates/engine/tests/text_thread_regressions.rs b/crates/engine/tests/it/text_thread_regressions.rs
similarity index 100%
rename from crates/engine/tests/text_thread_regressions.rs
rename to crates/engine/tests/it/text_thread_regressions.rs
diff --git a/crates/engine/tests/thread_color_safety.rs b/crates/engine/tests/it/thread_color_safety.rs
similarity index 100%
rename from crates/engine/tests/thread_color_safety.rs
rename to crates/engine/tests/it/thread_color_safety.rs
diff --git a/crates/epub/tests/export_tag_styles.rs b/crates/epub/tests/it/export_tag_styles.rs
similarity index 100%
rename from crates/epub/tests/export_tag_styles.rs
rename to crates/epub/tests/it/export_tag_styles.rs
diff --git a/crates/epub/tests/grouped_reading_order.rs b/crates/epub/tests/it/grouped_reading_order.rs
similarity index 100%
rename from crates/epub/tests/grouped_reading_order.rs
rename to crates/epub/tests/it/grouped_reading_order.rs
diff --git a/crates/epub/tests/it/main.rs b/crates/epub/tests/it/main.rs
new file mode 100644
index 000000000..69af15eaa
--- /dev/null
+++ b/crates/epub/tests/it/main.rs
@@ -0,0 +1,2 @@
+mod export_tag_styles;
+mod grouped_reading_order;
diff --git a/crates/fonts/tests/it/main.rs b/crates/fonts/tests/it/main.rs
new file mode 100644
index 000000000..1737c00b9
--- /dev/null
+++ b/crates/fonts/tests/it/main.rs
@@ -0,0 +1,4 @@
+// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code.
+#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+
+mod system_fonts;
diff --git a/crates/fonts/tests/system_fonts.rs b/crates/fonts/tests/it/system_fonts.rs
similarity index 86%
rename from crates/fonts/tests/system_fonts.rs
rename to crates/fonts/tests/it/system_fonts.rs
index 62310c34b..66db0f5b1 100644
--- a/crates/fonts/tests/system_fonts.rs
+++ b/crates/fonts/tests/it/system_fonts.rs
@@ -1,7 +1,5 @@
//! A new session finds the installed system fonts by family name (#22). One test in its own
//! process, so the process-wide database starts fresh, as it does when the app starts.
-// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code.
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use designcraft_fonts::{FontDb, system_font_dirs};
diff --git a/crates/geom/src/units.rs b/crates/geom/src/units.rs
index da1676d4b..f1077ffce 100644
--- a/crates/geom/src/units.rs
+++ b/crates/geom/src/units.rs
@@ -286,6 +286,8 @@ pub fn format_measure_prec(pt: f64, unit: Unit, decimals: usize) -> String {
whole += 1.0;
rest = 0.0;
}
+ // Decide the sign from the rounded value so float noise doesn't print "-0p0".
+ let neg = neg && (whole > 0.0 || rest > 0.0);
let sep = if unit == Unit::Picas { 'p' } else { 'c' };
format!("{}{}{}{}", if neg { "-" } else { "" }, whole as i64, sep, trim(rest))
}
@@ -304,6 +306,14 @@ mod tests {
(a - b).abs() < 1e-6
}
+ /// Tiny negatives that round to zero must not print a sign.
+ #[test]
+ fn tiny_negative_picas_ciceros_have_no_sign() {
+ assert_eq!(format_measure(-0.0004, Unit::Picas), "0p0");
+ assert_eq!(format_measure(-0.0004, Unit::Ciceros), "0c0");
+ assert_eq!(format_measure(-12.0, Unit::Picas), "-1p0");
+ }
+
/// Inches show four decimals, as InDesign's fields do; other units three.
#[test]
fn inches_show_four_decimals() {
diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs
index 99dcb383e..df5a1a243 100644
--- a/crates/idml/src/export.rs
+++ b/crates/idml/src/export.rs
@@ -2797,7 +2797,25 @@ impl<'a> Ex<'a> {
ce.set(&format!("{side}StrokePriority"), priority);
}
}
- for psr in self.story_paras(&cell.text) {
+ let mut paras = self.story_paras(&cell.text);
+ // A graphic cell holds its image as an inline object in the cell's first paragraph.
+ if let Some(g) = &cell.graphic
+ && let Some(object) = self.cell_graphic_el(g)
+ {
+ // The cell clips its graphic on screen and in PDF; IDML does so only when asked.
+ ce.set("ClipContentToCell", bool_s(true));
+ let mut range = self.csr_el(&CharFormat::default());
+ range.push(object);
+ match paras.first_mut() {
+ Some(first) => first.push(range),
+ None => {
+ let mut psr = self.psr_el(&ParaFormat::default());
+ psr.push(range);
+ paras.push(psr);
+ }
+ }
+ }
+ for psr in paras {
ce.push(psr);
}
el.push(ce);
@@ -2806,6 +2824,20 @@ impl<'a> Ex<'a> {
el
}
+ /// The frame of a table cell's graphic: an inline `Rectangle` around the shown image, or `None`
+ /// when the graphic's asset is missing.
+ fn cell_graphic_el(&mut self, g: &designcraft_doc::Graphic) -> Option {
+ self.d.assets.get(&g.asset)?;
+ let shown = g.xf.transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1));
+ let layer = self.d.layers.first().map(|l| l.id).unwrap_or(designcraft_doc::ids::LayerId(0));
+ let mut item = Item::new(designcraft_doc::ids::ItemId(0), layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(shown));
+ item.content = Content::Graphic(g.clone());
+ let mut el = self.item_el(&item, Affine::IDENTITY);
+ el.set("Self", self.fresh());
+ el.push(El::new("AnchoredObjectSetting").attr("AnchoredPosition", "InlinePosition").attr("AnchorYoffset", "0"));
+ Some(el)
+ }
+
fn psr_el(&mut self, pf: &ParaFormat) -> El {
let mut el = El::new("ParagraphStyleRange").attr("AppliedParagraphStyle", names::style_self("ParagraphStyle", PARA_BUILTINS, &pf.style));
let mut props = Vec::new();
diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs
index ef504065e..b57210576 100644
--- a/crates/idml/src/import.rs
+++ b/crates/idml/src/import.rs
@@ -295,6 +295,10 @@ struct Importer<'r> {
parents: Vec,
/// Things read but not kept as they were, for the import report.
warnings: Vec,
+ /// Cells whose text is rotated (Cell Options › Text › Rotation): the angle is kept and
+ /// written back, but only 0 is composed, so a narrow header column of vertical labels comes
+ /// in horizontal and wraps mid-word (#1468).
+ rotated_cells: usize,
parent_ids: HashMap,
spreads: Vec,
page_index: HashMap,
@@ -387,13 +391,10 @@ fn mojikumi_row(e: &El) -> Result {
Ok(row)
}
+/// The numbers in a whitespace-separated list; empty when any number is NaN or infinite (JSON
+/// can't store them, and dropping just those would shift the rest).
fn nums(s: &str) -> Vec {
- s.split_whitespace().filter_map(|v| v.parse().ok()).collect()
-}
-
-/// Like [`nums`], but empty when any number is NaN or infinite.
-fn finite_nums(s: &str) -> Vec {
- let v = nums(s);
+ let v: Vec = s.split_whitespace().filter_map(|v| v.parse().ok()).collect();
if v.iter().all(|x| x.is_finite()) { v } else { Vec::new() }
}
@@ -536,6 +537,7 @@ impl<'r> Importer<'r> {
story_ids: HashMap::new(),
parents: Vec::new(),
warnings: Vec::new(),
+ rotated_cells: 0,
parent_ids: HashMap::new(),
spreads: Vec::new(),
page_index: HashMap::new(),
@@ -791,6 +793,12 @@ impl<'r> Importer<'r> {
include_prefix: e.get("IncludeSectionPrefix") == Some("true"),
});
}
+ if self.rotated_cells > 0 {
+ let n = self.rotated_cells;
+ self.warnings.push(format!(
+ "{n} table cell(s) have rotated text (Cell Options › Text › Rotation): the angle is kept and written back, but the text is composed horizontally"
+ ));
+ }
self.sections.sort_by_key(|s| s.start);
self.sections.dedup_by_key(|s| s.start);
if self.sections.first().is_none_or(|s| s.start != 0) {
@@ -895,6 +903,15 @@ impl<'r> Importer<'r> {
_ if v.len() >= 4 => Color::cmyk((v[0] / 100.0) as f32, (v[1] / 100.0) as f32, (v[2] / 100.0) as f32, (v[3] / 100.0) as f32),
_ => return None,
};
+ // Huge values (1e300) overflow `f32` to infinity.
+ let parts = match c {
+ Color::Rgb { r, g, b } => [r, g, b, 0.0],
+ Color::Cmyk { c, m, y, k } => [c, m, y, k],
+ Color::Gray { k } => [k, 0.0, 0.0, 0.0],
+ };
+ if !parts.iter().all(|v| v.is_finite()) {
+ return None;
+ }
let ty = if e.get("Model") == Some("Spot") { ColorType::Spot } else { ColorType::Process };
Some((c, ty))
}
@@ -947,14 +964,14 @@ impl<'r> Importer<'r> {
let (Some(id), Some(name)) = (e.get("Self"), e.get("Name")) else { continue };
let kind = match e.local() {
"StripedStrokeStyle" => designcraft_doc::StrokeType::Stripes {
- bands: finite_nums(e.get("StripeArray").unwrap_or(""))
+ bands: nums(e.get("StripeArray").unwrap_or(""))
.as_chunks::<2>()
.0
.iter()
.map(|c| (c[0] / 100.0, (c[1] - c[0]) / 100.0))
.collect(),
},
- "DashedStrokeStyle" => designcraft_doc::StrokeType::Dashed { pattern: finite_nums(e.get("DashArray").unwrap_or("")) },
+ "DashedStrokeStyle" => designcraft_doc::StrokeType::Dashed { pattern: nums(e.get("DashArray").unwrap_or("")) },
_ => designcraft_doc::StrokeType::Dotted,
};
let _ = id;
@@ -2170,9 +2187,12 @@ impl<'r> Importer<'r> {
}
// Table-local options override the named table style. Cell styles are already
// materialized independently, so their edges retain their higher precedence.
+ let mut border_unset = style.border.is_none() && style.borders.iter().all(|b| b.is_empty());
let mut borders = std::array::from_fn::<_, 4, _>(|i| t.options.border_for(i).clone());
for (side, border) in ["TopBorder", "LeftBorder", "BottomBorder", "RightBorder"].iter().zip(&mut borders) {
- self.cell_stroke_attrs(e, side).apply_to(border);
+ let attrs = self.cell_stroke_attrs(e, side);
+ border_unset &= attrs.is_empty();
+ attrs.apply_to(border);
}
t.options.border = borders[0].clone();
t.options.borders = borders.map(Some);
@@ -2235,6 +2255,9 @@ impl<'r> Importer<'r> {
cell.first_baseline_min = min.clamp(0.0, 8640.0);
}
cell.rotation = ce.num("RotationAngle").unwrap_or(cell.rotation);
+ if cell.rotation != 0.0 {
+ self.rotated_cells += 1;
+ }
for (i, side) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().enumerate() {
let attrs = self.cell_stroke_attrs(ce, side);
attrs.apply_to(&mut cell.strokes[i]);
@@ -2243,6 +2266,10 @@ impl<'r> Importer<'r> {
cell.stroke_priorities[i] = priority;
// Preserve style-defined edges and #102's positive-priority local overrides.
cell.border_overrides[i] |= priority > 0;
+ // No table or table style border is given anywhere, so the built-in 1 pt default must not
+ // draw over an outer edge the cell explicitly hides (weight 0 or the None swatch), as InDesign draws nothing.
+ let hidden = attrs.weight == Some(0.0) || attrs.color.as_deref() == Some(swatch::NONE);
+ cell.border_overrides[i] |= border_unset && hidden;
}
if let Some(slot) = t.cell_mut(r, c) {
*slot = cell;
@@ -2856,7 +2883,7 @@ impl<'r> Importer<'r> {
{
it.fill.gradient_angle = Some(a);
}
- let start: Option> = e.prop("GradientFillStart").map(|s| finite_nums(&s));
+ let start: Option> = e.prop("GradientFillStart").map(|s| nums(&s));
if let (Some([x, y]), Some(len)) = (start.as_deref().and_then(|v| <[f64; 2]>::try_from(v).ok()), e.num("GradientFillLength"))
&& len > 0.0
{
@@ -2874,7 +2901,7 @@ impl<'r> Importer<'r> {
{
it.stroke.gradient_angle = Some(a);
}
- let start: Option> = e.prop("GradientStrokeStart").map(|s| finite_nums(&s));
+ let start: Option> = e.prop("GradientStrokeStart").map(|s| nums(&s));
if let (Some([x, y]), Some(len)) = (start.as_deref().and_then(|v| <[f64; 2]>::try_from(v).ok()), e.num("GradientStrokeLength"))
&& len > 0.0
{
@@ -3702,6 +3729,11 @@ fn path_capped(pg: &El, max: usize) -> PathData {
let closed = g.get("PathOpen") != Some("true");
let mut anchors = Vec::new();
if let Some(arr) = g.find("PathPointArray") {
+ // A NaN or infinite point drops its subpath (JSON can't store it).
+ let non_finite = |s: Option<&str>| s.is_some_and(|s| s.split_whitespace().any(|v| v.parse::().is_ok_and(|x| !x.is_finite())));
+ if arr.find_all("PathPointType").take(left).any(|p| ["Anchor", "LeftDirection", "RightDirection"].iter().any(|k| non_finite(p.get(k)))) {
+ continue;
+ }
for p in arr.find_all("PathPointType").take(left) {
let Some(a) = parse_point(p.get("Anchor")) else { continue };
let hin = parse_point(p.get("LeftDirection")).unwrap_or(a);
diff --git a/crates/idml/src/names.rs b/crates/idml/src/names.rs
index 569244470..b9102c9ae 100644
--- a/crates/idml/src/names.rs
+++ b/crates/idml/src/names.rs
@@ -144,16 +144,37 @@ pub const TABLE_BUILTINS: &[(&str, &str)] = &[("[Basic Table]", "$ID/[Basic Tabl
pub const OBJECT_BUILTINS: &[(&str, &str)] =
&[("[None]", "$ID/[None]"), ("[Basic Graphics Frame]", "$ID/[Normal Graphics Frame]"), ("[Basic Text Frame]", "$ID/[Normal Text Frame]")];
-/// Our style name → IDML `Name` (groups: `A/B` → `A:B`).
+/// Our style name → IDML `Name` (groups: `A/B` → `A:B`; a literal `:` or `\` is escaped as `\:`
+/// or `\\`, so it is not read back as a group separator).
pub fn style_name_out(builtins: &[(&str, &str)], name: &str) -> String {
- builtins.iter().find(|(o, _)| *o == name).map(|(_, i)| i.to_string()).unwrap_or_else(|| name.replace('/', ":"))
+ builtins
+ .iter()
+ .find(|(o, _)| *o == name)
+ .map(|(_, i)| i.to_string())
+ .unwrap_or_else(|| name.replace('\\', "\\\\").replace(':', "\\:").replace('/', ":"))
}
/// IDML `Name` → our style name.
pub fn style_name_in(builtins: &[(&str, &str)], name: &str) -> String {
if let Some((o, _)) = builtins.iter().find(|(_, i)| *i == name) {
return o.to_string();
}
- name.strip_prefix("$ID/").unwrap_or(name).replace(':', "/")
+ let name = name.strip_prefix("$ID/").unwrap_or(name);
+ let mut out = String::with_capacity(name.len());
+ let mut chars = name.chars();
+ while let Some(c) = chars.next() {
+ match c {
+ '\\' => match chars.clone().next() {
+ Some(next @ ('\\' | ':')) => {
+ out.push(next);
+ chars.next();
+ }
+ _ => out.push('\\'),
+ },
+ ':' => out.push('/'),
+ _ => out.push(c),
+ }
+ }
+ out
}
/// `ParagraphStyle/…` Self id for one of our names.
pub fn style_self(kind: &str, builtins: &[(&str, &str)], name: &str) -> String {
diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs
index 37fb2af50..35374a5e0 100644
--- a/crates/idml/src/tests.rs
+++ b/crates/idml/src/tests.rs
@@ -1100,6 +1100,35 @@ fn imports_and_round_trips_explicit_cell_border_overrides() {
assert_eq!(roundtrip.cell(0, 1).unwrap().border_overrides, [false; 4]);
}
+#[test]
+fn explicitly_hidden_cell_edges_hide_the_default_table_border() {
+ // No table border or table style is given, and every cell edge is explicitly hidden (issue #1623):
+ // the first cell by weight 0, the second by the None swatch.
+ let cell = |name: &str, attr: &str, value: &str| {
+ format!(
+ r#"
+ X | "#
+ )
+ };
+ let story = format!(
+ r#"
+
+ "#,
+ cell("0:0", "StrokeWeight", "0"),
+ cell("1:0", "StrokeColor", "Swatch/None")
+ );
+ let d = import_idml(&fixture_with_story(&story)).unwrap();
+ d.check().unwrap();
+ let table = d.stories.values().flat_map(|s| s.tables.values()).next().unwrap();
+ for c in 0..2 {
+ assert_eq!(table.cell(0, c).unwrap().border_overrides, [true; 4], "cell {c} suppresses the table border");
+ }
+}
+
#[test]
fn round_trips_footnotes_and_options() {
let mut d = Document::new(&NewDocument::default());
@@ -4368,3 +4397,144 @@ fn cell_first_baseline_comes_from_cell_styles_table_regions_and_cells_and_round_
let s2 = back.styles.cell.iter().find(|c| c.name == "Cap").unwrap();
assert_eq!(s2.first_baseline, Some(FirstBaseline::CapHeight));
}
+
+/// A graphic placed in a table cell is written into the cell, not dropped.
+#[test]
+fn exports_graphic_in_table_cell() {
+ let mut d = Document::new(&NewDocument::default());
+ let lid = d.default_layer();
+ let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(40.0, 40.0, 400.0, 400.0), lid, "Intro", ParaFormat::default()).unwrap();
+ let asset = designcraft_doc::AssetId(d.alloc());
+ let id = d.add_asset(designcraft_doc::Asset {
+ id: asset,
+ name: "tick.png".into(),
+ mime: "image/png".into(),
+ data: std::sync::Arc::new(vec![1, 2, 3]),
+ ..Default::default()
+ });
+ let mut t = designcraft_doc::Table::new(d.alloc(), 2, 2, 0, 0, 200.0);
+ t.cell_mut(1, 0).unwrap().graphic = Some(designcraft_doc::Graphic {
+ asset: id,
+ size: (24.0, 24.0),
+ xf: designcraft_geom::Affine::IDENTITY,
+ clip: None,
+ auto_fit: Default::default(),
+ fit_align: 4,
+ crop: [0.0; 4],
+ wrap: Default::default(),
+ fill: Default::default(),
+ });
+ d.story_mut(sid).unwrap().insert_table(5, t);
+ let story = zip_text(&export_idml(&d), "Stories/");
+ let cell = story.split("").next().unwrap_or_default();
+ assert!(cell.contains(" = match *color {
+ Color::Rgb { r, g, b } => vec![r, g, b],
+ Color::Cmyk { c, m, y, k } => vec![c, m, y, k],
+ Color::Gray { k } => vec![k],
+ };
+ assert!(parts.iter().all(|v| v.is_finite()), "swatch {} is not finite: {color:?}", s.name);
+ }
+ }
+ for sp in doc.spreads.iter().chain(&doc.parents) {
+ for p in &sp.pages {
+ assert!(p.width.is_finite() && p.height.is_finite() && p.x.is_finite(), "page {p:?}");
+ }
+ }
+ assert_eq!(doc.non_finite_item(), None);
+}
+
+/// Cell Options › Text › Rotation: the angle survives the round trip, and the import says it is
+/// not composed — narrow header columns of vertical labels come in horizontal and wrap mid-word,
+/// and nothing said so (#1468).
+#[test]
+fn rotated_cell_text_round_trips_and_the_import_says_it_is_not_composed() {
+ let mut d = Document::new(&NewDocument::default());
+ let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 300.0, 300.0), d.default_layer(), "", ParaFormat::default()).unwrap();
+ let mut t = designcraft_doc::Table::new(7, 1, 3, 0, 0, 300.0);
+ t.cell_mut(0, 0).unwrap().rotation = 90.0;
+ t.cell_mut(0, 1).unwrap().rotation = 270.0;
+ d.story_mut(sid).unwrap().insert_table(0, t);
+ let bytes = export_idml(&d);
+ let story = zip_text(&bytes, "Stories/");
+ assert!(story.contains(r#"RotationAngle="90""#) && story.contains(r#"RotationAngle="270""#), "{story}");
+
+ let back = import_idml_report(&bytes, &|_| None).unwrap();
+ let t = back.document.stories.values().find(|st| !st.tables.is_empty()).unwrap().tables.values().next().unwrap().clone();
+ assert_eq!((t.cell(0, 0).unwrap().rotation, t.cell(0, 1).unwrap().rotation, t.cell(0, 2).unwrap().rotation), (90.0, 270.0, 0.0));
+ let about: Vec<&String> = back.warnings.iter().filter(|w| w.contains("rotated text")).collect();
+ assert_eq!(about.len(), 1, "one line for the file, not one per cell: {:?}", back.warnings);
+ assert!(about[0].starts_with("2 table cell(s)"), "{}", about[0]);
+ assert!(about[0].contains("composed horizontally"), "{}", about[0]);
+
+ // A file with no rotated cell says nothing.
+ let mut plain = Document::new(&NewDocument::default());
+ let (_, sid) =
+ plain.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 300.0, 300.0), plain.default_layer(), "", ParaFormat::default()).unwrap();
+ plain.story_mut(sid).unwrap().insert_table(0, designcraft_doc::Table::new(8, 1, 2, 0, 0, 200.0));
+ let quiet = import_idml_report(&export_idml(&plain), &|_| None).unwrap();
+ assert!(quiet.warnings.iter().all(|w| !w.contains("rotated text")), "{:?}", quiet.warnings);
+}
+
+#[test]
+fn style_names_with_a_literal_colon_survive_a_round_trip() {
+ use designcraft_doc::styles::{CellStyle, CharacterStyle, ObjectStyle, ParagraphStyle, TableStyle};
+ let names = ["Title: Big", "Group/Child: X", "Back\\slash: \\:"];
+ let mut d = small_doc();
+ for n in names {
+ let st = d.styles_mut();
+ st.paragraph.push(ParagraphStyle {
+ name: n.into(),
+ based_on: None,
+ next_style: None,
+ para: Default::default(),
+ chars: Default::default(),
+ shortcut: String::new(),
+ });
+ st.character.push(CharacterStyle { name: n.into(), based_on: None, chars: Default::default(), shortcut: String::new() });
+ st.cell.push(CellStyle { name: n.into(), ..Default::default() });
+ st.table.push(TableStyle { name: n.into(), ..Default::default() });
+ st.object.push(ObjectStyle { name: n.into(), ..Default::default() });
+ }
+ let back = import_idml(&export_idml(&d)).unwrap();
+ for n in names {
+ assert!(
+ back.styles.paragraph.iter().any(|s| s.name == n),
+ "paragraph {n:?}: {:?}",
+ back.styles.paragraph.iter().map(|s| &s.name).collect::>()
+ );
+ assert!(back.styles.character.iter().any(|s| s.name == n), "character {n:?}");
+ assert!(back.styles.cell.iter().any(|s| s.name == n), "cell {n:?}");
+ assert!(back.styles.table.iter().any(|s| s.name == n), "table {n:?}");
+ assert!(back.styles.object.iter().any(|s| s.name == n), "object {n:?}");
+ }
+ // A group is still written as an IDML `:` group path.
+ assert_eq!(names::style_name_out(names::PARA_BUILTINS, "Group/Child"), "Group:Child");
+ for n in names {
+ assert_eq!(names::style_name_in(names::PARA_BUILTINS, &names::style_name_out(names::PARA_BUILTINS, n)), n);
+ }
+}
diff --git a/crates/images/tests/it/main.rs b/crates/images/tests/it/main.rs
new file mode 100644
index 000000000..d2f76fb71
--- /dev/null
+++ b/crates/images/tests/it/main.rs
@@ -0,0 +1 @@
+mod rgba_conversion_contract;
diff --git a/crates/images/tests/rgba_conversion_contract.rs b/crates/images/tests/it/rgba_conversion_contract.rs
similarity index 100%
rename from crates/images/tests/rgba_conversion_contract.rs
rename to crates/images/tests/it/rgba_conversion_contract.rs
diff --git a/crates/mcp/tests/automation_roots.rs b/crates/mcp/tests/it/automation_roots.rs
similarity index 99%
rename from crates/mcp/tests/automation_roots.rs
rename to crates/mcp/tests/it/automation_roots.rs
index 9380b1987..871945920 100644
--- a/crates/mcp/tests/automation_roots.rs
+++ b/crates/mcp/tests/it/automation_roots.rs
@@ -3,8 +3,6 @@
//! goes inside the write root. Paths that leave a root (`..`, another folder, a sibling with the
//! same prefix, a link) are refused, nothing from a refused file reaches the document, and nothing
//! is written outside.
-// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::{Path, PathBuf};
diff --git a/crates/mcp/tests/it/main.rs b/crates/mcp/tests/it/main.rs
new file mode 100644
index 000000000..be744719e
--- /dev/null
+++ b/crates/mcp/tests/it/main.rs
@@ -0,0 +1,4 @@
+// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
+#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+
+mod automation_roots;
diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs
index 3abcdb521..34934b911 100644
--- a/crates/pdf/src/export.rs
+++ b/crates/pdf/src/export.rs
@@ -1629,6 +1629,21 @@ fn is_rgb_icc(icc: &[u8]) -> bool {
icc.get(16..20) == Some(b"RGB ".as_slice())
}
+/// Whether an ICC profile describes grey.
+fn is_grey_icc(icc: &[u8]) -> bool {
+ icc.get(16..20) == Some(b"GRAY".as_slice())
+}
+
+/// Whether `data` decodes to grey (with or without alpha), read from its header.
+fn is_grey(data: &[u8]) -> bool {
+ use image::ImageDecoder;
+ image::ImageReader::new(std::io::Cursor::new(data))
+ .with_guessed_format()
+ .ok()
+ .and_then(|r| r.into_decoder().ok())
+ .is_some_and(|dec| !dec.color_type().has_color())
+}
+
/// A JPEG, passed through. With `tag_rgb`, a JPEG keeps its own profile, and one without gets
/// sRGB. krilla uses a profile only when its channels match the JPEG's, so a grey or CMYK JPEG
/// without a profile stays DeviceGray or DeviceCMYK.
@@ -1668,6 +1683,10 @@ fn lossless(data: &Arc>, fmt: Option, mode: RasterMo
_ => None,
};
direct.or_else(|| {
+ // A grey TIFF, BMP… stays DeviceGray, as a grey PNG or JPEG does (#1426).
+ if let Some(grey) = GreyImage::new(data) {
+ return Image::from_custom(grey, i).ok();
+ }
// TIFF, BMP, PSD composites… and, with `tag_rgb`, untagged RGB.
if mode.tag_rgb {
return SrgbImage::new(rgba).and_then(|img| Image::from_custom(img, i).ok());
@@ -1722,6 +1741,61 @@ impl krilla::image::CustomImage for SrgbImage {
}
}
+/// A grey raster as a DeviceGray image (ICCBased when the file has a grey profile of its own), so
+/// a page that only holds black prints as black, not as RGB.
+#[derive(Clone, Hash)]
+struct GreyImage {
+ grey: Arc>,
+ /// Only when some pixel is not opaque.
+ alpha: Option>>,
+ size: (u32, u32),
+ icc: Option>>,
+}
+
+impl GreyImage {
+ /// `None` when `data` isn't grey or can't be decoded.
+ fn new(data: &[u8]) -> Option {
+ if !is_grey(data) {
+ return None;
+ }
+ let img = image::load_from_memory(data).ok()?.to_luma_alpha8();
+ let size = img.dimensions();
+ let pixels = usize::try_from(size.0).ok()?.checked_mul(usize::try_from(size.1).ok()?)?;
+ if pixels == 0 {
+ return None;
+ }
+ let (mut grey, mut alpha) = (Vec::with_capacity(pixels), Vec::with_capacity(pixels));
+ for p in img.pixels() {
+ grey.push(p.0[0]);
+ alpha.push(p.0[1]);
+ }
+ let alpha = alpha.iter().any(|a| *a < 255).then(|| Arc::new(alpha));
+ let icc = embedded_icc(data).filter(|p| is_grey_icc(p)).map(Arc::new);
+ Some(Self { grey: Arc::new(grey), alpha, size, icc })
+ }
+}
+
+impl krilla::image::CustomImage for GreyImage {
+ fn color_channel(&self) -> &[u8] {
+ &self.grey
+ }
+ fn alpha_channel(&self) -> Option<&[u8]> {
+ self.alpha.as_deref().map(Vec::as_slice)
+ }
+ fn bits_per_component(&self) -> krilla::image::BitsPerComponent {
+ krilla::image::BitsPerComponent::Eight
+ }
+ fn size(&self) -> (u32, u32) {
+ self.size
+ }
+ fn icc_profile(&self) -> Option<&[u8]> {
+ self.icc.as_deref().map(Vec::as_slice)
+ }
+ fn color_space(&self) -> krilla::image::ImageColorspace {
+ krilla::image::ImageColorspace::Luma
+ }
+}
+
/// A CMYK raster as a krilla image: its samples are written unchanged as DeviceCMYK, like a
/// CMYK JPEG's. An embedded ICC profile is left out, as it is for JPEGs: the inks are meant for
/// the output intent's press, and tagging them with another CMYK profile would let a RIP
@@ -1768,11 +1842,18 @@ fn recompress(data: &[u8], mode: RasterMode) -> Option {
if img.color().has_alpha() && img.to_rgba8().pixels().any(|p| p[3] < 255) {
return None;
}
- let rgb = img.to_rgb8();
let mut buf = Vec::new();
let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, 90);
- image::ImageEncoder::write_image(enc, rgb.as_raw(), rgb.width(), rgb.height(), image::ExtendedColorType::Rgb8).ok()?;
- jpeg(buf.into(), || embedded_icc(data).filter(|p| is_rgb_icc(p)), mode)
+ // A grey image stays a grey JPEG (#1426).
+ let grey = !img.color().has_color();
+ if grey {
+ let l = img.to_luma8();
+ image::ImageEncoder::write_image(enc, l.as_raw(), l.width(), l.height(), image::ExtendedColorType::L8).ok()?;
+ } else {
+ let rgb = img.to_rgb8();
+ image::ImageEncoder::write_image(enc, rgb.as_raw(), rgb.width(), rgb.height(), image::ExtendedColorType::Rgb8).ok()?;
+ }
+ jpeg(buf.into(), || embedded_icc(data).filter(|p| if grey { is_grey_icc(p) } else { is_rgb_icc(p) }), mode)
}
#[cfg(test)]
diff --git a/crates/pdf/src/forms.rs b/crates/pdf/src/forms.rs
index 39aa597ec..07f4cf04c 100644
--- a/crates/pdf/src/forms.rs
+++ b/crates/pdf/src/forms.rs
@@ -8,7 +8,7 @@ use std::fmt::Write as _;
use designcraft_doc::{Document, FieldKind, FormField, Item, MediaOptions};
use crate::export::Sheet;
-use crate::transitions::{int_after, object, rfind};
+use crate::transitions::{bracketed, int_after, object, rfind};
/// A field placed on an output page: (page index, PDF rectangle, field).
pub(crate) type Placed = (usize, [f64; 4], FormField);
@@ -94,7 +94,7 @@ pub(crate) fn add_fields(pdf: &[u8], fields: &[Placed], media: &[PlacedMedia]) -
let pages_id = int_after(&cat, "/Pages")?;
let pages = String::from_utf8_lossy(&pdf[object(pdf, pages_id)?]).to_string();
let kids_str = &pages[pages.find("/Kids")? + 5..];
- let kids_str = &kids_str[kids_str.find('[')? + 1..kids_str.find(']')?];
+ let kids_str = bracketed(kids_str)?;
let nums: Vec = kids_str.split_whitespace().filter_map(|t| t.parse().ok()).collect();
let kids: Vec = nums.chunks(2).map(|c| c[0]).collect();
diff --git a/crates/pdf/src/pdfx.rs b/crates/pdf/src/pdfx.rs
index 3303ca879..2be38f97a 100644
--- a/crates/pdf/src/pdfx.rs
+++ b/crates/pdf/src/pdfx.rs
@@ -96,7 +96,7 @@ pub fn make_pdfx4(pdf: &[u8], title: &str, date: Option) -> Option>
let s0 = find(body, b"stream", 0)? + b"stream".len();
let s0 = s0 + body[s0..].iter().take_while(|c| **c == b'\r' || **c == b'\n').count();
let s1 = rfind(body, b"endstream")?;
- let xml = String::from_utf8_lossy(&body[s0..s1]).trim_end().to_string();
+ let xml = String::from_utf8_lossy(body.get(s0..s1)?).trim_end().to_string();
let dict = String::from_utf8_lossy(&body[..find(body, b"stream", 0)?]).to_string();
if dict.contains("/Filter") {
return None;
@@ -676,6 +676,18 @@ mod tests {
out
}
+ /// A metadata object whose only `stream` is the one inside `endstream` is refused, not a slice panic.
+ #[test]
+ fn metadata_with_only_endstream_does_not_panic() {
+ let objects = ["<>", "<>", "<>endstream"];
+ let mut out = b"%PDF-1.6\n".to_vec();
+ for (i, o) in objects.iter().enumerate() {
+ out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
+ }
+ out.extend_from_slice(b"trailer\n<>\nstartxref\n0\n%%EOF\n");
+ assert!(super::make_pdfx4(&out, "t", None).is_none());
+ }
+
/// DeviceRGB is allowed when a `DefaultRGB` colour space makes it colour-managed.
#[test]
fn default_rgb_makes_device_rgb_managed() {
diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs
index ee02e2190..39f472ffc 100644
--- a/crates/pdf/src/tests.rs
+++ b/crates/pdf/src/tests.rs
@@ -340,6 +340,26 @@ fn cmyk_tiff_keeps_its_inks() {
}
}
+/// A placed greyscale TIFF went into PDFs as DeviceRGB, unlike a greyscale PNG or JPEG (#1426):
+/// a page holding only a black-and-white scan declared colour.
+#[test]
+fn grey_tiff_exports_as_device_gray() {
+ let px: Vec = vec![0, 64, 128, 255, 255, 128, 64, 0];
+ let mut tif = Vec::new();
+ tiff::encoder::TiffEncoder::new(std::io::Cursor::new(&mut tif)).unwrap().write_image::(4, 2, &px).unwrap();
+ let mut d = doc_with_text("x");
+ place(&mut d, tif, (4, 2));
+ for standard in [Standard::None, Standard::PdfX4] {
+ let r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { standard, ..Default::default() }).unwrap();
+ assert!(r.warnings.is_empty(), "{standard:?}: {:?}", r.warnings);
+ assert_eq!(image_xobjects(&r.bytes), vec![("DeviceGray".to_string(), px.clone())], "{standard:?}");
+ }
+ // Recompressed, it stays a grey JPEG.
+ let r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { compress_images: true, compress: false, ..Default::default() }).unwrap();
+ let s = String::from_utf8_lossy(&r.bytes);
+ assert!(s.contains("/DCTDecode") && s.contains("/DeviceGray") && !s.contains("/DeviceRGB"), "a grey JPEG");
+}
+
/// An 8×8 raster encoded as `fmt`; `alpha` gives it a transparent corner.
fn raster(fmt: image::ImageFormat, alpha: bool) -> Vec {
let img =
diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs
index 300a47d93..1aaf435d9 100644
--- a/crates/pdf/src/text.rs
+++ b/crates/pdf/src/text.rs
@@ -160,7 +160,7 @@ fn cluster_chars(members: &[PlacedGlyph], cluster: &str) -> Option Vec<(designcraft_compose::Rule, Rect)> {
let mut bars: Vec<(designcraft_compose::Rule, Rect)> = Vec::new();
for line in &ft.lines {
- for g in line.glyphs.iter().filter(|g| g.visible) {
+ for g in line.glyphs.iter().filter(|g| g.spans_rules()) {
let style = &cs.styles[g.style as usize];
let (on, rule) = if underline { (style.underline, &style.underline_rule) } else { (style.strikethrough, &style.strike_rule) };
if !on {
@@ -638,3 +638,39 @@ fn glyph_outlines(glyphs: &[PlacedGlyph], size: f64, tan: f64) -> BezPath {
}
bp
}
+
+#[cfg(test)]
+mod rule_tests {
+ use super::*;
+ use designcraft_compose::Cache;
+ use designcraft_doc::{Document, ParaFormat, SpreadRef};
+ use designcraft_geom::Rect;
+
+ /// A tab draws nothing but advances to its stop, and the underline runs across it, as it does
+ /// on screen: a form's fill-in lines are underlined tabs (#1466).
+ #[test]
+ fn an_underline_runs_across_a_tab() {
+ let mut doc = Document::new(&Default::default());
+ for style in &mut doc.styles_mut().paragraph {
+ style.chars.underline = Some(true);
+ }
+ let layer = doc.default_layer();
+ let (_, sid) =
+ doc.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 400.0, 300.0), layer, "Name\tCity", ParaFormat::default()).unwrap();
+ let cs = Cache::new().get(&doc, sid, None);
+ let line = &cs.frames[0].lines[0];
+ let tab = line.glyphs.iter().find(|g| g.rendered_char == '\t').expect("the tab is placed on the line");
+ let bars = text_rules(&cs, &cs.frames[0], true);
+ assert_eq!(bars.len(), 1, "one bar across the line, not one before the tab and one after: {bars:?}");
+ let bar = bars[0].1;
+ assert!(bar.x0 <= tab.x + 0.5 && bar.x1 >= tab.x + tab.adv - 0.5, "the bar covers the tab: {bar:?}, tab {} + {}", tab.x, tab.adv);
+ // The strikethrough of the same text behaves the same way.
+ for style in &mut doc.styles_mut().paragraph {
+ style.chars.underline = None;
+ style.chars.strikethrough = Some(true);
+ }
+ let cs = Cache::new().get(&doc, sid, None);
+ assert!(text_rules(&cs, &cs.frames[0], true).is_empty(), "no underline now");
+ assert_eq!(text_rules(&cs, &cs.frames[0], false).len(), 1, "one strike across the line");
+ }
+}
diff --git a/crates/pdf/src/transitions.rs b/crates/pdf/src/transitions.rs
index cbfbe27cc..d37581afd 100644
--- a/crates/pdf/src/transitions.rs
+++ b/crates/pdf/src/transitions.rs
@@ -43,6 +43,14 @@ pub(crate) fn int_after(s: &str, key: &str) -> Option {
rest[..end].parse().ok()
}
+/// The text between the first `[` and the first `]` after it. `None` when there is no such pair (a `]`
+/// before the `[` included).
+pub(crate) fn bracketed(s: &str) -> Option<&str> {
+ let open = s.find('[')?;
+ let close = s[open..].find(']')? + open;
+ s.get(open + 1..close)
+}
+
/// Byte range of object `id`'s body (between `obj` and `endobj`).
pub(crate) fn object(pdf: &[u8], id: usize) -> Option> {
// The latest copy (incremental updates re-state objects further on).
@@ -132,7 +140,7 @@ pub fn add_page_entries(pdf: &[u8], extra: &[Option]) -> Option>
let pages = String::from_utf8_lossy(&pdf[object(pdf, pages_id)?]).to_string();
let kids_at = pages.find("/Kids")?;
let kids_str = &pages[kids_at + 5..];
- let kids_str = &kids_str[kids_str.find('[')? + 1..kids_str.find(']')?];
+ let kids_str = bracketed(kids_str)?;
let nums: Vec = kids_str.split_whitespace().filter_map(|t| t.parse().ok()).collect();
let kids: Vec = nums.chunks(2).map(|c| c[0]).collect();
let starts = object_starts(pdf, extra.iter().zip(&kids).filter_map(|(t, &kid)| t.as_ref().map(|_| kid)));
@@ -199,6 +207,31 @@ pub fn add_catalog_entries(pdf: &[u8], entries: &str) -> Option> {
Some(out)
}
+#[cfg(test)]
+mod bracket_tests {
+ use super::{add_page_entries, bracketed};
+
+ #[test]
+ fn bracketed_ignores_a_close_before_the_open() {
+ assert_eq!(bracketed(" [3 0 R 4 0 R] "), Some("3 0 R 4 0 R"));
+ assert_eq!(bracketed("] ["), None);
+ assert_eq!(bracketed("] [3 0 R]"), Some("3 0 R"));
+ assert_eq!(bracketed("[]"), Some(""));
+ }
+
+ /// `]` before the `[` of /Kids (a spoofed page tree) is refused, not a slice panic.
+ #[test]
+ fn kids_closed_before_opened_is_refused() {
+ let objects = ["<>", "<>"];
+ let mut out = b"%PDF-1.6\n".to_vec();
+ for (i, o) in objects.iter().enumerate() {
+ out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
+ }
+ out.extend_from_slice(b"trailer\n<>\nstartxref\n0\n%%EOF\n");
+ assert!(add_page_entries(&out, &[Some("/Rotate 0".to_string())]).is_none());
+ }
+}
+
#[cfg(test)]
mod object_index_tests {
use super::{MAX_INDEXED_PAGE_OBJECTS, object_starts};
diff --git a/crates/pdf/tests/image_content_cache.rs b/crates/pdf/tests/image_content_cache.rs
deleted file mode 100644
index dd3d413d0..000000000
--- a/crates/pdf/tests/image_content_cache.rs
+++ /dev/null
@@ -1,5 +0,0 @@
-//! Run public contracts with the production content-cache hasher.
-#[path = "support/image_content_cache_contract.rs"]
-mod contracts;
-#[path = "support/image_fixtures.rs"]
-mod image_fixtures;
diff --git a/crates/pdf/tests/gradient_color_spaces.rs b/crates/pdf/tests/it/gradient_color_spaces.rs
similarity index 98%
rename from crates/pdf/tests/gradient_color_spaces.rs
rename to crates/pdf/tests/it/gradient_color_spaces.rs
index 05806a120..d906413e1 100644
--- a/crates/pdf/tests/gradient_color_spaces.rs
+++ b/crates/pdf/tests/it/gradient_color_spaces.rs
@@ -42,7 +42,7 @@ fn mixed_gradient_export_survives_in_subprocess() {
const CHILD: &str = "DESIGNCRAFT_TEST_MIXED_GRADIENT_CHILD";
if std::env::var_os(CHILD).is_none() {
let out = std::process::Command::new(std::env::current_exe().unwrap())
- .args(["--exact", "mixed_gradient_export_survives_in_subprocess", "--nocapture"])
+ .args(["--exact", "gradient_color_spaces::mixed_gradient_export_survives_in_subprocess", "--nocapture"])
.env(CHILD, "1")
.output()
.unwrap();
diff --git a/crates/pdf/tests/it/image_content_cache.rs b/crates/pdf/tests/it/image_content_cache.rs
new file mode 100644
index 000000000..ec0f666c0
--- /dev/null
+++ b/crates/pdf/tests/it/image_content_cache.rs
@@ -0,0 +1,3 @@
+//! Run public contracts with the production content-cache hasher.
+#[path = "../support/image_content_cache_contract.rs"]
+mod contracts;
diff --git a/crates/pdf/tests/it/main.rs b/crates/pdf/tests/it/main.rs
new file mode 100644
index 000000000..e2e092e2f
--- /dev/null
+++ b/crates/pdf/tests/it/main.rs
@@ -0,0 +1,6 @@
+#[path = "../support/image_fixtures.rs"]
+mod image_fixtures;
+
+mod gradient_color_spaces;
+mod image_content_cache;
+mod page_entry_search_contract;
diff --git a/crates/pdf/tests/page_entry_search_contract.rs b/crates/pdf/tests/it/page_entry_search_contract.rs
similarity index 100%
rename from crates/pdf/tests/page_entry_search_contract.rs
rename to crates/pdf/tests/it/page_entry_search_contract.rs
diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs
index 70f31b4f6..a4fa80ad0 100644
--- a/crates/render/src/text.rs
+++ b/crates/render/src/text.rs
@@ -162,7 +162,7 @@ fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line, vertical: bool) -> Line
let mut strikes: Vec<(String, f32, Rect)> = Vec::new();
let mut glyphs = 0;
for g in &l.glyphs {
- if !g.visible {
+ if !g.spans_rules() {
continue;
}
let style = &cs.styles[g.style as usize];
@@ -185,6 +185,10 @@ fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line, vertical: bool) -> Line
}
}
}
+ // A tab carries the rule across its width but draws nothing itself.
+ if !g.visible {
+ continue;
+ }
let outline = db.outline(&g.face, g.gid);
if outline.elements().is_empty() {
continue;
@@ -725,3 +729,45 @@ mod cache_identity_tests {
assert_eq!(glyphs.lines[0].runs, runs);
}
}
+
+#[cfg(test)]
+mod rule_tests {
+ use super::*;
+
+ /// A tab is not drawn, but it is a character with an advance, and InDesign underlines it:
+ /// forms rule their fill-in lines with underlined tabs (`Name ⇥⇥⇥⇥, city ⇥⇥⇥⇥`). The bars
+ /// stopped at the last letter before each tab, so an imported form lost every writing line
+ /// (#1466).
+ #[test]
+ fn an_underline_runs_across_a_tab() {
+ let mut doc = designcraft_doc::Document::new(&Default::default());
+ for style in &mut doc.styles_mut().paragraph {
+ style.chars.underline = Some(true);
+ }
+ let layer = doc.default_layer();
+ let (_, sid) = doc
+ .add_text_frame(
+ designcraft_doc::SpreadRef::Doc(0),
+ Rect::new(36.0, 36.0, 400.0, 300.0),
+ layer,
+ "Name\tCity",
+ designcraft_doc::ParaFormat::default(),
+ )
+ .unwrap();
+ let owner = crate::Cache::new();
+ let cs = owner.get(&doc, sid, None);
+ let line = &cs.frames[0].lines[0];
+ let tab = line.glyphs.iter().find(|g| g.rendered_char == '\t').expect("the tab is placed on the line");
+ assert!(!tab.visible, "a tab draws nothing itself");
+ assert!(tab.adv > 1.0, "it advances to its stop: {}", tab.adv);
+
+ let mut cache = GlyphCache::default();
+ cache.tick();
+ let glyphs = cache.get(&cs, &cs.frames[0]);
+ let bars = &glyphs.lines[0].underlines;
+ assert_eq!(bars.len(), 1, "one bar across the line, not one before the tab and one after: {bars:?}");
+ let bar = bars[0].2;
+ assert!(bar.x0 <= tab.x + 0.5, "the bar starts at the text, not at the tab: {bar:?}");
+ assert!(bar.x1 >= tab.x + tab.adv - 0.5, "the bar covers the tab's width: {bar:?}, tab {} + {}", tab.x, tab.adv);
+ }
+}
diff --git a/crates/render/tests/blend_over_paper.rs b/crates/render/tests/it/blend_over_paper.rs
similarity index 100%
rename from crates/render/tests/blend_over_paper.rs
rename to crates/render/tests/it/blend_over_paper.rs
diff --git a/crates/render/tests/colorize.rs b/crates/render/tests/it/colorize.rs
similarity index 100%
rename from crates/render/tests/colorize.rs
rename to crates/render/tests/it/colorize.rs
diff --git a/crates/render/tests/effects.rs b/crates/render/tests/it/effects.rs
similarity index 99%
rename from crates/render/tests/effects.rs
rename to crates/render/tests/it/effects.rs
index a9420fbcb..230913173 100644
--- a/crates/render/tests/effects.rs
+++ b/crates/render/tests/it/effects.rs
@@ -3,7 +3,7 @@
//!
//! Goldens live in `tests/golden/*.png` and are compared with a tolerance (antialiasing and blur
//! rounding may differ by a few levels between SIMD levels). Regenerate them after an intended
-//! change with `DESIGNCRAFT_BLESS=1 cargo test -p designcraft-render --test effects`.
+//! change with `DESIGNCRAFT_BLESS=1 cargo test -p designcraft-render --test it effects::`.
use designcraft_compose::Cache;
use designcraft_doc::build::NewDocument;
diff --git a/crates/render/tests/glyph_retention.rs b/crates/render/tests/it/glyph_retention.rs
similarity index 100%
rename from crates/render/tests/glyph_retention.rs
rename to crates/render/tests/it/glyph_retention.rs
diff --git a/crates/render/tests/gradient_inks.rs b/crates/render/tests/it/gradient_inks.rs
similarity index 100%
rename from crates/render/tests/gradient_inks.rs
rename to crates/render/tests/it/gradient_inks.rs
diff --git a/crates/render/tests/gradients.rs b/crates/render/tests/it/gradients.rs
similarity index 100%
rename from crates/render/tests/gradients.rs
rename to crates/render/tests/it/gradients.rs
diff --git a/crates/render/tests/it/main.rs b/crates/render/tests/it/main.rs
new file mode 100644
index 000000000..52000ae2d
--- /dev/null
+++ b/crates/render/tests/it/main.rs
@@ -0,0 +1,12 @@
+mod blend_over_paper;
+mod colorize;
+mod effects;
+mod glyph_retention;
+mod gradient_inks;
+mod gradients;
+mod pdf_geometry;
+mod perf;
+mod placed_pdf;
+mod stroke_alignment;
+mod text_frame_bottom;
+mod text_stroke_behind;
diff --git a/crates/render/tests/pdf_geometry.rs b/crates/render/tests/it/pdf_geometry.rs
similarity index 100%
rename from crates/render/tests/pdf_geometry.rs
rename to crates/render/tests/it/pdf_geometry.rs
diff --git a/crates/render/tests/perf.rs b/crates/render/tests/it/perf.rs
similarity index 98%
rename from crates/render/tests/perf.rs
rename to crates/render/tests/it/perf.rs
index 1f23f88f4..46f33ab62 100644
--- a/crates/render/tests/perf.rs
+++ b/crates/render/tests/it/perf.rs
@@ -1,6 +1,6 @@
//! Renderer caching/culling behaviour, and a render-time regression test (`#[ignore]`: wall-clock
//! timings are only meaningful on an idle machine — run with
-//! `cargo test --release -p designcraft-render --test perf -- --ignored`).
+//! `cargo test --release -p designcraft-render --test it perf:: -- --ignored`).
use std::time::Instant;
diff --git a/crates/render/tests/placed_pdf.rs b/crates/render/tests/it/placed_pdf.rs
similarity index 100%
rename from crates/render/tests/placed_pdf.rs
rename to crates/render/tests/it/placed_pdf.rs
diff --git a/crates/render/tests/stroke_alignment.rs b/crates/render/tests/it/stroke_alignment.rs
similarity index 100%
rename from crates/render/tests/stroke_alignment.rs
rename to crates/render/tests/it/stroke_alignment.rs
diff --git a/crates/render/tests/text_frame_bottom.rs b/crates/render/tests/it/text_frame_bottom.rs
similarity index 100%
rename from crates/render/tests/text_frame_bottom.rs
rename to crates/render/tests/it/text_frame_bottom.rs
diff --git a/crates/render/tests/text_stroke_behind.rs b/crates/render/tests/it/text_stroke_behind.rs
similarity index 100%
rename from crates/render/tests/text_stroke_behind.rs
rename to crates/render/tests/it/text_stroke_behind.rs
diff --git a/crates/textimport/src/docx.rs b/crates/textimport/src/docx.rs
index 783b010c6..87aaa3359 100644
--- a/crates/textimport/src/docx.rs
+++ b/crates/textimport/src/docx.rs
@@ -115,6 +115,12 @@ pub(crate) fn parse(xml: &str) -> Result {
p.kids.push(Node::Text(s));
}
}
+ Event::CData(t) => {
+ let s = t.decode().map(|s| s.to_string()).unwrap_or_default();
+ if let Some(p) = stack.last_mut() {
+ p.kids.push(Node::Text(s));
+ }
+ }
Event::GeneralRef(e) => {
let name = String::from_utf8_lossy(&e).to_string();
let s = match name.as_str() {
@@ -180,8 +186,9 @@ fn run_attrs(rpr: Option<&El>) -> CharAttrs {
if let Some(s) = r.on("strike") {
a.strikethrough = Some(s);
}
- if let Some(sz) = r.val("sz").and_then(|v| v.parse::().ok()).filter(|v| v.is_finite()) {
- a.size = Some(sz / 2.0);
+ // Half-points; sizes outside the engine's 0.1–1296 pt range (or non-finite) are dropped.
+ if let Some(pt) = r.val("sz").and_then(|v| v.parse::().ok()).map(|sz| sz / 2.0).filter(|pt| (0.1..=1296.0).contains(pt)) {
+ a.size = Some(pt);
}
if let Some(f) = r.child("rFonts").and_then(|f| f.attr("ascii").or(f.attr("hAnsi"))) {
a.font_family = Some(f.to_string());
@@ -578,6 +585,12 @@ mod tests {
use super::*;
use std::io::Write;
+ #[test]
+ fn cdata_text() {
+ let el = parse(" ").unwrap();
+ assert_eq!(el.child("t").unwrap().text(), "x < y");
+ }
+
/// Issue #712: nesting past the budget is an error from the parser, not a stack overflow later.
#[test]
fn nesting_past_the_budget_is_refused() {
@@ -702,6 +715,19 @@ mod tests {
}
}
+ #[test]
+ fn out_of_range_sizes_are_dropped() {
+ let document = format!(
+ r#"
+ a b
+ "#
+ );
+ let st = import(&docx(&document, "", "")).unwrap().story;
+ for at in [0, 1] {
+ assert_eq!(st.format_after(at).over.size, None, "run {at}");
+ }
+ }
+
#[test]
fn tracked_changes_import_as_accepted_including_moves() {
// Regression (#256): text in a tracked move destination (`w:moveTo`) vanished on import.
diff --git a/crates/textimport/src/lib.rs b/crates/textimport/src/lib.rs
index ba9cb92e3..56eac1a00 100644
--- a/crates/textimport/src/lib.rs
+++ b/crates/textimport/src/lib.rs
@@ -114,7 +114,7 @@ pub fn import(name: &str, bytes: &[u8]) -> Result {
if l.ends_with(".doc") {
return Err(ImportError::Unsupported("legacy Word .doc (save it as .docx)".into()));
}
- Ok(plain(&String::from_utf8_lossy(bytes)))
+ Ok(plain(&tagged::decode(bytes)))
}
/// Plain text: line breaks become paragraphs (CRLF / CR normalised).
@@ -136,4 +136,14 @@ mod tests {
assert!(is_text_file("Report.DOCX") && !is_text_file("a.png"));
assert!(matches!(import("x.doc", b"\xD0\xCF"), Err(ImportError::Unsupported(_))));
}
+
+ #[test]
+ fn plain_text_utf16() {
+ let mut le = vec![0xFF, 0xFE];
+ le.extend("Hi there".encode_utf16().flat_map(u16::to_le_bytes));
+ assert_eq!(import("a.txt", &le).unwrap().story.text, "Hi there");
+ let mut be = vec![0xFE, 0xFF];
+ be.extend("Hi there".encode_utf16().flat_map(u16::to_be_bytes));
+ assert_eq!(import("a.txt", &be).unwrap().story.text, "Hi there");
+ }
}
diff --git a/crates/textimport/src/rtf.rs b/crates/textimport/src/rtf.rs
index baf065c30..782071798 100644
--- a/crates/textimport/src/rtf.rs
+++ b/crates/textimport/src/rtf.rs
@@ -95,6 +95,8 @@ pub fn import(bytes: &[u8]) -> Result {
stack.push(top);
}
first = false;
+ // A group is not a character: the `\uN` fallback skip ends here too.
+ pending = 0;
// `{\*\dest …}`: an ignorable destination.
if chars.get(i + 1) == Some(&'\\')
&& chars.get(i + 2) == Some(&'*')
@@ -134,6 +136,11 @@ pub fn import(bytes: &[u8]) -> Result {
}
if !next.is_ascii_alphabetic() {
// Control symbols: \\ \{ \} \~ (nbsp) \- (soft hyphen) \_ (nb hyphen).
+ i += 2;
+ if pending > 0 {
+ pending -= 1;
+ continue;
+ }
let skip = stack.last().is_some_and(|s| s.skip);
if !skip {
match next {
@@ -149,7 +156,6 @@ pub fn import(bytes: &[u8]) -> Result {
_ => {}
}
}
- i += 2;
continue;
}
// A control word with an optional numeric parameter and one delimiting space.
@@ -172,6 +178,11 @@ pub fn import(bytes: &[u8]) -> Result {
k += 1;
}
i = k;
+ // A control word is one character of the `\uN` fallback (a following `\u` is not).
+ if pending > 0 && word != "u" {
+ pending -= 1;
+ continue;
+ }
let skip = stack.last().is_some_and(|s| s.skip);
if SKIP.contains(&word.as_str()) {
flush(&mut st, &mut buf, &fmt);
@@ -234,9 +245,12 @@ pub fn import(bytes: &[u8]) -> Result {
}
"u" => {
if let Some(n) = param {
- let unit = if n < 0 { (n + 65536) as u32 } else { n as u32 };
- if let Some(ch) = units.push(unit) {
- buf.push(ch);
+ // RTF allows -32768..=65535; anything else is dropped.
+ if (-32768..=65535).contains(&n) {
+ let unit = if n < 0 { (n + 65536) as u32 } else { n as u32 };
+ if let Some(ch) = units.push(unit) {
+ buf.push(ch);
+ }
}
pending = stack.last().map_or(1, |s| s.uc);
}
@@ -282,6 +296,12 @@ fn cp1252(b: u8) -> char {
mod tests {
use super::*;
+ #[test]
+ fn out_of_range_unicode_escape_is_dropped() {
+ assert_eq!(import(br"{\rtf1 a\u4294967361?b}").unwrap().story.text, "ab");
+ assert_eq!(import(br"{\rtf1 a\u-4294967231?b}").unwrap().story.text, "ab");
+ }
+
#[test]
fn imports_rtf_text_and_formatting() {
let rtf = br"{\rtf1\ansi{\fonttbl{\f0 Times;}}{\colortbl;\red0\green0\blue0;}{\*\generator Test;}
@@ -320,6 +340,18 @@ Caf\'e9 \u8364? price\tab tab\line next\par
assert_eq!(i.story.text, "Rock\u{2014}-roll, x\t-y, ab");
}
+ /// A control word or control symbol is one character of the `\uN` fallback, so it is skipped
+ /// like a plain character and does not eat the text after it.
+ #[test]
+ fn control_fallback_after_unicode_is_skipped() {
+ let i = import(br"{\rtf1 \u8220\ldblquote Hi\u8221\rdblquote x\u8212\emdash y}").unwrap();
+ assert_eq!(i.story.text, "\u{201C}Hi\u{201D} x\u{2014}y");
+ let i = import(br"{\rtf1 \u8220\{Hi}").unwrap();
+ assert_eq!(i.story.text, "\u{201C}Hi");
+ let i = import(br"{\rtf1 \u8220{\b Hi}}").unwrap();
+ assert_eq!(i.story.text, "\u{201C}Hi");
+ }
+
/// Characters past U+FFFF are written as two `\uN` words, a UTF-16 surrogate pair (the form
/// our own exporter and Word write). Each half was converted alone and dropped.
#[test]
diff --git a/crates/textimport/src/tagged.rs b/crates/textimport/src/tagged.rs
index 9bc84b766..3a890734a 100644
--- a/crates/textimport/src/tagged.rs
+++ b/crates/textimport/src/tagged.rs
@@ -25,6 +25,37 @@ fn escape(out: &mut String, c: char) {
}
}
+/// A tag value with '<', '>' and '\\' escaped by a backslash, so it keeps the tag intact.
+fn escape_value(v: &str) -> String {
+ let mut out = String::with_capacity(v.len());
+ for c in v.chars() {
+ if matches!(c, '<' | '>' | '\\') {
+ out.push('\\');
+ }
+ out.push(c);
+ }
+ out
+}
+
+/// A tag value with the escapes `escape_value` writes (`\\`, `\<`, `\>`) decoded. Any other
+/// backslash, including a trailing one, stays literal, as older files and InDesign's `\:` expect.
+fn unescape_value(v: &str) -> String {
+ let mut out = String::with_capacity(v.len());
+ let mut it = v.chars().peekable();
+ while let Some(c) = it.next() {
+ if c == '\\'
+ && let Some(&n) = it.peek()
+ && matches!(n, '\\' | '<' | '>')
+ {
+ out.push(n);
+ it.next();
+ } else {
+ out.push(c);
+ }
+ }
+ out
+}
+
/// `story` as Tagged Text.
pub fn tagged_text(doc: &Document, story: &Story) -> String {
let mut out = String::from("\r\n\r\n");
@@ -34,7 +65,7 @@ pub fn tagged_text(doc: &Document, story: &Story) -> String {
out.push_str("\r\n");
}
let pf = story.paras.get(i).cloned().unwrap_or_default();
- let _ = write!(out, "", pf.style.replace(['<', '>', ':'], ""));
+ let _ = write!(out, "", escape_value(&pf.style));
for (rr, f) in story.runs() {
let (a, b) = (rr.start.max(r.start), rr.end.min(r.end));
if a >= b {
@@ -45,7 +76,7 @@ pub fn tagged_text(doc: &Document, story: &Story) -> String {
}
let mut open = Vec::new();
if f.style != NO_CHAR_STYLE {
- let _ = write!(out, "", f.style.replace(['<', '>', ':'], ""));
+ let _ = write!(out, "", escape_value(&f.style));
open.push("CharStyle");
}
if let Some(v) = &f.over.font_family {
@@ -72,7 +103,7 @@ pub fn tagged_text(doc: &Document, story: &Story) -> String {
}
/// Text of a Tagged Text file (ASCII, UTF-8 or UTF-16 by byte-order mark).
-fn decode(bytes: &[u8]) -> String {
+pub(crate) fn decode(bytes: &[u8]) -> String {
if bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]) {
let le = bytes[0] == 0xFF;
let units: Vec = bytes[2..]
@@ -153,7 +184,7 @@ pub fn import(bytes: &[u8]) -> Result {
}
"ParaStyle" => {
started = true;
- let v = value.trim().to_string();
+ let v = unescape_value(value.trim());
if !v.is_empty() && !para_styles.contains(&v) {
para_styles.push(v.clone());
}
@@ -162,10 +193,10 @@ pub fn import(bytes: &[u8]) -> Result {
}
}
"CharStyle" => {
- let v = value.trim();
- fmt.style = if v.is_empty() { NO_CHAR_STYLE.into() } else { v.to_string() };
- if !v.is_empty() && !char_styles.contains(&v.to_string()) {
- char_styles.push(v.to_string());
+ let v = unescape_value(value.trim());
+ fmt.style = if v.is_empty() { NO_CHAR_STYLE.into() } else { v.clone() };
+ if !v.is_empty() && !char_styles.contains(&v) {
+ char_styles.push(v);
}
}
"cFont" => fmt.over.font_family = (!value.is_empty()).then(|| value.to_string()),
@@ -268,6 +299,34 @@ mod tests {
assert_eq!(back.story.paras, story.paras);
}
+ /// Style names with ':', '<', '>' or '\' were written with those characters dropped, so
+ /// re-import gave the paragraphs a different style.
+ #[test]
+ fn style_names_with_tag_characters_round_trip() {
+ let doc = Document::new(&designcraft_doc::build::NewDocument::default());
+ for name in ["Body: Indent", "Ac", "x\\y", "end\\"] {
+ let mut story = Story::new(StoryId(0));
+ story.insert_with(0, "Text", CharFormat { style: name.into(), ..Default::default() });
+ story.paras[0].style = name.into();
+ let out = tagged_text(&doc, &story);
+ let back = import(out.as_bytes()).unwrap();
+ assert_eq!(back.story.text, "Text", "{out}");
+ assert_eq!(back.story.paras[0].style, name, "{out}");
+ assert_eq!(back.story.char_format_at(1).style, name, "{out}");
+ assert_eq!(back.para_styles.iter().map(|s| s.name.as_str()).collect::>(), [name]);
+ assert_eq!(back.char_styles.iter().map(|s| s.name.as_str()).collect::>(), [name]);
+ }
+ }
+
+ /// A backslash that isn't one of our escapes (older files, InDesign's `\:`) stays literal.
+ #[test]
+ fn unknown_backslashes_in_style_names_stay_literal() {
+ assert_eq!(unescape_value(r"x\y"), r"x\y");
+ assert_eq!(unescape_value(r"a\:b"), r"a\:b");
+ assert_eq!(unescape_value("end\\"), "end\\");
+ assert_eq!(unescape_value(r"\\\"), r" \");
+ }
+
#[test]
fn tagged_text_round_trips() {
let src = "\r\n\r\nCaf<0x00E9> \\<1\\>\r\nA word and big text";
diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs
index 866003102..c437484c2 100644
--- a/crates/tools/src/select.rs
+++ b/crates/tools/src/select.rs
@@ -1,6 +1,7 @@
//! Selection tool (V) and Direct Selection tool (A).
//!
-//! Click selects the frontmost item (Shift toggles), drag moves (Alt duplicates, Shift constrains),
+//! Click selects the frontmost item (Shift toggles; a click on one of several selected items makes
+//! it the key object for Align, a second click drops it), drag moves (Alt duplicates, Shift constrains),
//! the 8 bounding-box handles resize (Shift keeps proportions, Alt from centre, Cmd scales content),
//! empty-canvas drags make a marquee (it selects the ruler guides it crosses when it touches no
//! object), double-clicking a text frame switches to the Type tool and
@@ -223,6 +224,9 @@ pub struct SelectionTool {
guides: Vec,
/// Selected item to toggle if this Shift press never becomes a drag.
shift_release: Option,
+ /// One of several selected items to make (or stop being) the key object for Align if this
+ /// press never becomes a drag.
+ key_release: Option,
/// The loaded text cursor.
loaded: Option,
/// Frame whose live corners are being edited (diamonds shown).
@@ -231,7 +235,7 @@ pub struct SelectionTool {
impl SelectionTool {
pub fn new(direct: bool) -> Self {
- Self { direct, drag: Drag::None, hover_handle: None, guides: vec![], shift_release: None, loaded: None, live: None }
+ Self { direct, drag: Drag::None, hover_handle: None, guides: vec![], shift_release: None, key_release: None, loaded: None, live: None }
}
/// [`bracket_at`] for this tool. With Direct Selection an anchor under the pointer wins: the
@@ -735,6 +739,7 @@ impl Tool for SelectionTool {
}
PointerKind::Down => {
self.shift_release = None;
+ self.key_release = None;
// Type on a path: a bracket moves where the text starts or ends.
if !ev.mods.shift
&& let Some((id, end)) = self.bracket_at(cx, p)
@@ -825,6 +830,10 @@ impl Tool for SelectionTool {
}
} else if !cx.selection.contains(id) || (self.direct && !cx.selection.content) {
out.push(Action::Exec("selection.set".into(), json!({"ids": [id.0], "content": self.direct})));
+ } else if !self.direct && cx.selection.items.len() > 1 {
+ // InDesign: a click on one of several selected objects makes it the
+ // key object, on release so that a drag still moves them all.
+ self.key_release = Some(id.0);
}
let content = self.direct || on_selected_content(cx, p);
self.drag = Drag::Pending { start: p, hit: true, content };
@@ -852,6 +861,7 @@ impl Tool for SelectionTool {
return vec![];
}
self.shift_release = None;
+ self.key_release = None;
if hit && !cx.selection.items.is_empty() {
// Direct Selection on items without content moves (or duplicates) them whole.
let content = content && cx.selection.items.iter().any(|i| cx.doc.item(*i).is_some_and(holds_content));
@@ -1124,6 +1134,7 @@ impl Tool for SelectionTool {
PointerKind::Up => {
self.guides.clear();
let release = self.shift_release.take();
+ let key = self.key_release.take();
let d = std::mem::replace(&mut self.drag, Drag::None);
match d {
Drag::Move { .. }
@@ -1135,9 +1146,10 @@ impl Tool for SelectionTool {
| Drag::Bracket { .. } => {
vec![Action::Commit]
}
- Drag::Pending { .. } => match release {
- Some(id) => vec![Action::Exec("selection.toggle".into(), json!({"id": id}))],
- None => vec![],
+ Drag::Pending { .. } => match (release, key) {
+ (Some(id), _) => vec![Action::Exec("selection.toggle".into(), json!({"id": id}))],
+ (None, Some(id)) => vec![Action::Exec("selection.key".into(), json!({"id": id}))],
+ (None, None) => vec![],
},
Drag::Marquee { start, cur } => {
let r = Rect::from_points(start, cur);
diff --git a/crates/ui-egui/src/about.rs b/crates/ui-egui/src/about.rs
index 14745607a..75442b93c 100644
--- a/crates/ui-egui/src/about.rs
+++ b/crates/ui-egui/src/about.rs
@@ -77,16 +77,24 @@ pub fn paint_chat_icon(p: &egui::Painter, c: egui::Pos2, s: f32, color: Color32)
p.circle_filled(body.center() + vec2(s * 0.18, 0.0), eye, bg);
}
-/// The big "Join our Discord" button. Returns true when clicked.
+/// The Discord button's size for `label`: `min`, widened when the label would not fit inside it.
+pub fn discord_button_size(ctx: &egui::Context, label: &str, min: egui::Vec2) -> egui::Vec2 {
+ let galley = crate::rtl::plain(ctx, label, semibold((min.y * 0.42).max(11.0)), Color32::WHITE);
+ egui::vec2(min.x.max(min.y * 0.5 + 8.0 + galley.size().x + min.y), min.y)
+}
+
+/// The big "Join our Discord" button. Returns true when clicked. `size` is the minimum: a longer label
+/// widens the button (see `discord_button_size`) rather than spilling out of it.
pub fn discord_button(ui: &mut egui::Ui, label: &str, size: egui::Vec2) -> bool {
- let (r, resp) = ui.allocate_exact_size(size, Sense::click());
- let resp = resp.on_hover_text(links::DISCORD).on_hover_cursor(egui::CursorIcon::PointingHand);
- let fill = if resp.hovered() { Color32::from_rgb(0x47, 0x52, 0xc4) } else { DISCORD };
- ui.painter().rect_filled(r, size.y / 2.0, fill);
+ let size = discord_button_size(ui.ctx(), label, size);
let icon = size.y * 0.5;
let font = semibold((size.y * 0.42).max(11.0));
let galley = crate::rtl::plain(ui.ctx(), label, font, Color32::WHITE);
let total = icon + 8.0 + galley.size().x;
+ let (r, resp) = ui.allocate_exact_size(size, Sense::click());
+ let resp = resp.on_hover_text(links::DISCORD).on_hover_cursor(egui::CursorIcon::PointingHand);
+ let fill = if resp.hovered() { Color32::from_rgb(0x47, 0x52, 0xc4) } else { DISCORD };
+ ui.painter().rect_filled(r, size.y / 2.0, fill);
let x0 = r.center().x - total / 2.0;
paint_chat_icon(ui.painter(), pos2(x0 + icon / 2.0, r.center().y), icon, Color32::WHITE);
ui.painter().galley(pos2(x0 + icon + 8.0, r.center().y - galley.size().y / 2.0), galley, Color32::WHITE);
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index 318f82388..2118c4929 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -1251,6 +1251,9 @@ fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Docum
if sel.items.len() == 1 && sel.text.is_none() {
// InDesign draws a 1 pt bounding box around the selection.
painter.rect_stroke(xf.rect(a.transform_rect_bbox(it.bounds())), 0.0, Stroke::new(1.0, color), StrokeKind::Middle);
+ } else if sel.key == Some(*id) {
+ // The key object for Align: a thicker bounding box, as in InDesign.
+ painter.rect_stroke(xf.rect(a.transform_rect_bbox(it.bounds())), 0.0, Stroke::new(3.0, color), StrokeKind::Middle);
}
let b = xf.rect(a.transform_rect_bbox(it.bounds()));
union = Some(union.map_or(b, |u| u.union(b)));
@@ -5317,7 +5320,7 @@ mod tests {
let (pen, direct, text) = (shown("pen"), shown("directSelection"), shown("type"));
let ppp = h.ctx.pixels_per_point();
let images = &mut h.state_mut().cursors;
- match crate::cursors::MODE {
+ match crate::cursors::mode() {
crate::cursors::Mode::Os => {
assert_eq!(pen.1, images.get(Cursor::Pen, ppp), "Pen");
assert_eq!(direct.1, images.get(Cursor::ArrowHollow, ppp), "Direct Selection");
diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs
index e853e6793..b0fed5799 100644
--- a/crates/ui-egui/src/chrome.rs
+++ b/crates/ui-egui/src/chrome.rs
@@ -852,85 +852,97 @@ pub fn start_screen(app: &mut DesignApp, ui: &mut egui::Ui) {
egui::Align::Min
})),
|ui| {
- ui.label(crate::rtl::widget(
- ui,
- egui::RichText::new(crate::i18n::tr(&app.ui.language, "Welcome to DesignCraft")).font(semibold(28.0)).color(t.text_strong),
- ));
- ui.label(crate::rtl::widget(
- ui,
- egui::RichText::new(crate::i18n::tr(&app.ui.language, "Page layout for print and screen — fast, open and scriptable."))
- .size(15.0)
- .color(t.text_dim),
- ));
- ui.add_space(24.0);
- ui.horizontal(|ui| {
- if ui
- .add(
- egui::Button::new(crate::rtl::widget(
- ui,
- egui::RichText::new(crate::i18n::tr(&app.ui.language, " New file ")).size(14.0).color(Color32::WHITE),
- ))
- .fill(t.accent_strong)
- .corner_radius(16.0)
- .min_size(vec2(0.0, 32.0)),
- )
- .clicked()
- {
- let _ = app.run("app.newDocumentDialog", json!({}));
- }
- if ui
- .add(
- egui::Button::new(crate::rtl::widget(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, " Open ")).size(14.0)))
+ egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
+ ui.label(crate::rtl::widget(
+ ui,
+ egui::RichText::new(crate::i18n::tr(&app.ui.language, "Welcome to DesignCraft")).font(semibold(28.0)).color(t.text_strong),
+ ));
+ ui.label(crate::rtl::widget(
+ ui,
+ egui::RichText::new(crate::i18n::tr(&app.ui.language, "Page layout for print and screen — fast, open and scriptable."))
+ .size(15.0)
+ .color(t.text_dim),
+ ));
+ ui.add_space(24.0);
+ ui.horizontal(|ui| {
+ if ui
+ .add(
+ egui::Button::new(crate::rtl::widget(
+ ui,
+ egui::RichText::new(crate::i18n::tr(&app.ui.language, " New file ")).size(14.0).color(Color32::WHITE),
+ ))
+ .fill(t.accent_strong)
.corner_radius(16.0)
.min_size(vec2(0.0, 32.0)),
- )
- .clicked()
- {
- let _ = app.run("app.openDialog", json!({}));
- }
- if ui
- .add(
- egui::Button::new(crate::rtl::widget(
- ui,
- egui::RichText::new(crate::i18n::tr(&app.ui.language, " Open sample magazine ")).size(14.0),
- ))
- .corner_radius(16.0)
- .min_size(vec2(0.0, 32.0)),
- )
- .clicked()
- {
- let _ = app.run("file.newSample", json!({}));
- }
- });
- ui.add_space(20.0);
- community_card(app, ui);
- recent_files(app, ui);
- ui.add_space(24.0);
- ui.label(crate::rtl::widget(
- ui,
- egui::RichText::new(crate::i18n::tr(&app.ui.language, "Start a new document")).font(semibold(15.0)).color(t.text_strong),
- ));
- ui.add_space(8.0);
- ui.horizontal_wrapped(|ui| {
- for p in designcraft_doc::build::PRESETS.iter().filter(|p| p.intent == designcraft_doc::Intent::Print) {
- let (cr, resp) = ui.allocate_exact_size(vec2(120.0, 150.0), Sense::click());
- let hov = resp.hovered();
- ui.painter().rect_filled(cr, 6.0, if hov { t.hover } else { t.panel });
- let k = (80.0 / p.width.max(p.height)) as f32;
- let pr = egui::Rect::from_center_size(cr.center() - vec2(0.0, 14.0), vec2(p.width as f32 * k, p.height as f32 * k));
- ui.painter().rect_filled(pr, 0.0, Color32::from_gray(245));
- ui.painter().text(egui::pos2(cr.center().x, cr.max.y - 26.0), egui::Align2::CENTER_CENTER, p.name, semibold(12.0), t.text_strong);
- ui.painter().text(
- egui::pos2(cr.center().x, cr.max.y - 11.0),
- egui::Align2::CENTER_CENTER,
- format!("{} × {}", designcraft_geom::format_measure(p.width, p.units), designcraft_geom::format_measure(p.height, p.units)),
- egui::FontId::proportional(10.0),
- t.text_dim,
- );
- if resp.clicked() {
- let _ = app.run("file.new", json!({"preset": p.name}));
+ )
+ .clicked()
+ {
+ let _ = app.run("app.newDocumentDialog", json!({}));
}
- }
+ if ui
+ .add(
+ egui::Button::new(crate::rtl::widget(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, " Open ")).size(14.0)))
+ .corner_radius(16.0)
+ .min_size(vec2(0.0, 32.0)),
+ )
+ .clicked()
+ {
+ let _ = app.run("app.openDialog", json!({}));
+ }
+ if ui
+ .add(
+ egui::Button::new(crate::rtl::widget(
+ ui,
+ egui::RichText::new(crate::i18n::tr(&app.ui.language, " Open sample magazine ")).size(14.0),
+ ))
+ .corner_radius(16.0)
+ .min_size(vec2(0.0, 32.0)),
+ )
+ .clicked()
+ {
+ let _ = app.run("file.newSample", json!({}));
+ }
+ });
+ ui.add_space(20.0);
+ community_card(app, ui);
+ recent_files(app, ui);
+ ui.add_space(24.0);
+ ui.label(crate::rtl::widget(
+ ui,
+ egui::RichText::new(crate::i18n::tr(&app.ui.language, "Start a new document")).font(semibold(15.0)).color(t.text_strong),
+ ));
+ ui.add_space(8.0);
+ ui.horizontal_wrapped(|ui| {
+ for p in designcraft_doc::build::PRESETS.iter().filter(|p| p.intent == designcraft_doc::Intent::Print) {
+ let (cr, resp) = ui.allocate_exact_size(vec2(120.0, 150.0), Sense::click());
+ let hov = resp.hovered();
+ ui.painter().rect_filled(cr, 6.0, if hov { t.hover } else { t.panel });
+ let k = (80.0 / p.width.max(p.height)) as f32;
+ let pr = egui::Rect::from_center_size(cr.center() - vec2(0.0, 14.0), vec2(p.width as f32 * k, p.height as f32 * k));
+ ui.painter().rect_filled(pr, 0.0, Color32::from_gray(245));
+ ui.painter().text(
+ egui::pos2(cr.center().x, cr.max.y - 26.0),
+ egui::Align2::CENTER_CENTER,
+ p.name,
+ semibold(12.0),
+ t.text_strong,
+ );
+ ui.painter().text(
+ egui::pos2(cr.center().x, cr.max.y - 11.0),
+ egui::Align2::CENTER_CENTER,
+ format!(
+ "{} × {}",
+ designcraft_geom::format_measure(p.width, p.units),
+ designcraft_geom::format_measure(p.height, p.units)
+ ),
+ egui::FontId::proportional(10.0),
+ t.text_dim,
+ );
+ if resp.clicked() {
+ let _ = app.run("file.new", json!({"preset": p.name}));
+ }
+ }
+ });
});
},
);
@@ -1060,7 +1072,9 @@ fn community_card(app: &mut DesignApp, ui: &mut egui::Ui) {
let (r, _) = ui.allocate_exact_size(vec2(44.0, 44.0), Sense::hover());
crate::about::paint_mark(ui, r, crate::about::BRAND);
ui.add_space(8.0);
- let text_width = (ui.available_width() - BUTTON.x - 16.0).max(120.0);
+ let label = crate::i18n::tr(&app.ui.language, "Join our Discord");
+ let button = crate::about::discord_button_size(ui.ctx(), label, BUTTON);
+ let text_width = (ui.available_width() - button.x - 16.0).max(120.0);
ui.vertical(|ui| {
ui.set_max_width(text_width);
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap);
@@ -1090,7 +1104,7 @@ fn community_card(app: &mut DesignApp, ui: &mut egui::Ui) {
});
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
- if crate::about::discord_button(ui, crate::i18n::tr(&app.ui.language, "Join our Discord"), BUTTON) {
+ if crate::about::discord_button(ui, label, BUTTON) {
let _ = app.run("help.discord", json!({}));
}
});
diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs
index 566fe65f9..28f98a5e0 100644
--- a/crates/ui-egui/src/cursors.rs
+++ b/crates/ui-egui/src/cursors.rs
@@ -3,10 +3,10 @@
//! what a click does (add, delete, close, convert, continue), and a curved double arrow for rotating.
//! Cursors without a glyph stay system cursors.
//!
-//! Linux and Windows show the glyphs as OS cursors ([`Images`]): the system moves them itself, so
-//! they never trail the pointer. macOS sizes a cursor bitmap in points (a Retina one would show
-//! twice as big, or blurred), so there the canvas paints the glyph at the pointer. The
-//! web build keeps the system cursors.
+//! Linux (X11) and Windows show the glyphs as OS cursors ([`Images`]): the system moves them itself,
+//! so they never trail the pointer. macOS sizes a cursor bitmap in points (a Retina one would show
+//! twice as big, or blurred) and a Wayland session (native or through XWayland) scales it by its own
+//! rules, so there the canvas paints the glyph at the pointer. The web build keeps the system cursors.
use egui::epaint::{Mesh, TessellationOptions, Tessellator, Vertex};
use egui::{Color32, CustomCursorImage, Pos2, Shape, Stroke, Vec2, pos2, vec2};
@@ -27,13 +27,32 @@ pub enum Mode {
System,
}
-pub const MODE: Mode = if cfg!(target_arch = "wasm32") {
- Mode::System
-} else if cfg!(target_os = "macos") {
- Mode::Painted
-} else {
- Mode::Os
-};
+/// The mode for a platform: `wayland` is true on a Linux Wayland session.
+fn mode_for(wasm: bool, macos: bool, wayland: bool) -> Mode {
+ if wasm {
+ Mode::System
+ } else if macos || wayland {
+ Mode::Painted
+ } else {
+ Mode::Os
+ }
+}
+
+/// Whether the environment describes a Wayland session: a Wayland display, unless the session says X11.
+fn is_wayland(wayland_display: Option<&str>, session_type: Option<&str>) -> bool {
+ wayland_display.is_some_and(|d| !d.is_empty()) && !session_type.is_some_and(|t| t.eq_ignore_ascii_case("x11"))
+}
+
+/// How cursor glyphs are shown on this system. On Wayland the compositor sizes a cursor bitmap by
+/// its own scale, so the OS glyph came out far too big; there the canvas paints it, as on macOS.
+pub fn mode() -> Mode {
+ static MODE: std::sync::OnceLock = std::sync::OnceLock::new();
+ *MODE.get_or_init(|| {
+ let wayland = cfg!(target_os = "linux")
+ && is_wayland(std::env::var("WAYLAND_DISPLAY").ok().as_deref(), std::env::var("XDG_SESSION_TYPE").ok().as_deref());
+ mode_for(cfg!(target_arch = "wasm32"), cfg!(target_os = "macos"), wayland)
+ })
+}
/// The shapes of one glyph: every halo goes under all the ink, so one part's halo never cuts
/// through another part's ink.
@@ -289,9 +308,9 @@ pub fn system(c: Cursor) -> egui::CursorIcon {
}
/// Show cursor `c` with the pointer at `hover` (screen points): set the OS cursor bitmap or paint
-/// the glyph per [`MODE`], and return the cursor icon to set.
+/// the glyph per [`mode`], and return the cursor icon to set.
pub fn show(ctx: &egui::Context, images: &mut Images, c: Cursor, hover: Pos2) -> egui::CursorIcon {
- match MODE {
+ match mode() {
Mode::Os => {
// The glyphs keep the system cursors' size whatever the interface scale.
let ppp = ctx.pixels_per_point() / ctx.zoom_factor().max(0.01);
@@ -359,6 +378,19 @@ mod tests {
assert!(rasterize(Cursor::Pen, f32::NAN).is_none() && rasterize(Cursor::Pen, 0.0).is_none());
}
+ #[test]
+ fn wayland_paints_the_glyphs_and_x11_keeps_os_cursors() {
+ assert_eq!(mode_for(false, false, true), Mode::Painted);
+ assert_eq!(mode_for(false, false, false), Mode::Os);
+ assert_eq!(mode_for(false, true, false), Mode::Painted);
+ assert_eq!(mode_for(true, false, true), Mode::System);
+ assert!(is_wayland(Some("wayland-0"), Some("wayland")));
+ assert!(is_wayland(Some("wayland-0"), None));
+ assert!(!is_wayland(Some("wayland-0"), Some("x11")));
+ assert!(!is_wayland(Some(""), Some("wayland")));
+ assert!(!is_wayland(None, Some("wayland")));
+ }
+
#[test]
fn the_selection_arrows_differ_in_fill() {
// A pixel inside the arrow's body, below the tip: ink for Selection, halo for Direct.
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index 40787954f..53a2b350b 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -1991,8 +1991,8 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_he
egui::ComboBox::from_id_salt("pref_language").width(180.0).height(400.0).selected_text(crate::rtl::widget(ui, current)).show_ui(
ui,
|ui| {
- for (code, name) in crate::i18n::LANGUAGES {
- if ui.selectable_label(*code == app.ui.language, crate::rtl::widget(ui, *name)).clicked() {
+ for (code, name) in crate::i18n::picker_languages() {
+ if ui.selectable_label(code == app.ui.language, crate::rtl::widget(ui, name)).clicked() {
// Applied by `show` right after this frame, which has `&mut DesignApp`.
d.fields.insert("applyLanguage".into(), json!(code));
}
@@ -2456,6 +2456,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
{
match app.session.execute("color.loadProfile", &json!({"path": path})) {
Ok(_) => {
+ // Replayed in load order at start-up, so the latest load of a path comes last.
+ app.ui.color_profiles.retain(|p| p != &path);
+ app.ui.color_profiles.push(path);
let f = app.session.execute("color.settings", &json!({})).unwrap_or_default();
d.fields.insert("profiles".into(), f["profiles"].clone());
}
@@ -2836,6 +2839,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
ui.horizontal(|ui| {
if matches!(d.id.as_str(), "paragraphStyleOptions" | "characterStyleOptions") && !d.b("new") {
check(ui, &mut d, "preview", crate::i18n::tr(&app.ui.language, "Preview"));
+ app.ui.style_preview = d.b("preview");
style_options_preview(app, &mut d);
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
@@ -3275,13 +3279,29 @@ pub fn confirm(app: &mut DesignApp) -> Result {
}
"pdfImport" => {
let crop = d.s("crop");
+ // One page number, 1 or more; a blank field is page 1. A range or list ("4-5", "2,5"), a
+ // fraction or a negative is refused rather than placing some other page.
+ let raw = if d.fields.get("page").is_none_or(Value::is_null) { String::new() } else { d.s("page") };
+ let page = match raw.trim() {
+ "" => 1,
+ t => match t.parse::() {
+ Ok(n) if n >= 1 => n,
+ _ => {
+ let e = "Enter a single page number; one page is placed at a time".to_string();
+ let mut d = d;
+ d.fields.insert("status".into(), json!(e));
+ app.ui.dialog = Some(d);
+ return Err(e);
+ }
+ },
+ };
if d.b("async") {
- return app.confirm_pdf_import(d.n("page").unwrap_or(1.0).max(1.0) as u64, if crop.is_empty() { "crop".into() } else { crop });
+ return app.confirm_pdf_import(page, if crop.is_empty() { "crop".into() } else { crop });
}
if let Some(target) = d.fields.get("target").and_then(Value::as_u64) {
app.activate_import_target(&crate::ImportRequest { purpose: "place".into(), target: Some(target) })?;
}
- app.place_file(json!({"path": d.s("path"), "pdfPage": d.n("page").unwrap_or(1.0).max(1.0) as u64, "pdfCrop": if crop.is_empty() { "crop".to_string() } else { crop }}))
+ app.place_file(json!({"path": d.s("path"), "pdfPage": page, "pdfCrop": if crop.is_empty() { "crop".to_string() } else { crop }}))
}
"print" => {
let pages = if d.s("range") == "all" { Value::Null } else { json!(d.s("pages")) };
@@ -3337,7 +3357,7 @@ pub fn confirm(app: &mut DesignApp) -> Result {
params["title"] = json!(title);
}
let name = if title.is_empty() { "Export.pdf".to_string() } else { format!("{title}.pdf") };
- match app.services.pick_save.as_mut().and_then(|f| f(&name)) {
+ match app.pick_save(&name) {
Some(path) => {
params["path"] = json!(path);
crate::menus::export_pdf(app, ¶ms)
@@ -5154,7 +5174,7 @@ pub fn open_style_options(app: &mut DesignApp, para: bool, name: &str) {
return;
}
let id = if para { "paragraphStyleOptions" } else { "characterStyleOptions" };
- app.ui.dialog = Some(Dialog::new(id, json!({"name": name})));
+ app.ui.dialog = Some(Dialog::new(id, json!({"name": name, "preview": app.ui.style_preview})));
}
/// New Paragraph Style… / New Character Style…: the style options for a style made on OK, named
@@ -7770,6 +7790,33 @@ mod tests {
assert!(dialog.fields["status"].as_str().unwrap().contains("page size out of range"));
}
+ #[test]
+ fn style_options_preview_choice_is_remembered_on_reopen() {
+ use egui_kittest::{Harness, kittest::Queryable};
+ let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+ app.run("file.new", json!({})).unwrap();
+ app.run("style.paragraph.create", json!({"name":"Tutorial","chars":{"size":10}})).unwrap();
+ open_style_options(&mut app, true, "Tutorial");
+ let mut h = Harness::builder().with_size(egui::vec2(1000.0, 800.0)).build_ui_state(
+ |ui, app: &mut DesignApp| {
+ let key = egui::Id::new("style_preview_remember_fonts");
+ if ui.data(|d| d.get_temp::(key)).is_none() {
+ crate::theme::install_fonts(ui.ctx(), "");
+ ui.data_mut(|d| d.insert_temp(key, true));
+ return;
+ }
+ show(app, ui.ctx());
+ },
+ app,
+ );
+ h.run_steps(3);
+ h.get_by_label("Preview").click();
+ h.run_steps(3);
+ cancel(h.state_mut());
+ open_style_options(h.state_mut(), true, "Tutorial");
+ assert!(h.state().ui.dialog.as_ref().unwrap().b("preview"), "Preview stays checked on reopen");
+ }
+
#[test]
fn style_options_preview_updates_all_uses_cancels_and_commits_once() {
use egui_kittest::{Harness, kittest::Queryable};
diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs
index 6057abc97..be5f43c97 100644
--- a/crates/ui-egui/src/i18n.rs
+++ b/crates/ui-egui/src/i18n.rs
@@ -34,6 +34,16 @@ pub const LANGUAGES: &[(&str, &str)] = &[
("ka", "ქართული"),
];
+/// The interface languages in the order the Preferences picker lists them: English first (the
+/// source language), then the rest by their own name with accents folded, so Čeština sits under
+/// C. Names in other scripts follow the Latin ones in code point order: Cyrillic, Arabic,
+/// Georgian, then the CJK languages.
+pub fn picker_languages() -> Vec<(&'static str, &'static str)> {
+ let mut rest: Vec<(&str, &str)> = LANGUAGES.iter().copied().filter(|(code, _)| !code.is_empty()).collect();
+ rest.sort_by_cached_key(|(_, name)| name_sort_key(name));
+ std::iter::once(LANGUAGES[0]).chain(rest).collect()
+}
+
/// English → [German, French, Spanish, Japanese, Simplified Chinese].
const TABLE: &[(&str, [&str; 5])] = &[
// Menus.
@@ -4449,6 +4459,12 @@ fn column(lang: &str) -> Option {
/// `s` in `lang` (English, or the string itself, when there's no translation).
pub fn tr<'a>(lang: &str, s: &'a str) -> &'a str {
+ let text = lookup(lang, s);
+ // An empty row is a translation nobody has written yet.
+ if text.trim().is_empty() { s } else { text }
+}
+
+fn lookup<'a>(lang: &str, s: &'a str) -> &'a str {
static GERMAN: OnceLock> = OnceLock::new();
static JAPANESE: OnceLock> = OnceLock::new();
static TRANSLATIONS: OnceLock> = OnceLock::new();
@@ -4467,18 +4483,18 @@ pub fn tr<'a>(lang: &str, s: &'a str) -> &'a str {
// The German and Japanese tables cover the whole interface; the shared table's German and Japanese
// columns are the fallback for keys added there after them.
if lang == "de"
- && let Some(text) = GERMAN.get_or_init(|| de::TABLE.iter().copied().collect()).get(s).copied()
+ && let Some(text) = GERMAN.get_or_init(|| de::TABLE.iter().copied().collect()).get(s).copied().filter(|text| !text.trim().is_empty())
{
return text;
}
if lang == "ja"
- && let Some(text) = JAPANESE.get_or_init(|| ja::TABLE.iter().copied().collect()).get(s).copied()
+ && let Some(text) = JAPANESE.get_or_init(|| ja::TABLE.iter().copied().collect()).get(s).copied().filter(|text| !text.trim().is_empty())
{
return text;
}
// `fr::TABLE` holds the strings the shared table has no row for.
if lang == "fr"
- && let Some(text) = FRENCH.get_or_init(|| fr::TABLE.iter().copied().collect()).get(s).copied()
+ && let Some(text) = FRENCH.get_or_init(|| fr::TABLE.iter().copied().collect()).get(s).copied().filter(|text| !text.trim().is_empty())
{
return text;
}
@@ -4524,11 +4540,13 @@ pub fn tr_context<'a>(lang: &str, s: &'a str, context: &str) -> &'a str {
"ar" => ar::CONTEXT,
_ => &[],
};
- if let Some((_, _, text)) = contexts.iter().find(|(key, scope, _)| *key == s && *scope == context) {
+ let written = |text: &&str| !text.trim().is_empty();
+ if let Some(text) = contexts.iter().find(|(key, scope, _)| *key == s && *scope == context).map(|(_, _, text)| *text).filter(written) {
return text;
}
if let Some(c) = column(lang)
- && let Some(text) = CONTEXT.iter().find(|(key, scope, _)| *key == s && *scope == context).and_then(|(_, _, row)| row.get(c))
+ && let Some(text) =
+ CONTEXT.iter().find(|(key, scope, _)| *key == s && *scope == context).and_then(|(_, _, row)| row.get(c)).copied().filter(written)
{
return text;
}
@@ -4567,7 +4585,9 @@ pub fn count_label(lang: &str, noun: &str, count: usize) -> String {
2..=4 => few,
_ => other,
};
- return format!("{count} {form}");
+ if !form.trim().is_empty() {
+ return format!("{count} {form}");
+ }
}
format!("{count} {}", tr(lang, noun))
}
@@ -4625,15 +4645,9 @@ mod tests {
/// The German and Japanese tables translate the whole interface and win over the shared columns.
const WHOLE_INTERFACE: [&str; 2] = ["de", "ja"];
- #[test]
- fn every_interface_language_has_a_catalog() {
- for (lang, i) in SHARED_COLUMNS.iter().zip(0..) {
- assert_eq!(column(lang), Some(i), "{lang}");
- }
- for (code, name) in LANGUAGES.iter().filter(|(code, _)| !code.is_empty()) {
- assert!(!name.is_empty(), "{code}");
- assert!(LANGUAGE_TABLES.iter().any(|(lang, _)| lang == code) || column(code).is_some(), "{code} has no catalog in LANGUAGE_TABLES");
- }
+ /// What `tr` shows for a row: an empty one is still untranslated.
+ fn or_english<'a>(text: &'a str, english: &'a str) -> &'a str {
+ if text.trim().is_empty() { english } else { text }
}
#[test]
@@ -4644,13 +4658,16 @@ mod tests {
for (key, translations) in TABLE {
for (lang, shared) in SHARED_COLUMNS.into_iter().zip(translations) {
// A language's own table is looked up before the shared column.
- assert_eq!(tr(lang, key), own(lang, key).unwrap_or(*shared), "{lang}: {key}");
+ let expected = own(lang, key).filter(|text| !text.trim().is_empty()).unwrap_or(*shared);
+ assert_eq!(tr(lang, key), or_english(expected, key), "{lang}: {key}");
}
assert_eq!(tr("", key), *key);
}
for (lang, table) in LANGUAGE_TABLES {
for (key, expected) in *table {
- assert_eq!(tr(lang, key), *expected, "{lang}: {key}");
+ if !expected.trim().is_empty() {
+ assert_eq!(tr(lang, key), *expected, "{lang}: {key}");
+ }
assert_eq!(tr("", key), *key);
}
}
@@ -4667,7 +4684,9 @@ mod tests {
fn context_captions_come_from_their_context_or_the_plain_translation() {
for (lang, table) in CONTEXT_TABLES {
for (key, scope, text) in *table {
- assert_eq!(tr_context(lang, key, scope), *text, "{lang}: {key} ({scope})");
+ if !text.trim().is_empty() {
+ assert_eq!(tr_context(lang, key, scope), *text, "{lang}: {key} ({scope})");
+ }
}
}
for (key, scope, row) in CONTEXT {
@@ -4678,8 +4697,10 @@ mod tests {
.filter(|(code, _)| *code == lang)
.flat_map(|(_, table)| table.iter())
.find(|(k, s, _)| k == key && s == scope)
- .map(|(_, _, text)| *text);
- assert_eq!(tr_context(lang, key, scope), own.unwrap_or(*shared), "{lang}: {key} ({scope})");
+ .map(|(_, _, text)| *text)
+ .filter(|text| !text.trim().is_empty());
+ let expected = own.or(Some(*shared).filter(|text| !text.trim().is_empty())).unwrap_or_else(|| tr(lang, key));
+ assert_eq!(tr_context(lang, key, scope), expected, "{lang}: {key} ({scope})");
}
}
// An unknown context, or a language without one, keeps the plain translation.
@@ -4692,33 +4713,37 @@ mod tests {
}
}
- /// Every catalog: no duplicate or stale keys, no empty translations, and placeholders, padding,
- /// ellipses and label colons as in English. A string a catalog doesn't translate yet falls back
- /// to English, so missing translations don't fail here.
- #[test]
- fn every_catalog_is_unique_current_and_keeps_placeholders_and_markers() {
- // (language, key, translation) of every catalog, and every catalog's keys by name.
- let mut entries: Vec<(&str, &str, &str)> = Vec::new();
- let mut catalogs: Vec<(&str, HashSet<&str>)> = Vec::new();
- for (lang, table) in LANGUAGE_TABLES {
- let keys: HashSet<_> = table.iter().map(|(key, _)| *key).collect();
- assert_eq!(keys.len(), table.len(), "{lang}: duplicate keys");
- catalogs.push((*lang, keys));
- entries.extend(table.iter().map(|(key, text)| (*lang, *key, *text)));
- }
- let shared: HashSet<_> = TABLE.iter().map(|(key, _)| *key).collect();
- assert_eq!(shared.len(), TABLE.len(), "duplicate keys in the shared table");
- catalogs.push(("shared", shared.clone()));
+ /// The tables `tr` reads, as (language, key, translation), context captions included.
+ fn entries() -> Vec<(&'static str, &'static str, &'static str)> {
+ let mut entries: Vec<_> = LANGUAGE_TABLES.iter().flat_map(|(lang, table)| table.iter().map(|(key, text)| (*lang, *key, *text))).collect();
for (lang, i) in SHARED_COLUMNS.into_iter().zip(0..) {
entries.extend(TABLE.iter().filter_map(|(key, row)| row.get(i).map(|text| (lang, *key, *text))));
entries.extend(CONTEXT.iter().filter_map(|(key, _, row)| row.get(i).map(|text| (lang, *key, *text))));
}
- let mut contexts = vec![("shared", CONTEXT.iter().map(|(key, scope, _)| (*key, *scope)).collect::>())];
- for (lang, table) in CONTEXT_TABLES {
- contexts.push((*lang, table.iter().map(|(key, scope, _)| (*key, *scope)).collect()));
- entries.extend(table.iter().map(|(key, _, text)| (*lang, *key, *text)));
- }
+ entries.extend(CONTEXT_TABLES.iter().flat_map(|(lang, table)| table.iter().map(|(key, _, text)| (*lang, *key, *text))));
+ entries
+ }
+ /// Every catalog has one row per key and keeps the English placeholders. A string a catalog
+ /// doesn't translate shows in English, so a missing or empty row fails nothing here; the
+ /// `missing_translations_report` lists them.
+ #[test]
+ fn every_catalog_has_unique_keys_and_keeps_placeholders() {
+ for (lang, i) in SHARED_COLUMNS.iter().zip(0..) {
+ assert_eq!(column(lang), Some(i), "{lang}");
+ }
+ for (lang, table) in LANGUAGE_TABLES {
+ let keys: HashSet<_> = table.iter().map(|(key, _)| *key).collect();
+ assert_eq!(keys.len(), table.len(), "{lang}: duplicate keys");
+ }
+ let shared: HashSet<_> = TABLE.iter().map(|(key, _)| *key).collect();
+ assert_eq!(shared.len(), TABLE.len(), "duplicate keys in the shared table");
+ let shared_contexts: HashSet<_> = CONTEXT.iter().map(|(key, scope, _)| (*key, *scope)).collect();
+ assert_eq!(shared_contexts.len(), CONTEXT.len(), "duplicate keys in the shared context table");
+ for (lang, table) in CONTEXT_TABLES {
+ let keys: HashSet<_> = table.iter().map(|(key, scope, _)| (*key, *scope)).collect();
+ assert_eq!(keys.len(), table.len(), "{lang}: duplicate context keys");
+ }
// A per-language table is looked up before the shared table, so a key in both hides the
// shared column, except in the tables that translate the whole interface.
for (lang, table) in LANGUAGE_TABLES.iter().filter(|(lang, _)| column(lang).is_some() && !WHOLE_INTERFACE.contains(lang)) {
@@ -4726,41 +4751,58 @@ mod tests {
assert!(both.is_empty(), "{lang} keys also in the shared table: {both:?}");
}
- // A key is current while the interface or another catalog has it.
- let interface = interface_strings();
- let current = |key: &str, own: &str| interface.contains(key) || catalogs.iter().any(|(name, keys)| *name != own && keys.contains(key));
- for (name, keys) in &catalogs {
- let mut stale: Vec<_> = keys.iter().filter(|key| !current(key, name)).collect();
- stale.sort();
- assert!(stale.is_empty(), "{name}: entries for unknown keys: {stale:?}");
- }
- for (name, keys) in &contexts {
- let unique: HashSet<_> = keys.iter().collect();
- assert_eq!(unique.len(), keys.len(), "{name}: duplicate context keys");
- let stale: Vec<_> = keys.iter().filter(|(key, _)| !current(key, "")).collect();
- assert!(stale.is_empty(), "{name}: contexts for unknown keys: {stale:?}");
- }
-
let placeholders = |s: &'static str| {
let mut names: Vec<_> = s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name)).collect();
names.sort_unstable();
names
};
- let ellipsis = |s: &str| s.ends_with('…') || s.ends_with("...");
- let colon = |s: &str| s.ends_with(':') || s.ends_with(':');
- // Full-width punctuation carries its own spacing.
- let full_width = |c: char| ('\u{3000}'..='\u{303F}').contains(&c) || ('\u{FF00}'..='\u{FFEF}').contains(&c);
- for (lang, key, text) in entries {
- assert!(!text.trim().is_empty(), "{lang}: empty translation of {key:?}");
+ for (lang, key, text) in entries().into_iter().filter(|(_, _, text)| !text.trim().is_empty()) {
assert_eq!(placeholders(key), placeholders(text), "{lang}: placeholders of {key:?}");
- // Dialog ellipses, label colons and padded captions stay as in English.
- assert_eq!(ellipsis(key), ellipsis(text), "{lang}: ellipsis of {key:?}");
- assert_eq!(colon(key), colon(text), "{lang}: colon of {key:?}");
- if !text.starts_with(full_width) {
- assert_eq!(key.starts_with(' '), text.starts_with(' '), "{lang}: leading space of {key:?}");
- }
- if !text.ends_with(full_width) {
- assert_eq!(key.ends_with(' '), text.ends_with(' '), "{lang}: trailing space of {key:?}");
+ }
+ }
+
+ /// The strings each interface language shows in English, for the `cargo xtask ci` log. It
+ /// always passes: `cargo xtask i18n-report` runs it.
+ #[test]
+ #[ignore = "a report, run by `cargo xtask i18n-report`"]
+ fn missing_translations_report() {
+ let interface = interface_strings();
+ let catalogs: Vec<(&str, HashSet<&str>)> = LANGUAGE_TABLES
+ .iter()
+ .map(|(lang, table)| (*lang, table.iter().map(|(key, _)| *key).collect()))
+ .chain([("shared", TABLE.iter().map(|(key, _)| *key).collect())])
+ .collect();
+ let mut keys: Vec<&str> = interface.iter().copied().chain(catalogs.iter().flat_map(|(_, keys)| keys.iter().copied())).collect();
+ keys.sort_unstable();
+ keys.dedup();
+ // `tr` hands back the string it was given when it has no translation.
+ let missing = |lang: &str| -> Vec<&str> {
+ keys.iter()
+ .copied()
+ .filter(|key| {
+ let english = (*key).to_owned();
+ std::ptr::eq(tr(lang, &english), english.as_str())
+ })
+ .collect()
+ };
+ let report: Vec<(&str, Vec<&str>)> = LANGUAGES.iter().filter(|(code, _)| !code.is_empty()).map(|(code, _)| (*code, missing(code))).collect();
+ println!("i18n report: {} interface strings; a missing translation shows in English.", keys.len());
+ for (lang, missing) in &report {
+ println!(" {lang:<6} {:>5} missing", missing.len());
+ }
+ for (lang, missing) in report.iter().filter(|(_, missing)| !missing.is_empty()) {
+ println!("{lang} ({}): {}", missing.len(), missing.iter().map(|key| format!("{key:?}")).collect::>().join(", "));
+ }
+ // A catalog key neither the interface list nor another catalog knows is stale, or a label
+ // `interface_strings` doesn't list yet.
+ for (name, own) in &catalogs {
+ let mut unknown: Vec<_> = own
+ .iter()
+ .filter(|key| !interface.contains(*key) && !catalogs.iter().any(|(other, keys)| other != name && keys.contains(*key)))
+ .collect();
+ unknown.sort_unstable();
+ if !unknown.is_empty() {
+ println!("{name}: {} rows for keys the interface list doesn't have: {unknown:?}", unknown.len());
}
}
}
@@ -4788,47 +4830,21 @@ mod tests {
}
}
+ /// Languages with several plural forms put the category first, so every number reads the same.
#[test]
- fn georgian_counts_use_category_captions_for_every_number() {
- for n in [1, 2, 5, 21] {
- assert_eq!(count_label("ka", "pages", n), format!("გვერდი: {n}"));
- }
- }
-
- #[test]
- fn ukrainian_counts_use_category_captions_for_every_number() {
- for n in [0, 1, 2, 4, 5, 11, 21, 22, 25] {
- assert_eq!(count_label("uk", "pages", n), format!("сторінок: {n}"));
- assert_eq!(count_label("uk", "objects", n), format!("об’єктів: {n}"));
- assert_eq!(count_label("uk", "errors", n), format!("помилок: {n}"));
- let pages = tr("uk", "{pages} Pages in {spreads} Spreads").replace("{pages}", &n.to_string()).replace("{spreads}", &n.to_string());
- assert_eq!(pages, format!("Сторінок: {n}; розворотів: {n}"));
+ fn category_first_counts_put_the_category_before_every_number() {
+ assert!(!category_first("de") && !category_first(""));
+ for lang in ["ka", "uk", "ru", "pl"] {
+ assert!(category_first(lang), "{lang}");
+ for noun in ["pages", "objects", "errors"] {
+ for n in [0, 1, 2, 4, 5, 11, 12, 21, 22, 25, 101, 111] {
+ assert_eq!(count_label(lang, noun, n), format!("{}: {n}", tr(lang, noun)), "{lang}: {noun}");
+ }
+ }
}
assert_eq!(count_label("", "objects", 2), "2 objects");
}
- #[test]
- fn russian_counts_use_category_captions_for_every_number() {
- assert!(category_first("ru") && category_first("uk") && !category_first("de") && !category_first(""));
- for n in [0, 1, 2, 4, 5, 11, 12, 21, 22, 25, 101, 111] {
- assert_eq!(count_label("ru", "pages", n), format!("страниц: {n}"));
- assert_eq!(count_label("ru", "objects", n), format!("объектов: {n}"));
- assert_eq!(count_label("ru", "errors", n), format!("ошибок: {n}"));
- let pages = tr("ru", "{pages} Pages in {spreads} Spreads").replace("{pages}", &n.to_string()).replace("{spreads}", &n.to_string());
- assert_eq!(pages, format!("Страниц: {n}; разворотов: {n}"));
- }
- }
-
- #[test]
- fn polish_counts_use_category_captions_for_every_number() {
- for n in [0, 1, 2, 5, 12, 22, 25] {
- assert_eq!(count_label("pl", "pages", n), format!("Strony: {n}"));
- assert_eq!(count_label("pl", "objects", n), format!("Obiekty: {n}"));
- assert_eq!(count_label("pl", "errors", n), format!("Błędy: {n}"));
- }
- assert_eq!(tr("pl", "{pages} Pages in {spreads} Spreads"), "Strony: {pages}, rozkładówki: {spreads}");
- }
-
/// Every English string the interface passes to `tr`, as far as the command and tool tables and
/// the list of UI-only labels know them.
fn interface_strings() -> HashSet<&'static str> {
@@ -4929,26 +4945,52 @@ mod tests {
#[test]
fn czech_counts_use_the_plural_form_for_the_number() {
- for (n, form) in [(0, "chyb"), (1, "chyba"), (2, "chyby"), (4, "chyby"), (5, "chyb"), (11, "chyb"), (22, "chyb")] {
- assert_eq!(count_label("cs", "errors", n), format!("{n} {form}"));
+ for (noun, [one, few, other]) in cs::PLURALS {
+ for (n, form) in [(0, other), (1, one), (2, few), (4, few), (5, other), (11, other), (22, other)] {
+ let expected = if form.trim().is_empty() { format!("{n} {}", tr("cs", noun)) } else { format!("{n} {form}") };
+ assert_eq!(count_label("cs", noun, n), expected, "{noun}");
+ }
}
- // The status bar passes the singular noun for one error.
- assert_eq!(count_label("cs", "error", 1), "1 chyba");
- assert_eq!(count_label("cs", "pages", 1), "1 stránka");
- assert_eq!(count_label("cs", "pages", 3), "3 stránky");
- assert_eq!(count_label("cs", "pages", 5), "5 stránek");
- assert_eq!(count_label("cs", "stories", 2), "2 články");
- assert_eq!(count_label("cs", "items", 1), "1 položka");
- assert_eq!(count_label("cs", "objects", 7), "7 objektů");
- assert_eq!(count_label("cs", "warnings", 3), "3 varování");
- assert_eq!(count_label("cs", "layer", 1), "1 vrstva");
- assert_eq!(count_label("cs", "layers", 3), "3 vrstvy");
- assert_eq!(count_label("cs", "layers", 8), "8 vrstev");
// A noun without plural forms keeps the plain translation.
assert_eq!(count_label("cs", "a new noun", 3), "3 a new noun");
- for (noun, forms) in cs::PLURALS {
- assert!(forms.iter().all(|form| !form.is_empty()), "{noun}");
- assert!(forms.contains(&tr("cs", noun)), "{noun}: the catalog row is one of the plural forms");
- }
+ }
+}
+
+/// `name` lowercased with the Latin accents that occur in language names folded away, so a
+/// byte-wise sort reads alphabetically. Other scripts are left as they are.
+fn name_sort_key(name: &str) -> String {
+ name.chars()
+ .flat_map(char::to_lowercase)
+ .map(|c| match c {
+ 'à'..='å' | 'ā' | 'ă' | 'ą' => 'a',
+ 'ç' | 'ć' | 'č' => 'c',
+ 'è'..='ë' | 'ē' | 'ě' | 'ę' | 'ế' | 'ệ' => 'e',
+ 'ì'..='ï' | 'ī' => 'i',
+ 'ñ' | 'ń' | 'ň' => 'n',
+ 'ò'..='ö' | 'ø' | 'ő' => 'o',
+ 'ś' | 'š' => 's',
+ 'ù'..='ü' | 'ū' | 'ů' | 'ű' => 'u',
+ 'ý' | 'ÿ' => 'y',
+ 'ź' | 'ż' | 'ž' => 'z',
+ c => c,
+ })
+ .collect()
+}
+
+#[cfg(test)]
+mod picker_order_tests {
+ use super::*;
+
+ #[test]
+ fn picker_lists_english_first_then_names_alphabetically() {
+ let order = picker_languages();
+ assert_eq!(order.first(), Some(&("", "English")));
+ assert_eq!(order.len(), LANGUAGES.len());
+ let keys: Vec = order[1..].iter().map(|(_, name)| name_sort_key(name)).collect();
+ assert!(keys.windows(2).all(|w| w[0] <= w[1]), "{keys:?}");
+ let pos = |code: &str| order.iter().position(|(c, _)| *c == code).unwrap_or(usize::MAX);
+ assert!(pos("cs") < pos("de"), "Čeština sorts under C");
+ assert!(pos("pl") < pos("ru"), "Latin names come before Cyrillic");
+ assert!(pos("ru") < pos("ja"), "Cyrillic names come before CJK");
}
}
diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs
index 4f4177cbd..367ca52f8 100644
--- a/crates/ui-egui/src/i18n/de.rs
+++ b/crates/ui-egui/src/i18n/de.rs
@@ -2075,55 +2075,3 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Update Data Source", "Datenquelle aktualisieren"),
("Validate from Root Element", "Ab Stammelement prüfen"),
];
-
-#[cfg(test)]
-mod tests {
- use std::collections::HashSet;
-
- use super::TABLE;
- use crate::i18n::tr;
-
- fn placeholders(text: &str) -> Vec<&str> {
- let mut found = Vec::new();
- let mut rest = text;
- while let Some(start) = rest.find('{') {
- let Some(len) = rest[start..].find('}') else { break };
- found.push(&rest[start..start + len + 1]);
- rest = &rest[start + len + 1..];
- }
- found.sort_unstable();
- found
- }
-
- #[test]
- fn rows_are_unique_and_complete() {
- let mut seen = HashSet::new();
- for (en, de) in TABLE {
- assert!(seen.insert(*en), "duplicate {en}");
- assert!(!de.is_empty(), "{en}");
- }
- }
-
- #[test]
- fn rows_keep_placeholders_padding_and_endings() {
- let ellipsis = |text: &str| text.trim_end().ends_with('…') || text.trim_end().ends_with("...");
- for (en, de) in TABLE {
- assert_eq!(placeholders(en), placeholders(de), "placeholders of {en}");
- assert_eq!(en.len() - en.trim_start().len(), de.len() - de.trim_start().len(), "leading space of {en}");
- assert_eq!(en.len() - en.trim_end().len(), de.len() - de.trim_end().len(), "trailing space of {en}");
- assert_eq!(ellipsis(en), ellipsis(de), "ellipsis of {en}");
- // The numbering samples (`a, b, c, d...`) stay as they are; everything else uses `…`.
- assert!(!de.contains("...") || de == en, "ASCII ellipsis in {en}");
- assert_eq!(en.trim_end().ends_with(':'), de.trim_end().ends_with(':'), "colon of {en}");
- }
- }
-
- #[test]
- fn lookup_uses_this_table_and_leaves_unknown_text_alone() {
- assert_eq!(tr("de", "File"), "Datei");
- assert_eq!(tr("de", "Cancel"), "Abbrechen");
- assert_eq!(tr("de", "{count} glyphs — click to insert at the text cursor").matches("{count}").count(), 1);
- let unknown = String::from("A user-defined untranslated label");
- assert!(std::ptr::eq(tr("de", &unknown), unknown.as_str()));
- }
-}
diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs
index e23ef15c7..37b14ac5c 100644
--- a/crates/ui-egui/src/i18n/ja.rs
+++ b/crates/ui-egui/src/i18n/ja.rs
@@ -2124,55 +2124,3 @@ pub(super) const TABLE: &[(&str, &str)] = &[
/// Captions whose Japanese depends on where they appear: (English, context, Japanese).
pub(super) const CONTEXT: &[(&str, &str, &str)] = &[("Span Columns", "paragraphLayout", "段抜き"), ("Columns:", "grid", "列:")];
-
-#[cfg(test)]
-mod tests {
- use std::collections::HashSet;
-
- use super::TABLE;
- use crate::i18n::tr;
-
- fn placeholders(text: &str) -> Vec<&str> {
- let mut found = Vec::new();
- let mut rest = text;
- while let Some(start) = rest.find('{') {
- let Some(len) = rest[start..].find('}') else { break };
- found.push(&rest[start..start + len + 1]);
- rest = &rest[start + len + 1..];
- }
- found.sort_unstable();
- found
- }
-
- #[test]
- fn rows_are_unique_and_complete() {
- let mut seen = HashSet::new();
- for (en, ja) in TABLE {
- assert!(seen.insert(*en), "duplicate {en}");
- assert!(!ja.is_empty(), "{en}");
- }
- }
-
- #[test]
- fn rows_keep_placeholders_padding_and_endings() {
- let ellipsis = |text: &str| text.trim_end().ends_with('…') || text.trim_end().ends_with("...");
- for (en, ja) in TABLE {
- assert_eq!(placeholders(en), placeholders(ja), "placeholders of {en}");
- assert_eq!(en.len() - en.trim_start().len(), ja.len() - ja.trim_start().len(), "leading space of {en}");
- assert_eq!(en.len() - en.trim_end().len(), ja.len() - ja.trim_end().len(), "trailing space of {en}");
- assert_eq!(ellipsis(en), ellipsis(ja), "ellipsis of {en}");
- // The numbering samples (`a, b, c, d...`) stay as they are; everything else uses `…`.
- assert!(!ja.contains("...") || ja == en, "ASCII ellipsis in {en}");
- assert_eq!(en.trim_end().ends_with(':'), ja.trim_end().ends_with(':'), "colon of {en}");
- }
- }
-
- #[test]
- fn lookup_uses_this_table_and_leaves_unknown_text_alone() {
- assert_eq!(tr("ja", "File"), "ファイル");
- assert_eq!(tr("ja", "Cancel"), "キャンセル");
- assert_eq!(tr("ja", "{count} glyphs — click to insert at the text cursor").matches("{count}").count(), 1);
- let unknown = String::from("A user-defined untranslated label");
- assert!(std::ptr::eq(tr("ja", &unknown), unknown.as_str()));
- }
-}
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 535c87629..b149cea64 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -71,7 +71,8 @@ pub type Inbox = std::sync::Arc>>;
pub struct Services {
/// Open-file dialog for a purpose (`open`, `place`) → path.
pub pick_open: Option,
- /// Save dialog with a suggested name → path.
+ /// Save dialog with a suggested name → path. The name may start with the folder the dialog
+ /// opens in (see [`DesignApp::pick_save`]).
pub pick_save: Option,
pub read: Option,
pub write: Option,
@@ -223,6 +224,9 @@ pub struct UiState {
pub pages_panel: panels::pages::PanelOptions,
/// Edit › Color Settings (re-applied at start).
pub color_settings: Option,
+ /// ICC files loaded with Edit › Color Settings › Load Profile… (loaded again at start, before
+ /// `color_settings`, so a loaded working space stays chosen).
+ pub color_profiles: Vec,
/// View › Proof Colors and View › Proof Setup.
pub proof_colors: bool,
pub proof_setup: designcraft_color::cms::ProofSetup,
@@ -236,6 +240,8 @@ pub struct UiState {
pub flattener: String,
/// File › Export PDF… options (persisted; see `PdfExportSettings`).
pub pdf_export: PdfExportSettings,
+ /// The Preview checkbox of Paragraph/Character Style Options, remembered between openings.
+ pub style_preview: bool,
/// View › Separations Preview: a process plate (0–3) and/or an ink limit (total, 0–4).
#[serde(skip)]
pub separation: Option,
@@ -285,6 +291,10 @@ pub struct UiState {
pub custom_workspaces: Vec,
/// File › New › Document › Recent: the settings of the last documents made, newest first.
pub recent_new_documents: Vec,
+ /// The folder of the last file saved or exported: where the save dialog starts for a
+ /// document never saved.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub last_save_folder: Option,
/// The font menus' sample text (a key of `panels::FONT_SAMPLES`) and size (0–2).
pub font_sample: String,
pub font_preview_size: u8,
@@ -383,12 +393,14 @@ impl Default for UiState {
pages_parents_height: None,
pages_panel: Default::default(),
color_settings: None,
+ color_profiles: Vec::new(),
proof_colors: false,
proof_setup: Default::default(),
dynamic_spelling: false,
language: String::new(),
flattener: String::new(),
pdf_export: PdfExportSettings::default(),
+ style_preview: false,
separation: None,
ink_limit: None,
display_quality: designcraft_render::DisplayQuality::High,
@@ -412,6 +424,7 @@ impl Default for UiState {
workspace: "Essentials".into(),
custom_workspaces: Vec::new(),
recent_new_documents: Vec::new(),
+ last_save_folder: None,
font_sample: "selected".into(),
font_preview_size: 1,
new_document_preview: false,
@@ -751,6 +764,32 @@ impl DesignApp {
self.ui.status = s.into();
}
+ /// The host's save dialog for `name`, started beside the active document once it has been
+ /// saved, else in the folder of the last file saved or exported (#1653). The answer's folder
+ /// is remembered for the next one.
+ pub(crate) fn pick_save(&mut self, name: &str) -> Option {
+ use std::path::{Path, PathBuf};
+ let folder_of = |path: &str| Path::new(path).parent().filter(|f| !f.as_os_str().is_empty()).map(Path::to_path_buf);
+ let folder = self
+ .session
+ .active()
+ .and_then(|d| d.path.as_deref())
+ .and_then(folder_of)
+ .or_else(|| self.ui.last_save_folder.as_ref().map(PathBuf::from));
+ // Only the last part of a name goes after the folder: a title may hold a slash, and an
+ // empty name would make the folder itself the suggested file.
+ let file = Path::new(name).file_name();
+ let suggested = match (folder, file) {
+ (Some(f), Some(file)) => f.join(file).to_string_lossy().to_string(),
+ _ => name.to_string(),
+ };
+ let picked = self.services.pick_save.as_mut().and_then(|f| f(&suggested))?;
+ if let Some(folder) = folder_of(&picked) {
+ self.ui.last_save_folder = Some(folder.to_string_lossy().to_string());
+ }
+ Some(picked)
+ }
+
/// Did an IME composition end outside the IME this frame (a click, a tool switch)? egui's
/// `should_interrupt_composition` toggles winit's IME, which doesn't reach the macOS input
/// context: it keeps the marked text and types it again into the next composition, so the
@@ -1106,8 +1145,22 @@ impl DesignApp {
self.window_maximized = ctx.input(|i| i.viewport().maximized.unwrap_or(false));
if !self.color_applied {
self.color_applied = true;
+ for path in self.ui.color_profiles.clone() {
+ if let Err(e) = self.session.execute("color.loadProfile", &json!({"path": path})) {
+ log::warn!("Color Settings: {e}");
+ self.status_error("Color Settings", e);
+ }
+ }
if let Some(cs) = self.ui.color_settings.clone() {
- let _ = designcraft_color::cms::set_active(&cs.with_known_profiles());
+ let known = cs.with_known_profiles();
+ for (saved, used) in [(&cs.rgb, &known.rgb), (&cs.cmyk, &known.cmyk)] {
+ if saved != used {
+ let e = format!("saved profile {saved} is not available; using {used}");
+ log::warn!("Color Settings: {e}");
+ self.status_error("Color Settings", e);
+ }
+ }
+ let _ = designcraft_color::cms::set_active(&known);
}
}
let scale = self.ui.ui_scale.clamp(0.5, 3.0);
@@ -1834,6 +1887,70 @@ mod tests {
assert_eq!(app.view_info().unit, Unit::Inches);
}
+ /// Save and Export PDF dialogs started in the home folder (#1653): they start beside a saved
+ /// document, and for one never saved in the folder of the last save or export.
+ #[test]
+ fn save_dialogs_start_beside_the_document_or_in_the_last_folder() {
+ use std::{cell::RefCell, rc::Rc};
+ let home = tempfile::tempdir().unwrap();
+ let elsewhere = tempfile::tempdir().unwrap();
+ let asked: Rc>> = Rc::default();
+ let answer: Rc>> = Rc::default();
+ let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default());
+ let (seen, reply) = (asked.clone(), answer.clone());
+ app.services.pick_save = Some(Box::new(move |name: &str| {
+ seen.borrow_mut().push(name.to_string());
+ reply.borrow().clone()
+ }));
+ app.services.write = Some(Box::new(|_: &str, _: &[u8]| Ok(())));
+ let last = |asked: &Rc>>| asked.borrow().last().cloned().unwrap_or_default();
+ let export_pdf = |app: &mut DesignApp| {
+ app.ui.dialog = Some(dialogs::Dialog::new("pdfExport", json!({})));
+ app.ui.dialog.as_mut().unwrap().fields.insert("title".into(), json!("Report"));
+ dialogs::confirm(app).unwrap();
+ };
+
+ app.run("file.new", json!({})).unwrap();
+ let untitled = format!("{}.designcraft", app.session.active().unwrap().doc.title);
+ app.run("app.saveDialog", json!({})).unwrap();
+ assert_eq!(last(&asked), untitled, "never saved, nothing remembered: the system's own folder");
+
+ let saved = home.path().join("Brochure.designcraft");
+ *answer.borrow_mut() = Some(saved.to_string_lossy().to_string());
+ app.run("app.saveDialog", json!({})).unwrap();
+ assert_eq!(app.session.active().unwrap().path.as_deref(), Some(&*saved.to_string_lossy()));
+ // An export into another folder becomes the last folder used.
+ *answer.borrow_mut() = Some(elsewhere.path().join("Proof.pdf").to_string_lossy().to_string());
+ export_pdf(&mut app);
+ *answer.borrow_mut() = None;
+ export_pdf(&mut app);
+ assert_eq!(last(&asked), home.path().join("Report.pdf").to_string_lossy(), "a saved document exports beside itself");
+
+ app.run("file.new", json!({})).unwrap();
+ let untitled = format!("{}.designcraft", app.session.active().unwrap().doc.title);
+ app.run("app.saveDialog", json!({})).unwrap();
+ assert_eq!(last(&asked), elsewhere.path().join(untitled).to_string_lossy(), "a new document starts in the last folder used");
+ }
+
+ /// No name (Save with no document) suggested the remembered folder itself as the file, and a
+ /// title with a slash moved the suggestion out of the document's folder.
+ #[test]
+ fn save_dialog_suggestions_keep_the_folder_and_only_a_file_name() {
+ use std::{cell::RefCell, rc::Rc};
+ let folder = tempfile::tempdir().unwrap();
+ let asked: Rc>> = Rc::default();
+ let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default());
+ let seen = asked.clone();
+ app.services.pick_save = Some(Box::new(move |name: &str| {
+ seen.borrow_mut().push(name.to_string());
+ None
+ }));
+ app.ui.last_save_folder = Some(folder.path().join("Docs").to_string_lossy().to_string());
+ app.pick_save("");
+ app.pick_save("Q1/Q2.pdf");
+ assert_eq!(*asked.borrow(), ["".to_string(), folder.path().join("Docs").join("Q2.pdf").to_string_lossy().to_string()]);
+ }
+
/// An app with one document that has an unsaved edit.
fn app_with_unsaved_document() -> DesignApp {
let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default());
@@ -2929,6 +3046,19 @@ mod browser_import_tests {
assert_eq!(pixels.data().iter().filter(|p| p.r > 240 && p.g < 10 && p.b < 10).count(), 0);
}
+ #[test]
+ fn pdf_options_refuse_a_page_range_instead_of_placing_page_one() {
+ for spec in ["4-5", "2,5", "2-3", "2.5", "-3", "0", "nan", "inf"] {
+ let mut app = app();
+ let request = app.import_request("place");
+ deliver(&mut app, request, "three.pdf", pdf());
+ app.ui.dialog.as_mut().unwrap().fields.insert("page".into(), json!(spec));
+ assert!(dialogs::confirm(&mut app).is_err(), "{spec}");
+ assert!(app.session.doc().unwrap().doc.assets.is_empty(), "{spec}");
+ assert_eq!(app.ui.dialog.as_ref().unwrap().id, "pdfImport", "{spec}");
+ }
+ }
+
#[test]
fn place_loads_the_cursor_unless_a_frame_can_take_the_graphic() {
let mut app = app();
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
index 87537abd7..d97dcdeba 100644
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -1071,7 +1071,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option {
let name = app.session.active().map(|d| format!("{}.designcraft", d.doc.title)).unwrap_or_default();
- if let Some(path) = app.services.pick_save.as_mut().and_then(|f| f(&name)) {
+ if let Some(path) = app.pick_save(&name) {
return Some(app.run("file.saveAs", json!({"path": path})));
}
Ok(Value::Null)
@@ -1082,7 +1082,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option {
let name = app.session.active().map(|d| format!("{} copy.designcraft", d.doc.title)).unwrap_or_default();
- if let Some(path) = app.services.pick_save.as_mut().and_then(|f| f(&name)) {
+ if let Some(path) = app.pick_save(&name) {
return Some(app.run("file.saveACopy", json!({"path": path})));
}
Ok(Value::Null)
@@ -1156,7 +1156,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option export_bytes(app, p, "rtf", "file.exportText"),
"app.packageDialog" => {
let name = app.session.active().map(|d| format!("{} Folder", d.doc.title)).unwrap_or_default();
- match app.services.pick_save.as_mut().and_then(|f| f(&name)) {
+ match app.pick_save(&name) {
Some(dir) => {
let r = app.run("file.package", json!({"dir": dir}));
if let Ok(v) = &r {
@@ -1217,7 +1217,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option {
let path = match p.get("path").and_then(Value::as_str) {
Some(s) => Some(s.to_string()),
- None => app.services.pick_save.as_mut().and_then(|f| f("Swatches.ase")),
+ None => app.pick_save("Swatches.ase"),
};
let Some(path) = path else { return Some(Ok(Value::Null)) };
let r = match app.run("swatch.save", json!({})) {
@@ -2011,7 +2011,7 @@ fn export_png(app: &mut DesignApp, p: &Value) -> Result {
Some(s) => Some(s.to_string()),
None => {
let name = format!("{}-p{}.png", st.doc.title, page + 1);
- app.services.pick_save.as_mut().and_then(|f| f(&name))
+ app.pick_save(&name)
}
};
let Some(path) = path else { return Ok(Value::Null) };
@@ -2029,7 +2029,7 @@ fn export_idml(app: &mut DesignApp, p: &Value) -> Result {
Some(s) => Some(s.to_string()),
None => {
let name = format!("{}.idml", st.doc.title);
- app.services.pick_save.as_mut().and_then(|f| f(&name))
+ app.pick_save(&name)
}
};
let Some(path) = path else { return Ok(Value::Null) };
@@ -2049,7 +2049,7 @@ fn export_bytes(app: &mut DesignApp, p: &Value, ext: &str, cmd: &str) -> Result<
Some(s) => Some(s.to_string()),
None => {
let name = format!("{}.{ext}", st.doc.title);
- app.services.pick_save.as_mut().and_then(|f| f(&name))
+ app.pick_save(&name)
}
};
let Some(path) = path else { return Ok(Value::Null) };
@@ -2077,7 +2077,7 @@ pub(crate) fn export_pdf(app: &mut DesignApp, p: &Value) -> Result Some(s.to_string()),
None => {
let name = format!("{}.pdf", st.doc.title);
- app.services.pick_save.as_mut().and_then(|f| f(&name))
+ app.pick_save(&name)
}
};
let Some(path) = path else { return Ok(Value::Null) };
@@ -5642,4 +5642,111 @@ mod tests {
assert!(texts.iter().any(|t| t == "Recent files"), "{texts:?}");
assert!(texts.iter().any(|t| t == "gone"), "the card names it without `.designcraft`: {texts:?}");
}
+
+ /// On a short window (1366 x 768 laptops) the start screen is taller than the window; the part
+ /// below the fold has to be reachable by scrolling instead of being cut off.
+ #[test]
+ fn start_screen_scrolls_when_the_window_is_too_short() {
+ let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+ let ctx = egui::Context::default();
+ crate::theme::install_fonts(&ctx, "");
+ let size = egui::vec2(1366.0, 300.0);
+ let mut tile_y = |events: Vec| {
+ let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), events, ..Default::default() };
+ let mut out = ctx.run_ui(input, |ui| {
+ app.logic(&ui.ctx().clone());
+ app.ui(ui);
+ });
+ out.textures_delta.clear();
+ out.shapes.iter().find_map(|s| match &s.shape {
+ // The preset tiles paint their names even when scrolled out of view (labels skip that).
+ egui::Shape::Text(t) if t.galley.text() == "Tabloid" => Some(t.pos.y),
+ _ => None,
+ })
+ };
+ let at = egui::pos2(600.0, 150.0);
+ let mut before = None;
+ for _ in 0..3 {
+ before = tile_y(vec![egui::Event::PointerMoved(at)]);
+ }
+ let before = before.expect("the Tabloid tile is laid out");
+ let wheel = egui::Event::MouseWheel {
+ unit: egui::MouseWheelUnit::Point,
+ delta: egui::vec2(0.0, -400.0),
+ phase: egui::TouchPhase::Move,
+ modifiers: egui::Modifiers::NONE,
+ };
+ let mut after = tile_y(vec![egui::Event::PointerMoved(at), wheel]);
+ for _ in 0..30 {
+ after = tile_y(Vec::new());
+ }
+ let after = after.expect("the Tabloid tile is laid out");
+ assert!(after < before - 50.0, "the start screen should scroll: {before} -> {after}");
+ }
+
+ // Regression test for #329: on the start screen (no document) the Help menu's links were greyed out.
+ #[test]
+ fn help_links_are_enabled_without_a_document() {
+ let app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+ assert!(app.session.active().is_none());
+ let ids: Vec<&str> = UI_COMMANDS.iter().map(|c| c.0).filter(|id| id.starts_with("help.")).collect();
+ assert!(ids.contains(&"help.discord") && ids.contains(&"help.github"), "{ids:?}");
+ for id in ids {
+ assert!(menu_enabled(&app, id), "{id} must work from the start screen");
+ }
+ assert!(!menu_enabled(&app, "view.rulers"), "other UI commands still need a document");
+ }
+
+ // Regression test for #248: the start screen offered no A6 sheet (it listed only the first ten presets).
+ #[test]
+ fn start_screen_offers_every_print_preset_including_a6() {
+ let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+ let texts = start_screen_texts(&mut app);
+ for p in designcraft_doc::build::PRESETS.iter().filter(|p| p.intent == designcraft_doc::Intent::Print) {
+ assert!(texts.iter().any(|t| t == p.name), "start screen lacks the {} card: {texts:?}", p.name);
+ }
+ assert!(texts.iter().any(|t| t == "A6"), "{texts:?}");
+ let a6 = designcraft_doc::build::PRESETS.iter().find(|p| p.name == "A6").unwrap();
+ assert!((a6.width - 105.0 * 72.0 / 25.4).abs() < 0.01 && (a6.height - 148.0 * 72.0 / 25.4).abs() < 0.01, "{a6:?}");
+ let page = crate::panels::properties::PAGE_PRESETS.iter().find(|p| p.0 == "A6").expect("Properties page-size list has A6");
+ assert!((page.1 - a6.width).abs() < 0.01 && (page.2 - a6.height).abs() < 0.01, "{page:?} vs {a6:?}");
+ }
+
+ // Regression test for #249: a menu row only reacted over its text, not across the whole row.
+ #[test]
+ fn menu_rows_are_selectable_across_the_full_width() {
+ let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+ let ctx = egui::Context::default();
+ let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 400.0));
+ let entries = vec![cmd_item("app.preferences", Value::Null)];
+ let mut tick = 0;
+ let mut frame = |app: &mut DesignApp, events: Vec| {
+ tick += 1;
+ let mut row = egui::Rect::NOTHING;
+ let mut output =
+ ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(tick as f64 / 60.0), events, ..Default::default() }, |ui| {
+ egui::Area::new(egui::Id::new("menu_rows")).fixed_pos(egui::pos2(50.0, 50.0)).show(ui.ctx(), |ui| {
+ let before = ui.cursor().min;
+ menu_column(app, ui, &[("Test", entries.clone())]);
+ row = egui::Rect::from_min_max(before, ui.min_rect().max);
+ });
+ });
+ output.textures_delta.clear();
+ row
+ };
+ frame(&mut app, vec![]);
+ let row = frame(&mut app, vec![]);
+ assert!(row.width() >= 239.0, "the column is at least 240 wide: {row:?}");
+ let far = egui::pos2(row.right() - 6.0, row.top() + 8.0);
+ for pressed in [true, false] {
+ frame(
+ &mut app,
+ vec![
+ egui::Event::PointerMoved(far),
+ egui::Event::PointerButton { pos: far, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE },
+ ],
+ );
+ }
+ assert!(app.ui.dialog.is_some(), "clicking the empty end of the Preferences… row must choose it");
+ }
}
diff --git a/crates/ui-egui/src/panels/library.rs b/crates/ui-egui/src/panels/library.rs
index 56d6c8cae..65fb16862 100644
--- a/crates/ui-egui/src/panels/library.rs
+++ b/crates/ui-egui/src/panels/library.rs
@@ -33,7 +33,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
ui.separator();
ui.horizontal(|ui| {
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Library…"))).clicked() {
- let path = app.services.pick_save.as_mut().and_then(|f| f("Library.dclib"));
+ let path = app.pick_save("Library.dclib");
let _ = app.run("library.new", json!({"path": path}));
}
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Open…"))).clicked()
@@ -90,7 +90,7 @@ pub fn book(app: &mut DesignApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
ui.horizontal(|ui| {
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Book…"))).clicked()
- && let Some(path) = app.services.pick_save.as_mut().and_then(|f| f("Book.dcbook"))
+ && let Some(path) = app.pick_save("Book.dcbook")
&& let Err(e) = app.run("book.new", json!({"path": path}))
{
app.status_error("Book", e);
@@ -154,7 +154,7 @@ pub fn book(app: &mut DesignApp, ui: &mut egui::Ui) {
let _ = app.run("book.syncStyles", json!({}));
}
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Export PDF…"))).clicked()
- && let Some(path) = app.services.pick_save.as_mut().and_then(|f| f("Book.pdf"))
+ && let Some(path) = app.pick_save("Book.pdf")
&& let Err(e) = app.run("book.exportPdf", json!({"path": path}))
{
app.status_error("Book", e);
diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs
index fd0094f9b..0422936e3 100644
--- a/crates/ui-egui/src/panels/properties.rs
+++ b/crates/ui-egui/src/panels/properties.rs
@@ -982,7 +982,9 @@ fn align_row(app: &mut DesignApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 8.0;
- let to = app.ui.align_to.clone();
+ // A key object switches Align To to Key Object; choosing another target drops the key.
+ let key = app.session.active().and_then(|st| st.selection.key.filter(|_| st.selection.items.len() > 1));
+ let to = if key.is_some() { "keyObject".to_string() } else { app.ui.align_to.clone() };
let to_label = match to.as_str() {
"selection" => "Selection",
"keyObject" => "Key Object",
@@ -1001,7 +1003,10 @@ fn align_row(app: &mut DesignApp, ui: &mut Ui) {
("Align to Page", "page"),
("Align to Spread", "spread"),
] {
- if ui.selectable_label(app.ui.align_to == id, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() {
+ if ui.selectable_label(to == id, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() {
+ if let Some(k) = key.filter(|_| id != "keyObject") {
+ let _ = app.run("selection.key", json!({"id": k.0}));
+ }
app.ui.align_to = id.into();
ui.close();
}
diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs
index 3e7030e87..878904a5f 100644
--- a/crates/ui-egui/src/panels/swatches.rs
+++ b/crates/ui-egui/src/panels/swatches.rs
@@ -18,53 +18,63 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
// Unnamed colours (mixed in the Color panel) aren't listed until Add to Swatches; swatches in
// colour groups are listed under their folder.
let grouped: Vec<&str> = doc.color_groups.iter().flat_map(|g| g.swatches.iter().map(String::as_str)).collect();
- for sw in doc.swatches.iter().filter(|w| !w.hidden && !grouped.contains(&w.name.as_str())) {
- swatch_row(app, ui, &doc, sw, 0.0, fill_cur.as_deref());
- }
- for g in &doc.color_groups {
- let open_id = egui::Id::new(("color_group_open", &g.name));
- let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true);
- let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
- resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &g.name));
- if resp.hovered() {
- ui.painter().rect_filled(row, 0.0, t.hover);
+ // A gradient dragged from the Gradient panel's thumbnail is dropped on the list, colour
+ // groups included.
+ let (_, dropped) = ui.dnd_drop_zone::(egui::Frame::NONE, |ui| {
+ for sw in doc.swatches.iter().filter(|w| !w.hidden && !grouped.contains(&w.name.as_str())) {
+ swatch_row(app, ui, &doc, sw, 0.0, fill_cur.as_deref());
}
- icons::paint(
- ui.painter(),
- egui::Rect::from_min_size(row.min + vec2(2.0, 3.0), vec2(16.0, 16.0)),
- if open { "chevron-down" } else { "chevron-right" },
- t.text_dim,
- );
- // A folder.
- let f = egui::Rect::from_min_size(row.min + vec2(20.0, 5.0), vec2(16.0, 12.0));
- ui.painter().rect_stroke(f, 1.5, egui::Stroke::new(1.2, t.text_dim), egui::StrokeKind::Inside);
- ui.painter().line_segment([f.left_top() + vec2(1.0, 3.0), f.right_top() + vec2(-1.0, 3.0)], egui::Stroke::new(1.0, t.text_dim));
- ui.painter().text(row.min + vec2(42.0, 11.0), egui::Align2::LEFT_CENTER, &g.name, egui::FontId::proportional(12.5), t.text);
- // A double click's first click folded the group: it stays as it was.
- if resp.clicked() {
- open = !open;
- ui.data_mut(|d| d.insert_temp(open_id, open));
- }
- if resp.double_clicked() {
- let _ = app.run("app.colorGroupOptionsDialog", json!({"name": g.name}));
- }
- resp.context_menu(|ui| {
- if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Color Group Options…"))).clicked() {
+ for g in &doc.color_groups {
+ let open_id = egui::Id::new(("color_group_open", &g.name));
+ let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true);
+ let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
+ resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &g.name));
+ if resp.hovered() {
+ ui.painter().rect_filled(row, 0.0, t.hover);
+ }
+ icons::paint(
+ ui.painter(),
+ egui::Rect::from_min_size(row.min + vec2(2.0, 3.0), vec2(16.0, 16.0)),
+ if open { "chevron-down" } else { "chevron-right" },
+ t.text_dim,
+ );
+ // A folder.
+ let f = egui::Rect::from_min_size(row.min + vec2(20.0, 5.0), vec2(16.0, 12.0));
+ ui.painter().rect_stroke(f, 1.5, egui::Stroke::new(1.2, t.text_dim), egui::StrokeKind::Inside);
+ ui.painter().line_segment([f.left_top() + vec2(1.0, 3.0), f.right_top() + vec2(-1.0, 3.0)], egui::Stroke::new(1.0, t.text_dim));
+ ui.painter().text(row.min + vec2(42.0, 11.0), egui::Align2::LEFT_CENTER, &g.name, egui::FontId::proportional(12.5), t.text);
+ // A double click's first click folded the group: it stays as it was.
+ if resp.clicked() {
+ open = !open;
+ ui.data_mut(|d| d.insert_temp(open_id, open));
+ }
+ if resp.double_clicked() {
let _ = app.run("app.colorGroupOptionsDialog", json!({"name": g.name}));
- ui.close();
}
- if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Ungroup Color Group"))).clicked() {
- let _ = app.run("swatch.ungroupColorGroup", json!({"name": g.name}));
- ui.close();
- }
- });
- if open {
- for n in &g.swatches {
- if let Some(sw) = doc.swatches.iter().find(|w| w.name == *n) {
- swatch_row(app, ui, &doc, sw, 16.0, fill_cur.as_deref());
+ resp.context_menu(|ui| {
+ if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Color Group Options…"))).clicked() {
+ let _ = app.run("app.colorGroupOptionsDialog", json!({"name": g.name}));
+ ui.close();
+ }
+ if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Ungroup Color Group"))).clicked() {
+ let _ = app.run("swatch.ungroupColorGroup", json!({"name": g.name}));
+ ui.close();
+ }
+ });
+ if open {
+ for n in &g.swatches {
+ if let Some(sw) = doc.swatches.iter().find(|w| w.name == *n) {
+ swatch_row(app, ui, &doc, sw, 16.0, fill_cur.as_deref());
+ }
}
}
}
+ });
+ // An unnamed gradient becomes "New Gradient Swatch"; one that is a swatch already stays one.
+ if let Some(g) = dropped
+ && doc.swatch(&g.0).is_some_and(|w| w.hidden)
+ {
+ let _ = app.run("swatch.new", json!({"swatch": g.0}));
}
ui.add_space(6.0);
// InDesign's bottom row: New Color Group, New Swatch, Delete Swatch (the one last clicked).
@@ -575,6 +585,11 @@ fn spectrum(app: &mut DesignApp, ui: &mut egui::Ui, target: &str) {
#[derive(Clone, Debug)]
pub struct SwatchDrag(pub String);
+/// The selection's gradient dragged from the Gradient panel's thumbnail (its swatch name): the
+/// Swatches panel takes it as a new gradient swatch.
+#[derive(Clone, Debug)]
+pub struct GradientDrag(pub String);
+
/// A colour in `object.gradient` form, keeping its model (CMYK stays CMYK).
fn color_json(c: &designcraft_color::Color) -> serde_json::Value {
use designcraft_color::Color;
@@ -680,8 +695,18 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
let full = ui.available_width().max(60.0);
ui.add_enabled_ui(has_item, |ui| {
ui.horizontal(|ui| {
- let (r, resp) = ui.allocate_exact_size(vec2(34.0, 34.0), Sense::click());
+ let (r, resp) = ui.allocate_exact_size(vec2(34.0, 34.0), Sense::click_and_drag());
thumbnail(ui.painter(), r.shrink(1.0), &g, t.field_border);
+ // Drag the gradient onto the Swatches panel to keep it as a swatch.
+ if is_gradient {
+ resp.dnd_set_drag_payload(GradientDrag(fill.swatch.clone()));
+ if resp.dragged()
+ && let Some(p) = ui.ctx().pointer_interact_pos()
+ {
+ let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("gradient_drag")));
+ thumbnail(&painter, egui::Rect::from_min_size(p + vec2(8.0, 8.0), vec2(18.0, 18.0)), &g, t.field_border);
+ }
+ }
// Clicking the thumbnail applies the gradient shown (InDesign's way to start one).
if resp.clicked() && !is_gradient {
send = Some(json!({}));
@@ -1025,6 +1050,64 @@ mod gradient_panel_tests {
assert_eq!(g.stops[1].color, Color::cmyk(1.0, 0.0, 0.0, 0.0));
assert!((g.stops[1].offset - 0.5).abs() < 0.05, "{}", g.stops[1].offset);
}
+
+ /// The Gradient panel's thumbnail drags the selection's gradient onto the Swatches panel,
+ /// which keeps it as a gradient swatch.
+ #[test]
+ fn a_gradient_dragged_onto_the_swatches_becomes_a_swatch() {
+ let mut app = app_with_frame();
+ app.run("object.gradient", json!({})).unwrap();
+ app.run("window.panel.float", json!({"panel": "gradient", "x": 300, "y": 120})).unwrap();
+ app.run("window.panel.float", json!({"panel": "swatches", "x": 620, "y": 120})).unwrap();
+ let named =
+ |app: &DesignApp| app.session.doc().unwrap().doc.swatches.iter().filter(|w| !w.hidden).map(|w| w.name.clone()).collect::>();
+ let before = named(&app);
+ let mut h = crate::test_window::open(app, vec2(1400.0, 900.0));
+ h.run_steps(6);
+ drag_gradient_to(&mut h, "[Black]");
+ let after = named(&h.state().app);
+ assert_eq!(after.len(), before.len() + 1);
+ assert_eq!(after.last().map(String::as_str), Some("New Gradient Swatch"));
+ let d = &h.state().app.session.doc().unwrap().doc;
+ assert!(matches!(d.swatch("New Gradient Swatch").map(|w| &w.value), Some(SwatchValue::Gradient { .. })));
+ let id = h.state().app.session.doc().unwrap().selection.items[0];
+ assert_eq!(d.item(id).unwrap().fill.swatch, "New Gradient Swatch", "the object keeps it, now by name");
+ }
+
+ /// Drag the Gradient panel's thumbnail onto the Swatches panel row labelled `label`.
+ fn drag_gradient_to(h: &mut egui_kittest::Harness<'static, crate::test_window::Window>, label: &str) {
+ use egui::{Event, Modifiers, PointerButton};
+ // The thumbnail sits left of the Type row.
+ let ty = h.get_by_label("Type:").rect();
+ let from = egui::pos2(ty.min.x - 21.0, ty.min.y + 12.0);
+ let to = h.get_by_label(label).rect().center();
+ h.event(Event::PointerMoved(from));
+ h.step();
+ h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
+ h.step();
+ for i in 1..=10 {
+ h.event(Event::PointerMoved(from + (to - from) * (i as f32 / 10.0)));
+ h.step();
+ }
+ h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
+ h.run_steps(4);
+ }
+
+ /// A colour group's row is part of the list too: a gradient dropped on it becomes a swatch.
+ #[test]
+ fn a_gradient_dropped_on_a_colour_group_becomes_a_swatch() {
+ let mut app = app_with_frame();
+ app.run("object.gradient", json!({})).unwrap();
+ let group = app.run("swatch.newColorGroup", json!({})).unwrap()["name"].as_str().unwrap().to_string();
+ app.run("window.panel.float", json!({"panel": "gradient", "x": 300, "y": 120})).unwrap();
+ // Tall enough to show the group's row below the swatches.
+ app.run("window.panel.float", json!({"panel": "swatches", "x": 620, "y": 120, "height": 700})).unwrap();
+ let mut h = crate::test_window::open(app, vec2(1400.0, 900.0));
+ h.run_steps(6);
+ drag_gradient_to(&mut h, &group);
+ let d = &h.state().app.session.doc().unwrap().doc;
+ assert!(matches!(d.swatch("New Gradient Swatch").map(|w| &w.value), Some(SwatchValue::Gradient { .. })), "dropped on the group's row");
+ }
}
#[cfg(test)]
diff --git a/crates/ui-egui/tests/it/color_profiles_restart.rs b/crates/ui-egui/tests/it/color_profiles_restart.rs
new file mode 100644
index 000000000..ad7ebcc75
--- /dev/null
+++ b/crates/ui-egui/tests/it/color_profiles_restart.rs
@@ -0,0 +1,43 @@
+//! A working space loaded with Edit › Color Settings › Load Profile… is active again after a
+//! restart (#1474). Colour settings are process-wide, so this lives in the integration tests.
+
+use designcraft_color::cms::{self, ColorSettings};
+use designcraft_engine::Session;
+use designcraft_ui_egui::{DesignApp, Services, UiState};
+use serde_json::json;
+
+/// The app's first frame, where saved settings are applied.
+fn start(app: &mut DesignApp) {
+ let mut out = egui::Context::default().run_ui(egui::RawInput::default(), |ui| app.logic(&ui.ctx().clone()));
+ out.textures_delta.clear();
+}
+
+#[test]
+fn loaded_working_space_survives_restart() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("restart-test.icc");
+ let mut profile = cms::icc_out::generic_cmyk_profile().unwrap().clone();
+ profile.description = "Restart Test CMYK".into();
+ std::fs::write(&path, profile.with_encoded_bytes().to_bytes().as_slice()).unwrap();
+
+ // What ui.json holds after Load Profile… and choosing that profile in a previous session.
+ let chosen = ColorSettings { cmyk: "Restart Test CMYK".into(), ..Default::default() };
+ let saved = json!({"colorProfiles": [path.to_string_lossy()], "colorSettings": chosen});
+
+ // This process has never loaded the profile: only the built-in ones are known.
+ assert!(!cms::profiles().iter().any(|p| p.name == chosen.cmyk));
+ let mut app = DesignApp::new(Session::new(), Services::default());
+ app.ui = serde_json::from_value::(saved).unwrap();
+ start(&mut app);
+ let active = cms::active_settings();
+ cms::set_active(&ColorSettings::default()).unwrap();
+ assert_eq!(active, chosen, "the loaded working space is active again");
+
+ // A saved profile that can't be loaded any more is reported, not silently replaced.
+ let gone = json!({"colorProfiles": [dir.path().join("gone.icc").to_string_lossy()], "colorSettings": ColorSettings { cmyk: "Gone CMYK".into(), ..Default::default() }});
+ let mut app = DesignApp::new(Session::new(), Services::default());
+ app.ui = serde_json::from_value::(gone).unwrap();
+ start(&mut app);
+ assert_eq!(cms::active_settings(), ColorSettings::default());
+ assert!(app.ui.status.contains("Gone CMYK"), "{}", app.ui.status);
+}
diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs
new file mode 100644
index 000000000..6950fc776
--- /dev/null
+++ b/crates/ui-egui/tests/it/main.rs
@@ -0,0 +1,4 @@
+#[cfg(not(target_arch = "wasm32"))]
+mod color_profiles_restart;
+#[cfg(not(target_arch = "wasm32"))]
+mod native_job_lifetime_control;
diff --git a/crates/ui-egui/tests/native_job_lifetime_control.rs b/crates/ui-egui/tests/it/native_job_lifetime_control.rs
similarity index 98%
rename from crates/ui-egui/tests/native_job_lifetime_control.rs
rename to crates/ui-egui/tests/it/native_job_lifetime_control.rs
index 2cf82e5bd..b7fa87214 100644
--- a/crates/ui-egui/tests/native_job_lifetime_control.rs
+++ b/crates/ui-egui/tests/it/native_job_lifetime_control.rs
@@ -1,5 +1,4 @@
//! Own a real native Job across last close. This does not run the asynchronous worker.
-#![cfg(not(target_arch = "wasm32"))]
use std::sync::Arc;
diff --git a/docs/gaps.md b/docs/gaps.md
index 1076f6218..75befc060 100644
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -1,6 +1,6 @@
# Where DesignCraft falls short of InDesign
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (gap 28 added: placed-PDF font repair remains after visible unembedded font detection, #909; gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (gap 28 added: placed-PDF font repair remains after visible unembedded font detection, #909; gap 26 shrunk: autoflow fills the following pages before it adds pages at the end; gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6)
Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an
InDesign professional from doing real work. This is the work list: agents pick from the top.
@@ -218,13 +218,11 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format ·
parallel story composition.
- **Estimate:** 40–80 h.
-### 26. Autoflow fills the following pages before it adds pages (F)
-- **Missing:** Shift-click autoflow (and autoflow on Place) always adds new pages after the
- story's last frame, one margin-sized frame each. InDesign first threads a frame per column on
- the existing pages that follow, then adds pages at the end of the document.
-- **Impact:** autoflowing into a document that already has pages inserts new pages in the
- middle of it instead of using the empty pages.
-- **Estimate:** 4–8 h.
+### 26. Autoflow flows into the primary text frame of the pages that follow (F)
+- **Missing:** autoflow threads a frame per column on the pages that follow, then adds pages at
+ the end of the document, as InDesign does. On a page whose parent has a primary text frame it
+ still sets a new frame instead of flowing into the primary one.
+- **Estimate:** 2–4 h.
## Lower
@@ -261,6 +259,7 @@ Cue. These are excluded from every percentage.
| Date | Change | Summary |
|---|---|---|
+| 2026-10-22 | minor | Gap 26 shrunk: autoflow threads a frame per column on the pages that follow the story's last frame, then adds pages at the end of the document |
| 2026-10-19 | minor | Gap 28 added for remaining placed-PDF font embedding, substitution or outlining; visible unembedded font detection now ships in PDF/X-4 export and `validate-pdf`, with invisible text exempt and operation- or depth-limited checks reported incomplete (#909) |
| 2026-10-16 | minor | Gap 27 closed: the loaded text cursor threads into a text frame inside a group (#1075) |
| 2026-10-16 | trivial | Gap 13 shrunk: Greek hyphenates, so five languages do (#1077) |
diff --git a/docs/localization-parity.md b/docs/localization-parity.md
index fa75a20d8..bc209c118 100644
--- a/docs/localization-parity.md
+++ b/docs/localization-parity.md
@@ -1,6 +1,6 @@
# Localization parity
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (Polish and Georgian interfaces; catalog counts remain the 2026-10-10 measurement) · **Target:** Adobe InDesign 2026 (21.6)
+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (missing translations are reported, never a failure; catalog counts remain the 2026-10-10 measurement) · **Target:** Adobe InDesign 2026 (21.6)
Per-language status of the DesignCraft interface and of document typesetting in each script.
Script-specific typesetting detail: [cjk.md](cjk.md), [cjk-typography.md](cjk-typography.md),
@@ -19,6 +19,10 @@ in the UI crate appear in no catalog (some are test data or proper names), so th
somewhat higher and every percentage below is a slight overestimate. Help: DesignCraft has no
help content in any language (Help ▸ opens the project's pages).
+A missing translation never fails a test, a build or a release. `cargo xtask i18n-report` (also
+a step of `cargo xtask ci`, so the list is in every gate log) prints how many interface strings
+each language lacks, then the strings.
+
InDesign 2026 ships 25 interface localizations (`.lproj` folders: cs, da, de, el, en, en_GB, es,
fi, fr, hu, it, ja, ko, nb, nl, pl, pt, ro, ru, sq, sv, tr, uk, zh_CN, zh_TW); Arabic and Hebrew
come as Middle East editions with an English or French interface and right-to-left features.
diff --git a/docs/mcp.md b/docs/mcp.md
index 7cf8227da..741174e13 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -182,7 +182,9 @@ and `text.move` return, a story's `length` in `get_story` and `inspect_document`
`get_story`. A character outside ASCII takes 2 to 4 bytes (é 2, — and は 3), so the story `はただ商店` has length 15
and its second character starts at 3. An offset inside a character snaps back to the start of that character, except
in `story.replaceRange`: there an offset inside a character, an `end` past the end of the story or a `start` after `end`
-is an error that gives the story length in bytes, and the story is left unchanged.
+is an error that gives the story length in bytes, and the story is left unchanged. A `find.find` or `find.next` match
+that has a `cell` (`{table, row, col}`) is in that table cell's text, and its `start` / `end` count from the start of
+the cell.
## Text and image fitting
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
index 53c0f2842..ffb2ac993 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -1,6 +1,6 @@
# DesignCraft parity with Adobe InDesign
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (PDF/X-4 visible unembedded font detection, #909; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (PDF/X-4 visible unembedded font detection, #909; autoflow fills the following pages first; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS)
The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md)
summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature
@@ -211,7 +211,7 @@ table is the one summed.
| Fill, stroke, colour | 100% | 60% | 20–35 | hex entry (#323); Assign Profiles / Convert to Profile; color-managed proofing depth; Adobe Color Themes OOS |
| Effects & transparency | 100% | 50% | 20–30 | effects fidelity vs InDesign unmeasured; Clear Effects / Clear All Transparency; flattener depth |
| Placing & links | 93% | 55% | 20–35 | EPS is preview-only (no PostScript interpreter); Edit Original; placed PDF layer visibility; INDD page place |
-| Type & text frames | 99% | 45% | 40–60 | Primary Text Frame pages users can't delete (alpha blocker, #165); autoflow doesn't fill existing pages; no threading into grouped frames with the loaded cursor; caret formatting discarded (#289); Notes/Track Changes menus; frame grids |
+| Type & text frames | 99% | 45% | 40–60 | Primary Text Frame pages users can't delete (alpha blocker, #165); autoflow sets new frames on following pages instead of flowing into their primary text frame; caret formatting discarded (#289); Notes/Track Changes menus; frame grids |
| Typography | 100% | 45% | (typography dimension) | see [typography-parity.md](typography-parity.md) |
| Styles | 100% | 60% | 15–25 | Paragraph Styles edit/delete reported failing (#169); style options depth |
| Tables | 100% | 55% | 25–40 | anchored objects in cells not drawn (#163); Table ▸ Table Options submenus, Diagonal Lines, Edit Header/Footer |
@@ -306,7 +306,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool).
- Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy; R/G/B, C/M/Y/K and hex fields; a CMYK colour stays CMYK unless R/G/B values are typed); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`.
- Threading: click a selected text frame's in or out port (overset or not) to load the text cursor, then click a frame to thread it (stories merge), click empty space for a column-wide frame down to the bottom margin, or drag a frame; Shift-click autoflows, Alt-click stays loaded; click the linked frame or double-click a port to break the thread; Esc unloads. Link and unthread cursors show where a click threads or unthreads. `text.thread`, `text.unthread`, `text.removeFromThread`.
-- Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow threads one frame per page column and adds pages until the text fits; Remove Styles option (`designcraft-textimport`).
+- Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow threads one frame per page column, on the pages that follow first, then adds pages at the end of the document until the text fits; Remove Styles option (`designcraft-textimport`).
- Gradient Feather (⇧G tool, `object.gradientFeather`): objects fade along a linear or radial opacity gradient, on screen and as a soft mask in PDF.
- Script Label (`object.label`, `object.findByLabel`), alt text (`object.altText`), Isolate Blending / Knockout Group (`object.transparencyGroup`; on screen, PDF isolates), Table ▸ Sort (`table.sortRows`, numeric-aware, header/footer fixed).
- File ▸ Export Text (`file.exportText`): a story as Text Only or RTF (fonts, styles, alignment, indents, tables as tab-separated rows); Export EPUB now asks for a path in the UI.
@@ -375,7 +375,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- Object States (`states.create/show/rename/release/list`, Object States panel): multi-state objects show one state on screen and in PDF.
- Buttons (`button.set/clear/list`, Buttons and Forms panel): go to page / next / previous / first / last / URL as PDF link annotations. Form fields too (`form.set/clear/list`: text fields, check boxes, combo and list boxes, signature fields; required, multiline, defaults) exported as AcroForm widgets through an incremental update (not in PDF/X or PDF/A).
- Power Zoom (Hand tool: press and hold still for half a second, or Alt-press): zoom out to the spread, aim the red view rectangle, release to zoom back in there.
-- Graphic cells (`table.placeGraphic`, `table.textCell`, Table › Convert Cell Type, Table panel): images in table cells, fitted or filled, clipped to the cell on screen and in PDF.
+- Graphic cells (`table.placeGraphic`, `table.textCell`, Table › Convert Cell Type, Table panel): images in table cells, fitted or filled, clipped to the cell on screen and in PDF; IDML export writes them as an inline graphic in the cell.
- Live Distribute (Space while dragging a selection handle; `transform.resize {distribute}`): objects keep their size and spread with the bounds.
- Flex Layout (InDesign 2026; `flex.add`, `flex.convert`, `flex.remove`, `flex.set`, `flex.reorder`, Window › Flex Layout and the Properties panel's Flex Layout section): a frame lays out its objects in rows or columns by direction, wrap, justification (Flex Start, Center, Flex End, Space Between, Space Around, Space Evenly), the alignment grid, Fixed/Auto width and height, padding and spacing; aligned objects are detected on Add (direction, Auto, Space Between); resizing a container reflows it; dragging an object in one reorders it. Not yet: item sizing (Fixed/Auto/Fill Space per object), object styles with Flex Layout settings, IDML (a container exports as a frame with pasted-in objects), nested selection cues on the canvas.
- Liquid Layout (`liquid.pageRule/object`, `guide.liquid`, `layout.createAlternate`, Liquid Layout panel): scale / re-center / guide-based / object-based page rules applied on page resize and Document Setup; dashed liquid guides; alternate layouts as resized page copies in a named section. Text in an alternate layout stays linked to the original stories (Links panel shows them out of date; `story.updateLink`).
diff --git a/docs/user/guides/export-a-pdf.md b/docs/user/guides/export-a-pdf.md
index 3bec469cd..827f54c4e 100644
--- a/docs/user/guides/export-a-pdf.md
+++ b/docs/user/guides/export-a-pdf.md
@@ -17,7 +17,7 @@ Shortcuts on this page use Ctrl. On macOS, use Cmd instead of Ctrl.
4. Under **Compression**, tick or clear **Compress images**.
5. Under **Marks and Bleeds**, tick **Include document bleed**, **Crop Marks**, **Bleed Marks** or **Page Information** as your printer asks. **Weight:** and **Offset:** set the marks.
6. Under **Advanced**, pick a **Standard:** (**None**, **PDF/X-4** or **PDF/A-2b**) and decide on **Tagged PDF**.
-7. Click **OK**, then choose where to save. The file name starts as your document's title with `.pdf`.
+7. Click **OK**, then choose where to save. The file name starts as your document's title with `.pdf`. The save dialog opens in your document's folder, or, if the document has never been saved, in the folder you last saved or exported to.
## Presets
diff --git a/packaging/env.sh b/packaging/env.sh
index 0eb58029b..55ff3943c 100644
--- a/packaging/env.sh
+++ b/packaging/env.sh
@@ -19,10 +19,11 @@ workspace_version() {
/^\[/ { in_pkg = ($0 == "[workspace.package]") ; next }
in_pkg && $1 == "version" { gsub(/[" ]/, "", $3); print $3; exit }
' "$ROOT/Cargo.toml"
+ return
}
VERSION="${DESIGNCRAFT_VERSION:-$(workspace_version)}"
-if [ -z "$VERSION" ]; then
+if [[ -z "$VERSION" ]]; then
echo "error: could not read [workspace.package] version from $ROOT/Cargo.toml" >&2
exit 1
fi
@@ -32,7 +33,7 @@ DIST="${DIST:-$ROOT/dist/release}"
mkdir -p "$DIST"
export DIST
-if [ -z "${DESIGNCRAFT_BUILD_SHA:-}" ]; then
+if [[ -z "${DESIGNCRAFT_BUILD_SHA:-}" ]]; then
DESIGNCRAFT_BUILD_SHA="$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || true)"
fi
export DESIGNCRAFT_BUILD_SHA
@@ -41,29 +42,33 @@ export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}"
# Emit a GitHub Actions warning (plain stderr outside Actions).
warn() {
- if [ -n "${GITHUB_ACTIONS:-}" ]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi
+ if [[ -n "${GITHUB_ACTIONS:-}" ]]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi
+ return
}
# Copy licence and readme files that exist into a package directory.
copy_docs() {
local dest="$1" f
for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT; do
- if [ -f "$ROOT/$f" ]; then cp "$ROOT/$f" "$dest/"; fi
+ if [[ -f "$ROOT/$f" ]]; then cp "$ROOT/$f" "$dest/"; fi
done
copy_font_licences "$dest"
+ return
}
# Builds made with craft-fonts (CRAFT_FONTS_DIR, set by the release workflow) embed its fonts, so
# the package carries their licences as OFL-.txt. Nothing without CRAFT_FONTS_DIR.
copy_font_licences() {
local dest="$1" f
- [ -n "${CRAFT_FONTS_DIR:-}" ] || return 0
+ [[ -n "${CRAFT_FONTS_DIR:-}" ]] || return 0
for f in "$CRAFT_FONTS_DIR"/fonts/*/OFL.txt; do
- if [ -f "$f" ]; then cp "$f" "$dest/OFL-$(basename "$(dirname "$f")").txt"; fi
+ if [[ -f "$f" ]]; then cp "$f" "$dest/OFL-$(basename "$(dirname "$f")").txt"; fi
done
}
# Portable SHA-256 of a file (prints just the hash).
sha256() {
- if command -v sha256sum >/dev/null; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi
+ local file="$1"
+ if command -v sha256sum >/dev/null; then sha256sum "$file" | cut -d' ' -f1; else shasum -a 256 "$file" | cut -d' ' -f1; fi
+ return
}
diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh
index 1854c284a..d11434be0 100755
--- a/packaging/freebsd/package.sh
+++ b/packaging/freebsd/package.sh
@@ -20,7 +20,7 @@ APP_ID=ai.storyteller.designcraft
SKIP_BUILD=0
DRY_RUN=0
-while [ $# -gt 0 ]; do
+while [[ $# -gt 0 ]]; do
case "$1" in
--skip-build) SKIP_BUILD=1; shift ;;
--dry-run) DRY_RUN=1; SKIP_BUILD=1; shift ;;
@@ -36,7 +36,7 @@ case "$MACHINE" in
arm64 | aarch64) ARCH=aarch64 ;;
*) echo "unsupported architecture $MACHINE" >&2; exit 2 ;;
esac
-if [ "$DRY_RUN" = 0 ] && [ "$(uname -s)" != FreeBSD ]; then
+if [[ "$DRY_RUN" = 0 ]] && [[ "$(uname -s)" != FreeBSD ]]; then
echo "error: build on FreeBSD (or pass --dry-run to check the tree layout)" >&2
exit 2
fi
@@ -46,7 +46,7 @@ echo "==> DesignCraft $VERSION for FreeBSD $ARCH"
# The release VM has 12 GB; full parallelism on the biggest crates runs it out of memory.
export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-4}"
-if [ "$SKIP_BUILD" = 0 ]; then
+if [[ "$SKIP_BUILD" = 0 ]]; then
(cd "$ROOT" && cargo build --release --locked -p designcraft -p designcraft-cli)
fi
WORK="$CARGO_TARGET_DIR/freebsd-package"
@@ -56,7 +56,7 @@ mkdir -p "$WORK"
BIN="$CARGO_TARGET_DIR/release"
OUT_DIR="$DIST"
-if [ "$DRY_RUN" = 1 ]; then
+if [[ "$DRY_RUN" = 1 ]]; then
# Stub binaries, and the tarball stays in the work dir so dist/ only ever holds real builds.
BIN="$WORK/stub-bin"
OUT_DIR="$WORK"
@@ -81,13 +81,13 @@ sed -e "s/@VERSION@/$VERSION/g" -e "s/@DATE@/$DESIGNCRAFT_BUILD_DATE/g" \
cp -R "$ROOT/assets/app-icon/hicolor" "$STAGE/share/icons/"
copy_docs "$STAGE/share/doc/designcraft"
for f in NOTICE ATTRIBUTION.md; do
- if [ -f "$ROOT/$f" ]; then install -m 644 "$ROOT/$f" "$STAGE/share/doc/designcraft/"; fi
+ if [[ -f "$ROOT/$f" ]]; then install -m 644 "$ROOT/$f" "$STAGE/share/doc/designcraft/"; fi
done
for f in bin/designcraft bin/designcraft-cli "share/applications/$APP_ID.desktop" \
"share/icons/hicolor/256x256/apps/$APP_ID.png" "share/icons/hicolor/scalable/apps/$APP_ID.svg" \
share/doc/designcraft/LICENSE-MIT share/doc/designcraft/LICENSE-APACHE; do
- if [ ! -e "$STAGE/$f" ]; then echo "error: $f is missing from the package" >&2; exit 1; fi
+ if [[ ! -e "$STAGE/$f" ]]; then echo "error: $f is missing from the package" >&2; exit 1; fi
done
# ---- .tar.gz ------------------------------------------------------------------------------------
@@ -96,7 +96,7 @@ tar -C "$WORK" -czf "$OUT_DIR/$BASENAME.tar.gz" "$BASENAME"
echo "wrote $OUT_DIR/$BASENAME.tar.gz"
"$STAGE/bin/designcraft-cli" --version
-if [ "$DRY_RUN" = 1 ]; then
+if [[ "$DRY_RUN" = 1 ]]; then
echo "==> dry run: tarball contents"
tar -tzvf "$OUT_DIR/$BASENAME.tar.gz"
fi
diff --git a/packaging/icons.sh b/packaging/icons.sh
index 6e06fb9ad..7ac9e2954 100755
--- a/packaging/icons.sh
+++ b/packaging/icons.sh
@@ -21,7 +21,10 @@ MAC="$TMP/mac.svg"
sed 's/viewBox="0 0 512 512"/viewBox="-62.136 -62.136 636.272 636.272"/' "$SVG" >"$MAC"
grep -q 'viewBox="-62.136' "$MAC" || { echo "error: expected viewBox=\"0 0 512 512\" in $SVG" >&2; exit 1; }
-render() { resvg -w "$2" -h "$2" "$1" "$3" &2; exit 1; }
+[[ -f "$TARBALL" ]] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; }
for tool in flatpak flatpak-builder; do
command -v "$tool" >/dev/null || { echo "error: $tool not found (apt install flatpak flatpak-builder librsvg2-common)" >&2; exit 1; }
done
@@ -49,7 +49,7 @@ WORK="$CARGO_TARGET_DIR/flatpak-bundle"
rm -rf "$WORK"
mkdir -p "$WORK/stage"
tar -xzf "$TARBALL" -C "$WORK/stage" --strip-components=1
-[ -x "$WORK/stage/bin/designcraft" ] || { echo "error: $TARBALL has no bin/designcraft" >&2; exit 1; }
+[[ -x "$WORK/stage/bin/designcraft" ]] || { echo "error: $TARBALL has no bin/designcraft" >&2; exit 1; }
cp "$HERE/flatpak/$APP_ID.bundle.yml" "$WORK/$APP_ID.yml"
flatpak remote-add --user --if-not-exists flathub "$FLATHUB"
@@ -62,7 +62,7 @@ OUT="$DIST/$BASENAME.flatpak"
flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable
echo "wrote $OUT"
-if [ "$TEST" = 1 ]; then
+if [[ "$TEST" = 1 ]]; then
flatpak install --user -y --noninteractive --reinstall "$OUT"
flatpak info --user "$APP_ID"
flatpak run --command=designcraft-cli "$APP_ID" --version
diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh
index 12ad8e0f2..c4d843eca 100755
--- a/packaging/linux/package.sh
+++ b/packaging/linux/package.sh
@@ -22,7 +22,7 @@ APP_ID=ai.storyteller.designcraft
SKIP_BUILD=0
FORMATS="appimage deb rpm arch tar"
-while [ $# -gt 0 ]; do
+while [[ $# -gt 0 ]]; do
case "$1" in
--skip-build) SKIP_BUILD=1; shift ;;
--formats) FORMATS="$2"; shift 2 ;;
@@ -43,8 +43,8 @@ BASENAME="designcraft-$VERSION-linux-$ARCH"
echo "==> DesignCraft $VERSION for Linux $ARCH ($FORMATS)"
-if [ "$SKIP_BUILD" = 0 ]; then
- if [ -n "${CROSS_TARGET:-}" ]; then
+if [[ "$SKIP_BUILD" = 0 ]]; then
+ if [[ -n "${CROSS_TARGET:-}" ]]; then
(cd "$ROOT" && cargo build --release --locked -p designcraft -p designcraft-cli --target "$CROSS_TARGET")
else
(cd "$ROOT" && cargo build --release --locked -p designcraft -p designcraft-cli)
@@ -76,7 +76,10 @@ if command -v appstreamcli >/dev/null; then
appstreamcli validate --no-net --explain "$STAGE/usr/share/metainfo/$APP_ID.metainfo.xml"
fi
-has() { case " $FORMATS " in *" $1 "*) return 0 ;; *) return 1 ;; esac; }
+has() {
+ local fmt="$1"
+ case " $FORMATS " in *" $fmt "*) return 0 ;; *) return 1 ;; esac
+}
# ---- .tar.gz ------------------------------------------------------------------------------------
if has tar; then
@@ -134,6 +137,8 @@ if has appimage; then
case "$ARCH" in
x86_64) APPIMAGETOOL_SHA256=ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 ;;
aarch64) APPIMAGETOOL_SHA256=f0837e7448a0c1e4e650a93bb3e85802546e60654ef287576f46c71c126a9158 ;;
+ # No pinned download (e.g. riscv64): set APPIMAGETOOL or put appimagetool on PATH.
+ *) APPIMAGETOOL_SHA256="" ;;
esac
APPDIR="$WORK/DesignCraft.AppDir"
cp -R "$STAGE" "$APPDIR"
@@ -144,9 +149,9 @@ if has appimage; then
ln -s "$APP_ID.png" "$APPDIR/.DirIcon"
TOOL="${APPIMAGETOOL:-$(command -v appimagetool || true)}"
- if [ -z "$TOOL" ]; then
+ if [[ -z "$TOOL" ]]; then
TOOL="$CARGO_TARGET_DIR/appimagetool-$APPIMAGETOOL_VERSION-$ARCH.AppImage"
- if [ ! -x "$TOOL" ]; then
+ if [[ ! -x "$TOOL" ]]; then
download="$TOOL.download"
curl --proto '=https' -fsSL -o "$download" \
"https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-$ARCH.AppImage"
@@ -174,16 +179,16 @@ if has appimage; then
# package) appimagetool also writes the .zsync, into its working directory, hence the cd.
(cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream -u "$UPDATE_INFO" "$APPDIR" "$OUT")
echo "wrote $OUT"
- if [ -s "$OUT.zsync" ]; then
+ if [[ -s "$OUT.zsync" ]]; then
echo "wrote $OUT.zsync"
else
warn "zsyncmake not found, so $OUT.zsync was not written; AppImage delta updates need it"
fi
fi
-if [ -z "${CROSS_TARGET:-}" ]; then
+if [[ -z "${CROSS_TARGET:-}" ]]; then
"$STAGE/usr/bin/designcraft-cli" --version
-elif [ -n "${EMULATOR:-}" ]; then
+elif [[ -n "${EMULATOR:-}" ]]; then
"$EMULATOR" "$STAGE/usr/bin/designcraft-cli" --version
fi
echo "==> done"
diff --git a/packaging/macos/import-cert.sh b/packaging/macos/import-cert.sh
index 9e549610f..8669d8675 100755
--- a/packaging/macos/import-cert.sh
+++ b/packaging/macos/import-cert.sh
@@ -12,7 +12,7 @@ umask 077
# shellcheck source=../env.sh
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
-if [ -z "${APPLE_CERTIFICATE:-}" ]; then
+if [[ -z "${APPLE_CERTIFICATE:-}" ]]; then
warn "APPLE_CERTIFICATE is not set: macOS builds will be ad-hoc signed and not notarized"
exit 0
fi
@@ -36,13 +36,13 @@ security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KC_PASS"
security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"')
IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN" | awk '/Developer ID Application/ { print $2; exit }')"
-if [ -z "$IDENTITY" ]; then
+if [[ -z "$IDENTITY" ]]; then
security find-identity -v -p codesigning "$KEYCHAIN" >&2
echo "error: no 'Developer ID Application' identity in APPLE_CERTIFICATE" >&2
exit 1
fi
echo "Imported signing identity $IDENTITY"
-if [ -n "${GITHUB_ENV:-}" ]; then
+if [[ -n "${GITHUB_ENV:-}" ]]; then
{ echo "MACOS_SIGN_IDENTITY=$IDENTITY"; echo "MACOS_KEYCHAIN=$KEYCHAIN"; } >>"$GITHUB_ENV"
else
echo "MACOS_SIGN_IDENTITY=$IDENTITY"
diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh
index c818ae6db..21edfa574 100755
--- a/packaging/macos/package.sh
+++ b/packaging/macos/package.sh
@@ -19,7 +19,7 @@ HERE="$ROOT/packaging/macos"
ARCH=universal
SKIP_BUILD=0
-while [ $# -gt 0 ]; do
+while [[ $# -gt 0 ]]; do
case "$1" in
--arch) ARCH="$2"; shift 2 ;;
--skip-build) SKIP_BUILD=1; shift ;;
@@ -44,9 +44,9 @@ DMG="$DIST/designcraft-$VERSION-macos-$ARCH.dmg"
CLI_ZIP="$DIST/designcraft-cli-$VERSION-macos-$ARCH.zip"
NOTARIZE=0
-if [ "$IDENTITY" = "-" ]; then
+if [[ "$IDENTITY" = "-" ]]; then
warn "macOS: ad-hoc signing (no MACOS_SIGN_IDENTITY); artifacts are not notarized"
-elif [ -n "${APPLE_ID:-}" ] && [ -n "${APPLE_PASSWORD:-}" ] && [ -n "${APPLE_TEAM_ID:-}" ]; then
+elif [[ -n "${APPLE_ID:-}" ]] && [[ -n "${APPLE_PASSWORD:-}" ]] && [[ -n "${APPLE_TEAM_ID:-}" ]]; then
NOTARIZE=1
else
warn "macOS: APPLE_ID / APPLE_PASSWORD / APPLE_TEAM_ID incomplete; signed but not notarized"
@@ -55,7 +55,7 @@ fi
echo "==> DesignCraft $VERSION for macOS ($ARCH), identity: $IDENTITY, notarize: $NOTARIZE"
# ---- build -------------------------------------------------------------------------------------
-if [ "$SKIP_BUILD" = 0 ]; then
+if [[ "$SKIP_BUILD" = 0 ]]; then
args=()
for t in "${TARGETS[@]}"; do args+=(--target "$t"); done
(cd "$ROOT" && cargo build --release --locked -p designcraft -p designcraft-cli "${args[@]}")
@@ -74,9 +74,9 @@ done
sign() {
# Hardened runtime + secure timestamp for a real identity; ad-hoc can't be timestamped.
local ts=(--timestamp)
- [ "$IDENTITY" = "-" ] && ts=(--timestamp=none)
+ [[ "$IDENTITY" = "-" ]] && ts=(--timestamp=none)
local kc=()
- [ -n "${MACOS_KEYCHAIN:-}" ] && kc=(--keychain "$MACOS_KEYCHAIN")
+ [[ -n "${MACOS_KEYCHAIN:-}" ]] && kc=(--keychain "$MACOS_KEYCHAIN")
codesign --force --sign "$IDENTITY" ${kc[@]+"${kc[@]}"} "${ts[@]}" "$@"
}
@@ -88,8 +88,8 @@ notarize() {
echo "$out"
id="$(printf '%s' "$out" | plutil -extract id raw -o - - 2>/dev/null || true)"
status="$(printf '%s' "$out" | plutil -extract status raw -o - - 2>/dev/null || true)"
- if [ "$status" != "Accepted" ]; then
- if [ -n "$id" ]; then
+ if [[ "$status" != "Accepted" ]]; then
+ if [[ -n "$id" ]]; then
xcrun notarytool log "$id" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" --team-id "$APPLE_TEAM_ID" || true
fi
echo "error: notarization of $(basename "$file") failed (status: ${status:-unknown})" >&2
@@ -117,7 +117,7 @@ sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP/Contents/
sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP"
codesign --verify --strict --deep --verbose=2 "$APP"
-if [ "$NOTARIZE" = 1 ]; then
+if [[ "$NOTARIZE" = 1 ]]; then
ditto -c -k --keepParent "$APP" "$WORK/DesignCraft-notarize.zip"
notarize "$WORK/DesignCraft-notarize.zip"
xcrun stapler staple "$APP"
@@ -151,7 +151,7 @@ hdiutil_retry() {
if hdiutil "$@"; then
return 0
fi
- [ "$attempt" = 5 ] && { echo "hdiutil $what failed 5 times" >&2; return 1; }
+ [[ "$attempt" = 5 ]] && { echo "hdiutil $what failed 5 times" >&2; return 1; }
echo "hdiutil $what failed (attempt $attempt of 5); retrying in $((attempt * 10))s" >&2
sleep $((attempt * 10))
done
@@ -169,7 +169,7 @@ if ! codesign --verify --strict --deep --verbose=2 "$MOUNT/DesignCraft.app"; the
exit 1
fi
hdiutil_retry detach detach "$MOUNT" >/dev/null
-if [ "$NOTARIZE" = 1 ]; then
+if [[ "$NOTARIZE" = 1 ]]; then
notarize "$DMG"
xcrun stapler staple "$DMG"
xcrun stapler validate "$DMG"
@@ -187,7 +187,7 @@ codesign --verify --strict --verbose=2 "$CLI_DIR/designcraft-cli"
rm -f "$CLI_ZIP"
ditto -c -k --keepParent "$CLI_DIR" "$CLI_ZIP"
# A bare Mach-O can't carry a stapled ticket; Gatekeeper looks the notarization up online.
-if [ "$NOTARIZE" = 1 ]; then notarize "$CLI_ZIP"; fi
+if [[ "$NOTARIZE" = 1 ]]; then notarize "$CLI_ZIP"; fi
"$WORK/bin/designcraft-cli" --version
echo "==> done"
diff --git a/packaging/web/package.sh b/packaging/web/package.sh
index 4aafc3e80..62c220f0c 100755
--- a/packaging/web/package.sh
+++ b/packaging/web/package.sh
@@ -11,13 +11,13 @@ set -euo pipefail
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
HERE="$ROOT/packaging/web"
-if [ "${1:-}" != "--skip-build" ]; then
+if [[ "${1:-}" != "--skip-build" ]]; then
command -v trunk >/dev/null || { echo "error: trunk not found (cargo install trunk --locked)" >&2; exit 1; }
(cd "$ROOT/apps/designcraft-web" && trunk build --release)
fi
SITE="$ROOT/dist/web"
-[ -f "$SITE/index.html" ] || { echo "error: $SITE/index.html missing; run without --skip-build" >&2; exit 1; }
+[[ -f "$SITE/index.html" ]] || { echo "error: $SITE/index.html missing; run without --skip-build" >&2; exit 1; }
# Paths must be relative so the site works under any prefix (public_url = "./" in Trunk.toml).
if grep -Eq '(src|href)="/[^/]' "$SITE/index.html"; then
echo "error: $SITE/index.html has root-absolute URLs; it would break when served from a sub-path" >&2
diff --git a/xtask/src/main.rs b/xtask/src/main.rs
index e730feb51..c1d08071b 100644
--- a/xtask/src/main.rs
+++ b/xtask/src/main.rs
@@ -12,7 +12,7 @@ mod stats;
mod version;
use std::path::{Path, PathBuf};
-use std::process::{Command, ExitCode};
+use std::process::{Command, ExitCode, Stdio};
const USAGE: &str = "\
usage: cargo xtask
@@ -21,8 +21,11 @@ commands:
assets check that every icon/image/font/asset is attributed in ASSETS.md
layers enforce the crate dependency layering (plan/architecture.md §3)
pdf-deps verify the pinned PDF backend, including Krilla dependencies
- wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates
- ci fmt --check, clippy -D warnings, test, assets, layers, wasm (stops at first failure)
+ wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates (one call)
+ test workspace tests (cargo nextest when installed, else cargo test), then doctests
+ i18n-report list the interface strings each language shows in English (always passes)
+ ci fmt --check, pdf-deps, clippy -D warnings, test, i18n-report, doctest, assets,
+ layers, wasm (stops at first failure)
corpus [--download]
show where test corpora live; --download fetches PngSuite into corpus/pngsuite
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
@@ -39,6 +42,8 @@ fn main() -> ExitCode {
Some("layers") => cmd_layers(),
Some("pdf-deps") => pdf_deps::run(&root()),
Some("wasm") => cmd_wasm(),
+ Some("test") => cmd_test().and_then(|()| cmd_doctest()),
+ Some("i18n-report") => cmd_i18n_report(),
Some("ci") => cmd_ci(),
Some("corpus") => cmd_corpus(rest.contains(&"--download")),
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
@@ -134,21 +139,66 @@ fn wasm_set() -> Result, String> {
.collect())
}
+/// One `cargo check` for the whole set: each crate is checked once, and
+/// `--keep-going` still reports every failing crate. Features unify across the
+/// set, so each crate is checked with the union of the features the set enables.
fn cmd_wasm() -> Result<(), String> {
let set = wasm_set()?;
- let mut results = Vec::new();
+ if set.is_empty() {
+ return Err("no wasm-safe crates found in the workspace metadata".into());
+ }
+ let mut c = cargo();
+ c.args(["check", "--target", "wasm32-unknown-unknown", "--keep-going"]);
for pkg in &set {
- let mut c = cargo();
- c.args(["check", "--target", "wasm32-unknown-unknown", "-p", pkg]);
- let ok = run(c, &format!("cargo check --target wasm32-unknown-unknown -p {pkg}")).is_ok();
- results.push((pkg.clone(), ok));
+ c.args(["-p", pkg]);
}
- println!("\nwasm32-unknown-unknown check:");
- for (p, ok) in &results {
- println!(" {:<6} {p}", if *ok { "ok" } else { "FAIL" });
+ run(c, &format!("cargo check --target wasm32-unknown-unknown --keep-going -p {}", set.join(" -p ")))?;
+ println!("\nwasm32-unknown-unknown check: ok ({} crates: {})", set.len(), set.join(", "));
+ Ok(())
+}
+
+/// Test targets for the test build. Examples are left out so they aren't
+/// linked; clippy `--all-targets` still compiles them.
+const TEST_TARGETS: [&str; 4] = ["--workspace", "--lib", "--bins", "--tests"];
+
+fn nextest_available() -> bool {
+ cargo().args(["nextest", "--version"]).stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok_and(|s| s.success())
+}
+
+fn cmd_test() -> Result<(), String> {
+ let mut c = cargo();
+ let tool = if nextest_available() {
+ c.args(["nextest", "run"]);
+ "nextest run"
+ } else {
+ eprintln!("note: cargo-nextest is not installed, running cargo test (cargo install cargo-nextest --locked)");
+ c.arg("test");
+ "test"
+ };
+ c.args(TEST_TARGETS);
+ run(c, &format!("cargo {tool} {}", TEST_TARGETS.join(" ")))
+}
+
+/// Runs the ignored `missing_translations_report` test with its output shown. It takes the test
+/// step's targets, so it reuses that build.
+fn cmd_i18n_report() -> Result<(), String> {
+ const REPORT: &str = "i18n::tests::missing_translations_report";
+ let mut c = cargo();
+ if nextest_available() {
+ c.args(["nextest", "run", "--cargo-quiet"]).args(TEST_TARGETS);
+ c.args(["--run-ignored", "only", "--no-capture", "--status-level", "fail", "--final-status-level", "fail", "-E"]);
+ c.arg(format!("package(designcraft-ui-egui) & test(={REPORT})"));
+ } else {
+ c.args(["test", "-q"]).args(TEST_TARGETS).args(["--", "--ignored", "--exact", REPORT, "--nocapture"]);
}
- let failed = results.iter().filter(|r| !r.1).count();
- if failed == 0 { Ok(()) } else { Err(format!("{failed} crate(s) failed the wasm check")) }
+ run(c, &format!("missing translations ({REPORT})"))
+}
+
+/// nextest does not run doctests.
+fn cmd_doctest() -> Result<(), String> {
+ let mut c = cargo();
+ c.args(["test", "--workspace", "--doc"]);
+ run(c, "cargo test --workspace --doc")
}
fn cmd_ci() -> Result<(), String> {
@@ -171,14 +221,9 @@ fn cmd_ci() -> Result<(), String> {
run(c, "cargo clippy --workspace --all-targets -- -D warnings")
}),
),
- (
- "test",
- Box::new(|| {
- let mut c = cargo();
- c.args(["test", "--workspace"]);
- run(c, "cargo test --workspace")
- }),
- ),
+ ("test", Box::new(cmd_test)),
+ ("i18n-report", Box::new(cmd_i18n_report)),
+ ("doctest", Box::new(cmd_doctest)),
("assets", Box::new(|| assets::run(&root()))),
("layers", Box::new(cmd_layers)),
("wasm", Box::new(cmd_wasm)),
|