diff --git a/.coderabbit.yaml b/.coderabbit.yaml
new file mode 100644
index 000000000..2408084cf
--- /dev/null
+++ b/.coderabbit.yaml
@@ -0,0 +1,6 @@
+# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
+reviews:
+ auto_review:
+ # Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
+ ignore_usernames:
+ - "dependabot[bot]"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 000000000..d0d1fd873
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,29 @@
+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]
+ pull_request:
+ workflow_dispatch:
+
+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.event_name == 'pull_request' && github.ref || github.run_id }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+jobs:
+ gate:
+ # 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: a71c20e30af613287c504f7e685ca8fad77752bc
+ 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..d6b897cec 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:
@@ -26,4 +27,4 @@ jobs:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
- craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
+ craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml
index 18392587a..01cb9cd66 100644
--- a/.github/workflows/nix.yml
+++ b/.github/workflows/nix.yml
@@ -18,16 +18,6 @@ concurrency:
cancel-in-progress: true
jobs:
- eval:
- runs-on: ubuntu-latest
- timeout-minutes: 15
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- - name: nix flake check --no-build
- run: |
- if [ -f flake.nix ]; then
- nix flake check --no-build --all-systems
- else
- echo "No flake.nix present in this repository; skipping check."
- fi
+ nix:
+ # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
+ uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 57eb9bb53..f65960f27 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -11,20 +11,65 @@ on:
default: ""
permissions:
- contents: write
+ contents: read
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
+ git-sources:
+ name: Git dependencies come from storytold
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - name: Git dependencies come from storytold
+ run: |
+ set -euo pipefail
+ test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
+ # Source replacement, patches and path overrides in cargo config never show up
+ # in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
+ # Cargo still reads the legacy .cargo/config.
+ python3 - <<'PY'
+ import os, sys, tomllib
+ for path in (".cargo/config", ".cargo/config.toml"):
+ if not os.path.exists(path):
+ continue
+ config = tomllib.load(open(path, "rb"))
+ replaced = [name for name, source in config.get("source", {}).items()
+ if isinstance(source, dict) and "replace-with" in source]
+ if replaced or "patch" in config or "paths" in config:
+ print(f"{path} replaces, patches or overrides a source")
+ sys.exit(1)
+ PY
+ # Fails when Cargo.lock is out of date with the manifests.
+ cargo metadata --locked --format-version 1 > /dev/null
+ python3 - <<'PY'
+ import sys, tomllib
+ lock = tomllib.load(open("Cargo.lock", "rb"))
+ bad = sorted({p["source"] for p in lock.get("package", [])
+ if p.get("source", "").startswith("git+")
+ and not p["source"].startswith("git+https://github.com/storytold/")})
+ if bad:
+ print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
+ sys.exit(1)
+ PY
+
release:
+ needs: git-sources
+ permissions:
+ contents: write
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
app-name: designcraft
app-title: DesignCraft
- craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
+ craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
+ web-craft-fonts: true
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml
new file mode 100644
index 000000000..e78fe1ea3
--- /dev/null
+++ b/.github/workflows/sonarcloud.yml
@@ -0,0 +1,21 @@
+name: SonarCloud
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ types: [opened, synchronize, reopened]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: sonarcloud-${{ github.ref }}
+ # 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:
+ # Tracked at @main so org-wide CI improvements propagate without PR churn.
+ uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
+ secrets: inherit
diff --git a/.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/AGENTS.md b/AGENTS.md
index 65693656c..0be7fa87b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -26,6 +26,7 @@ People trust DesignCraft with their layouts; a crash loses their work. **This ou
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. Tools emit commands (Begin/Preview/Commit). UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The control channel and MCP reach all of them.
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd. The UI crate is swappable.
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
+- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math). A crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). One task id per commit (`M2.1: type tool caret navigation`).
diff --git a/ASSETS.md b/ASSETS.md
index 2731f47bf..407a26c92 100644
--- a/ASSETS.md
+++ b/ASSETS.md
@@ -40,12 +40,12 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| `docs/images/page-4.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI |
| `docs/images/page-cover.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI |
| `docs/images/ui-spread.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI |
-| `crates/render/tests/golden/drop-shadow-spread.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
-| `crates/render/tests/golden/drop-shadow-text.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
-| `crates/render/tests/golden/drop-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
-| `crates/render/tests/golden/feather.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
-| `crates/render/tests/golden/inner-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
-| `crates/render/tests/golden/outer-glow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
+| `crates/render/tests/golden/drop-shadow-spread.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
+| `crates/render/tests/golden/drop-shadow-text.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
+| `crates/render/tests/golden/drop-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
+| `crates/render/tests/golden/feather.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
+| `crates/render/tests/golden/inner-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
+| `crates/render/tests/golden/outer-glow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `assets/app-icon/LICENSE.txt` | (licence text) | this project | — | Licence for the app icon |
| `assets/app-icon/designcraft-1024.png` | DesignCraft project owner (drawn in ArtCraft) | rendered from `assets/app-icon/designcraft.svg` by `packaging/icons.sh` | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon: calico cat in a polka-dot scarf (see `assets/app-icon/README.md`) |
| `assets/app-icon/designcraft-macos-512.png` | DesignCraft project owner (drawn in ArtCraft) | rendered from `assets/app-icon/designcraft.svg` by `packaging/icons.sh` | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon: calico cat in a polka-dot scarf (see `assets/app-icon/README.md`) |
@@ -69,3 +69,28 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| `docs/brand/LICENSE-brand.txt` | (licence text) | ArtCraft Team | — | Terms for the ArtCraft Marks in `docs/brand/` |
| `packaging/macos/dmg/background.svg` | @XusBadia | original work for this repository, derived from the DesignCraft app icon (`assets/app-icon/designcraft.svg`, referenced via ``, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | DesignCraft macOS DMG window background for the Finder window; `packaging/macos/dmg/generate.py` renders it to `background.tiff` (2026-10-08) |
| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") |
+
+## Assets bundled by the PDF backend
+
+These assets were already enabled by Hayro’s default font and CMap features. The pinned
+backend keeps the same files; hashes and upstream licence notices are retained here.
+
+| Crate / file | Author | Source | Licence | SHA-256 | Use |
+| --- | --- | --- | --- | --- | --- |
+| `hayro-interpret 0.7.0 / assets/FoxitDingbats.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitDingbats.pfb | BSD-3-Clause | `845c752392b6c914fb989c75a08b7792b88f542d2499042ef2889f8c814a16ed` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
+| `hayro-interpret 0.7.0 / assets/FoxitFixed.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitFixed.pfb | BSD-3-Clause | `b6c8fe53f134b8b6d4578cd2d544df4cee9624c4efa8d51a560fb40ea296101b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
+| `hayro-interpret 0.7.0 / assets/FoxitFixedBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitFixedBold.pfb | BSD-3-Clause | `f1b7159702973f54fc86254ea38bcf3712b2a736eb3a0995751e9f4bb45ad603` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
+| `hayro-interpret 0.7.0 / assets/FoxitFixedBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitFixedBoldItalic.pfb | BSD-3-Clause | `8a000945843bd31add06aee63bf9fd41b7578b4f3242a4b7cd46349ad9d24d4d` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
+| `hayro-interpret 0.7.0 / assets/FoxitFixedItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitFixedItalic.pfb | BSD-3-Clause | `5007faf8320fa1fcc08b8894b356ad976f60b992ba29ee5272578bbdebaf3876` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
+| `hayro-interpret 0.7.0 / assets/FoxitSans.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSans.pfb | BSD-3-Clause | `a02339e95d20db14cbdfc1c6baf359cd36a091d5434925c3a4040f60e1a1af9e` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
+| `hayro-interpret 0.7.0 / assets/FoxitSansBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSansBold.pfb | BSD-3-Clause | `7bdfa36de1d5757dcfaf8b8a4be95c6e41c40fa70dbed9d78bf979a7b9959054` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
+| `hayro-interpret 0.7.0 / assets/FoxitSansBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSansBoldItalic.pfb | BSD-3-Clause | `555ce45924338e2e4b32821910221ce85da691fd59a9723c4c971a7c5627fc8b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
+| `hayro-interpret 0.7.0 / assets/FoxitSansItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSansItalic.pfb | BSD-3-Clause | `7751cbefeda351708dae8c4a88db043411bc39ab2ae2097685da845d4d16f307` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
+| `hayro-interpret 0.7.0 / assets/FoxitSerif.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSerif.pfb | BSD-3-Clause | `4f57d2b9d884af8f907bf22df6019b52d86cbf6214fdbd02b1ae05472a543f35` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
+| `hayro-interpret 0.7.0 / assets/FoxitSerifBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSerifBold.pfb | BSD-3-Clause | `0bdf4b04e964139818d51eda03d566ba999fc3ba2421b1c6c51f9dc969022e80` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
+| `hayro-interpret 0.7.0 / assets/FoxitSerifBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSerifBoldItalic.pfb | BSD-3-Clause | `a406cac82583bf98175cb62c87ed5e95c45fbb34eece63a10b0a13e793cb2e10` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
+| `hayro-interpret 0.7.0 / assets/FoxitSerifItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSerifItalic.pfb | BSD-3-Clause | `610ae0687198045c4db5d1a6650fd5e92536631706382eb8b72e38df578d0ae9` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
+| `hayro-interpret 0.7.0 / assets/FoxitSymbol.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSymbol.pfb | BSD-3-Clause | `47967d055530e7357088a08403115425643ec2cdfd6201ba8af0fbd7116c1539` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
+| `hayro-interpret 0.7.0 / assets/CGATS001Compat-v2-micro.icc` | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc | CC0-1.0 | `73e1ba37d2bad5bab2a964f40a9eed96209666efc067c3322626214bbef234a0` | Default CMYK colour conversion (pinned hayro-interpret) |
+| `hayro-cmap 0.1.0 / assets/cmaps.brotli` | Adobe | https://crates.io/crates/hayro-cmap/0.1.0 | BSD-3-Clause | `ddf37af521d904d5646c2fc37b2b7e912dc1b56f0d210190b61f598154758e46` | Character-code mapping tables required to read CJK PDFs. Non-visual data, allowed by the PDF compatibility exception |
+| `hayro-interpret 0.7.0 / src/font/generated/metrics.rs` | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/src/font/generated/metrics.rs | MIT OR Apache-2.0 | `2ec9e608573eda459de1e1d9bda3bb494315e36bee885acc88912a11fe0caa41` | Glyph widths and encodings for non-embedded standard fonts. Non-visual data, allowed by the PDF compatibility exception |
diff --git a/Cargo.lock b/Cargo.lock
index 92345bf86..c083c4236 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -683,6 +683,15 @@ dependencies = [
"libc",
]
+[[package]]
+name = "chrono"
+version = "0.4.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
+dependencies = [
+ "num-traits",
+]
+
[[package]]
name = "clipboard-win"
version = "5.4.1"
@@ -832,8 +841,12 @@ source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09
[[package]]
name = "craft-pdf"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8"
+source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
dependencies = [
+ "bytemuck",
+ "hayro",
+ "hayro-interpret",
+ "hayro-syntax",
"krilla",
"thiserror 2.0.21",
]
@@ -929,7 +942,7 @@ dependencies = [
[[package]]
name = "designcraft"
-version = "0.9.0"
+version = "0.12.0"
dependencies = [
"arboard",
"designcraft-engine",
@@ -951,7 +964,7 @@ dependencies = [
[[package]]
name = "designcraft-cli"
-version = "0.9.0"
+version = "0.12.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -967,7 +980,7 @@ dependencies = [
[[package]]
name = "designcraft-color"
-version = "0.9.0"
+version = "0.12.0"
dependencies = [
"craft-cms",
"kurbo",
@@ -979,7 +992,7 @@ dependencies = [
[[package]]
name = "designcraft-compose"
-version = "0.9.0"
+version = "0.12.0"
dependencies = [
"designcraft-color",
"designcraft-doc",
@@ -999,8 +1012,9 @@ dependencies = [
[[package]]
name = "designcraft-doc"
-version = "0.9.0"
+version = "0.12.0"
dependencies = [
+ "chrono",
"designcraft-color",
"designcraft-geom",
"proptest",
@@ -1011,7 +1025,7 @@ dependencies = [
[[package]]
name = "designcraft-engine"
-version = "0.9.0"
+version = "0.12.0"
dependencies = [
"caseless",
"craft-atomic",
@@ -1050,8 +1064,9 @@ dependencies = [
[[package]]
name = "designcraft-epub"
-version = "0.9.0"
+version = "0.12.0"
dependencies = [
+ "chrono",
"designcraft-color",
"designcraft-doc",
"zip",
@@ -1059,7 +1074,7 @@ dependencies = [
[[package]]
name = "designcraft-fonts"
-version = "0.9.0"
+version = "0.12.0"
dependencies = [
"core-text",
"harfrust 0.13.3",
@@ -1072,7 +1087,7 @@ dependencies = [
[[package]]
name = "designcraft-format"
-version = "0.9.0"
+version = "0.12.0"
dependencies = [
"designcraft-doc",
"designcraft-images",
@@ -1084,7 +1099,7 @@ dependencies = [
[[package]]
name = "designcraft-geom"
-version = "0.9.0"
+version = "0.12.0"
dependencies = [
"flo_curves",
"kurbo",
@@ -1097,7 +1112,7 @@ dependencies = [
[[package]]
name = "designcraft-idml"
-version = "0.9.0"
+version = "0.12.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1112,7 +1127,7 @@ dependencies = [
[[package]]
name = "designcraft-images"
-version = "0.9.0"
+version = "0.12.0"
dependencies = [
"craft-cms",
"designcraft-color",
@@ -1127,7 +1142,7 @@ dependencies = [
[[package]]
name = "designcraft-mcp"
-version = "0.9.0"
+version = "0.12.0"
dependencies = [
"designcraft-engine",
"designcraft-geom",
@@ -1140,8 +1155,9 @@ dependencies = [
[[package]]
name = "designcraft-pdf"
-version = "0.9.0"
+version = "0.12.0"
dependencies = [
+ "chrono",
"craft-pdf",
"designcraft-color",
"designcraft-compose",
@@ -1162,8 +1178,9 @@ dependencies = [
[[package]]
name = "designcraft-pptx"
-version = "0.9.0"
+version = "0.12.0"
dependencies = [
+ "chrono",
"designcraft-color",
"designcraft-compose",
"designcraft-doc",
@@ -1179,25 +1196,26 @@ dependencies = [
[[package]]
name = "designcraft-render"
-version = "0.9.0"
+version = "0.12.0"
dependencies = [
+ "craft-pdf",
"designcraft-color",
"designcraft-compose",
"designcraft-doc",
"designcraft-fonts",
"designcraft-geom",
"designcraft-images",
- "hayro",
"image",
"log",
"serde",
+ "thiserror 2.0.21",
"vello_common 0.2.0",
"vello_cpu 0.2.0",
]
[[package]]
name = "designcraft-textimport"
-version = "0.9.0"
+version = "0.12.0"
dependencies = [
"designcraft-doc",
"quick-xml",
@@ -1206,7 +1224,7 @@ dependencies = [
[[package]]
name = "designcraft-tools"
-version = "0.9.0"
+version = "0.12.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1218,7 +1236,7 @@ dependencies = [
[[package]]
name = "designcraft-ui-egui"
-version = "0.9.0"
+version = "0.12.0"
dependencies = [
"craft-ui",
"designcraft-color",
@@ -1245,7 +1263,7 @@ dependencies = [
[[package]]
name = "designcraft-web"
-version = "0.9.0"
+version = "0.12.0"
dependencies = [
"designcraft-engine",
"designcraft-format",
@@ -2105,7 +2123,7 @@ dependencies = [
[[package]]
name = "hayro"
version = "0.7.1"
-source = "git+https://github.com/storytold/pdfcraft?rev=b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9#b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9"
+source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
dependencies = [
"bytemuck",
"hayro-interpret",
@@ -2125,8 +2143,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
[[package]]
name = "hayro-cmap"
version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "24d285dc30731c8485de5fa732fbdf2b3affdf01e4da7c2135022ca6fa664bf6"
+source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
dependencies = [
"brotli",
"hayro-postscript",
@@ -2135,7 +2152,7 @@ dependencies = [
[[package]]
name = "hayro-interpret"
version = "0.7.0"
-source = "git+https://github.com/storytold/pdfcraft?rev=b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9#b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9"
+source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
dependencies = [
"bitflags 2.13.2",
"hayro-cmap",
@@ -2153,7 +2170,7 @@ dependencies = [
[[package]]
name = "hayro-jbig2"
version = "0.3.0"
-source = "git+https://github.com/storytold/pdfcraft?rev=b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9#b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9"
+source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
dependencies = [
"fearless_simd",
"hayro-ccitt",
@@ -2177,7 +2194,7 @@ checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa"
[[package]]
name = "hayro-syntax"
version = "0.7.2"
-source = "git+https://github.com/storytold/pdfcraft?rev=b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9#b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9"
+source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
dependencies = [
"flate2",
"hayro-ccitt",
@@ -2541,7 +2558,7 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.8.2"
-source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8"
+source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
dependencies = [
"base64",
"bumpalo",
@@ -5658,7 +5675,7 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winit"
version = "0.30.13"
-source = "git+https://github.com/skylartaylor/winit.git?rev=3d51077fd1a966f84df9ef287839dc84fd5b4ca7#3d51077fd1a966f84df9ef287839dc84fd5b4ca7"
+source = "git+https://github.com/storytold/winit.git?rev=3d51077fd1a966f84df9ef287839dc84fd5b4ca7#3d51077fd1a966f84df9ef287839dc84fd5b4ca7"
dependencies = [
"ahash",
"android-activity",
@@ -5833,7 +5850,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
-version = "0.9.0"
+version = "0.12.0"
dependencies = [
"serde_json",
]
diff --git a/Cargo.toml b/Cargo.toml
index ca5d0baaf..0245708f0 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,15 +4,15 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"]
[workspace.package]
-version = "0.9.0"
+version = "0.12.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
repository = "https://github.com/storytold/designcraft"
[workspace.dependencies]
-# Shared writer; the immutable revision must be published before this consumer.
-craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.1.0", default-features = false }
+# Shared reader and writer; publish the immutable revision before this consumer.
+craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.1.0", default-features = false }
# internal
designcraft-geom = { path = "crates/geom" }
designcraft-color = { path = "crates/color" }
@@ -39,6 +39,8 @@ thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
+# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
+chrono = { version = "0.4.45", default-features = false, features = ["std"] }
proptest = "1"
rayon = "1"
unicode-bidi = "0.3"
@@ -66,7 +68,6 @@ krilla-svg = "=0.8.1"
flo_curves = "0.8"
# QR codes (Object › Generate QR Code); MIT.
qrcodegen = "1.8"
-hayro = "0.7"
quick-xml = "0.41"
zip = { version = "3", default-features = false, features = ["deflate"] }
# INDD/INDT → IDML conversion for File › Open: storytold/indd-utils, tracking its main branch
@@ -102,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
@@ -116,10 +122,10 @@ debug = 0
# Opt-in cancelable macOS termination on the pinned winit 0.30 line.
[patch.crates-io]
-winit = { git = "https://github.com/skylartaylor/winit.git", rev = "3d51077fd1a966f84df9ef287839dc84fd5b4ca7" }
-krilla = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.8.2" }
-# The hayro crates from pdfcraft's vendor/ directory, with the patches its README lists.
-hayro = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" }
-hayro-interpret = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" }
-hayro-syntax = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" }
-hayro-jbig2 = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" }
+krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.8.2" }
+winit = { git = "https://github.com/storytold/winit.git", rev = "3d51077fd1a966f84df9ef287839dc84fd5b4ca7" }
+hayro = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.7.1" }
+hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.7.0" }
+hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.7.2" }
+hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.3.0" }
+hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.1.0" }
diff --git a/NOTICE b/NOTICE
index 0a875190e..327c6e0cc 100644
--- a/NOTICE
+++ b/NOTICE
@@ -81,16 +81,40 @@ list (author, source, license) is in ASSETS.md.
packages as OFL-.txt. Builds without CRAFT_FONTS_DIR do not include them.
- All UI icons are drawn in code (crates/ui-egui/src/icons.rs) and are original work, MIT OR Apache-2.0.
- hayro PDF crates (placed PDFs): hayro, hayro-interpret, hayro-syntax and hayro-jbig2
- (Apache-2.0 OR MIT) are pdfcraft's patched copies, fetched by git from its vendor/ directory at
- the commit that Cargo.toml pins (https://github.com/storytold/pdfcraft, MIT OR Apache-2.0).
+ (Apache-2.0 OR MIT) are fetched from the pinned craft-libs source documented below.
hayro-interpret embeds PDFium's Foxit standard-14 fonts under PDFium's BSD-style license
(vendor/hayro-interpret/assets/LICENSE_FOXIT) and a CMYK profile under CC0-1.0.
Rust dependencies (vello_cpu, kurbo, skrifa, harfrust, image, egui, eframe, …) are permissively
-licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io.
+licensed (MIT/Apache-2.0/BSD/ISC/Zlib), fetched through crates.io or the pinned source below.
No Adobe icons, images or artwork are included.
+PDF backend source
+
+The patched hayro 0.7.1, hayro-interpret 0.7.0, hayro-syntax 0.7.2, hayro-jbig2 0.3.0 and
+hayro-cmap 0.1.0 are fetched from https://github.com/storytold/craft-libs at revision
+9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef. The first four source snapshots were transferred
+from https://github.com/storytold/pdfcraft at revision
+7437bde378f43856eada40af25b983812e615e8a and hayro-cmap at revision
+a2ec27b4775f53c40cad3456936d9406611e3a8d, then patched in craft-libs. Their upstream project is
+https://github.com/LaurenzV/hayro, licensed MIT OR Apache-2.0. The snapshot retains each
+crate's upstream licence files. The patch ledger is at
+https://github.com/storytold/craft-libs/blob/9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef/vendor/README.craft-reader.md.
+Per-crate vendor/SOURCE.json files record transferred and local source hashes; asset
+origins and unchanged per-file hashes are recorded separately in ASSETS.md.
+
+Assets already bundled through Hayro's default features retain their licences:
+- Foxit standard-14 substitutes: Copyright 2014 PDFium Authors. All rights reserved.
+ Original code copyright 2014 Foxit Software Inc. BSD-3-Clause; see
+ licenses/pdf-backend/hayro-interpret/assets/LICENSE_FOXIT.
+- CGATS001Compat-v2-micro.icc: CC0-1.0, Clinton Ingram; see
+ licenses/pdf-backend/hayro-interpret/assets/CGATS_LICENSE.txt.
+- Predefined CMaps: Copyright 1990-2023 Adobe. All rights reserved. BSD-3-Clause,
+ non-visual character-mapping data; see licenses/pdf-backend/hayro-cmap/LICENSE.txt.
+ Adobe and contributor names do not endorse or promote this product.
+Per-file hashes and sources are recorded in ASSETS.md.
+
Linux Wayland window decorations
-------------------------------
sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
diff --git a/README.md b/README.md
index 880433e33..cd0f56479 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,10 @@
+
+
+
+
DesignCraft
@@ -25,6 +29,9 @@
+
+
+
diff --git a/ROADMAP.md b/ROADMAP.md
index 0128924c0..153555913 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -11,6 +11,14 @@
DesignCraft aims at full Adobe InDesign parity — and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web.
+- **2026-10-23** · PDF layer choices now validate before editing all targets; rejected rewrites
+ leave document/undo state intact. Null trailer entries remain unencrypted, and unsupported
+ all-content crops retain the whole page rather than silently clipping hidden layers.
+- **2026-10-23** · PDF layer visibility variants retain catalog/configuration entries and full
+ object references; bounded incremental updates replace the existing catalog. Synthetic
+ regression tests cover metadata, output-intent references, nonzero generations and
+ forward Prev links; repeated cross-reference offsets are rejected explicitly. No readiness percentage change.
+
## Status (2026-10-01)
**Working today**
@@ -33,7 +41,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Index: page references (topic from the selection or up to 4 levels, sort keys; current page, to end of story, next n paragraphs, suppressed, See / See also), Generate / Update Index (section headings, nested or run-in, page ranges merged, Index Title / Section Head / Level 1–4 styles); IDML export/import as InDesign topics, page references and topic cross-references (verified in InDesign 2026); `index.*` commands.
- Text clipboard keeps formatting (character/paragraph formats, footnotes, cross-references, index markers, tables) and falls back to plain text when the system clipboard changed; Paste without Formatting (⇧⌘V); Change Case (UPPERCASE, lowercase, Title Case, Sentence case — formatting kept); Type › Insert Special Character / White Space / Break Character submenus.
- Anchored objects: items flow in the text inline (on the baseline, with Y offset; lines grow to fit) or above the line (left/center/right, space before/after); paste copied items into text to anchor them; Anchored Object Options and Release (back onto the page where shown); rendered, exported to PDF and to/from IDML (InDesign anchored object settings; verified in InDesign 2026); copy/paste and threading carry them; `anchored.*` commands.
-- Ruler guides: drag out of the rulers (page guides, or spread guides on the pasteboard), drag to move, drop on a ruler to delete; Layout › Create Guides (rows/columns with gutters, fit to margins or page); Delete All Guides on Spread; `guide.*` commands.
+- Ruler guides: drag out of the rulers (page guides, or spread guides on the pasteboard), drag to move, drop on a ruler to delete; a dragged guide snaps along its axis to other guides, margins, columns, bleed, page and object edges and centers and the document grid (Shift: the ruler's ticks; Cmd suspends snapping); selected like objects (click, Shift-click, a marquee that touches no object, Edit › Select All Guides ⌥⌘G), moved together, nudged, deleted, and placed exactly with the X or Y field; Layout › Create Guides (rows/columns with gutters, fit to margins or page); Delete All Guides on Spread; `guide.*` commands.
- Links panel: every placed graphic with status (OK / Modified / Missing / Embedded, checked against the file on disk), page and effective PPI (low resolution flagged); Relink…, Go To, Update, Embed; `links.*` commands.
- Missing fonts: highlighted pink on screen (never in output), listed by Preflight; Type › Find/Replace Font (fonts in the document, missing first; replace in text, cells, footnotes and styles); `font.list` / `font.replace`.
- Missing glyphs, as in InDesign: a character the applied font lacks is drawn as that font's missing-glyph box (its .notdef; a drawn box when the font's is empty), on screen and in PDF (as a path, so PDF/A and PDF/UA exports take it), and Preflight lists the characters by font. Preferences › Composition › Draw Missing Glyphs from Fallback Fonts (`document.preferences {glyphFallback}`, stored with the document) draws them from other fonts instead; it is off in new documents and IDML imports, on in documents saved before it existed. A missing font's substitute always uses fallback fonts.
@@ -42,7 +50,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 +309,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,6 +358,28 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
+- **2026-10-24** · Custom-positioned anchored objects honour Relative to Spine (`anchored.options {spineRelative}`, IDML `SpineRelative`): the settings describe a left-hand page and mirror on a right-hand page, so marginal notes stay in the outside margin on both pages of a spread. On right-hand pages they took the left-hand placement and landed in the inside margin, across the spine or over the body text. No readiness percentage change.
+- **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.
+- **2026-10-22** · Paragraph rules: Rule Above's offset is measured from the first line's baseline to the rule's bottom edge, as in InDesign. Rules sat one ascent too high and crossed the previous paragraph's text (reported from an imported INDD document). No readiness percentage change.
+- **2026-10-21** · Ruler guides are selected like objects (#1282): a click with the Selection tools selects a guide (also where it crosses an object), Shift-click adds or removes one, a new guide from the rulers is selected, a marquee that touches no object selects the guides it crosses, and Edit › Select All Guides (⌥⌘G, `guide.selectAll`) selects every unlocked guide on the spread. Selected guides draw thicker in their layer's color, drag together (all of them shown moving), nudge with the arrow keys and go with Delete, each as one undo step (`guide.select`, `guide.deselect`, `guide.moveBy`, `guide.delete {}`). With one guide selected the Control bar's X (vertical guide) or Y (horizontal guide) field and Properties › Transform show where it is, as the rulers measure, and typing a value moves it there. No readiness percentage change.
+- **2026-10-17** · PDF/X-4 export warns about fonts drawing visible text without an embedded
+ font program, including placed PDF forms; `validate-pdf` reports the font and page (#909).
+ Invisible OCR text is exempt. Detection only: fonts are not embedded or outlined automatically.
+ Repeated forms reuse context-aware font findings; a document-wide operation limit and a form-nesting depth limit
+ report incomplete checks instead of allowing excessive nested-form traversal.
+ No readiness percentage change.
+- **2026-10-16** · Selection: drag a column guide to move its gutter, as in InDesign (#1096). Both guides of the gutter move together and keep its width, stop at the neighbouring guides and the margins, and snap to the document grid, guides and objects, or with Shift to the ruler's tick marks; Escape cancels and the drag is one undo step. Locked column guides (the default) are not picked. A document page takes the result as its own columns; on a parent page it passes to the pages that follow it. `layout.moveColumnGuide` does the same over the control channel and MCP. No readiness percentage change.
+- **2026-10-16** · Layout: autoflow, inserted pages and new parents follow dragged column guides (#1096). Autoflow sets one frame per dragged column and counts the pages it adds from them; Insert Pages and New Parent keep the custom positions with the column count and gutter; a page that moves to the other side of the spine (inserted, deleted or moved pages, Duplicate Spread, a new parent's other page) has them mirrored across its margin box, as InDesign does; `document.inspect` reports each page's gutter, column rects and whether its columns are custom. No readiness percentage change.
+- **2026-10-16** · Layout: Margins and Columns keeps dragged column guides unless the column count or gutter changes (#1096). The dialog sends every field on OK, so a margin-only change re-divided the columns evenly. Margin changes keep the positions; the columns follow them only while they fit the margin box. No readiness percentage change.
+- **2026-10-16** · Lock Guides and Lock Column Guides are commands in View ▸ Grids & Guides (`view.lockGuides`, ⌥⌘;, and `view.lockColumnGuides`), so the control channel and MCP reach them. Lock Column Guides is an undo step, is saved with the document, on in new ones, and read from and written to IDML's `ColumnGuideLocked`, which export always wrote as locked (#1096). No readiness percentage change.
+- **2026-10-16** · IDML: a page keeps column guides that were dragged off centre (InDesign's Custom columns). They were divided evenly on import, so the gutter sprang back to the middle; the imported positions now place the columns, with the canvas guides, snapping and text placement following them, and they are written back on export. Layout › Margins and Columns re-divides them evenly when the count or gutter changes, as InDesign's does. No readiness percentage change.
+- **2026-10-16** · IDML: custom column positions are checked before they are used (#1096). A set must give two edges per column, ascending from the left margin to the right one; any other (a wrong count, unsorted or non-numeric values, a list longer than 216 columns allow, or positions left behind by a margin or page-size change) is the even split, in the canvas, on import and on export, instead of columns drawn outside the margins. No readiness percentage change.
- **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change.
- **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change.
- **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change.
@@ -355,6 +387,14 @@ Newest first.
- **2026-10-17** · Object strokes reject malformed types and unknown type fields before changing the document; command help lists every stroke kind and its required fields. No readiness percentage change.
- **2026-10-17** · Docs: documented a persistent source CLI install with Cargo, MCP-client PATH setup and bare-name server registration in `docs/mcp.md`. No readiness percentage change.
+- **2026-10-16** · Selection: drag a column guide to move its gutter, as in InDesign (#1096). Both guides of the gutter move together and keep its width, stop at the neighbouring guides and the margins, and snap to the document grid, guides and objects, or with Shift to the ruler's tick marks; Escape cancels and the drag is one undo step. Locked column guides (the default) are not picked. A document page takes the result as its own columns; on a parent page it passes to the pages that follow it. `layout.moveColumnGuide` does the same over the control channel and MCP. No readiness percentage change.
+- **2026-10-16** · Layout: Margins and Columns keeps dragged column guides unless the column count or gutter changes (#1096). The dialog sends every field on OK, so a margin-only change re-divided the columns evenly. Margin changes keep the positions; the columns follow them only while they fit the margin box. No readiness percentage change.
+- **2026-10-16** · Layout: autoflow, inserted pages and new parents follow dragged column guides (#1096). Autoflow sets one frame per dragged column and counts the pages it adds from them; Insert Pages and New Parent keep the custom positions with the column count and gutter; `document.inspect` reports each page's gutter, column rects and whether its columns are custom. No readiness percentage change.
+- **2026-10-16** · Layout: autoflow, inserted pages and new parents follow dragged column guides (#1096). Autoflow sets one frame per dragged column and counts the pages it adds from them; Insert Pages and New Parent keep the custom positions with the column count and gutter; a page that moves to the other side of the spine (inserted, deleted or moved pages, Duplicate Spread, a new parent's other page) has them mirrored across its margin box, as InDesign does; `document.inspect` reports each page's gutter, column rects and whether its columns are custom. No readiness percentage change.
+- **2026-10-16** · IDML: custom column positions are checked before they are used (#1096). A set must give two edges per column, ascending from the left margin to the right one; any other (a wrong count, unsorted or non-numeric values, a list longer than 216 columns allow, or positions left behind by a margin or page-size change) is the even split, in the canvas, on import and on export, instead of columns drawn outside the margins. No readiness percentage change.
+- **2026-10-16** · IDML: a page keeps column guides that were dragged off centre (InDesign's Custom columns). They were divided evenly on import, so the gutter sprang back to the middle; the imported positions now place the columns, with the canvas guides, snapping and text placement following them, and they are written back on export. Layout › Margins and Columns re-divides them evenly when the count or gutter changes, as InDesign's does. No readiness percentage change.
+- **2026-10-16** · Lock Guides and Lock Column Guides are commands in View ▸ Grids & Guides (`view.lockGuides`, ⌥⌘;, and `view.lockColumnGuides`), so the control channel and MCP reach them. Lock Column Guides is saved with the document, on in new ones, and read from and written to IDML's `ColumnGuideLocked`, which export always wrote as locked (#1096). No readiness percentage change.
+- **2026-10-16** · Lock Guides and Lock Column Guides are commands in View ▸ Grids & Guides (`view.lockGuides`, ⌥⌘;, and `view.lockColumnGuides`), so the control channel and MCP reach them. Lock Column Guides is an undo step, is saved with the document, on in new ones, and read from and written to IDML's `ColumnGuideLocked`, which export always wrote as locked (#1096). No readiness percentage change.
- **2026-10-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change.
- **2026-10-16** · Greek text hyphenates. The patterns were never compiled in, so Greek set unhyphenated however narrow the column; `"Greek"`, `"Greek: Greece"`, `el` and `el-GR` all resolve now and ανθρωπος breaks αν-θρω-πος. They are the monotonic patterns (hyph-el-monoton, Dimitrios Filippou, under MIT), which is what polytonic text hyphenates by too. Five of InDesign's ~40 hyphenation languages. No readiness percentage change.
- **2026-10-16** · A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign: IDML's `` is read and written, greyscale pixels become a tint of the swatch on opaque white and a bitmap's white lets the frame show through, on screen and in PDF (spot swatches print on their plate). Colour images keep their colours. Applying the swatch from the Swatches panel comes next. No readiness percentage change.
@@ -638,3 +678,5 @@ Newest first.
The shared craft-installer UI preserves this revision's install options and provides persistent installation, repair and Windows Settings removal completion screens. Silent modes stay unattended. Earlier Windows acceptance is recorded in packaging/windows/README.md; normal CI and production-profile upgrade qualification against pinned current releases are in progress. No release or PR has been published.
- **2026-10-15** · Windows recovery folders validate the identity of the live locked file without reading it through a second handle. Missing or replaced locks disable writes and cleanup; regression tests cover retained exclusive locking and replacement-file protection. No readiness percentage change.
+
+- PDF reader infrastructure: pinned shared parsing/rasterization, transformed page-box geometry and explicit failures for known incomplete layered-PDF exports. Application ownership and parity targets are unchanged.
diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs
index 71e05a480..3d18dcaee 100644
--- a/apps/designcraft-cli/src/main.rs
+++ b/apps/designcraft-cli/src/main.rs
@@ -39,7 +39,7 @@ use designcraft_engine::Session;
use serde_json::{Value, json};
/// The usage block. Shared by `--help` (stdout, success) and an unknown command (stderr, failure).
-const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR]\n the two roots confine every file the agent's commands read (open, placed images, data sources, libraries) and\n write (save, export, package) to those folders, links followed. A root left out grants none of its access.\n They run the in-process server, so they conflict with --connect (a running app is not confined by them).\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli links\n designcraft-cli --version";
+const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR]\n the two roots confine every file the agent's commands read (open, placed images, data sources, libraries) and\n write (save, export, package) to those folders, links followed. A root left out grants none of its access.\n They run the in-process server, so they conflict with --connect (a running app is not confined by them).\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli validate-pdf FILE.pdf # built-in PDF/X-4 checks (including visible unembedded fonts)\n designcraft-cli links\n designcraft-cli --version";
/// `script --help`: the script syntax, otherwise only in `describe script.run` and docs/agents.md.
const SCRIPT_HELP: &str = r#"usage: designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]
@@ -124,7 +124,7 @@ fn main() -> ExitCode {
Some("bench") => report(perf::bench(&args[1..])),
Some("validate-pdf") => report(validate_pdf(args.get(1).map(String::as_str))),
Some("links") => {
- use designcraft_engine::links::*;
+ use designcraft_engine::links::{APP_PAGE, DISCORD, GITHUB, ISSUES, WEBSITE};
outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}\nIssues {ISSUES}");
ExitCode::SUCCESS
}
@@ -345,33 +345,7 @@ fn run(args: &[String]) -> Result<(), String> {
}
"--export" => {
let out = val()?;
- match Export::of(&out)? {
- Export::Image => export(&mut s, &out, page, scale)?,
- Export::Pdf => {
- let mut p = pdf_opts.clone();
- p["path"] = json!(out);
- let r = s.execute("file.exportPdf", &p).map_err(|e| e.to_string())?;
- eprintln!("wrote {out} ({} pages, {} bytes)", r["pages"], r["bytes"]);
- for w in r["warnings"].as_array().into_iter().flatten() {
- eprintln!("warning: {}", w.as_str().unwrap_or_default());
- }
- }
- Export::Document => {
- s.execute("file.saveAs", &json!({"path": out})).map_err(|e| e.to_string())?;
- eprintln!("saved {out}");
- }
- Export::Pptx => {
- let r = s.execute("file.exportPptx", &json!({"path": out})).map_err(|e| e.to_string())?;
- eprintln!("wrote {out} ({} slides, {} bytes)", r["slides"], r["bytes"]);
- for w in r["warnings"].as_array().into_iter().flatten() {
- eprintln!("warning: {}", w.as_str().unwrap_or_default());
- }
- }
- Export::Command(cmd) => {
- let r = s.execute(cmd, &json!({"path": out})).map_err(|e| e.to_string())?;
- eprintln!("wrote {out} ({} bytes)", r["bytes"]);
- }
- }
+ handle_export(&mut s, &out, page, scale, &pdf_opts)?;
}
"--all-pages" => {
let dir = val()?;
@@ -387,6 +361,37 @@ fn run(args: &[String]) -> Result<(), String> {
Ok(())
}
+fn handle_export(s: &mut Session, out: &str, page: usize, scale: f64, pdf_opts: &Value) -> Result<(), String> {
+ match Export::of(out)? {
+ Export::Image => export(s, out, page, scale)?,
+ Export::Pdf => {
+ let mut p = pdf_opts.clone();
+ p["path"] = json!(out);
+ let r = s.execute("file.exportPdf", &p).map_err(|e| e.to_string())?;
+ eprintln!("wrote {out} ({} pages, {} bytes)", r["pages"], r["bytes"]);
+ for w in r["warnings"].as_array().into_iter().flatten() {
+ eprintln!("warning: {}", w.as_str().unwrap_or_default());
+ }
+ }
+ Export::Document => {
+ s.execute("file.saveAs", &json!({"path": out})).map_err(|e| e.to_string())?;
+ eprintln!("saved {out}");
+ }
+ Export::Pptx => {
+ let r = s.execute("file.exportPptx", &json!({"path": out})).map_err(|e| e.to_string())?;
+ eprintln!("wrote {out} ({} slides, {} bytes)", r["slides"], r["bytes"]);
+ for w in r["warnings"].as_array().into_iter().flatten() {
+ eprintln!("warning: {}", w.as_str().unwrap_or_default());
+ }
+ }
+ Export::Command(cmd) => {
+ let r = s.execute(cmd, &json!({"path": out})).map_err(|e| e.to_string())?;
+ eprintln!("wrote {out} ({} bytes)", r["bytes"]);
+ }
+ }
+ Ok(())
+}
+
/// A page as PNG, or as JPEG for `.jpg` and `.jpeg`.
fn export(s: &mut Session, out: &str, page: usize, scale: f64) -> Result<(), String> {
let st = s.doc().map_err(|e| e.to_string())?;
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-web/src/web.rs b/apps/designcraft-web/src/web.rs
index fe0f4a7fa..27d236a2f 100644
--- a/apps/designcraft-web/src/web.rs
+++ b/apps/designcraft-web/src/web.rs
@@ -395,6 +395,8 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
let ctx = ctx.clone();
let dialog = if request.purpose == "swatches" {
rfd::AsyncFileDialog::new().add_filter("Swatch Exchange (ASE)", &["ase"])
+ } else if request.purpose == "shortcuts" {
+ rfd::AsyncFileDialog::new().add_filter("Shortcut set (text)", &["txt"])
} else if request.purpose == "place" {
rfd::AsyncFileDialog::new().add_filter("Graphics", IMAGE_EXTS)
} else {
diff --git a/apps/designcraft/src/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/logging.rs b/apps/designcraft/src/logging.rs
index d3e0dbea7..04238e853 100644
--- a/apps/designcraft/src/logging.rs
+++ b/apps/designcraft/src/logging.rs
@@ -90,27 +90,16 @@ fn matches(name: &str, target: &str) -> bool {
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
+/// A clock far past chrono's year range still converts (the calendar repeats every 400 years), so
+/// logging never panics and never silently resets to the epoch for a convertible instant.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
- let secs = since.as_secs();
- let (days, rem) = (secs / 86_400, secs % 86_400);
- let (y, m, d) = civil_from_days(days);
- format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
-}
-
-/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
-/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch).
-fn civil_from_days(days: u64) -> (u64, u64, u64) {
- let z = days + 719_468;
- let era = z / 146_097;
- let doe = z % 146_097;
- let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
- let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
- let mp = (5 * doy + 2) / 153;
- let d = doy - (153 * mp + 2) / 5 + 1;
- let m = if mp < 10 { mp + 3 } else { mp - 9 };
- let y = yoe + era * 400 + u64::from(m <= 2);
- (y, m, d)
+ // Pre-1970 is clamped to the epoch by `unwrap_or_default` above. `as_secs` is `u64`; a value
+ // past `i64::MAX` seconds saturates rather than falling back to the epoch.
+ let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX);
+ let (y, t) = designcraft_engine::doc::time::utc_parts(secs, since.subsec_nanos());
+ // `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
+ format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ"))
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
@@ -317,6 +306,20 @@ mod tests {
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_704_067_199_999)), "2023-12-31T23:59:59.999Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
+ // Past chrono's year range still converts via the 400-year cycle (the old code did too,
+ // through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows'
+ // SystemTime can't hold this instant, so it is only checked where it exists.
+ // `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output).
+ if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
+ assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
+ }
+ // Years past 9999 keep the plain zero-padded year (no `+`).
+ assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
+ // 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857.
+ // Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
+ if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
+ assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
+ }
}
#[test]
diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs
index b56878176..e694fe84f 100644
--- a/apps/designcraft/src/main.rs
+++ b/apps/designcraft/src/main.rs
@@ -187,6 +187,7 @@ fn open_filters(purpose: &str) -> &'static [OpenFilter] {
match purpose {
"swatches" => &[OpenFilter { name: "Swatch Exchange (ASE)", extensions: &["ase"] }],
"script" => &[OpenFilter { name: "Script", extensions: &["dcscript", "txt", "json"] }],
+ "shortcuts" => &[OpenFilter { name: "Shortcut set (text)", extensions: &["txt"] }],
"icc" => &[OpenFilter { name: "ICC profile", extensions: &["icc", "icm"] }],
"book" => &[OpenFilter { name: "Book", extensions: &["dcbook"] }],
"xml" => &[OpenFilter { name: "XML", extensions: &["xml"] }],
@@ -276,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]);
}
@@ -605,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/apps/designcraft/src/native_menu.rs b/apps/designcraft/src/native_menu.rs
index 86a88e5c0..1ac9437d1 100644
--- a/apps/designcraft/src/native_menu.rs
+++ b/apps/designcraft/src/native_menu.rs
@@ -27,6 +27,9 @@ pub struct NativeMenu {
shown_for: (String, bool),
/// File › Open Recent, rebuilt when the list of recent files changes.
recent: Option,
+ /// The commands with key equivalents and the shortcut each was given: the menu is rebuilt
+ /// when one changes, so a shortcut moved to another command leaves its old item.
+ keys: Vec<(String, Option)>,
}
struct RecentMenu {
@@ -50,21 +53,28 @@ fn menu_title(app: &DesignApp, label: &str) -> String {
menus::native_label(designcraft_ui_egui::i18n::tr(&app.ui.language, label))
}
-/// A command's default shortcut, as the in-window menus show it.
-fn shortcut(id: &str) -> Option<&'static str> {
- menus::ui_label(id).and_then(|(_, sc)| sc).or_else(|| designcraft_engine::find_command(id).and_then(|c| c.shortcut))
+/// A command's shortcut in effect (the user's, else the default), as the in-window menus show it.
+fn shortcut(app: &DesignApp, id: &str) -> Option {
+ menus::shortcut_of(app, id)
}
impl NativeMenu {
pub fn install(app: &mut DesignApp, ctx: &egui::Context) -> Self {
// A click in the menu bar reaches the app with no input over the window, so nothing would
// draw the frame that runs the command until the pointer or a key comes back: wake egui.
+ // muda keeps the first handler for the life of the process, so this channel serves every
+ // menu bar built later (`build_bar`).
let (tx, events) = channel();
let repaint = ctx.clone();
MenuEvent::set_event_handler(Some(move |e: MenuEvent| {
let _ = tx.send(e);
repaint.request_repaint();
}));
+ Self::build_bar(app, events)
+ }
+
+ /// The menu bar for the current language and settings, fed by `events`.
+ fn build_bar(app: &mut DesignApp, events: Receiver) -> Self {
let menu = Menu::new();
let mut items = HashMap::new();
let mut counter = 0usize;
@@ -75,12 +85,24 @@ impl NativeMenu {
for entry in menus::MAC_APP_MENU {
let _ = match *entry {
AppMenuItem::Cmd { id, label, command } => {
- let sc = shortcut(command).and_then(accel);
+ let sc = shortcut(app, command).as_deref().and_then(accel);
let item = MenuItem::with_id(id, menu_title(app, label), true, sc);
let appended = app_menu.append(&item);
items.insert(id.into(), (command.into(), Value::Null, Handle::Plain(item)));
appended
}
+ AppMenuItem::Language => {
+ let sub = Submenu::new(menu_title(app, "Language"), true);
+ for (code, name) in designcraft_ui_egui::i18n::LANGUAGES {
+ let id = format!("dc-language-{}", if code.is_empty() { "en" } else { code });
+ let params = serde_json::json!({ "lang": code });
+ let checked = menus::checked(app, "app.language", ¶ms).unwrap_or(false);
+ let item = CheckMenuItem::with_id(id.clone(), menus::native_label(name), true, checked, None);
+ let _ = sub.append(&item);
+ items.insert(id, ("app.language".into(), params, Handle::Check(item)));
+ }
+ app_menu.append(&sub)
+ }
AppMenuItem::Sep => app_menu.append(&PredefinedMenuItem::separator()),
AppMenuItem::Services => app_menu.append(&PredefinedMenuItem::services(None)),
AppMenuItem::Hide => app_menu.append(&PredefinedMenuItem::hide(None)),
@@ -96,15 +118,19 @@ impl NativeMenu {
}
menu.init_for_nsapp();
app.native_menu = true;
- app.native_shortcuts = items
- .values()
- .filter_map(|(id, p, _)| {
- let sc = shortcut(id)?;
- (p.is_null() && accel(sc).is_some()).then(|| id.clone())
- })
- .collect();
+ let keys: Vec<(String, Option)> =
+ items.values().filter(|(_, p, _)| p.is_null()).map(|(id, _, _)| (id.clone(), shortcut(app, id))).collect();
+ app.native_shortcuts = keys.iter().filter(|(_, sc)| sc.as_deref().and_then(accel).is_some()).map(|(id, _)| id.clone()).collect();
let recent = recent.map(|submenu| RecentMenu { submenu, shown: Vec::new(), ids: Vec::new(), separator: None });
- Self { _menu: menu, items, events, last_refresh: 0.0, shown_for: (app.ui.language.clone(), designcraft_ui_egui::cjk_features(app)), recent }
+ Self {
+ _menu: menu,
+ items,
+ events,
+ last_refresh: 0.0,
+ shown_for: (app.ui.language.clone(), designcraft_ui_egui::cjk_features(app)),
+ recent,
+ keys,
+ }
}
/// Put the current recent files at the top of File › Open Recent when they changed.
@@ -153,13 +179,21 @@ impl NativeMenu {
// back by Cancel): the system menu bar follows at once, like the panels (the new menu
// replaces the old one as the application's menu before that one is dropped).
if app.ui.language != self.shown_for.0 || designcraft_ui_egui::cjk_features(app) != self.shown_for.1 {
- *self = Self::install(app, ctx);
+ let events = std::mem::replace(&mut self.events, channel().1);
+ *self = Self::build_bar(app, events);
}
let now = designcraft_ui_egui::now_ms();
if now - self.last_refresh < 250.0 {
return;
}
self.last_refresh = now;
+ // A shortcut set in the Keyboard Shortcuts dialog: macOS gives key equivalents to the
+ // menu bar before the window sees the key, so the bar must carry the new keys.
+ if self.keys.iter().any(|(id, sc)| shortcut(app, id) != *sc) {
+ let events = std::mem::replace(&mut self.events, channel().1);
+ *self = Self::build_bar(app, events);
+ return;
+ }
self.refresh_recent(app);
for (cmd, params, h) in self.items.values() {
let en = menus::native_menu_enabled(app, ctx, cmd);
@@ -199,10 +233,10 @@ fn build(
}
// Hidden CJK commands; `NativeMenu::poll` rebuilds the bar when Show CJK Features changes.
Item::Cmd { id, .. } if !menus::shown(app, id) => {}
- Item::Cmd { label, id, params, shortcut } => {
+ Item::Cmd { label, id, params, .. } => {
*counter += 1;
let mid = format!("dc{counter}");
- let sc = if params.is_null() { shortcut.and_then(accel) } else { None };
+ let sc = if params.is_null() { shortcut(app, id).as_deref().and_then(accel) } else { None };
let label = menu_title(app, label);
let handle = if menus::checked(app, id, params).is_some() {
let c = CheckMenuItem::with_id(mid.clone(), label, true, false, sc);
diff --git a/crates/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/color/src/cms/mod.rs b/crates/color/src/cms/mod.rs
index 3be294ae5..1a1374cd3 100644
--- a/crates/color/src/cms/mod.rs
+++ b/crates/color/src/cms/mod.rs
@@ -17,7 +17,7 @@ pub mod lab;
use std::borrow::Cow;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
-use std::sync::{Arc, Mutex, OnceLock, RwLock};
+use std::sync::{Arc, Mutex, OnceLock, PoisonError, RwLock};
use serde::{Deserialize, Serialize};
@@ -201,7 +201,7 @@ static USER: RwLock>> = RwLock::new(Vec::new());
fn builtin_rgb_cache(name: &str) -> Option> {
static CACHE: Mutex>> = Mutex::new(Vec::new());
- let mut c = CACHE.lock().unwrap_or_else(|e| e.into_inner());
+ let mut c = CACHE.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(p) = c.iter().find(|p| p.name == name) {
return Some(p.clone());
}
@@ -211,7 +211,7 @@ fn builtin_rgb_cache(name: &str) -> Option> {
}
fn user_profile(name: &str) -> Option> {
- USER.read().unwrap_or_else(|e| e.into_inner()).iter().find(|p| p.name == name).cloned()
+ USER.read().unwrap_or_else(PoisonError::into_inner).iter().find(|p| p.name == name).cloned()
}
/// The default CMYK space: Coated FOGRA39, or Generic CMYK should that profile be unusable.
@@ -255,7 +255,7 @@ pub fn profiles() -> Vec {
.map(|n| ProfileInfo { name: (*n).into(), kind: ProfileKind::Rgb, builtin: true })
.chain([COATED_FOGRA39, GENERIC_CMYK, DEVICE_CMYK].iter().map(|n| ProfileInfo { name: (*n).into(), kind: ProfileKind::Cmyk, builtin: true }))
.collect();
- for p in USER.read().unwrap_or_else(|e| e.into_inner()).iter() {
+ for p in USER.read().unwrap_or_else(PoisonError::into_inner).iter() {
v.push(ProfileInfo { name: p.name.clone(), kind: p.kind, builtin: false });
}
v
@@ -275,7 +275,7 @@ pub fn register_icc(bytes: &[u8], name: Option) -> Result Arc {
let key = format!("{:?}|{:?}|{:?}|{}", self.settings, proof.target, proof.intent, proof.simulate_paper);
- let mut cache = PROOF_CACHE.lock().unwrap_or_else(|e| e.into_inner());
+ let mut cache = PROOF_CACHE.lock().unwrap_or_else(PoisonError::into_inner);
if let Some((_, l)) = cache.iter().find(|(k, _)| *k == key) {
return l.clone();
}
@@ -538,7 +538,7 @@ impl Cms {
static PROOF_CACHE: Mutex)>> = Mutex::new(Vec::new());
fn clear_proof_cache() {
- PROOF_CACHE.lock().unwrap_or_else(|e| e.into_inner()).clear();
+ PROOF_CACHE.lock().unwrap_or_else(PoisonError::into_inner).clear();
}
fn cmyk4(v: Option>) -> Option<[f32; 4]> {
@@ -704,10 +704,10 @@ pub fn generation() -> u64 {
/// The active colour-management context (Edit → Color Settings).
pub fn active() -> Arc {
- if let Some(c) = ACTIVE.read().unwrap_or_else(|e| e.into_inner()).as_ref() {
+ if let Some(c) = ACTIVE.read().unwrap_or_else(PoisonError::into_inner).as_ref() {
return c.clone();
}
- let mut w = ACTIVE.write().unwrap_or_else(|e| e.into_inner());
+ let mut w = ACTIVE.write().unwrap_or_else(PoisonError::into_inner);
w.get_or_insert_with(|| Arc::new(Cms::default())).clone()
}
@@ -721,7 +721,7 @@ pub fn set_active(settings: &ColorSettings) -> Result<(), CmsError> {
let changed = active().settings != *settings;
RGB_IS_SRGB.store(cms.rgb_is_srgb(), Ordering::Relaxed);
CMYK_IS_DEVICE.store(matches!(cms.cmyk, CmykSpace::Device), Ordering::Relaxed);
- *ACTIVE.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(cms));
+ *ACTIVE.write().unwrap_or_else(PoisonError::into_inner) = Some(Arc::new(cms));
if changed {
GENERATION.fetch_add(1, Ordering::Relaxed);
}
diff --git a/crates/compose/src/breaker.rs b/crates/compose/src/breaker.rs
index 0dff34702..85b3a0dc2 100644
--- a/crates/compose/src/breaker.rs
+++ b/crates/compose/src/breaker.rs
@@ -295,7 +295,7 @@ fn cjk_break(a: &Glyph, b: &Glyph, sp: &Spacing) -> bool {
}
pub fn is_forced(c: char) -> bool {
- use designcraft_doc::story::*;
+ use designcraft_doc::story::{FORCED_LINE_BREAK, is_break_char};
c == FORCED_LINE_BREAK || is_break_char(c)
}
diff --git a/crates/compose/src/cache.rs b/crates/compose/src/cache.rs
index 77bb30f95..6ca7c7366 100644
--- a/crates/compose/src/cache.rs
+++ b/crates/compose/src/cache.rs
@@ -7,7 +7,7 @@
//! can't be reused by other allocations while the entry lives.
use std::collections::HashMap;
-use std::sync::{Arc, Mutex};
+use std::sync::{Arc, Mutex, PoisonError};
use designcraft_doc::{Document, Item, Story, StoryId, Styles};
@@ -196,7 +196,7 @@ impl Cache {
sig.push(std::hash::Hasher::finish(&h) as usize);
}
{
- let mut g = self.map.lock().unwrap_or_else(|e| e.into_inner());
+ let mut g = self.map.lock().unwrap_or_else(PoisonError::into_inner);
g.1 += 1;
let stamp = g.1;
if let Some(e) = g.0.get_mut(&key)
@@ -216,7 +216,7 @@ impl Cache {
&ComposeOptions { page_name, section_marker, page, running, label: None, xrefs: with_xrefs.then(|| self.xref_index(doc)) },
&others.iter().map(|o| &**o).collect::>(),
));
- let mut g = self.map.lock().unwrap_or_else(|e| e.into_inner());
+ let mut g = self.map.lock().unwrap_or_else(PoisonError::into_inner);
let stamp = g.1;
if g.0.len() > 4096 {
g.0.retain(|_, e| stamp - e.stamp < 64);
@@ -228,7 +228,7 @@ impl Cache {
&& cross
&& let Some(old) = replaced
{
- self.previous.lock().unwrap_or_else(|e| e.into_inner()).insert(sid, (old.sig, old.out));
+ self.previous.lock().unwrap_or_else(PoisonError::into_inner).insert(sid, (old.sig, old.out));
}
out
}
@@ -241,7 +241,7 @@ impl Cache {
// Only the geometry/story part of the signature (variables etc. add to it).
let matches = |s: &[usize]| s.len() >= sig.len() && s[..sig.len()] == sig[..];
{
- let g = self.map.lock().unwrap_or_else(|e| e.into_inner());
+ let g = self.map.lock().unwrap_or_else(PoisonError::into_inner);
for cross in [true, false] {
if let Some(e) = g.0.get(&(sid, ShownOn::Own, cross))
&& matches(&e.sig)
@@ -250,7 +250,7 @@ impl Cache {
}
}
}
- let g = self.previous.lock().unwrap_or_else(|e| e.into_inner());
+ let g = self.previous.lock().unwrap_or_else(PoisonError::into_inner);
g.get(&sid).filter(|(s, _)| matches(s)).map(|(_, c)| c.clone())
}
@@ -306,34 +306,34 @@ impl Cache {
}
pub fn clear(&self) {
- self.map.lock().unwrap_or_else(|e| e.into_inner()).0.clear();
- *self.running.lock().unwrap_or_else(|e| e.into_inner()) = None;
- *self.xrefs.lock().unwrap_or_else(|e| e.into_inner()) = None;
+ self.map.lock().unwrap_or_else(PoisonError::into_inner).0.clear();
+ *self.running.lock().unwrap_or_else(PoisonError::into_inner) = None;
+ *self.xrefs.lock().unwrap_or_else(PoisonError::into_inner) = None;
}
/// Anchor pages for cross-references (rebuilt when the document changes).
pub fn xref_index(&self, doc: &Document) -> Arc {
let sig = doc_signature(doc);
- if let Some((s, r)) = &*self.xrefs.lock().unwrap_or_else(|e| e.into_inner())
+ if let Some((s, r)) = &*self.xrefs.lock().unwrap_or_else(PoisonError::into_inner)
&& *s == sig
{
return r.clone();
}
let r = Arc::new(crate::xref::XrefIndex::build(doc, &|sid| self.get(doc, sid, None)));
- *self.xrefs.lock().unwrap_or_else(|e| e.into_inner()) = Some((sig, r.clone()));
+ *self.xrefs.lock().unwrap_or_else(PoisonError::into_inner) = Some((sig, r.clone()));
r
}
/// The running-header index for `doc` (rebuilt when the document changes).
pub fn running_index(&self, doc: &Document) -> Arc {
let sig = doc_signature(doc);
- if let Some((s, r)) = &*self.running.lock().unwrap_or_else(|e| e.into_inner())
+ if let Some((s, r)) = &*self.running.lock().unwrap_or_else(PoisonError::into_inner)
&& *s == sig
{
return r.clone();
}
let r = Arc::new(RunningIndex::build(doc, &|sid| self.get(doc, sid, None)));
- *self.running.lock().unwrap_or_else(|e| e.into_inner()) = Some((sig, r.clone()));
+ *self.running.lock().unwrap_or_else(PoisonError::into_inner) = Some((sig, r.clone()));
r
}
}
diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs
index 4a2c66397..a3fac1eab 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
@@ -569,6 +583,9 @@ pub struct FrameSpec {
pub grid: Option<(f64, f64)>,
/// The frame is on a left page (for towards/away-from-spine alignment).
pub left_page: bool,
+ /// The frame is on a right page of a facing spread (spine-relative anchored objects mirror
+ /// there; a single page has no spine).
+ pub right_page: bool,
/// The page and its margins in the frame's inner space (custom anchored objects).
pub page_rect: Option<(Rect, Rect)>,
}
@@ -648,7 +665,10 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec {
let loc = doc.find(fid);
let Some(item) = loc.as_ref().and_then(|l| doc.item_at(l)).or_else(|| doc.anchored_item(fid)) else { continue };
let Some(tf) = item.text_frame() else { continue };
- let xf = loc.as_ref().map(|l| doc.parent_xf(l)).unwrap_or(designcraft_geom::Affine::IDENTITY) * item.xf;
+ let parent = loc.as_ref().map(|l| doc.parent_xf(l)).unwrap_or(designcraft_geom::Affine::IDENTITY);
+ let xf = parent * item.xf;
+ // Where the frame is on the spread (a group's transform included), to find its page.
+ let center_x = parent.transform_rect_bbox(item.bounds()).center().x;
let inv = xf.inverse();
let mut exclusions = Vec::new();
let spread_ref = loc.as_ref().map(|l| l.spread);
@@ -672,25 +692,27 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec {
r.is_finite() && r.x0 < a.x1 && r.x1 > a.x0 && r.y0 < a.y1 && r.y1 > a.y0
});
}
- let (page_name, page, left_page) = match (spread_ref, spread) {
+ let (page_name, page, side) = match (spread_ref, spread) {
(Some(designcraft_doc::SpreadRef::Doc(si)), Some(sp)) => {
- let c = item.bounds().center();
- let pi = sp.page_at_x(c.x).unwrap_or(0);
+ let pi = sp.page_at_x(center_x).unwrap_or(0);
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(doc.page_name(abs)), Some(abs), sp.pages.get(pi).map(|p| p.side))
}
- (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(center_x).and_then(|pi| sp.pages.get(pi));
+ (Some(prefix), None, under.map(|p| p.side))
}
- _ => (None, None, false),
+ _ => (None, None, None),
};
+ let left_page = side == Some(designcraft_doc::PageSide::Left);
+ let right_page = side == Some(designcraft_doc::PageSide::Right);
let section_marker = page.map(|abs| section_marker(doc, abs));
// The page under the frame and its margins, in inner space (bounding boxes).
let page_rect = match (spread_ref, spread) {
(_, Some(sp)) => sp
- .page_at_x(item.bounds().center().x)
+ .page_at_x(center_x)
.and_then(|pi| sp.pages.get(pi))
.map(|pg| (inv.transform_rect_bbox(pg.bounds()), inv.transform_rect_bbox(pg.margin_rect()))),
_ => None,
@@ -757,6 +779,7 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec {
page,
grid,
left_page,
+ right_page,
page_rect,
});
}
@@ -1674,10 +1697,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;
@@ -1860,11 +1889,12 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
cur.band = Band { para: pi + 1, line0: ft.lines.len(), above: Some(above), tail: None };
}
// Rules and shading for the paragraph.
- if let Some((fi, bl, asc, _)) = first_line_rect {
+ if let Some((fi, bl, _, _)) = first_line_rect {
let ft = &mut out.frames[fi];
if pp.rule_above.on {
let r = &pp.rule_above;
- let y = bl - asc - r.offset;
+ // The offset runs from the first line's baseline up to the rule's bottom edge.
+ let y = bl - r.offset;
let col = ft
.lines
.iter()
@@ -1981,6 +2011,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
object_point,
ref_point,
keep_within_column,
+ spine_relative,
} => {
use designcraft_doc::anchored::AnchorRelative as R;
let col = ft.columns.get(l.column as usize).copied().unwrap_or(f.area);
@@ -1992,15 +2023,21 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
R::PageEdge => Some(f.page_rect.map_or(f.area, |p| p.0)),
};
let frac = |i: u8| ([0.0, 0.5, 1.0][(i % 3) as usize], [0.0, 0.5, 1.0][(i / 3).min(2) as usize]);
- let (rfx, rfy) = frac(*ref_point);
- let (ofx, ofy) = frac(*object_point);
+ let (mut rfx, rfy) = frac(*ref_point);
+ let (mut ofx, ofy) = frac(*object_point);
+ let mut dx = *x_offset;
+ // The settings describe a left-hand page; a right-hand page mirrors them
+ // across the spine.
+ if *spine_relative && f.right_page {
+ (rfx, ofx, dx) = (1.0 - rfx, 1.0 - ofx, -dx);
+ }
let rx = area(*x_relative).map_or(o.x, |a| a.x0 + a.width() * rfx);
let ry = area(*y_relative).map_or(l.baseline, |a| a.y0 + a.height() * rfy);
let mut y = ry + y_offset - h * ofy;
if *keep_within_column {
y = y.clamp(col.y0, (col.y1 - h).max(col.y0));
}
- Point::new(rx + x_offset - w * ofx, y)
+ Point::new(rx + dx - w * ofx, y)
}
};
}
@@ -4626,6 +4663,7 @@ mod column_allocation_safety_tests {
page: None,
grid: None,
left_page: false,
+ right_page: false,
page_rect: None,
outline: None,
}
diff --git a/crates/compose/src/notes.rs b/crates/compose/src/notes.rs
index e72ecde02..2a465fbfa 100644
--- a/crates/compose/src/notes.rs
+++ b/crates/compose/src/notes.rs
@@ -72,7 +72,7 @@ pub(crate) struct Notes {
pub key: Option,
pub placed: Vec,
/// Composed footnote text by (index, label, width).
- cache: HashMap<(usize, String, u64), (Arc, NoteMetrics)>,
+ cache: HashMap<(usize, String, u64, bool, bool), (Arc, NoteMetrics)>,
notes: Vec>,
/// Footnotes span the columns of their frame.
span: bool,
@@ -153,7 +153,8 @@ impl Notes {
fn compose(&mut self, doc: &Document, k: usize, w: f64, f: &FrameSpec, opts: &ComposeOptions) -> (Arc, NoteMetrics) {
let o = &doc.footnote_options;
let label = format!("{}{}", o.label(self.numbers[k], true), o.separator);
- let key = (k, label.clone(), w.to_bits());
+ // The page side sets towards/away-from-spine alignment and spine-relative anchored objects.
+ let key = (k, label.clone(), w.to_bits(), f.left_page, f.right_page);
if let Some(c) = self.cache.get(&key) {
return c.clone();
}
@@ -169,6 +170,7 @@ impl Notes {
page: f.page,
grid: None,
left_page: f.left_page,
+ right_page: f.right_page,
page_rect: None,
};
let nopts = ComposeOptions {
diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs
index 940e0aa77..39219e43b 100644
--- a/crates/compose/src/table.rs
+++ b/crates/compose/src/table.rs
@@ -121,7 +121,7 @@ impl CellLineExtent {
}
/// Compose a cell story at width `w` (content space, top-left at the origin).
-fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, left_page: bool) -> CellLayout {
+fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, (left_page, right_page): (bool, bool)) -> CellLayout {
let spec = FrameSpec {
id: ItemId(0),
area: Rect::new(0.0, 0.0, w.max(1.0), 1.0e6),
@@ -134,6 +134,7 @@ fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, left
page: opts.page,
grid: None,
left_page,
+ right_page,
page_rect: None,
};
let cs = crate::compose(doc, &cell.text, std::slice::from_ref(&spec), opts);
@@ -200,7 +201,7 @@ pub(crate) fn place_table(
let (nr, nc) = (table.nrows(), table.ncols());
let owners = table.owners();
let is_owner = |r: usize, c: usize| owners[r * nc + c] == (r, c);
- let left_page = frames[cur.fi].left_page;
+ let side = (frames[cur.fi].left_page, frames[cur.fi].right_page);
// Compose every owner cell.
let mut layouts: Vec