diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 72bac7ef7..d0d1fd873 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,10 +1,7 @@
-# The CLAUDE.md quality gate (`cargo xtask ci`: fmt, clippy -D warnings, tests, assets, layers,
-# wasm) on every pull request and every push to main. Today only path-filtered workflows run on
-# pull requests, so most PRs land with no test run at all.
-#
-# Ported from wordcraft#800. Linux only; standard GitHub-hosted runner, no secrets.
name: CI
+# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
+# suite's shared gate workflow in craft-common-actions.
on:
push:
branches: [main]
@@ -14,54 +11,19 @@ on:
permissions:
contents: read
+# A newer push to a PR branch cancels its older run; every push to main runs to the end.
concurrency:
- group: ci-${{ github.ref }}
+ group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
-env:
- # Same craft-fonts commit as release.yml, so the font tests see what a release build embeds.
- CRAFT_FONTS_REF: abb83316d96aa59c1cf64784289e378fe9fa5695
-
jobs:
gate:
- name: cargo xtask ci
- runs-on: ubuntu-24.04
- timeout-minutes: 90
- env:
- CARGO_TERM_COLOR: always
- CARGO_INCREMENTAL: "0"
- RUST_BACKTRACE: "1"
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
-
- - name: Check out craft-fonts
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- repository: storytold/craft-fonts
- ref: ${{ env.CRAFT_FONTS_REF }}
- path: craft-fonts
- persist-credentials: false
-
- - name: Install Linux build dependencies
- run: |
- sudo apt-get update
- sudo apt-get install -y --no-install-recommends \
- pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev \
- libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
-
- - name: Install Rust (stable + clippy, rustfmt, wasm32)
- run: |
- rustup toolchain install stable --profile minimal --component clippy,rustfmt \
- --target wasm32-unknown-unknown
- rustup default stable
-
- - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- with:
- save-if: ${{ github.ref == 'refs/heads/main' }}
-
- - name: Quality gate
- env:
- CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
- run: cargo xtask ci
+ # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
+ uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
+ with:
+ # Same craft-fonts commit as release.yml.
+ craft-fonts-ref: 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/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
index ecee45a6d..e78fe1ea3 100644
--- a/.github/workflows/sonarcloud.yml
+++ b/.github/workflows/sonarcloud.yml
@@ -11,7 +11,8 @@ permissions:
concurrency:
group: sonarcloud-${{ github.ref }}
- cancel-in-progress: true
+ # A push to main is the run that publishes coverage; superseded runs there would never finish.
+ cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
sonarcloud:
diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml
index ed7060b99..85856800b 100644
--- a/.github/workflows/windows-arm64.yml
+++ b/.github/workflows/windows-arm64.yml
@@ -4,19 +4,17 @@
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
name: Windows ARM64
+# Nightly on main and on pushes to `release` (dependency and runner-image drift shows up within a
+# day, and always before a release ships), and on PRs that change the Windows packaging itself.
on:
+ schedule:
+ - cron: "3 5 * * *"
push:
- branches: [main]
+ branches: [release]
pull_request:
paths:
- - Cargo.toml
- - Cargo.lock
- - "**/Cargo.toml"
- - "**/build.rs"
- .github/workflows/windows-arm64.yml
- packaging/windows/**
- schedule:
- - cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
workflow_dispatch:
concurrency:
diff --git a/ASSETS.md b/ASSETS.md
index 40e8a97b4..407a26c92 100644
--- a/ASSETS.md
+++ b/ASSETS.md
@@ -40,12 +40,12 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| `docs/images/page-4.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI |
| `docs/images/page-cover.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI |
| `docs/images/ui-spread.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI |
-| `crates/render/tests/golden/drop-shadow-spread.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
-| `crates/render/tests/golden/drop-shadow-text.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
-| `crates/render/tests/golden/drop-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
-| `crates/render/tests/golden/feather.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
-| `crates/render/tests/golden/inner-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
-| `crates/render/tests/golden/outer-glow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
+| `crates/render/tests/golden/drop-shadow-spread.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
+| `crates/render/tests/golden/drop-shadow-text.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
+| `crates/render/tests/golden/drop-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
+| `crates/render/tests/golden/feather.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
+| `crates/render/tests/golden/inner-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
+| `crates/render/tests/golden/outer-glow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects |
| `assets/app-icon/LICENSE.txt` | (licence text) | this project | — | Licence for the app icon |
| `assets/app-icon/designcraft-1024.png` | DesignCraft project owner (drawn in ArtCraft) | rendered from `assets/app-icon/designcraft.svg` by `packaging/icons.sh` | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon: calico cat in a polka-dot scarf (see `assets/app-icon/README.md`) |
| `assets/app-icon/designcraft-macos-512.png` | DesignCraft project owner (drawn in ArtCraft) | rendered from `assets/app-icon/designcraft.svg` by `packaging/icons.sh` | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon: calico cat in a polka-dot scarf (see `assets/app-icon/README.md`) |
diff --git a/Cargo.lock b/Cargo.lock
index 0b022ecfe..beb1f6f8f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -841,7 +841,7 @@ 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=89dcbb3060377a26c99a6d80caa16084366704c4#89dcbb3060377a26c99a6d80caa16084366704c4"
+source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
dependencies = [
"bytemuck",
"hayro",
@@ -942,7 +942,7 @@ dependencies = [
[[package]]
name = "designcraft"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"arboard",
"designcraft-engine",
@@ -964,7 +964,7 @@ dependencies = [
[[package]]
name = "designcraft-cli"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -980,7 +980,7 @@ dependencies = [
[[package]]
name = "designcraft-color"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"craft-cms",
"kurbo",
@@ -992,7 +992,7 @@ dependencies = [
[[package]]
name = "designcraft-compose"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"designcraft-color",
"designcraft-doc",
@@ -1012,7 +1012,7 @@ dependencies = [
[[package]]
name = "designcraft-doc"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"chrono",
"designcraft-color",
@@ -1025,7 +1025,7 @@ dependencies = [
[[package]]
name = "designcraft-engine"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"caseless",
"craft-atomic",
@@ -1064,7 +1064,7 @@ dependencies = [
[[package]]
name = "designcraft-epub"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"chrono",
"designcraft-color",
@@ -1074,7 +1074,7 @@ dependencies = [
[[package]]
name = "designcraft-fonts"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"core-text",
"harfrust 0.13.3",
@@ -1087,7 +1087,7 @@ dependencies = [
[[package]]
name = "designcraft-format"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"designcraft-doc",
"designcraft-images",
@@ -1099,7 +1099,7 @@ dependencies = [
[[package]]
name = "designcraft-geom"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"flo_curves",
"kurbo",
@@ -1112,7 +1112,7 @@ dependencies = [
[[package]]
name = "designcraft-idml"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1127,7 +1127,7 @@ dependencies = [
[[package]]
name = "designcraft-images"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"craft-cms",
"designcraft-color",
@@ -1142,7 +1142,7 @@ dependencies = [
[[package]]
name = "designcraft-mcp"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"designcraft-engine",
"designcraft-geom",
@@ -1155,7 +1155,7 @@ dependencies = [
[[package]]
name = "designcraft-pdf"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"chrono",
"craft-pdf",
@@ -1178,7 +1178,7 @@ dependencies = [
[[package]]
name = "designcraft-pptx"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"chrono",
"designcraft-color",
@@ -1196,7 +1196,7 @@ dependencies = [
[[package]]
name = "designcraft-render"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"craft-pdf",
"designcraft-color",
@@ -1215,7 +1215,7 @@ dependencies = [
[[package]]
name = "designcraft-textimport"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"designcraft-doc",
"quick-xml",
@@ -1224,7 +1224,7 @@ dependencies = [
[[package]]
name = "designcraft-tools"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1236,7 +1236,7 @@ dependencies = [
[[package]]
name = "designcraft-ui-egui"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"craft-ui",
"designcraft-color",
@@ -1263,7 +1263,7 @@ dependencies = [
[[package]]
name = "designcraft-web"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"designcraft-engine",
"designcraft-format",
@@ -2123,7 +2123,7 @@ dependencies = [
[[package]]
name = "hayro"
version = "0.7.1"
-source = "git+https://github.com/storytold/craft-libs?rev=89dcbb3060377a26c99a6d80caa16084366704c4#89dcbb3060377a26c99a6d80caa16084366704c4"
+source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
dependencies = [
"bytemuck",
"hayro-interpret",
@@ -2143,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",
@@ -2153,7 +2152,7 @@ dependencies = [
[[package]]
name = "hayro-interpret"
version = "0.7.0"
-source = "git+https://github.com/storytold/craft-libs?rev=89dcbb3060377a26c99a6d80caa16084366704c4#89dcbb3060377a26c99a6d80caa16084366704c4"
+source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
dependencies = [
"bitflags 2.13.2",
"hayro-cmap",
@@ -2171,7 +2170,7 @@ dependencies = [
[[package]]
name = "hayro-jbig2"
version = "0.3.0"
-source = "git+https://github.com/storytold/craft-libs?rev=89dcbb3060377a26c99a6d80caa16084366704c4#89dcbb3060377a26c99a6d80caa16084366704c4"
+source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
dependencies = [
"fearless_simd",
"hayro-ccitt",
@@ -2195,7 +2194,7 @@ checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa"
[[package]]
name = "hayro-syntax"
version = "0.7.2"
-source = "git+https://github.com/storytold/craft-libs?rev=89dcbb3060377a26c99a6d80caa16084366704c4#89dcbb3060377a26c99a6d80caa16084366704c4"
+source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
dependencies = [
"flate2",
"hayro-ccitt",
@@ -2559,7 +2558,7 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.8.2"
-source = "git+https://github.com/storytold/craft-libs?rev=89dcbb3060377a26c99a6d80caa16084366704c4#89dcbb3060377a26c99a6d80caa16084366704c4"
+source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
dependencies = [
"base64",
"bumpalo",
@@ -5814,7 +5813,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"serde_json",
]
diff --git a/Cargo.toml b/Cargo.toml
index 0e728e87f..0245708f0 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"]
[workspace.package]
-version = "0.11.0"
+version = "0.12.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -12,7 +12,7 @@ repository = "https://github.com/storytold/designcraft"
[workspace.dependencies]
# Shared reader and writer; publish the immutable revision before this consumer.
-craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.1.0", default-features = false }
+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" }
@@ -103,11 +103,16 @@ todo = "warn"
too_many_arguments = "allow"
type_complexity = "allow"
+# Line tables keep file and line in panics and backtraces; full debug info
+# for every test binary and dependency would multiply the target dir size.
+# The test profile inherits both settings.
[profile.dev.package."*"]
opt-level = 2
+debug = false
[profile.dev]
opt-level = 1
+debug = "line-tables-only"
[profile.release]
opt-level = 3
@@ -117,9 +122,10 @@ debug = 0
# Opt-in cancelable macOS termination on the pinned winit 0.30 line.
[patch.crates-io]
-krilla = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.8.2" }
+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 = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.7.1" }
-hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.7.0" }
-hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.7.2" }
-hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.3.0" }
+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 f1e997156..71abff776 100644
--- a/NOTICE
+++ b/NOTICE
@@ -92,14 +92,15 @@ 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 and hayro-jbig2 0.3.0
-are fetched from https://github.com/storytold/craft-libs at revision
-89dcbb3060377a26c99a6d80caa16084366704c4. The four source snapshots were transferred
+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, then patched in craft-libs. Their upstream project is
+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/89dcbb3060377a26c99a6d80caa16084366704c4/vendor/README.craft-reader.md.
+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.
diff --git a/README.md b/README.md
index 4ca03d4dc..cd0f56479 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,10 @@
+
+
+
+
DesignCraft
diff --git a/ROADMAP.md b/ROADMAP.md
index 24f93bd87..4fbe6497e 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**
@@ -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,11 +358,22 @@ 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.
diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs
index 71e05a480..faa4f4af0 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
}
@@ -263,7 +263,7 @@ impl Export {
/// A name with another extension is an error: it used to get a PNG of the page. One with no
/// extension still does (`--export /dev/stdout`).
fn of(out: &str) -> Result {
- let ext = std::path::Path::new(out).extension().and_then(|e| e.to_str()).map(str::to_lowercase);
+ let ext = std::path::Path::new(out).extension().and_then(std::ffi::OsStr::to_str).map(str::to_lowercase);
Ok(match ext.as_deref() {
None | Some("png" | "jpg" | "jpeg") => Export::Image,
Some("pdf") => Export::Pdf,
@@ -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/src/perf.rs b/apps/designcraft-cli/src/perf.rs
index bed57cfcc..0c665f7e7 100644
--- a/apps/designcraft-cli/src/perf.rs
+++ b/apps/designcraft-cli/src/perf.rs
@@ -335,7 +335,7 @@ fn row(rows: &mut Vec, name: impl Into, ms: f64, budget: Option Result, String> {
let mut rows = vec![];
- let cores = std::thread::available_parallelism().map_or(1, |c| c.get());
+ let cores = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
let chars: usize = doc.stories.values().map(|s| s.text.len()).sum();
// Budget: 250 ms per 300k characters (MT), scaled to this document.
let compose_budget = 250.0 * (chars as f64 / 300_000.0).max(1.0);
@@ -532,7 +532,7 @@ fn print(title: &str, rows: &[Row], noisy: bool) -> Result<(), String> {
}
fn header() -> (usize, bool) {
- let cores = std::thread::available_parallelism().map_or(1, |c| c.get());
+ let cores = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
let load = load_average();
let noisy = load.is_some_and(|l| l > cores as f64 * 0.75);
outln!(
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/main.rs b/apps/designcraft/src/main.rs
index b56878176..195a2dc6e 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"] }],
@@ -255,7 +256,7 @@ const ALL_FILES: &str = if cfg!(target_os = "macos") { "public.data" } else { "*
/// The extension of the file name a save dialog suggests (`Brochure.designcraft`), if it has one.
/// The dialog filters on it, so the macOS and Windows dialogs add it to a name typed without it.
fn suggested_extension(name: &str) -> Option<&str> {
- std::path::Path::new(name).extension().and_then(|e| e.to_str()).filter(|e| !e.is_empty() && !e.contains(char::is_whitespace))
+ std::path::Path::new(name).extension().and_then(std::ffi::OsStr::to_str).filter(|e| !e.is_empty() && !e.contains(char::is_whitespace))
}
/// The file a save dialog's answer writes: `picked` with the suggested extension added when it has
@@ -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/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 89ea89bd3..15202de7a 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;
@@ -1982,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);
@@ -1993,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)
}
};
}
@@ -4004,7 +4040,7 @@ fn hyphenation_points(
// Words repeat a lot: remember their points per set of limits.
static CACHE: Mutex), Box<[usize]>>)>> = Mutex::new(Vec::new());
let key = (lim.min_word, lim.after_first, lim.before_last, lim.capitalized);
- let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());
+ let mut guard = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let idx = match guard.iter().position(|e| e.0 == key) {
Some(i) => i,
None => {
@@ -4505,7 +4541,7 @@ pub fn adjacent_row(l: &Line, x: f64, caret_y: f64, up: bool) -> Option {
// count as still inside the note.
let over = |r: &(f64, f64, f64)| x >= r.1 - 0.01 && x < r.2 - 0.01;
let candidates = rows.iter().filter(|r| over(r)).filter(|r| if up { r.0 < caret_y - ROW_Y } else { r.0 > caret_y + ROW_Y });
- if up { candidates.map(|r| r.0).max_by(|a, b| a.total_cmp(b)) } else { candidates.map(|r| r.0).min_by(|a, b| a.total_cmp(b)) }
+ if up { candidates.map(|r| r.0).max_by(f64::total_cmp) } else { candidates.map(|r| r.0).min_by(f64::total_cmp) }
}
#[cfg(test)]
@@ -4627,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/parabox.rs b/crates/compose/src/parabox.rs
index b8b58cb44..f6b90e127 100644
--- a/crates/compose/src/parabox.rs
+++ b/crates/compose/src/parabox.rs
@@ -122,6 +122,45 @@ pub(crate) fn draw(out: &mut ComposedStory, boxes: &[Option], frames: &
}
}
+/// The shading box for one part of a paragraph run, if it has any area.
+fn shading_deco(s: &Shading, part: &Part<'_>, frames: &[FrameSpec], first_part: bool, last_part: bool) -> Option {
+ let r = inner_rect(&part.lines, s.width, s.top, s.bottom)?;
+ let [t, l, bo, rr] = s.offsets;
+ let mut rect = Rect::new(r.x0 - l, r.y0 - t, r.x1 + rr, r.y1 + bo);
+ if s.clip
+ && let Some(f) = frames.get(part.fi)
+ {
+ rect = rect.intersect(frame_rect(f));
+ }
+ if rect.width() > 0.0 && rect.height() > 0.0 {
+ // Corners stay square where the box is cut by a split.
+ let corners = cut_corners(s.corners, !first_part, !last_part);
+ let path = (!corners.iter().all(Corner::is_none)).then(|| box_outline(rect, &corners, [true; 4]));
+ return Some(Deco { rect, color: s.color.clone(), tint: s.tint, path, nonprinting: s.nonprinting });
+ }
+ None
+}
+
+/// The border strokes (and plain edges) for one part of a paragraph run, pushed onto `decos`.
+fn border_decos(bd: &Border, part: &Part<'_>, first_part: bool, last_part: bool, decos: &mut Vec<(usize, bool, Deco)>) {
+ let Some(r) = inner_rect(&part.lines, bd.width, bd.top, bd.bottom) else { return };
+ let [t, l, bo, rr] = bd.offsets;
+ let o = Rect::new(r.x0 - l, r.y0 - t, r.x1 + rr, r.y1 + bo);
+ let open_top = !first_part && !bd.display_if_splits;
+ let open_bottom = !last_part && !bd.display_if_splits;
+ for (path, gap) in border_paths(bd, o, open_top, open_bottom) {
+ let (color, tint) = if gap { (bd.gap_color.clone(), bd.gap_tint) } else { (bd.color.clone(), bd.tint) };
+ decos.push((part.fi, false, Deco { rect: path.bounding_box(), color, tint, path: Some(path), nonprinting: false }));
+ }
+ if is_plain(bd, open_top, open_bottom) {
+ decos.extend(
+ plain_edges(bd, o, open_top, open_bottom)
+ .into_iter()
+ .map(|rect| (part.fi, false, Deco { rect, color: bd.color.clone(), tint: bd.tint, path: None, nonprinting: false })),
+ );
+ }
+}
+
/// The decorations of paragraphs `paras` sharing box `b`: (frame, drawn under the rest, deco).
fn group_decos(out: &ComposedStory, paras: std::ops::RangeInclusive, b: &ParaBox, frames: &[FrameSpec]) -> Vec<(usize, bool, Deco)> {
let mut parts: Vec> = Vec::new();
@@ -136,40 +175,12 @@ fn group_decos(out: &ComposedStory, paras: std::ops::RangeInclusive, b: &
for (k, part) in parts.iter().enumerate() {
let (first_part, last_part) = (k == 0, k + 1 == n);
if let Some(s) = &b.shading
- && let Some(r) = inner_rect(&part.lines, s.width, s.top, s.bottom)
+ && let Some(deco) = shading_deco(s, part, frames, first_part, last_part)
{
- let [t, l, bo, rr] = s.offsets;
- let mut rect = Rect::new(r.x0 - l, r.y0 - t, r.x1 + rr, r.y1 + bo);
- if s.clip
- && let Some(f) = frames.get(part.fi)
- {
- rect = rect.intersect(frame_rect(f));
- }
- if rect.width() > 0.0 && rect.height() > 0.0 {
- // Corners stay square where the box is cut by a split.
- let corners = cut_corners(s.corners, !first_part, !last_part);
- let path = (!corners.iter().all(Corner::is_none)).then(|| box_outline(rect, &corners, [true; 4]));
- decos.push((part.fi, true, Deco { rect, color: s.color.clone(), tint: s.tint, path, nonprinting: s.nonprinting }));
- }
+ decos.push((part.fi, true, deco));
}
- if let Some(bd) = &b.border
- && let Some(r) = inner_rect(&part.lines, bd.width, bd.top, bd.bottom)
- {
- let [t, l, bo, rr] = bd.offsets;
- let o = Rect::new(r.x0 - l, r.y0 - t, r.x1 + rr, r.y1 + bo);
- let open_top = !first_part && !bd.display_if_splits;
- let open_bottom = !last_part && !bd.display_if_splits;
- for (path, gap) in border_paths(bd, o, open_top, open_bottom) {
- let (color, tint) = if gap { (bd.gap_color.clone(), bd.gap_tint) } else { (bd.color.clone(), bd.tint) };
- decos.push((part.fi, false, Deco { rect: path.bounding_box(), color, tint, path: Some(path), nonprinting: false }));
- }
- if is_plain(bd, open_top, open_bottom) {
- decos.extend(
- plain_edges(bd, o, open_top, open_bottom)
- .into_iter()
- .map(|rect| (part.fi, false, Deco { rect, color: bd.color.clone(), tint: bd.tint, path: None, nonprinting: false })),
- );
- }
+ if let Some(bd) = &b.border {
+ border_decos(bd, part, first_part, last_part, &mut decos);
}
}
decos
diff --git a/crates/compose/src/ruby.rs b/crates/compose/src/ruby.rs
index 1afea6b0d..47c48a728 100644
--- a/crates/compose/src/ruby.rs
+++ b/crates/compose/src/ruby.rs
@@ -71,6 +71,36 @@ fn tcy_char(c: char, roman: bool) -> bool {
c.is_ascii_digit() || (roman && c.is_ascii_alphabetic())
}
+/// Tate-chu-yoko in vertical ruby: each short run of digits (and Latin letters) is set in one
+/// ruby em across the line, scaled down to fit when the spec asks for it.
+fn apply_tcy(glyphs: &mut [RubyGlyph], spec: &RubySpec, size: f64) {
+ let roman = spec.auto_tcy_include_roman;
+ let mut i = 0;
+ while i < glyphs.len() {
+ let j = i + glyphs.get(i..).unwrap_or_default().iter().take_while(|g| tcy_char(g.ch, roman)).count();
+ if j == i {
+ i += 1;
+ continue;
+ }
+ if u32::try_from(j - i).is_ok_and(|n| n <= spec.auto_tcy_digits)
+ && let Some(run) = glyphs.get_mut(i..j)
+ {
+ let total: f64 = run.iter().map(|g| g.adv).sum();
+ let f = if spec.auto_tcy_auto_scale && total > size && total > 0.0 { size / total } else { 1.0 };
+ let mut across = -total * f / 2.0;
+ for (n, g) in run.iter_mut().enumerate() {
+ g.tcy = Some([size / 2.0, across + g.x * f, size]);
+ across += g.adv * f;
+ g.sx *= f;
+ g.x = 0.0;
+ g.adv = if n == 0 { size } else { 0.0 };
+ g.joined = n > 0;
+ }
+ }
+ i = j;
+ }
+}
+
/// Shape `text` as the ruby of parent text `parent_size` points big.
fn shape_ruby(db: &ScopedFonts<'_>, parent: FaceRef, text: &str, spec: &RubySpec, parent_size: f64, fallback: bool, vertical: bool) -> Shaped {
let size = spec.size_for(parent_size);
@@ -96,31 +126,7 @@ fn shape_ruby(db: &ScopedFonts<'_>, parent: FaceRef, text: &str, spec: &RubySpec
.collect();
// Tate-chu-yoko: short runs of digits (and Latin letters) take one ruby em along the line.
if vertical && spec.auto_tcy_digits > 0 {
- let roman = spec.auto_tcy_include_roman;
- let mut i = 0;
- while i < glyphs.len() {
- let j = i + glyphs.get(i..).unwrap_or_default().iter().take_while(|g| tcy_char(g.ch, roman)).count();
- if j == i {
- i += 1;
- continue;
- }
- if u32::try_from(j - i).is_ok_and(|n| n <= spec.auto_tcy_digits)
- && let Some(run) = glyphs.get_mut(i..j)
- {
- let total: f64 = run.iter().map(|g| g.adv).sum();
- let f = if spec.auto_tcy_auto_scale && total > size && total > 0.0 { size / total } else { 1.0 };
- let mut across = -total * f / 2.0;
- for (n, g) in run.iter_mut().enumerate() {
- g.tcy = Some([size / 2.0, across + g.x * f, size]);
- across += g.adv * f;
- g.sx *= f;
- g.x = 0.0;
- g.adv = if n == 0 { size } else { 0.0 };
- g.joined = n > 0;
- }
- }
- i = j;
- }
+ apply_tcy(&mut glyphs, spec, size);
}
// Pen positions: a joined glyph sits at its group's start.
let mut pen = 0.0;
diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs
index ffbc4857d..0f739e16e 100644
--- a/crates/compose/src/shape.rs
+++ b/crates/compose/src/shape.rs
@@ -950,10 +950,10 @@ static WORD_CACHE: RwLock>)>> = RwLock::new(Ve
const WORD_CACHE_MAX: usize = 50_000;
fn word_map(key: WordKey) -> Arc> {
- if let Some((_, m)) = WORD_CACHE.read().unwrap_or_else(|e| e.into_inner()).iter().find(|e| e.0 == key) {
+ if let Some((_, m)) = WORD_CACHE.read().unwrap_or_else(std::sync::PoisonError::into_inner).iter().find(|e| e.0 == key) {
return m.clone();
}
- let mut w = WORD_CACHE.write().unwrap_or_else(|e| e.into_inner());
+ let mut w = WORD_CACHE.write().unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some((_, m)) = w.iter().find(|e| e.0 == key) {
return m.clone();
}
diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs
index 940e0aa77..9a41a546e 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