diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml
index 450e57347..77c030386 100644
--- a/.github/workflows/freebsd.yml
+++ b/.github/workflows/freebsd.yml
@@ -27,16 +27,15 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 300
steps:
- - uses: actions/checkout@v4
- # The Japanese font tests run against craft-fonts (an optional build input; they skip without it).
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Check out craft-fonts
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- name: Build and test in a FreeBSD VM
- uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
+ uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
with:
release: "14.3"
usesh: true
diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml
index 603764095..59c0a1ceb 100644
--- a/.github/workflows/packaging-lint.yml
+++ b/.github/workflows/packaging-lint.yml
@@ -23,7 +23,7 @@ jobs:
env:
ACTIONLINT_VERSION: 1.7.12
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install tools
run: |
sudo apt-get update
@@ -54,4 +54,15 @@ jobs:
desktop-file-validate packaging/linux/ai.storyteller.designcraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.designcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.designcraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.designcraft.metainfo.xml"
- python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.designcraft.yml')); assert m['id'] == 'ai.storyteller.designcraft' and m['command'] == 'designcraft'"
+ python3 - <<'EOF'
+ import yaml
+ src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.designcraft.yml"))
+ bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml"))
+ for m in (src, bundle):
+ assert m["id"] == "ai.storyteller.designcraft" and m["command"] == "designcraft", m["id"]
+ for key in ("runtime", "runtime-version", "sdk", "finish-args"):
+ assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
+ print("Flatpak manifests ok")
+ EOF
+ - name: FreeBSD package layout (dry run)
+ run: bash packaging/freebsd/package.sh --dry-run
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 5cd3f09ed..634552c49 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,9 +1,12 @@
# Release pipeline: every push to `release` builds signed installers for every platform and
# creates or updates the draft GitHub Release "DesignCraft v". See docs/releasing.md.
#
-# All jobs run in the `release` environment (restricted to the `release` branch), which holds
-# the signing secrets. Every secret is optional: missing ones produce unsigned artifacts and a
-# warning, never a failed build.
+# The signing jobs (macOS, Windows) and the draft-release job run in the `release` environment
+# (restricted to the `release` branch), which holds the signing secrets. Every secret is
+# optional: missing ones produce unsigned artifacts and a warning, never a failed build. The jobs
+# that sign nothing (version, Linux, Flatpak, FreeBSD, web) get no environment and no secrets,
+# so a `workflow_dispatch` on another branch dry-runs them; the signing jobs are refused there,
+# which skips the draft release.
name: Release
on:
@@ -35,13 +38,13 @@ jobs:
version:
name: version
runs-on: ubuntu-latest
- environment: release
+ # No `environment: release`: no secrets needed, and branch dry runs need this job.
outputs:
version: ${{ steps.v.outputs.version }}
date: ${{ steps.v.outputs.date }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- id: v
name: Resolve version
run: |
@@ -69,18 +72,17 @@ jobs:
DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }}
DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- - uses: actions/checkout@v4
- # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it.
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Check out craft-fonts
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- - uses: dtolnay/rust-toolchain@stable
+ - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
- - uses: Swatinem/rust-cache@v2
+ - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-macos-universal
- name: Apply version override
@@ -100,7 +102,7 @@ jobs:
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: packaging/macos/package.sh --arch universal
- - uses: actions/upload-artifact@v4
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-macos
path: dist/release/*
@@ -128,18 +130,17 @@ jobs:
DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }}
DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- - uses: actions/checkout@v4
- # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it.
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Check out craft-fonts
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- - uses: dtolnay/rust-toolchain@stable
+ - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: ${{ matrix.target }}
- - uses: Swatinem/rust-cache@v2
+ - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-windows-${{ matrix.arch }}
- name: Apply version override
@@ -163,7 +164,7 @@ jobs:
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- - uses: actions/upload-artifact@v4
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-windows-${{ matrix.arch }}
path: dist/release/*
@@ -175,7 +176,7 @@ jobs:
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
runs-on: ${{ matrix.runner }}
- environment: release
+ # No `environment: release`: this job signs nothing, so it gets no release secrets.
strategy:
fail-fast: false
matrix:
@@ -187,10 +188,9 @@ jobs:
DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
NFPM_VERSION: 2.47.0
steps:
- - uses: actions/checkout@v4
- # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it.
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Check out craft-fonts
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
@@ -200,12 +200,12 @@ jobs:
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
- desktop-file-utils appstream python3-yaml
+ desktop-file-utils appstream python3-yaml zsync
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
- - uses: dtolnay/rust-toolchain@stable
- - uses: Swatinem/rust-cache@v2
+ - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
+ - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-linux-${{ matrix.arch }}
- name: Apply version override
@@ -225,35 +225,127 @@ jobs:
for appimage in dist/release/*.AppImage; do
chmod +x "$appimage"
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
+ # Update information is embedded, and the .zsync for delta updates sits beside it.
+ "$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
+ test -s "$appimage.zsync"
done
- - uses: actions/upload-artifact@v4
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
+ flatpak:
+ name: Flatpak ${{ matrix.arch }}
+ needs: [version, linux]
+ # Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
+ # build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships.
+ runs-on: ${{ matrix.runner }}
+ # No `environment: release`: this job signs nothing, so it gets no release secrets.
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - { arch: x86_64, runner: ubuntu-24.04 }
+ - { arch: aarch64, runner: ubuntu-24.04-arm }
+ env:
+ DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }}
+ DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
+ with:
+ name: release-linux-${{ matrix.arch }}
+ path: dist/linux
+ - name: Install flatpak-builder
+ run: |
+ sudo apt-get update
+ # librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's
+ # `appstreamcli compose`, which renders the scalable icon and fails without it.
+ sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
+ # Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
+ sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
+ - name: Build the Flatpak bundle
+ run: packaging/linux/flatpak-bundle.sh "dist/linux/designcraft-$DESIGNCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ with:
+ name: release-flatpak-${{ matrix.arch }}
+ path: dist/release/*.flatpak
+ if-no-files-found: error
+
+ freebsd:
+ name: FreeBSD x86_64
+ needs: version
+ # GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM, the same image and packages as
+ # .github/workflows/freebsd.yml.
+ runs-on: ubuntu-latest
+ timeout-minutes: 180
+ # No `environment: release`: this job signs nothing, so it gets no release secrets.
+ env:
+ DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }}
+ DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - name: Check out craft-fonts
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ repository: storytold/craft-fonts
+ ref: abb83316d96aa59c1cf64784289e378fe9fa5695
+ path: craft-fonts
+ - name: Build and package in a FreeBSD VM (tar.gz)
+ # vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
+ uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
+ with:
+ release: "14.3"
+ usesh: true
+ cpu: 4
+ mem: 12288
+ # Passed into the VM by name; never templated into the script below.
+ envs: "DESIGNCRAFT_VERSION DESIGNCRAFT_BUILD_DATE DESIGNCRAFT_BUILD_SHA INPUT_VERSION"
+ # The FreeBSD CI packages, plus bash for the packaging script.
+ prepare: |
+ pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
+ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
+ run: |
+ set -e
+ . "$HOME/.cargo/env"
+ git config --global --add safe.directory "$PWD"
+ # Build outside the checkout so only dist/ is copied back out of the VM (copying the
+ # target dir back fails on large builds). 4 jobs: more runs the VM out of memory.
+ export CARGO_TARGET_DIR=/var/tmp/designcraft-target CARGO_BUILD_JOBS=4
+ if [ -n "$INPUT_VERSION" ]; then cargo xtask version set "$DESIGNCRAFT_VERSION"; fi
+ # craft-fonts was checked out next to the sources and synced into the VM with them.
+ export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
+ bash packaging/freebsd/package.sh
+ tar -tzf dist/release/designcraft-*-freebsd-x86_64.tar.gz \
+ | grep -E '/bin/designcraft$|/bin/designcraft-cli$|/share/applications/ai\.storyteller\.designcraft\.desktop$|/share/doc/designcraft/LICENSE-MIT$'
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ with:
+ name: release-freebsd-x86_64
+ path: dist/release/*
+ if-no-files-found: error
+
web:
name: Web (wasm)
needs: version
runs-on: ubuntu-latest
- environment: release
+ # No `environment: release`: this job signs nothing, so it gets no release secrets.
env:
DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }}
DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
TRUNK_VERSION: 0.21.14
steps:
- - uses: actions/checkout@v4
- # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it.
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Check out craft-fonts
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- - uses: dtolnay/rust-toolchain@stable
+ - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: wasm32-unknown-unknown
- - uses: Swatinem/rust-cache@v2
+ - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-web
- name: Install trunk
@@ -269,7 +361,7 @@ jobs:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/web/package.sh
- - uses: actions/upload-artifact@v4
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-web
path: dist/release/*
@@ -277,7 +369,7 @@ jobs:
release:
name: Draft GitHub Release
- needs: [version, macos, windows, linux, web]
+ needs: [version, macos, windows, linux, flatpak, freebsd, web]
runs-on: ubuntu-latest
environment: release
permissions:
@@ -288,7 +380,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- - uses: actions/download-artifact@v4
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: release-*
path: artifacts
diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml
index f2c54375d..b568d4e78 100644
--- a/.github/workflows/windows-arm64.yml
+++ b/.github/workflows/windows-arm64.yml
@@ -36,11 +36,11 @@ jobs:
runs-on: windows-latest
timeout-minutes: 90
steps:
- - uses: actions/checkout@v4
- - uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: aarch64-pc-windows-msvc
- - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
+ - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: windows-arm64-package
- name: Install WiX
@@ -51,7 +51,7 @@ jobs:
env:
DIST: ${{ github.workspace }}\dist\arm64
run: ./packaging/windows/package.ps1 -Arch arm64
- - uses: actions/upload-artifact@v4
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: designcraft-windows-arm64
path: dist/arm64/*
@@ -64,7 +64,7 @@ jobs:
runs-on: windows-11-arm
timeout-minutes: 30
steps:
- - uses: actions/download-artifact@v4
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: designcraft-windows-arm64
path: dist
diff --git a/AGENTS.md b/AGENTS.md
index 3a2bcb59a..bedbfb6e6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,6 +1,6 @@
# DesignCraft — instructions for agents
-DesignCraft is a clean-room, open-source, Rust-native page-layout application targeting Adobe InDesign parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings with the same conventions: `../drawcraft` (Illustrator-class), `../photocraft` (Photoshop), `../pdfcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom).
+DesignCraft is a clean-room, open-source, Rust-native page-layout application targeting Adobe InDesign parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings with the same conventions: `../vectorcraft` (Illustrator-class), `../photocraft` (Photoshop), `../pdfcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom).
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/indesign/*.md` (`11-observed-ui.md` holds measured observations of the running app).
diff --git a/ASSETS.md b/ASSETS.md
index cfe1f807d..66479bd7b 100644
--- a/ASSETS.md
+++ b/ASSETS.md
@@ -8,6 +8,10 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| Asset | Author | Source | Licence | Notes |
|---|---|---|---|---|
+| `crates/ui-egui/src/i18n/uk.rs` (332 common phrases) | VectorCraft contributors | https://github.com/storytold/vectorcraft/blob/01f329aa99fa0c9517311c7b6cf7233ffc207742/crates/ui-egui/src/i18n/uk.tsv | MIT OR Apache-2.0 | Adapted for DesignCraft context; remaining Ukrainian phrases are original or credited below |
+| `crates/ui-egui/src/i18n/uk.rs` (68 common phrases) | PhotoCraft contributors | https://github.com/storytold/photocraft/blob/ed057bdc6fd89a858c5e0ea4eab158a2a7052dde/crates/ui-egui/src/i18n/uk.tsv | MIT OR Apache-2.0 | Adapted interface vocabulary |
+| `crates/ui-egui/src/i18n/uk.rs` (37 common phrases) | WordCraft contributors | https://github.com/storytold/wordcraft/blob/7fb7b013591e2593fbd038f90bbc535be318e5a3/crates/ui-egui/src/i18n/uk.tsv | MIT OR Apache-2.0 | Adapted interface vocabulary |
+| `crates/ui-egui/src/i18n/uk.rs` (15 common phrases) | DeckCraft contributors | https://github.com/storytold/deckcraft/blob/3a45c614a409349fc13d449f35bd98f3321f0df5/crates/ui-egui/src/i18n/uk.tsv | MIT OR Apache-2.0 | Adapted interface vocabulary; validation dictionaries are external and are not bundled |
| `assets/hyphenation/en-us.dic` | Grady Ward (Moby Hyphenator II word list); converted by DesignCraft contributors | https://www.gutenberg.org/ebooks/3204 (file `https://www.gutenberg.org/files/3204/files/mhyph.txt`, SHA-256 `eeb30474c86b8af3469035ec1a0913e35905325ca885290db9dfed9881e230ac`) | Public Domain ("Public Domain material by grant from the author, January, 2001"; Project Gutenberg: "Public domain in the USA") | ~165k words with break points, front-coded + deflate; regenerate with `cargo run --release -p designcraft-compose --example hyphgen -- mhyph.txt assets/hyphenation` |
| `assets/hyphenation/en-us.pat` | DesignCraft contributors (generated) | trained from `en-us.dic`'s source list by `crates/compose/src/hyphen/patgen.rs` (`examples/hyphgen.rs`) | Public Domain (CC0-1.0) | Our own Liang hyphenation patterns (no TeX/hyph-utf8 patterns used) |
| `assets/fonts/Inter-Medium.ttf` | Rasmus Andersson / The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | Open-source typeface |
@@ -60,3 +64,5 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| `docs/brand/artcraft-mark.svg`, `docs/brand/artcraft-mark.png` | ArtCraft / Storyteller | ArtCraft brand kit (project owner) | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | The mark; its outline is drawn in code by `crates/ui-egui/src/about.rs` (About splash, start screen) |
| `docs/brand/artcraft-mark-black.svg`, `docs/brand/artcraft-mark-black.png` | ArtCraft / Storyteller | ArtCraft brand kit (project owner) | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Mark for light backgrounds |
| `docs/brand/LICENSE-brand.txt` | (licence text) | ArtCraft Team | — | Terms for the ArtCraft Marks in `docs/brand/` |
+| `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") |
diff --git a/CLAUDE.md b/CLAUDE.md
index 7d0673992..dfe78adb6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,6 +1,6 @@
# DesignCraft — instructions for agents
-DesignCraft is a clean-room, open-source, Rust-native page-layout application targeting Adobe InDesign parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings with the same conventions: `../drawcraft` (Illustrator-class), `../photocraft` (Photoshop), `../pdfcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom).
+DesignCraft is a clean-room, open-source, Rust-native page-layout application targeting Adobe InDesign parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings with the same conventions: `../vectorcraft` (Illustrator-class), `../photocraft` (Photoshop), `../pdfcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom).
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/indesign/*.md` (`11-observed-ui.md` holds measured observations of the running app).
diff --git a/Cargo.lock b/Cargo.lock
index 76d00d16e..6473b84f8 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -635,6 +635,12 @@ dependencies = [
"wayland-client",
]
+[[package]]
+name = "cast"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
+
[[package]]
name = "cc"
version = "1.5.1"
@@ -855,7 +861,7 @@ dependencies = [
[[package]]
name = "designcraft"
-version = "0.3.0"
+version = "0.5.0"
dependencies = [
"designcraft-engine",
"designcraft-ui-egui",
@@ -871,7 +877,7 @@ dependencies = [
[[package]]
name = "designcraft-cli"
-version = "0.3.0"
+version = "0.5.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -887,7 +893,7 @@ dependencies = [
[[package]]
name = "designcraft-color"
-version = "0.3.0"
+version = "0.5.0"
dependencies = [
"kurbo",
"moxcms",
@@ -899,7 +905,7 @@ dependencies = [
[[package]]
name = "designcraft-compose"
-version = "0.3.0"
+version = "0.5.0"
dependencies = [
"designcraft-color",
"designcraft-doc",
@@ -915,7 +921,7 @@ dependencies = [
[[package]]
name = "designcraft-doc"
-version = "0.3.0"
+version = "0.5.0"
dependencies = [
"designcraft-color",
"designcraft-geom",
@@ -927,7 +933,7 @@ dependencies = [
[[package]]
name = "designcraft-engine"
-version = "0.3.0"
+version = "0.5.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -952,12 +958,13 @@ dependencies = [
"serde",
"serde_json",
"thiserror 2.0.21",
+ "wasm-bindgen-test",
"zip",
]
[[package]]
name = "designcraft-epub"
-version = "0.3.0"
+version = "0.5.0"
dependencies = [
"designcraft-color",
"designcraft-doc",
@@ -966,7 +973,7 @@ dependencies = [
[[package]]
name = "designcraft-fonts"
-version = "0.3.0"
+version = "0.5.0"
dependencies = [
"harfrust 0.13.3",
"kurbo",
@@ -978,7 +985,7 @@ dependencies = [
[[package]]
name = "designcraft-format"
-version = "0.3.0"
+version = "0.5.0"
dependencies = [
"designcraft-doc",
"serde",
@@ -989,7 +996,7 @@ dependencies = [
[[package]]
name = "designcraft-geom"
-version = "0.3.0"
+version = "0.5.0"
dependencies = [
"flo_curves",
"kurbo",
@@ -1002,7 +1009,7 @@ dependencies = [
[[package]]
name = "designcraft-idml"
-version = "0.3.0"
+version = "0.5.0"
dependencies = [
"designcraft-color",
"designcraft-doc",
@@ -1014,7 +1021,7 @@ dependencies = [
[[package]]
name = "designcraft-images"
-version = "0.3.0"
+version = "0.5.0"
dependencies = [
"designcraft-color",
"designcraft-fonts",
@@ -1025,7 +1032,7 @@ dependencies = [
[[package]]
name = "designcraft-mcp"
-version = "0.3.0"
+version = "0.5.0"
dependencies = [
"designcraft-engine",
"designcraft-geom",
@@ -1038,7 +1045,7 @@ dependencies = [
[[package]]
name = "designcraft-pdf"
-version = "0.3.0"
+version = "0.5.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1060,7 +1067,7 @@ dependencies = [
[[package]]
name = "designcraft-render"
-version = "0.3.0"
+version = "0.5.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1078,7 +1085,7 @@ dependencies = [
[[package]]
name = "designcraft-textimport"
-version = "0.3.0"
+version = "0.5.0"
dependencies = [
"designcraft-doc",
"quick-xml 0.38.4",
@@ -1087,7 +1094,7 @@ dependencies = [
[[package]]
name = "designcraft-tools"
-version = "0.3.0"
+version = "0.5.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1099,7 +1106,7 @@ dependencies = [
[[package]]
name = "designcraft-ui-egui"
-version = "0.3.0"
+version = "0.5.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1123,12 +1130,14 @@ dependencies = [
[[package]]
name = "designcraft-web"
-version = "0.3.0"
+version = "0.5.0"
dependencies = [
"designcraft-engine",
+ "designcraft-format",
"designcraft-ui-egui",
"eframe",
"egui",
+ "form_urlencoded",
"js-sys",
"log",
"rfd",
@@ -2550,6 +2559,16 @@ dependencies = [
"autocfg",
]
+[[package]]
+name = "minicov"
+version = "0.3.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d"
+dependencies = [
+ "cc",
+ "walkdir",
+]
+
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2673,6 +2692,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -3022,6 +3050,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+[[package]]
+name = "oorandom"
+version = "11.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
+
[[package]]
name = "open"
version = "5.4.4"
@@ -4746,6 +4780,45 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "wasm-bindgen-test"
+version = "0.3.79"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae7499dfd45780a0a91d7ee6bb9ac51970a4479a41a89da443fdda5a39547d42"
+dependencies = [
+ "async-trait",
+ "cast",
+ "js-sys",
+ "libm",
+ "minicov",
+ "nu-ansi-term",
+ "num-traits",
+ "oorandom",
+ "serde",
+ "serde_json",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm-bindgen-test-macro",
+ "wasm-bindgen-test-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-test-macro"
+version = "0.3.79"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b84b5ac638bfb168196a1a461fcc8f46a294a18b1b6be52133b4e0db122cc9f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "wasm-bindgen-test-shared"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4f692aa943ccd88363733b77063f32cfed5bc6cbea8e6e8b251b302f881606fe"
+
[[package]]
name = "wayland-backend"
version = "0.3.17"
@@ -5562,7 +5635,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
-version = "0.3.0"
+version = "0.5.0"
dependencies = [
"serde_json",
]
diff --git a/Cargo.toml b/Cargo.toml
index 1e2ae948e..af62549eb 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.3.0"
+version = "0.5.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
diff --git a/README.md b/README.md
index ff321c21a..6884a15e5 100644
--- a/README.md
+++ b/README.md
@@ -54,6 +54,7 @@
Quick start ·
Web ·
Architecture ·
+ Downloads ·
The Crafting Apps ·
License and credits
@@ -86,7 +87,8 @@ by its own renderer. Try it yourself with `File → New → Sample Document`, or
Line breaks are identical on screen and in PDF.
- **Fast.** Multithreaded SIMD rendering (vello_cpu), copy-on-write documents with O(1) undo
snapshots, and cached composition.
-- **Open.** A documented native format, IDML import and export, PNG export, and PDF on the roadmap.
+- **Open.** A documented native format, IDML import and export, and PNG, PDF (PDF/X-4, PDF/A-2b)
+ and EPUB export.
No subscription, no licence server, no telemetry.
- **Agent-native.** Every menu item, tool gesture, panel control and dialog can be driven over a
JSON control channel and an **MCP server**, so Claude and other agents can lay out and edit
@@ -121,6 +123,23 @@ include it.
To drive a running app, send JSON lines to `127.0.0.1:7979`. The protocol is described in
[`docs/control-protocol.md`](docs/control-protocol.md).
+### Logs
+
+The desktop app writes its `log` records to standard error and to `logs/designcraft.log` in the
+settings directory, beside `ui.json` and `prefs.json` (Linux `~/.config/designcraft/logs/`, or
+`$XDG_CONFIG_HOME/designcraft/logs/`; macOS `~/Library/Application Support/DesignCraft/logs/`;
+Windows `%APPDATA%\DesignCraft\logs\`). A start launched from a desktop menu or the Dock has no
+terminal, so this file is what to attach to a bug report: the crash guard's panic report and a
+failed crash-recovery save land there. Each launch moves the previous log to `designcraft.1.log`
+(and that one to `designcraft.2.log`), so the log of a run that crashed survives the next start.
+The file stops growing at 16 MiB. `--version` writes no file.
+
+| Variable | Effect |
+|---|---|
+| `RUST_LOG` | Log levels for standard error and the log file. Default: `info` for DesignCraft's own crates, `warn` for everything else. env_logger-style directives replace that, e.g. `RUST_LOG=debug`, `RUST_LOG=warn,designcraft_render=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`designcraft*=debug`). |
+
+The logger is `apps/designcraft/src/logging.rs`; the web build logs to the browser console instead.
+
### Web
```sh
@@ -154,6 +173,51 @@ egui frontend is a separate crate, so the UI can be swapped without touching the
- **Bundled assets:** every one is listed with its licence in [`ASSETS.md`](ASSETS.md)
- **App icon and colour:** a calico cat in a polka-dot scarf on DesignCraft green `#7bb51c`; see [`assets/app-icon/`](assets/app-icon/README.md)
+## Downloads
+
+**New to DesignCraft?** Download it from the [DesignCraft page on getartcraft.com](https://getartcraft.com/apps/designcraft). That's the easiest way to install it.
+
+**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/designcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/designcraft/releases) has earlier versions and their notes. `` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
+
+### Windows
+
+| Build | Installer | Portable |
+|---|---|---|
+| x64 (64-bit Intel/AMD) | `designcraft--windows-x64.msi` | `designcraft--windows-x64-portable.zip` |
+| arm64 (Snapdragon and other ARM PCs) | `designcraft--windows-arm64.msi` | `designcraft--windows-arm64-portable.zip` |
+| x86 (32-bit) | `designcraft--windows-x86.msi` | `designcraft--windows-x86-portable.zip` |
+
+Installers and executables are code-signed.
+
+### macOS
+
+| Build | File | Notes |
+|---|---|---|
+| App, universal (Apple silicon + Intel) | `designcraft--macos-universal.dmg` | Signed and notarized |
+| Command-line tool, universal | `designcraft-cli--macos-universal.zip` | Signed and notarized |
+
+### Linux
+
+| Format | x86_64 | aarch64 (ARM64) | Notes |
+|---|---|---|---|
+| AppImage | `designcraft--linux-x86_64.AppImage` | `designcraft--linux-aarch64.AppImage` | Runs anywhere; updates itself with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) |
+| Flatpak | `designcraft--linux-x86_64.flatpak` | `designcraft--linux-aarch64.flatpak` | Sandboxed; `flatpak install --user ` |
+| Debian/Ubuntu | `designcraft--linux-x86_64.deb` | `designcraft--linux-aarch64.deb` | |
+| Fedora/RHEL/openSUSE | `designcraft--linux-x86_64.rpm` | `designcraft--linux-aarch64.rpm` | |
+| Tarball | `designcraft--linux-x86_64.tar.gz` | `designcraft--linux-aarch64.tar.gz` | Unpack anywhere |
+
+### FreeBSD
+
+| Build | File |
+|---|---|
+| x86_64 | `designcraft--freebsd-x86_64.tar.gz` |
+
+### Web (WebAssembly)
+
+| Build | File | Notes |
+|---|---|---|
+| Static site | `designcraft-web-.zip` | Runs in a modern browser; host it on any static server |
+
## The Crafting Apps
DesignCraft is one of the **Crafting Apps**: free, open-source creative tools from the
diff --git a/ROADMAP.md b/ROADMAP.md
index aa4c17396..bb517b5a3 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,5 +1,7 @@
# DesignCraft roadmap
+- FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Linear/radial, midpoint, archival and print policies have synthetic regression coverage.
+
- AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it.
- AR1: Arabic IDML controls now retain character direction, kashida switches, diacritic offsets/forms, paragraph policy values and independent story/table directions. Shaping preserves joining context and script/language selection; paragraph-level bidi, contextual digit conversion, mark-safe elongation, RTL columns/tables and physical column insertion have regression coverage. Vendor-specific justification and diacritic presets remain explicit preflight warnings; see docs/arabic-typography.md.
@@ -12,6 +14,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
**Working today**
- Document model: facing/non-facing spreads, parent pages with page-number markers, sections, layers, frames (text / graphic / unassigned), threaded stories, paragraph/character/object styles with based-on, swatches (process/spot/tints/gradients), corner options, text wrap, drop shadow, opacity/blend.
- Text engine: Knuth–Plass paragraph composer and single-line composer, dictionary hyphenation (public-domain Moby list + our own trained Liang patterns), justification with word/letter/glyph-scaling ranges, keep options (keep with next, keep lines together, widows/orphans), balance ragged lines, optical margin alignment, hyphenation zone/limit, columns, threading across frames and spreads, tabs, bullets/numbering, rules, shading, baseline grid, first-baseline options, vertical justification, wrap exclusions, overset detection, caret/hit testing.
+- FID2: IDML underline/strikethrough Text Color resets survive style inheritance and roundtrip. Raster and PDF paint underlines behind glyphs, retain strikethrough above, and center rules on their offsets; synthetic overlap tests cover both backends.
- Rendering: vello_cpu (SIMD, multithreaded) — pages, items, images with mip levels, gradients, composed text.
- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts, document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches, Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes.
- Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows.
@@ -88,11 +91,12 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Edit ▸ Menus (`window.hideMenuItem`): hide menu items; menus with hidden items end in Show All Menu Items.
- View ▸ Separations Preview (`view.separations`): one process plate as ink density, or areas over an ink limit in red (a GCR preview of the rendered view; spot plates and the flattener preview aren't there).
- Separations Preview plates now come from each object's own colour (CMYK as authored; RGB and images through the colour settings, images via a cached 17³ table), so pure black shows only on the black plate.
-- Language (Character panel ▸ Language, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Hyphenation dictionaries for other languages aren't bundled yet.
+- Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Hyphenation dictionaries for other languages aren't bundled yet.
- Books (`book.new/open/add/remove/list/styleSource/paginate/syncStyles/exportPdf`, Book panel): `.dcbook` files, continuous page numbering, style sync from a style source, one merged PDF.
- Track Changes (`changes.track/list/acceptAll/rejectAll`, Type ▸ Track Changes): typing is marked added (highlighted on screen), deletions are kept but hidden from layout and exports until accepted or rejected. Per-change accept/reject and authors aren't there yet.
- Track Changes: per-change Accept / Reject (`changes.accept`, `changes.reject`) and a Track Changes panel listing every change.
- Move table rows and columns (`table.moveRow`, `table.moveColumn`, Table panel buttons; with whole rows or columns selected, drag them with the Type tool — `table.dropCells`).
+- Distribute Rows Evenly (`table.distributeRows`, Table menu and panel): selected rows receive equal fixed heights preserving their composed total height, with undo/redo. Row-spanning merges and rows outside the composed frames are rejected; shrinking tall cells can overset their text.
- Object States (`states.create/show/rename/release/list`, Object States panel): multi-state objects show one state on screen and in PDF.
- Buttons (`button.set/clear/list`, Buttons and Forms panel): go to page / next / previous / first / last / URL as PDF link annotations. Form fields too (`form.set/clear/list`: text fields, check boxes, combo and list boxes, signature fields; required, multiline, defaults) exported as AcroForm widgets through an incremental update (not in PDF/X).
- Power Zoom (Hand tool: press and hold still for half a second, or Alt-press): zoom out to the spread, aim the red view rectangle, release to zoom back in there.
@@ -103,7 +107,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Transparency flattener (`file.exportPdf {flatten: high|medium|low|ppi}`, Edit ▸ Transparency Flattener Presets): each object involving transparency becomes an opaque image of its area over what's beneath it, so everything else stays vector (spreads with transparent parent items are rasterised whole).
- Video and sound (place .mp4/.mov/.webm/.mp3/.wav…, `media.options/get`, Media panel): media frames show a poster or placeholder on screen, in print and in PDF, and export as HTML5 ``/`` in EPUB. Interactive PDF export (`file.exportPdf {media: true}`, File ▸ Export PDF (Interactive)) embeds each file and plays it in a Screen annotation through a rendition action (controls, loop, play on page load). No in-app playback.
- Right-to-left text (World-Ready basics): right-to-left runs are shaped right to left, lines are reordered by the Unicode Bidirectional Algorithm (matched against the reference implementation), Paragraph Direction (`type.para {direction}`, Paragraph panel, IDML ParagraphDirection, EPUB `dir`). Digits (`digits`: Default, Arabic, Hindi, Farsi, Native by language; Character panel for right-to-left text; IDML DigitsType) draw 0–9 in another script. Kashidas (`kashidas` paragraph attribute, on by default): justified Arabic lines stretch at one join per word (after seen/sad when possible, never inside lam–alef) with a drawn tatweel before word spaces widen. Carets follow the drawing: the caret before a right-to-left glyph sits at its right edge, arrow keys move as drawn (by words in the line's direction), clicks find the nearest drawn caret position and selections highlight the selected glyphs. Binding: Right to Left (Document Setup, `layout.documentSetup {binding}`, IDML PageBinding) makes page 1 a left page and lays spreads out right to left.
-- Interface language (Edit ▸ Interface Language, `app.language`): German, French, Spanish, Japanese, Simplified Chinese and Arabic menu titles, common menu items and panel names (our own translations). Common dialog, panel and tooltip labels are also localized, with English fallback for untranslated strings. Arabic translations cover every key in the shared interface translation table, including menu commands and utility labels. The Type menu's Story Direction, Horizontal/Vertical, Tate-Chu-Yoko, Ruby… and Kenten are translated too. Arabic labels use Unicode bidi run ordering and contextual shaping; welcome and new-document form layouts follow RTL direction independently of document binding. CJK and Arabic glyphs come from [craft-fonts](https://github.com/storytold/craft-fonts) when built with `CRAFT_FONTS_DIR` (all releases): BIZ UDPGothic and Noto Sans CJK SC for the UI (Chinese forms first in the Chinese interface), Noto Sans Arabic, and Mincho for Japanese documents (the web build embeds BIZ UDPGothic only); otherwise from system fonts.
+- Interface language (Edit ▸ Interface Language, `app.language`): German, French, Spanish, Japanese, Simplified Chinese and Arabic menu titles, common menu items and panel names (our own translations). Common dialog, panel and tooltip labels are also localized, with English fallback for untranslated strings. Arabic translations cover every key in the shared interface translation table, including menu commands and utility labels. The Type menu's Story Direction, Horizontal/Vertical, Tate-Chu-Yoko, Ruby… and Kenten are translated too. Arabic labels use Unicode bidi run ordering and contextual shaping; welcome and new-document form layouts follow RTL direction independently of document binding. CJK and Arabic glyphs come from [craft-fonts](https://github.com/storytold/craft-fonts) when built with `CRAFT_FONTS_DIR` (all releases): BIZ UDPGothic and Noto Sans CJK SC for the UI (Chinese forms first in the Chinese interface), Noto Sans Arabic, and Mincho for Japanese documents (the web build embeds BIZ UDPGothic only); otherwise from system fonts. Ukrainian covers all 1,430 existing catalog keys, including publishing dialogs and built-in names, with persisted selection, grammatical count captions, English fallback and bundled Cyrillic UI glyph coverage.
- Page transitions (`page.transition/transitions`, Page Transitions panel): twelve transition types with speed and direction per spread, written into exported PDFs as `/Trans` entries through an incremental update.
- PDF/X-4 export (`file.exportPdf {standard: "x4"}`): an output intent with our own Generic CMYK ICC profile (lut16 tables generated from the parametric press model), GTS_PDFXVersion in Info and XMP, and built-in PDF/X-4 checks reported as warnings. qpdf finds no errors in the result.
- EPS place: placed at its (high-resolution) bounding box and shown, printed and exported through its TIFF preview, or a placeholder when it has none. PostScript itself isn't interpreted.
@@ -161,7 +165,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel sets type, angle, reverse and edits stops on a ramp (drag, add, drag off to remove, location, colour). Edited gradients are unnamed swatches; `object.gradient`; the vector round-trips through IDML.
- Document Setup (⌥⌘P): intent, number of pages, start page # (an even start begins with a left page), facing pages, size presets and orientation, bleed and slug per edge; `layout.documentSetup {}` reports the setup.
- Stroke panel: cap, join, miter limit, alignment, type, Start/End arrowheads (12 kinds, drawn on screen and in PDF; the path is shortened under pointed heads) and gap colour under dashes and dots.
-- Preferences (⌘K: General, Type, Units & Increments, Grids, Guides & Pasteboard, Display Performance) backed by `prefs.set` (application) and `document.preferences` (units, increments, grids — undoable, saved with the document); scaling applies to content and scales stroke weights (Include Stroke Weight), and X/Y/W/H measure the stroke's outer edge (Dimensions Include Stroke Weight). Quick Apply (⌘Return) finds paragraph, character and object styles as well as commands.
+- Preferences (⌘K: General, Type, Units & Increments, Grids, Guides & Pasteboard, Display Performance) backed by `prefs.set` (application) and `document.preferences` (units, increments, grids — undoable, saved with the document; right-click a ruler to pick its units, the horizontal and vertical rulers each have their own); scaling applies to content and scales stroke weights (Include Stroke Weight), and X/Y/W/H measure the stroke's outer edge (Dimensions Include Stroke Weight). Quick Apply (⌘Return) finds paragraph, character and object styles as well as commands.
- Parent item overrides: Cmd+Shift-click a parent item on a page (or Override All Parent Page Items, ⌥⇧⌘L) makes an editable local copy that hides the parent's; Remove All Local Overrides and Detach All Objects from Parent in the Pages panel menu.
- Place PDF: placed PDF pages are sized by their crop box, drawn on screen through hayro (with mip levels) and embedded in exported PDFs as vector form XObjects (krilla), `` in IDML.
- Menus expose the engine's features (TOC, numbering & sections, text variables, hyperlinks, cross-references, footnotes, step and repeat, spelling, fitting/content, effects, Data Merge, Preflight, EPUB); any menu command with parameters gets a dialog generated from its parameter documentation.
diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs
index 9e9eba25d..b1819c357 100644
--- a/apps/designcraft-cli/src/main.rs
+++ b/apps/designcraft-cli/src/main.rs
@@ -1,7 +1,8 @@
//! Headless DesignCraft.
//!
//! ```text
-//! designcraft-cli run [--in FILE.designcraft|FILE.idml | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--export OUT.png|.jpg|.pdf|.designcraft|.idml|.epub] [--pdf-options JSON] [--all-pages DIR]
+//! designcraft-cli run [--in FILE.designcraft|FILE.idml | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT.png|.jpg|.pdf|.designcraft|.idml|.epub] [--all-pages DIR]
+//! # --page, --scale and --pdf-options apply to the exports that follow them
//! designcraft-cli commands [FILTER] # list commands (JSON), optionally only ids/labels/menus containing FILTER
//! designcraft-cli describe ID # one command: label, menu, shortcut, parameters
//! designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]
@@ -11,6 +12,7 @@
//! designcraft-cli mcp [--connect PORT] [--sample] # MCP server over stdio (docs/mcp.md)
//! designcraft-cli perf [--pages N] [--frames N] [--chars N] [--images N] [--runs N] [--strict] # budgets on a synthetic stress document
//! designcraft-cli bench FILE [--runs N] # the same measurements on one document
+//! designcraft-cli validate-pdf FILE.pdf # run the built-in PDF/X-4 conformance checks
//! designcraft-cli links # Discord, website, app page and GitHub links
//! designcraft-cli --version # print the version
//! ```
@@ -33,6 +35,19 @@ mod perf;
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]\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli links\n designcraft-cli --version";
+
+/// The links line under the usage block.
+fn usage_footer() -> String {
+ format!(
+ "\nCommunity: {} · {} · {}",
+ designcraft_engine::links::DISCORD,
+ designcraft_engine::links::APP_PAGE,
+ designcraft_engine::links::GITHUB
+ )
+}
+
/// stdout went away. A reader that stopped early (a closed pipe) ends the program quietly, as
/// ripgrep does; any other write error is reported.
fn stdout_failed(e: std::io::Error) -> ! {
@@ -70,21 +85,22 @@ fn main() -> ExitCode {
Some("mcp") => report(mcp(&args[1..])),
Some("perf") => report(perf::perf(&args[1..])),
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::*;
outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}\nIssues {ISSUES}");
ExitCode::SUCCESS
}
+ // Asking for help is not an error: it goes to stdout and succeeds. An unknown command
+ // still prints the same usage to stderr and fails.
+ Some("--help" | "-h" | "help") => {
+ outln!("{USAGE}");
+ outln!("{}", usage_footer());
+ ExitCode::SUCCESS
+ }
_ => {
- eprintln!(
- "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\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]\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli links\n designcraft-cli --version"
- );
- eprintln!(
- "\nCommunity: {} · {} · {}",
- designcraft_engine::links::DISCORD,
- designcraft_engine::links::APP_PAGE,
- designcraft_engine::links::GITHUB
- );
+ eprintln!("{USAGE}");
+ eprintln!("{}", usage_footer());
ExitCode::FAILURE
}
}
@@ -100,6 +116,16 @@ fn report(r: Result<(), String>) -> ExitCode {
}
}
+fn validate_pdf(path: Option<&str>) -> Result<(), String> {
+ let path = path.ok_or("usage: designcraft-cli validate-pdf FILE.pdf")?;
+ let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
+ let issues = designcraft_pdf::check_pdfx4(&bytes);
+ let valid = issues.is_empty();
+ let report = json!({"path": path, "standard": "PDF/X-4", "valid": valid, "issues": issues});
+ outln!("{}", serde_json::to_string_pretty(&report).map_err(|e| e.to_string())?);
+ if valid { Ok(()) } else { Err(format!("{path} failed PDF/X-4 validation")) }
+}
+
/// `mcp` (headless, in-process engine) or `mcp --connect PORT|HOST:PORT` (drive a running app
/// started with `designcraft --control PORT`). JSON-RPC on stdin/stdout; logs on stderr.
fn mcp(args: &[String]) -> Result<(), String> {
@@ -136,7 +162,36 @@ fn mcp(args: &[String]) -> Result<(), String> {
Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())
}
+/// The first `--page`, `--scale` or `--pdf-options` with no `--export` or `--all-pages` after it.
+/// They apply to the exports that follow them, so one after the last export would do nothing.
+fn option_after_last_export(args: &[String]) -> Option<&str> {
+ let mut unused = None;
+ let mut it = args.iter();
+ while let Some(a) = it.next() {
+ match a.as_str() {
+ "--page" | "--scale" | "--pdf-options" => {
+ unused = unused.or(Some(a.as_str()));
+ it.next();
+ }
+ "--export" | "--all-pages" => {
+ unused = None;
+ it.next();
+ }
+ "--in" | "--cmd" => {
+ it.next();
+ }
+ _ => {}
+ }
+ }
+ unused
+}
+
fn run(args: &[String]) -> Result<(), String> {
+ if let Some(opt) = option_after_last_export(args) {
+ return Err(format!(
+ "{opt} has no --export or --all-pages after it; options apply to the exports that follow them, so put it before its --export"
+ ));
+ }
let mut s = Session::new();
let mut page = 0usize;
let mut scale = 1.0f64;
diff --git a/apps/designcraft-cli/tests/cli.rs b/apps/designcraft-cli/tests/cli.rs
index 75f6d90b9..1a06d13b0 100644
--- a/apps/designcraft-cli/tests/cli.rs
+++ b/apps/designcraft-cli/tests/cli.rs
@@ -67,6 +67,26 @@ fn run_cmd_references_and_describe() {
assert!(v.as_array().unwrap().iter().all(|c| c.to_string().contains("footnote")));
}
+/// `designcraft-cli --help` printed usage to stderr and exited 1; asking for help is not an
+/// error (#243). It now goes to stdout and succeeds, while an unknown command keeps the same
+/// usage on stderr and fails.
+#[test]
+fn help_goes_to_stdout_and_unknown_args_to_stderr() {
+ for flag in ["--help", "-h", "help"] {
+ let out = cli().arg(flag).output().unwrap();
+ let stdout = String::from_utf8_lossy(&out.stdout);
+ assert!(out.status.success(), "{flag}: {:?}", out.status);
+ assert!(stdout.starts_with("usage: designcraft-cli"), "{flag}: {stdout:?}");
+ assert!(stdout.contains("designcraft-cli --version"), "{flag}: {stdout:?}");
+ assert!(out.stderr.is_empty(), "{flag} wrote to stderr: {}", String::from_utf8_lossy(&out.stderr));
+ }
+ // An unknown option is still an error: usage on stderr, nothing on stdout, non-zero exit.
+ let out = cli().arg("--no-such-option").output().unwrap();
+ assert!(!out.status.success(), "{:?}", out.status);
+ assert!(out.stdout.is_empty(), "unexpected stdout: {}", String::from_utf8_lossy(&out.stdout));
+ assert!(String::from_utf8_lossy(&out.stderr).starts_with("usage: designcraft-cli"));
+}
+
/// `designcraft-cli commands | head -1` panicked with "failed printing to
/// stdout: Broken pipe (os error 32)" and exit status 101.
#[test]
@@ -80,3 +100,25 @@ fn closed_stdout_ends_quietly() {
let err = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success() && !err.contains("panicked"), "{:?}: {err}", out.status);
}
+
+/// `--scale`, `--page` and `--pdf-options` apply to the exports after them; one with no export
+/// after it was silently ignored.
+#[test]
+fn run_export_options_come_before_their_export() {
+ let dir = std::env::temp_dir().join(format!("dc-cli-order-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let (a, b) = (dir.join("a.png"), dir.join("b.png"));
+ let (a, b) = (a.to_str().unwrap(), b.to_str().unwrap());
+ for (opt, val) in [("--scale", "0.5"), ("--page", "1"), ("--pdf-options", r#"{"pages":"1"}"#)] {
+ let out = cli().args(["run", "--sample", "--export", a, opt, val]).output().unwrap();
+ let err = String::from_utf8_lossy(&out.stderr);
+ assert!(!out.status.success(), "{opt} after the last --export: {err}");
+ assert!(err.contains(opt) && err.contains("before"), "{err}");
+ }
+ assert!(!std::path::Path::new(a).exists(), "nothing is exported when the arguments are wrong");
+ let out = cli().args(["run", "--sample", "--scale", "0.5", "--export", a, "--scale", "0.25", "--export", b]).output().unwrap();
+ let err = String::from_utf8_lossy(&out.stderr);
+ assert!(out.status.success(), "{err}");
+ assert!(err.contains("wrote") && std::path::Path::new(a).exists() && std::path::Path::new(b).exists(), "{err}");
+ let _ = std::fs::remove_dir_all(&dir);
+}
diff --git a/apps/designcraft-web/Cargo.toml b/apps/designcraft-web/Cargo.toml
index d27b3c416..75775c31e 100644
--- a/apps/designcraft-web/Cargo.toml
+++ b/apps/designcraft-web/Cargo.toml
@@ -15,6 +15,8 @@ path = "src/main.rs"
[dependencies]
designcraft-engine = { workspace = true }
+designcraft-format = { workspace = true }
+form_urlencoded = "1.2"
designcraft-ui-egui = { workspace = true }
egui = { workspace = true }
log = { workspace = true }
diff --git a/apps/designcraft-web/src/browser_policy.rs b/apps/designcraft-web/src/browser_policy.rs
new file mode 100644
index 000000000..524df6cc5
--- /dev/null
+++ b/apps/designcraft-web/src/browser_policy.rs
@@ -0,0 +1,67 @@
+//! Pure browser startup and download metadata policies.
+
+/// Query flags are case-sensitive decoded keys. Values are ignored: `?sample=false`
+/// still enables sample startup, and repeated keys have the same presence semantics.
+pub(crate) fn has_query_flag(query: &str, flag: &str) -> bool {
+ let query = query.strip_prefix('?').unwrap_or(query);
+ form_urlencoded::parse(query.as_bytes()).any(|(key, _)| key == flag)
+}
+
+pub(crate) fn mime_for(name: &str) -> &'static str {
+ match name.rsplit('.').next().map(str::to_ascii_lowercase).as_deref() {
+ Some("png") => "image/png",
+ Some("jpg" | "jpeg") => "image/jpeg",
+ Some("pdf") => "application/pdf",
+ Some("designcraft") => designcraft_format::MIME,
+ Some("idml") => "application/vnd.adobe.indesign-idml-package",
+ _ => "application/octet-stream",
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{has_query_flag, mime_for};
+
+ #[test]
+ fn startup_flags_match_complete_decoded_keys() {
+ for flag in ["sample", "webgl"] {
+ for query in [format!("?{flag}"), format!("?{flag}=false"), format!("?{flag}=1&{flag}=0"), format!("?other=value&{flag}")] {
+ assert!(has_query_flag(&query, flag), "{query}");
+ }
+ for query in [
+ String::new(),
+ format!("?note={flag}"),
+ format!("?{flag}d=1"),
+ format!("?note=not_a_{flag}"),
+ "?note=%73ample%77ebgl".to_string(),
+ format!("?{flag}+extra=1"),
+ format!("?{flag}%26extra=1"),
+ format!("?{flag}%3Dextra=1"),
+ ] {
+ assert!(!has_query_flag(&query, flag), "{query}");
+ }
+ }
+ assert!(has_query_flag("?webgl&sample", "sample"));
+ assert!(has_query_flag("?webgl&sample", "webgl"));
+ assert!(has_query_flag("?%73ample", "sample"));
+ assert!(has_query_flag("?%77ebgl", "webgl"));
+ assert!(!has_query_flag("?Sample&WEBGL", "sample"));
+ assert!(!has_query_flag("?Sample&WEBGL", "webgl"));
+ }
+
+ #[test]
+ fn download_mime_matches_native_package_and_existing_exports() {
+ for (name, expected) in [
+ ("Quarterly.designcraft", designcraft_format::MIME),
+ ("日本語.multi.DEsignCraft", designcraft_format::MIME),
+ ("page.pdf", "application/pdf"),
+ ("page.png", "image/png"),
+ ("page.jpg", "image/jpeg"),
+ ("page.JPEG", "image/jpeg"),
+ ("document.idml", "application/vnd.adobe.indesign-idml-package"),
+ ("unknown.bin", "application/octet-stream"),
+ ] {
+ assert_eq!(mime_for(name), expected, "{name}");
+ }
+ }
+}
diff --git a/apps/designcraft-web/src/main.rs b/apps/designcraft-web/src/main.rs
index ed05d5950..527baff47 100644
--- a/apps/designcraft-web/src/main.rs
+++ b/apps/designcraft-web/src/main.rs
@@ -12,9 +12,13 @@
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox.
//!
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?sample` opens the
-//! sample magazine on start.
+//! sample magazine on start. Flags match case-sensitive, percent-decoded query keys; values
+//! are ignored and repeated keys have the same presence semantics (`?sample=false` enables it).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+#[cfg(any(target_arch = "wasm32", test))]
+mod browser_policy;
+
#[cfg(target_arch = "wasm32")]
mod web;
diff --git a/apps/designcraft-web/src/web.rs b/apps/designcraft-web/src/web.rs
index c0466b691..f5659863f 100644
--- a/apps/designcraft-web/src/web.rs
+++ b/apps/designcraft-web/src/web.rs
@@ -1,5 +1,6 @@
//! The browser shell: web `Services`, drag-and-drop, and the eframe web runner.
+use super::browser_policy::{has_query_flag, mime_for};
use designcraft_engine::Session;
use designcraft_ui_egui::{DesignApp, Inbox, Services};
use wasm_bindgen::JsCast as _;
@@ -41,7 +42,7 @@ pub fn start() {
return;
};
let mut options = eframe::WebOptions::default();
- if query().contains("webgl")
+ if has_query_flag(&query(), "webgl")
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
{
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
@@ -56,7 +57,7 @@ pub fn start() {
}
let inbox: Inbox = Inbox::default();
let mut app = DesignApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
- if query().contains("sample") {
+ if has_query_flag(&query(), "sample") {
let _ = app.run("file.newSample", serde_json::json!({}));
}
Ok(Box::new(WebShell { app, inbox }))
@@ -103,7 +104,8 @@ impl eframe::App for WebShell {
self.app.logic(ctx);
}
- fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
+ fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
+ designcraft_ui_egui::drop_key_name_text(raw, ctx.text_edit_focused());
self.app.raw_input_hook(raw);
}
@@ -173,14 +175,3 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000).map_err(js)?;
Ok(())
}
-
-fn mime_for(name: &str) -> &'static str {
- match name.rsplit('.').next().map(str::to_ascii_lowercase).as_deref() {
- Some("png") => "image/png",
- Some("jpg" | "jpeg") => "image/jpeg",
- Some("pdf") => "application/pdf",
- Some("designcraft") => "application/json",
- Some("idml") => "application/vnd.adobe.indesign-idml-package",
- _ => "application/octet-stream",
- }
-}
diff --git a/apps/designcraft/src/logging.rs b/apps/designcraft/src/logging.rs
new file mode 100644
index 000000000..d3e0dbea7
--- /dev/null
+++ b/apps/designcraft/src/logging.rs
@@ -0,0 +1,437 @@
+//! The desktop app's logger: `log::` records go to standard error and to a log file.
+//!
+//! Without a logger every `log::warn!`/`log::error!` in the workspace (the crash guard's panic
+//! report, a failed crash-recovery save, font folders that can't be read) vanished. A launch from
+//! a desktop menu has no terminal, so the file is what a bug report can attach:
+//! `/logs/designcraft.log` (the directory beside `ui.json` and `prefs.json`, see
+//! `config_dir` in `main.rs`). Each start moves the previous log to `designcraft.1.log` (and that
+//! one to `.2`), so the log of a run that crashed survives the next launch.
+//!
+//! Levels: `info` for DesignCraft's own crates, `warn` for everything else (wgpu and naga are
+//! chatty). `RUST_LOG` replaces that with env_logger-style directives: `debug`,
+//! `warn,designcraft_render=trace`, `wgpu_core=info`. A directive ending in `*` matches every
+//! target that starts with it (`designcraft*=debug`).
+//!
+//! Records logged before the settings directory is known are kept (up to [`MAX_PENDING`]) and
+//! written once the file is attached. Writing never panics: a file that can't be created or
+//! written leaves standard error as the only sink.
+
+use std::fs::File;
+use std::io::Write;
+use std::path::{Path, PathBuf};
+use std::sync::{Mutex, OnceLock, PoisonError};
+use std::time::SystemTime;
+
+use log::LevelFilter;
+
+/// The current log file's name inside the log directory.
+pub const LOG_FILE: &str = "designcraft.log";
+/// How many previous logs are kept (`designcraft.1.log` … `designcraft..log`).
+pub const KEEP: usize = 2;
+/// The log file stops growing past this size (a runaway warning can't fill the disk).
+pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
+/// Records kept in memory until the log file is attached.
+pub const MAX_PENDING: usize = 512;
+/// The built-in filter when `RUST_LOG` is unset or empty.
+pub const DEFAULT_FILTER: &str = "warn,designcraft*=info";
+
+/// Per-target level filter parsed from env_logger-style directives.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Filter {
+ default: LevelFilter,
+ directives: Vec<(String, LevelFilter)>,
+}
+
+impl Filter {
+ /// Parse `spec`; unknown levels and empty parts are skipped. Without a bare level, targets no
+ /// directive names log errors only.
+ pub fn parse(spec: &str) -> Filter {
+ let mut f = Filter { default: LevelFilter::Error, directives: Vec::new() };
+ for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) {
+ match part.split_once('=') {
+ Some((name, level)) => {
+ let name = name.trim();
+ if let (false, Some(level)) = (name.is_empty(), parse_level(level)) {
+ f.directives.push((name.to_owned(), level));
+ }
+ }
+ None => match parse_level(part) {
+ Some(level) => f.default = level,
+ // A bare target name: everything from it (env_logger does the same).
+ None => f.directives.push((part.to_owned(), LevelFilter::Trace)),
+ },
+ }
+ }
+ f
+ }
+
+ /// The level that applies to `target` (the most specific matching directive wins).
+ pub fn level_for(&self, target: &str) -> LevelFilter {
+ self.directives.iter().filter(|(name, _)| matches(name, target)).max_by_key(|(name, _)| name.len()).map_or(self.default, |(_, level)| *level)
+ }
+
+ /// The most verbose level any target can reach (for `log::set_max_level`).
+ pub fn max(&self) -> LevelFilter {
+ self.directives.iter().map(|(_, level)| *level).fold(self.default, Ord::max)
+ }
+}
+
+fn parse_level(s: &str) -> Option {
+ s.trim().parse().ok()
+}
+
+/// `name` matches `target` itself and its submodules (`a` matches `a` and `a::b`, not `ab`);
+/// `name*` matches every target starting with `name`.
+fn matches(name: &str, target: &str) -> bool {
+ match name.strip_suffix('*') {
+ Some(prefix) => target.starts_with(prefix),
+ None => target.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with("::")),
+ }
+}
+
+/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
+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)
+}
+
+fn numbered(dir: &Path, n: usize) -> PathBuf {
+ if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("designcraft.{n}.log")) }
+}
+
+/// Shift the previous logs up one (`.log` → `.1.log` → … → `..log`, the oldest dropped)
+/// and create a fresh, empty log file in `dir` (created if missing).
+pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
+ std::fs::create_dir_all(dir)?;
+ for n in (1..=KEEP).rev() {
+ match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) {
+ Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
+ _ => {}
+ }
+ }
+ let path = numbered(dir, 0);
+ let file = File::create(&path)?;
+ Ok((path, file))
+}
+
+/// One formatted log line: ` [] : \n`.
+pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
+ let message = message.strip_suffix('\n').unwrap_or(message);
+ format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
+}
+
+/// Where formatted lines go besides standard error: kept in memory until a file is attached,
+/// then appended to it until it reaches its size cap.
+pub struct Sink {
+ file: Option,
+ pending: Vec,
+ dropped: usize,
+ written: u64,
+ max_bytes: u64,
+ capped: bool,
+}
+
+impl Sink {
+ pub fn new(max_bytes: u64) -> Sink {
+ Sink { file: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false }
+ }
+
+ /// Append `line` to the file, or keep it until [`Sink::attach`].
+ pub fn write(&mut self, line: &str) {
+ if self.file.is_none() {
+ if self.pending.len() < MAX_PENDING {
+ self.pending.push(line.to_owned());
+ } else {
+ self.dropped = self.dropped.saturating_add(1);
+ }
+ return;
+ }
+ self.append(line);
+ }
+
+ /// Start writing to `file`, first the lines kept so far.
+ pub fn attach(&mut self, file: File) {
+ self.file = Some(file);
+ for line in std::mem::take(&mut self.pending) {
+ self.append(&line);
+ }
+ if self.dropped > 0 {
+ let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped);
+ self.dropped = 0;
+ self.append(¬e);
+ }
+ }
+
+ fn append(&mut self, line: &str) {
+ if self.capped {
+ return;
+ }
+ let Some(file) = self.file.as_mut() else { return };
+ let len = u64::try_from(line.len()).unwrap_or(u64::MAX);
+ let text = if self.written.saturating_add(len) > self.max_bytes {
+ self.capped = true;
+ format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes)
+ } else {
+ self.written = self.written.saturating_add(len);
+ line.to_owned()
+ };
+ // A full disk or a vanished file must not take the app down: drop the file sink.
+ if file.write_all(text.as_bytes()).is_err() {
+ self.file = None;
+ self.capped = true;
+ }
+ }
+}
+
+/// The installed logger: a [`Filter`], standard error and a [`Sink`].
+pub struct AppLogger {
+ filter: Filter,
+ stderr: bool,
+ sink: Mutex,
+}
+
+impl AppLogger {
+ pub fn new(filter: Filter, stderr: bool) -> AppLogger {
+ AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)) }
+ }
+
+ /// Rotate the logs in `dir` and send records to the fresh file; returns its path.
+ pub fn attach_dir(&self, dir: &Path) -> Result {
+ let (path, file) = rotate(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
+ self.sink.lock().unwrap_or_else(PoisonError::into_inner).attach(file);
+ Ok(path)
+ }
+}
+
+impl log::Log for AppLogger {
+ fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
+ metadata.level() <= self.filter.level_for(metadata.target())
+ }
+
+ fn log(&self, record: &log::Record<'_>) {
+ if !self.enabled(record.metadata()) {
+ return;
+ }
+ let thread = std::thread::current();
+ let line =
+ format_line(×tamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string());
+ if self.stderr {
+ let _ = std::io::stderr().write_all(line.as_bytes());
+ }
+ self.sink.lock().unwrap_or_else(PoisonError::into_inner).write(&line);
+ }
+
+ fn flush(&self) {}
+}
+
+/// Install the logger (filter from `RUST_LOG`, else [`DEFAULT_FILTER`]); `None` when another
+/// logger was installed first. Call [`AppLogger::attach_dir`] once the settings directory is known.
+pub fn install() -> Option<&'static AppLogger> {
+ static LOGGER: OnceLock = OnceLock::new();
+ let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty());
+ let filter = Filter::parse(spec.as_deref().unwrap_or(DEFAULT_FILTER));
+ let max = filter.max();
+ let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true));
+ log::set_logger(logger).ok()?;
+ log::set_max_level(max);
+ Some(logger)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::sync::atomic::{AtomicUsize, Ordering};
+ use std::time::{Duration, UNIX_EPOCH};
+
+ fn temp_dir(tag: &str) -> PathBuf {
+ static N: AtomicUsize = AtomicUsize::new(0);
+ let d = std::env::temp_dir().join(format!("designcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
+ let _ = std::fs::remove_dir_all(&d);
+ d
+ }
+
+ #[test]
+ fn the_default_filter_shows_designcraft_info_and_other_crates_warnings() {
+ let f = Filter::parse(DEFAULT_FILTER);
+ assert_eq!(f.level_for("designcraft"), LevelFilter::Info);
+ assert_eq!(f.level_for("designcraft::control_server"), LevelFilter::Info);
+ assert_eq!(f.level_for("designcraft_render::tile"), LevelFilter::Info);
+ assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
+ assert_eq!(f.level_for("naga"), LevelFilter::Warn);
+ assert_eq!(f.max(), LevelFilter::Info);
+ }
+
+ #[test]
+ fn directives_follow_env_logger_and_the_most_specific_one_wins() {
+ let f = Filter::parse("info,wgpu_core=error,designcraft_render=trace,designcraft_render::tile=off");
+ assert_eq!(f.level_for("eframe"), LevelFilter::Info);
+ assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
+ assert_eq!(f.level_for("designcraft_render::raster"), LevelFilter::Trace);
+ assert_eq!(f.level_for("designcraft_render::tile"), LevelFilter::Off);
+ assert_eq!(f.max(), LevelFilter::Trace);
+ // A module name is matched at `::` boundaries, not as a bare prefix.
+ assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
+ // A bare target name sets that target to the most verbose level, as env_logger does.
+ assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
+ assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
+ }
+
+ #[test]
+ fn hostile_specs_never_panic_and_fall_back_sensibly() {
+ for spec in ["", ",,,", "=", "=debug", "designcraft=", "nonsense=loud", "🦀=info", "*", "a=b=c", "warn,,designcraft=DEBUG"] {
+ let f = Filter::parse(spec);
+ let _ = f.level_for("designcraft");
+ let _ = f.max();
+ }
+ assert_eq!(Filter::parse("warn,,designcraft=DEBUG").level_for("designcraft"), LevelFilter::Debug);
+ assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
+ }
+
+ #[test]
+ fn timestamps_are_utc_with_milliseconds() {
+ assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
+ // 2026-10-08T07:59:17.728Z
+ assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
+ // Leap days, including the century rule (2000 is a leap year, 2100 is not).
+ assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
+ assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
+ assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
+ 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");
+ }
+
+ #[test]
+ fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
+ let dir = temp_dir("rotate");
+ for run in 1..=4 {
+ let (path, _file) = rotate(&dir).expect("rotate");
+ assert_eq!(path, dir.join(LOG_FILE));
+ assert_eq!(std::fs::read_to_string(&path).expect("read"), "");
+ std::fs::write(&path, format!("run {run}")).expect("write");
+ }
+ assert_eq!(std::fs::read_to_string(dir.join(LOG_FILE)).expect("current"), "run 4");
+ assert_eq!(std::fs::read_to_string(dir.join("designcraft.1.log")).expect("1"), "run 3");
+ assert_eq!(std::fs::read_to_string(dir.join("designcraft.2.log")).expect("2"), "run 2");
+ assert!(!dir.join("designcraft.3.log").exists(), "only {KEEP} old logs are kept");
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
+ let dir = temp_dir("blocked");
+ std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
+ std::fs::write(&dir, "a file where the directory should be").expect("block");
+ assert!(rotate(&dir).is_err());
+ let _ = std::fs::remove_file(&dir);
+ }
+ fn read(path: &Path) -> String {
+ std::fs::read_to_string(path).expect("read log")
+ }
+
+ #[test]
+ fn a_line_carries_time_level_thread_target_and_message() {
+ assert_eq!(
+ format_line("2026-10-08T07:59:17.728Z", log::Level::Error, "main", "designcraft_engine::guard", "command `frame.create` panicked: boom"),
+ "2026-10-08T07:59:17.728Z ERROR [main] designcraft_engine::guard: command `frame.create` panicked: boom\n"
+ );
+ // A multi-line message (a panic with a backtrace) keeps its lines; the record still ends in one newline.
+ assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
+ }
+
+ #[test]
+ fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
+ let dir = temp_dir("pending");
+ let (path, file) = rotate(&dir).expect("rotate");
+ let mut sink = Sink::new(MAX_FILE_BYTES);
+ sink.write("early 1\n");
+ sink.write("early 2\n");
+ sink.attach(file);
+ sink.write("late\n");
+ assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
+ let dir = temp_dir("bounded");
+ let (path, file) = rotate(&dir).expect("rotate");
+ let mut sink = Sink::new(MAX_FILE_BYTES);
+ for i in 0..MAX_PENDING + 5 {
+ sink.write(&format!("line {i}\n"));
+ }
+ sink.attach(file);
+ let text = read(&path);
+ assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
+ assert!(text.contains("5 earlier log lines were dropped"), "{text}");
+ assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn the_file_stops_at_its_size_cap_with_one_note() {
+ let dir = temp_dir("cap");
+ let (path, file) = rotate(&dir).expect("rotate");
+ let mut sink = Sink::new(100);
+ sink.attach(file);
+ for i in 0..50 {
+ sink.write(&format!("line {i:02} with some padding\n"));
+ }
+ let text = read(&path);
+ assert!(text.lines().filter(|l| l.starts_with("line ")).count() < 5, "{text}");
+ assert_eq!(text.matches("log file reached").count(), 1, "{text}");
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
+ use log::Log;
+ let dir = temp_dir("logger");
+ let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
+ let record = |level, target: &'static str, msg: &'static str| {
+ logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
+ };
+ record(log::Level::Info, "designcraft::prefs", "settings directory");
+ record(log::Level::Info, "wgpu_core::device", "too chatty");
+ let path = logger.attach_dir(&dir).expect("attach");
+ assert_eq!(path, dir.join(LOG_FILE));
+ record(log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
+ record(log::Level::Debug, "designcraft", "below info");
+ let text = read(&path);
+ assert!(text.contains(" INFO [") && text.contains("designcraft::prefs: settings directory\n"), "{text}");
+ assert!(text.contains("wgpu_hal::vulkan: a real warning\n"), "{text}");
+ assert!(!text.contains("too chatty") && !text.contains("below info"), "{text}");
+ assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("designcraft_render").build()));
+ assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
+ use log::Log;
+ let dir = temp_dir("unusable");
+ std::fs::write(&dir, "not a directory").expect("block");
+ let logger = AppLogger::new(Filter::parse("info"), false);
+ assert!(logger.attach_dir(&dir).is_err());
+ logger.log(&log::Record::builder().level(log::Level::Error).target("x").args(format_args!("still fine")).build());
+ let _ = std::fs::remove_file(&dir);
+ }
+}
diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs
index 101a5f5f5..fa413e3ac 100644
--- a/apps/designcraft/src/main.rs
+++ b/apps/designcraft/src/main.rs
@@ -9,6 +9,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod control_server;
+mod logging;
#[cfg(target_os = "macos")]
mod native_menu;
@@ -25,7 +26,7 @@ impl eframe::App for App {
self.1 = Some(native_menu::NativeMenu::install(&mut self.0));
}
if let Some(m) = &mut self.1 {
- m.poll(&mut self.0);
+ m.poll(&mut self.0, ctx);
}
}
self.0.logic(ctx);
@@ -41,8 +42,9 @@ impl eframe::App for App {
}
}
-fn prefs_path() -> Option {
- let base = if cfg!(target_os = "macos") {
+/// The per-user settings directory: `ui.json`, `prefs.json` and `logs/` live here.
+fn config_dir() -> Option {
+ if cfg!(target_os = "macos") {
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/DesignCraft"))
} else if cfg!(windows) {
std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join("DesignCraft"))
@@ -51,8 +53,11 @@ fn prefs_path() -> Option {
.map(std::path::PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
.map(|c| c.join("designcraft"))
- };
- base.map(|b| b.join("ui.json"))
+ }
+}
+
+fn prefs_path() -> Option {
+ config_dir().map(|b| b.join("ui.json"))
}
fn load_prefs(app: &mut DesignApp) {
@@ -182,14 +187,28 @@ fn app_icon() -> Option {
eframe::icon_data::from_png_bytes(png).map_err(|e| log::warn!("app icon: {e}")).ok()
}
+/// The control port from `--control ` or `DESIGNCRAFT_CONTROL_PORT` (`source`); a value that
+/// is not a port is logged and ignored instead of silently starting no control channel.
+fn control_port_from(source: &str, value: Option) -> Option {
+ let value = value?;
+ let port = value.trim().parse().ok();
+ if port.is_none() {
+ log::warn!("{source}: {value:?} is not a port number; the control channel is off");
+ }
+ port
+}
+
fn main() -> eframe::Result {
- let mut control_port: Option = std::env::var("DESIGNCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
+ // First, so every start-up record (and the engine's panic hook, installed with the first
+ // Session) is captured; see `logging`.
+ let logger = logging::install();
+ let mut control_port = control_port_from("DESIGNCRAFT_CONTROL_PORT", std::env::var("DESIGNCRAFT_CONTROL_PORT").ok());
let mut files = Vec::new();
let mut sample = false;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
- "--control" => control_port = args.next().and_then(|p| p.parse().ok()),
+ "--control" => control_port = control_port_from("--control", args.next()),
"--sample" => sample = true,
"--version" => {
println!("designcraft {}", env!("CARGO_PKG_VERSION"));
@@ -198,6 +217,15 @@ fn main() -> eframe::Result {
_ => files.push(a),
}
}
+ // The log file lives under the settings directory; opened after the arguments, so `--version`
+ // leaves no file behind. Records logged until now are written to it first.
+ if let (Some(logger), Some(dir)) = (logger, config_dir()) {
+ match logger.attach_dir(&dir.join("logs")) {
+ Ok(path) => log::info!("DesignCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()),
+ // Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics.
+ Err(e) => log::warn!("no log file: {e}"),
+ }
+ }
let mut options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("DesignCraft")
@@ -245,7 +273,7 @@ fn main() -> eframe::Result {
let _ = app.run("file.newSample", serde_json::json!({}));
}
for f in files {
- if let Err(e) = app.run("file.open", serde_json::json!({"path": f})) {
+ if let Err(e) = app.open_file("file.open", serde_json::json!({"path": f})) {
eprintln!("designcraft: {f}: {e}");
}
}
@@ -260,6 +288,16 @@ fn main() -> eframe::Result {
#[cfg(test)]
mod tests {
+ #[test]
+ fn control_port_values_that_are_not_ports_are_ignored() {
+ assert_eq!(super::control_port_from("--control", Some("7979".into())), Some(7979));
+ assert_eq!(super::control_port_from("--control", Some(" 7979 ".into())), Some(7979));
+ assert_eq!(super::control_port_from("--control", None), None);
+ for bad in ["", "abc", "-1", "65536", "99999999999999999999"] {
+ assert_eq!(super::control_port_from("--control", Some(bad.into())), None, "{bad:?}");
+ }
+ }
+
#[test]
fn data_merge_open_dialog_lists_table_extensions() {
let filters = super::open_filters("dataMerge");
diff --git a/apps/designcraft/src/native_menu.rs b/apps/designcraft/src/native_menu.rs
index 4c039efd3..8bf75b159 100644
--- a/apps/designcraft/src/native_menu.rs
+++ b/apps/designcraft/src/native_menu.rs
@@ -72,10 +72,10 @@ impl NativeMenu {
Self { _menu: menu, items, last_refresh: 0.0 }
}
- pub fn poll(&mut self, app: &mut DesignApp) {
+ pub fn poll(&mut self, app: &mut DesignApp, ctx: &egui::Context) {
while let Ok(ev) = MenuEvent::receiver().try_recv() {
if let Some((cmd, params, _)) = self.items.get(ev.id.as_ref()) {
- designcraft_ui_egui::menus::activate(app, &cmd.clone(), ¶ms.clone());
+ menus::activate_native(app, ctx, &cmd.clone(), ¶ms.clone());
}
}
let now = designcraft_ui_egui::now_ms();
@@ -84,7 +84,7 @@ impl NativeMenu {
}
self.last_refresh = now;
for (cmd, params, h) in self.items.values() {
- let en = menus::menu_enabled(app, cmd);
+ let en = menus::native_menu_enabled(app, ctx, cmd);
match h {
Handle::Plain(i) => i.set_enabled(en),
Handle::Check(c) => {
diff --git a/contributors/contributors.json b/contributors/contributors.json
index c21fcc6eb..cf12c54c2 100644
--- a/contributors/contributors.json
+++ b/contributors/contributors.json
@@ -2,49 +2,49 @@
"schema": 1,
"repo": "storytold/designcraft",
"branch": "main",
- "head": "f17397c305ecda9d644105f7a4e9b9d65d34bb60",
- "head_date": "2026-10-07T18:55:09Z",
- "commits": 294,
+ "head": "66c7ce7e96ec286a091878cc2541ee8369aa79d6",
+ "head_date": "2026-10-08T13:12:32Z",
+ "commits": 350,
"unattributed_commits": 0,
"contributors": [
{
"login": "echelon",
"people_entry": true,
"github_name": null,
- "prs": 22,
- "commits": 260,
- "lines_added": 99427,
- "lines_deleted": 6061,
+ "prs": 25,
+ "commits": 270,
+ "lines_added": 100858,
+ "lines_deleted": 6338,
"binary_added": 42,
"binary_deleted": 2,
"first_commit": "2026-10-01T13:00:02Z",
- "last_commit": "2026-10-07T18:55:09Z"
+ "last_commit": "2026-10-08T13:50:30Z"
},
{
"login": "storybored379",
"people_entry": false,
"github_name": null,
- "prs": 4,
- "commits": 4,
- "lines_added": 150,
- "lines_deleted": 11,
+ "prs": 5,
+ "commits": 5,
+ "lines_added": 181,
+ "lines_deleted": 14,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-03T16:31:40Z",
- "last_commit": "2026-10-05T03:53:26Z"
+ "last_commit": "2026-10-06T07:03:03Z"
},
{
"login": "CameronGilroy",
"people_entry": false,
"github_name": null,
- "prs": 1,
- "commits": 18,
- "lines_added": 5236,
- "lines_deleted": 381,
+ "prs": 2,
+ "commits": 23,
+ "lines_added": 8982,
+ "lines_deleted": 609,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-05T06:15:59Z",
- "last_commit": "2026-10-05T18:56:22Z"
+ "last_commit": "2026-10-07T10:22:06Z"
},
{
"login": "midasdf",
@@ -63,14 +63,14 @@
"login": "ShaoWenbinSaleh",
"people_entry": false,
"github_name": null,
- "prs": 1,
- "commits": 2,
- "lines_added": 6020,
- "lines_deleted": 1278,
+ "prs": 3,
+ "commits": 10,
+ "lines_added": 10282,
+ "lines_deleted": 2843,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-05T19:15:57Z",
- "last_commit": "2026-10-06T14:32:13Z"
+ "last_commit": "2026-10-07T11:47:38Z"
},
{
"login": "japbcoelho",
@@ -84,6 +84,123 @@
"binary_deleted": 0,
"first_commit": "2026-10-06T01:43:44Z",
"last_commit": "2026-10-06T01:45:41Z"
+ },
+ {
+ "login": "aiongg",
+ "people_entry": false,
+ "github_name": null,
+ "prs": 3,
+ "commits": 19,
+ "lines_added": 5933,
+ "lines_deleted": 735,
+ "binary_added": 0,
+ "binary_deleted": 0,
+ "first_commit": "2026-10-06T15:11:56Z",
+ "last_commit": "2026-10-07T13:18:17Z"
+ },
+ {
+ "login": "epfabian",
+ "people_entry": false,
+ "github_name": null,
+ "prs": 1,
+ "commits": 1,
+ "lines_added": 50,
+ "lines_deleted": 4,
+ "binary_added": 0,
+ "binary_deleted": 0,
+ "first_commit": "2026-10-06T18:31:52Z",
+ "last_commit": "2026-10-06T18:31:52Z"
+ },
+ {
+ "login": "EthanSuquet",
+ "people_entry": false,
+ "github_name": null,
+ "prs": 2,
+ "commits": 2,
+ "lines_added": 689,
+ "lines_deleted": 5,
+ "binary_added": 0,
+ "binary_deleted": 0,
+ "first_commit": "2026-10-06T19:15:09Z",
+ "last_commit": "2026-10-06T20:13:06Z"
+ },
+ {
+ "login": "Luskish",
+ "people_entry": false,
+ "github_name": null,
+ "prs": 1,
+ "commits": 1,
+ "lines_added": 259,
+ "lines_deleted": 32,
+ "binary_added": 0,
+ "binary_deleted": 0,
+ "first_commit": "2026-10-07T02:49:05Z",
+ "last_commit": "2026-10-07T02:49:05Z"
+ },
+ {
+ "login": "denizkoekden",
+ "people_entry": false,
+ "github_name": null,
+ "prs": 1,
+ "commits": 1,
+ "lines_added": 7,
+ "lines_deleted": 0,
+ "binary_added": 0,
+ "binary_deleted": 0,
+ "first_commit": "2026-10-07T19:23:57Z",
+ "last_commit": "2026-10-07T19:23:57Z"
+ },
+ {
+ "login": "skylartaylor",
+ "people_entry": false,
+ "github_name": null,
+ "prs": 4,
+ "commits": 4,
+ "lines_added": 528,
+ "lines_deleted": 28,
+ "binary_added": 0,
+ "binary_deleted": 0,
+ "first_commit": "2026-10-07T20:27:50Z",
+ "last_commit": "2026-10-07T22:10:32Z"
+ },
+ {
+ "login": "RicSchonfelder",
+ "people_entry": false,
+ "github_name": null,
+ "prs": 1,
+ "commits": 1,
+ "lines_added": 1059,
+ "lines_deleted": 4,
+ "binary_added": 0,
+ "binary_deleted": 0,
+ "first_commit": "2026-10-07T20:43:34Z",
+ "last_commit": "2026-10-07T20:43:34Z"
+ },
+ {
+ "login": "h-a-n-a-s-h-i",
+ "people_entry": false,
+ "github_name": null,
+ "prs": 0,
+ "commits": 2,
+ "lines_added": 42,
+ "lines_deleted": 42,
+ "binary_added": 0,
+ "binary_deleted": 0,
+ "first_commit": "2026-10-08T12:13:12Z",
+ "last_commit": "2026-10-08T14:02:08Z"
+ },
+ {
+ "login": "XusBadia",
+ "people_entry": false,
+ "github_name": null,
+ "prs": 1,
+ "commits": 1,
+ "lines_added": 241,
+ "lines_deleted": 1,
+ "binary_added": 2,
+ "binary_deleted": 0,
+ "first_commit": "2026-10-08T13:12:32Z",
+ "last_commit": "2026-10-08T13:12:32Z"
}
],
"models": [
@@ -92,9 +209,18 @@
"company": "Anthropic",
"model": "Claude Opus",
"version": "5.5",
- "commits": 266,
- "lines_added": 83821,
- "lines_deleted": 6091
+ "commits": 282,
+ "lines_added": 86274,
+ "lines_deleted": 6420
+ },
+ {
+ "id": "openai/codex",
+ "company": "OpenAI",
+ "model": "Codex",
+ "version": "",
+ "commits": 2,
+ "lines_added": 57,
+ "lines_deleted": 8
},
{
"id": "anthropic/claude-opus/4.8",
@@ -104,15 +230,6 @@
"commits": 1,
"lines_added": 1633,
"lines_deleted": 3
- },
- {
- "id": "openai/codex",
- "company": "OpenAI",
- "model": "Codex",
- "version": "",
- "commits": 1,
- "lines_added": 26,
- "lines_deleted": 5
}
]
}
diff --git a/contributors/people.toml b/contributors/people.toml
index 89c6db208..c480bd68a 100644
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -21,3 +21,6 @@
real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
sync_github_name = false
+
+[people.thaynej]
+real_name = "Jeffrey Thayne"
diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs
index 5a3056d8f..8f34d1177 100644
--- a/crates/compose/src/lib.rs
+++ b/crates/compose/src/lib.rs
@@ -24,6 +24,7 @@ pub mod xref;
use std::collections::HashMap;
use std::ops::Range;
+use std::rc::Rc;
use std::sync::{Arc, Mutex};
use designcraft_doc::{
@@ -71,7 +72,7 @@ pub struct RunStyle {
pub kenten_character: String,
}
-/// An underline or strikethrough bar: its top edge `offset` below the baseline (negative =
+/// An underline or strikethrough bar: its centre `offset` below the baseline (negative =
/// above), thickness and colour.
#[derive(Clone, Debug, PartialEq)]
pub struct Rule {
@@ -81,13 +82,21 @@ pub struct Rule {
pub tint: f32,
}
+impl Rule {
+ /// The stroke is centred on the offset, matching Underline / Strikethrough Options.
+ pub fn rect(&self, x0: f64, x1: f64, baseline: f64) -> Rect {
+ let center = baseline + self.offset;
+ Rect::new(x0, center - self.weight / 2.0, x1, center + self.weight / 2.0)
+ }
+}
+
impl RunStyle {
/// The bars this run draws between `x0` and `x1` on a line at `baseline`.
pub fn rules(&self, x0: f64, x1: f64, baseline: f64) -> impl Iterator- {
[(self.underline, &self.underline_rule), (self.strikethrough, &self.strike_rule)]
.into_iter()
.filter(|(on, _)| *on)
- .map(move |(_, r)| (r, Rect::new(x0, baseline + r.offset, x1, baseline + r.offset + r.weight)))
+ .map(move |(_, r)| (r, r.rect(x0, x1, baseline)))
}
}
@@ -462,7 +471,17 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
.zip(&cols)
.map(|(f, columns)| FrameText { frame: f.id, vertical: f.vertical, columns: columns.clone(), ..Default::default() })
.collect();
- let mut cur = Cursor { fi: 0, col: 0, last_baseline: None, last_descent: 0.0, last_reference: 0.0, pending: 0.0 };
+ let mut cur = Cursor {
+ fi: 0,
+ col: 0,
+ last_baseline: None,
+ last_descent: 0.0,
+ last_reference: 0.0,
+ pending: 0.0,
+ pi: 0,
+ band: Band::default(),
+ limits: Rc::default(),
+ };
let para_ranges = story.para_ranges();
let np = para_ranges.len();
let hyph_exceptions = doc.hyphenation_exception_map();
@@ -496,14 +515,33 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let mut notes = Notes::new(doc, story);
let mut note_snaps: Vec<(u32, Option
, usize)> = Vec::with_capacity(np);
let mut var_cache: std::collections::HashMap, std::sync::Arc>> = Default::default();
+ // Column balancing above spanning paragraphs: settled and trial bottoms by band, the search
+ // in progress, and a budget for the re-lays.
+ let mut limits: Rc> = Rc::default();
+ let mut trial: Option = None;
+ let mut balance_runs = 0usize;
+ let balance_budget = 20 * story.paras.iter().filter(|p| matches!(doc.styles.resolve_para(p).0.span_columns, SpanColumns::Span(_))).count();
+ // Paragraphs set across columns (vertical justification moves their frames' lines together).
+ let mut span_paras = vec![false; np];
'paras: while pi < np {
let prange = para_ranges[pi].clone();
+ cur.pi = pi;
+ cur.limits = Rc::clone(&limits);
let snap = Snapshot::take(&out, &cur, list_counter);
snaps.truncate(pi);
snaps.push(snap);
note_snaps.truncate(pi);
note_snaps.push((notes.num, notes.key, notes.placed.len()));
info.truncate(pi);
+ // A balancing trial whose band ran past its frame: re-lay the band with more room.
+ if trial.as_ref().is_some_and(|t| cur.fi > t.key.0)
+ && let Some(j) = trial_failed(&mut trial, &mut limits, balance_runs < balance_budget)
+ {
+ balance_runs += 1;
+ rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
+ pi = j;
+ continue 'paras;
+ }
let pf = &story.paras[pi];
let (pp, base_chars) = doc.styles.resolve_para(pf);
if let Some(t) = story.para_table(pi) {
@@ -520,6 +558,12 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let start_at = (cur.fi, cur.col);
let anchor = prange.start + story.text[prange.clone()].find(story::TABLE_ANCHOR).unwrap_or(0);
if !table::place_table(doc, t, anchor, pi, &pp, frames, &cols, &mut cur, &mut out, opts) {
+ if let Some(j) = trial_failed(&mut trial, &mut limits, balance_runs < balance_budget) {
+ balance_runs += 1;
+ rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
+ pi = j;
+ continue 'paras;
+ }
out.overset_at = Some(prange.start);
break 'paras;
}
@@ -603,7 +647,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
// Nested line styles: find where the first lines end in this column, restyle them, and
// look again (the style changes the widths) until the lines settle.
let cols_here = &cols[cur.fi];
- let col = cols_here[cur.col.min(cols_here.len() - 1)];
+ let col = span_rect(cols_here, cols_here[cur.col.min(cols_here.len() - 1)], pp.span_columns);
let spacing = spacing_for(&pp, base_chars.size);
let mut lines: Vec<(std::ops::Range, String)> = Vec::new();
for _ in 0..3 {
@@ -694,17 +738,63 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
designcraft_doc::Leading::Points(v) => v,
};
let spacing = spacing_for(&pp, base_size);
+ // A paragraph set across the columns moves on to the next frame where others move on to
+ // the next column.
+ let spanning = matches!(pp.span_columns, SpanColumns::Span(n) if n != 1);
+ span_paras[pi] = spanning;
+ let next_column = |cur: &mut Cursor| if spanning { cur.next_frame() } else { cur.next_column(&cols) };
// Paragraph start options.
if force_col[pi] {
- cur.next_column(&cols);
+ next_column(&mut cur);
}
if cur.last_baseline.is_some() {
match pp.start_paragraph {
- StartParagraph::NextColumn => cur.next_column(&cols),
+ StartParagraph::NextColumn => next_column(&mut cur),
StartParagraph::NextFrame | StartParagraph::NextPage | StartParagraph::NextOddPage | StartParagraph::NextEvenPage => cur.next_frame(),
StartParagraph::Anywhere => {}
}
}
+ if spanning && cur.fi < frames.len() {
+ // Close the band: the paragraph goes below the deepest of its columns, which are
+ // balanced first.
+ let key = cur.band_key();
+ let deepest = out.frames[cur.fi]
+ .lines
+ .get(cur.band.line0..)
+ .unwrap_or_default()
+ .iter()
+ .map(|l| (l.baseline, l.descent))
+ .max_by(|a, b| (a.0 + a.1).total_cmp(&(b.0 + b.1)));
+ if let Some((baseline, descent)) = deepest {
+ let bottom = baseline + descent;
+ let multi = cols[cur.fi].len() > 1;
+ let active = trial.as_ref().is_some_and(|t| t.key == key && t.span == pi);
+ if !active && let Some(t) = trial.take() {
+ set_limit(&mut limits, t.key, None);
+ }
+ if multi && (active || limit_of(&limits, key).is_none() && balance_runs < balance_budget) {
+ let top = cur.clip(cols[cur.fi][0]).y0;
+ let mut t = trial.take().unwrap_or(Trial { key, span: pi, lo: top, hi: bottom, tries: 0, done: false });
+ // This layout fits: its bottom is the best so far.
+ t.hi = t.hi.min(bottom);
+ let next = if t.done { t.hi } else { t.next() };
+ set_limit(&mut limits, key, Some(next));
+ if !t.done {
+ trial = Some(t);
+ balance_runs += 1;
+ rewind(key.1, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
+ pi = key.1;
+ continue 'paras;
+ }
+ }
+ cur.col = 0;
+ cur.last_baseline = Some(baseline);
+ cur.last_descent = descent;
+ } else if cur.col != 0 {
+ cur.col = 0;
+ cur.resume();
+ }
+ }
if cur.last_baseline.is_some() {
cur.pending += pp.space_before;
}
@@ -719,21 +809,22 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let mut first_line_rect: Option<(usize, f64, f64, f64)> = None; // frame, baseline, ascent, x-span
loop {
if cur.fi >= frames.len() {
+ if let Some(j) = trial_failed(&mut trial, &mut limits, balance_runs < balance_budget) {
+ balance_runs += 1;
+ rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
+ pi = j;
+ continue 'paras;
+ }
out.overset_at = Some(glyphs.get(g0).map(|g| g.byte).unwrap_or(prange.start));
// The part of the paragraph that fits keeps its shading and border.
para_box_decos(&mut out, pi, &pp);
break 'paras;
}
let f = &frames[cur.fi];
- let col = cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)];
- let col = match pp.span_columns {
- SpanColumns::Span(n) if cur.col == 0 => {
- let n = if n == 0 { cols[cur.fi].len() } else { (n as usize).min(cols[cur.fi].len()) };
- let other = cols[cur.fi][n - 1];
- Rect::new(col.x0.min(other.x0), col.y0, col.x1.max(other.x1), col.y1)
- }
- _ => col,
- };
+ let base = cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)];
+ let col = cur.clip(base);
+ // A spanning paragraph isn't held to the balanced bottom of the columns above it.
+ let col = if spanning { span_rect(&cols[cur.fi], Rect::new(col.x0, col.y0, col.x1, base.y1), pp.span_columns) } else { col };
// Estimate slots for the breaker with the paragraph's base leading.
let est_first = cur.next_baseline(f, col, base_leading, base_chars.size * 0.75, &pp);
let slots = estimate_slots(f, col, est_first, base_leading, base_chars.size, &glyphs[g0..], &pp, line_no);
@@ -809,7 +900,11 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
remaining: breaks.len() - k,
here: (cur.fi, cur.col),
};
- let action = if restores < 4 * np + 64 { keep_violation(&ctx, &pp, &info, &force_col, &line_cap) } else { None };
+ let action = if restores < (4 * np + 64) * (balance_runs + 1) {
+ keep_violation(&ctx, &pp, &info, &force_col, &line_cap)
+ } else {
+ None
+ };
if let Some(action) = action {
restores += 1;
let j = match action {
@@ -822,20 +917,14 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
j
}
};
- for k in j + 1..np {
- force_col[k] = false;
- line_cap[k] = None;
- }
- snaps[j].restore(&mut out, &mut cur, &mut list_counter);
- (notes.num, notes.key) = (note_snaps[j].0, note_snaps[j].1);
- notes.placed.truncate(note_snaps[j].2);
+ rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
pi = j;
continue 'paras;
}
}
// Next column / frame; re-break the rest of the paragraph there.
g0 = s;
- cur.next_column(&cols);
+ next_column(&mut cur);
col_first_line = line_no;
moved = true;
break;
@@ -844,8 +933,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let lx0 = x0 + ind_l;
let lx1 = x1 - pp.right_indent;
let last = k + 1 == breaks.len();
+ // The break character that ends this line, if any (it is the line's last glyph).
+ let brk = if b.forced && e > s {
+ e.checked_sub(1).and_then(|i| glyphs.get(i)).map(|g| g.ch).filter(|&c| breaker::is_forced(c))
+ } else {
+ None
+ };
+ // A column, frame or page break ends the line as a paragraph's last line (last-line
+ // alignment); a justified line ended by a forced line break stays justified.
+ let ends_para = last || matches!(brk, Some(story::COLUMN_BREAK | story::FRAME_BREAK | story::PAGE_BREAK));
+ let forced_mid = brk == Some(story::FORCED_LINE_BREAK) && !last;
let (mut placed, end_x, ratio) =
- layout_line(&glyphs, s, e, b.hyphen, lx0, lx1, col.x0, &pp, &spacing, last, b.forced && !last, f.left_page, &bidi_info);
+ layout_line(&glyphs, s, e, b.hyphen, lx0, lx1, col.x0, &pp, &spacing, ends_para, forced_mid, f.left_page, &bidi_info);
ruby::annotate(db, &styles_tab, &mut placed, doc.settings.glyph_fallback);
let range_end = if last { prange.end } else { glyphs.get(g0 + b.next).map(|g| g.byte).unwrap_or(prange.end) };
let range_start = glyphs.get(s).map(|g| g.byte).unwrap_or(prange.start).min(range_end);
@@ -894,10 +993,9 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
line_no += 1;
// Column / frame / page break characters.
if b.forced && e < glyphs.len() + 1 {
- let brk = glyphs.get(g0 + b.next.saturating_sub(1)).map(|g| g.ch);
let jumped = match brk {
Some(story::COLUMN_BREAK) => {
- cur.next_column(&cols);
+ next_column(&mut cur);
true
}
Some(story::FRAME_BREAK) | Some(story::PAGE_BREAK) => {
@@ -933,6 +1031,20 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
keep_first: pp.keep_first as usize,
keep_last: pp.keep_last as usize,
});
+ // The columns below a spanning paragraph start under it.
+ if spanning
+ && let Some(ft) = out.frames.get(cur.fi)
+ && let Some(l) = ft.lines.last().filter(|l| l.para == pi)
+ {
+ let above = Above {
+ top: l.baseline + l.descent,
+ baseline: l.baseline,
+ descent: l.descent,
+ reference: cur.last_reference,
+ pending: pp.space_after,
+ };
+ cur.band = Band { para: pi + 1, line0: ft.lines.len(), above: Some(above) };
+ }
// Rules and shading for the paragraph.
if let Some((fi, bl, asc, _)) = first_line_rect {
let ft = &mut out.frames[fi];
@@ -972,7 +1084,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
ft.range = p..p;
}
let before: Vec = ft.tables.iter().map(|t| ft.lines.get(t.line).map_or(0.0, |l| l.baseline)).collect();
- vertical_justify(ft, f);
+ let spanned = ft.columns.len() > 1 && ft.lines.iter().any(|l| span_paras.get(l.para) == Some(&true));
+ vertical_justify(ft, f, spanned);
for (t, b) in ft.tables.iter_mut().zip(before) {
let now = ft.lines.get(t.line).map_or(b, |l| l.baseline);
t.shift(now - b);
@@ -1041,6 +1154,14 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
out
}
+/// The columns a spanning paragraph is set across, from `col`'s top to its bottom.
+fn span_rect(cols: &[Rect], col: Rect, span: SpanColumns) -> Rect {
+ let SpanColumns::Span(n) = span else { return col };
+ let n = if n == 0 { cols.len() } else { (n as usize).min(cols.len()) };
+ let (Some(a), Some(b)) = (cols.first(), n.checked_sub(1).and_then(|i| cols.get(i))) else { return col };
+ Rect::new(a.x0.min(b.x0), col.y0, a.x1.max(b.x1), col.y1)
+}
+
/// Flag the lines of paragraphs whose Keep Options the layout couldn't honour: lines kept
/// together that split, too few lines at the bottom (orphans) or top (widows) of a column, or a
/// keep-with-next paragraph separated from the next one.
@@ -1236,6 +1357,31 @@ impl Snapshot {
}
}
+/// Re-lay from paragraph `j`: back to its snapshot and footnote numbering, with fresh keep
+/// decisions for the paragraphs after it.
+#[allow(clippy::too_many_arguments)]
+fn rewind(
+ j: usize,
+ snaps: &[Snapshot],
+ note_snaps: &[(u32, Option, usize)],
+ notes: &mut Notes,
+ out: &mut ComposedStory,
+ cur: &mut Cursor,
+ list_counter: &mut u32,
+ force_col: &mut [bool],
+ line_cap: &mut [Option],
+) {
+ force_col.iter_mut().skip(j + 1).for_each(|f| *f = false);
+ line_cap.iter_mut().skip(j + 1).for_each(|c| *c = None);
+ if let Some(s) = snaps.get(j) {
+ s.restore(out, cur, list_counter);
+ }
+ if let Some(&(num, key, placed)) = note_snaps.get(j) {
+ (notes.num, notes.key) = (num, key);
+ notes.placed.truncate(placed);
+ }
+}
+
/// Where a placed paragraph sits (for keep resolution).
#[derive(Clone, Debug)]
struct ParaInfo {
@@ -1356,23 +1502,136 @@ struct Cursor {
last_reference: f64,
/// Space before/after waiting to be added to the next line.
pending: f64,
+ /// The paragraph being laid.
+ pi: usize,
+ /// The band of columns the cursor is in.
+ band: Band,
+ /// Column bottoms that balance bands, by band key (kept by the compose loop).
+ limits: Rc>,
+}
+
+/// A band's frame and first paragraph.
+type BandKey = (usize, usize);
+
+/// A run of the frame's columns: the whole frame, or the part between spanning paragraphs.
+/// Text fills its columns one after the other; a spanning paragraph closes it.
+#[derive(Clone, Copy, Debug, Default)]
+struct Band {
+ /// First paragraph laid (at least in part) in the band: balancing re-lays from there.
+ para: usize,
+ /// Lines of the frame above the band.
+ line0: usize,
+ /// Below a spanning paragraph, its last line: every column of the band continues from it.
+ above: Option,
+}
+
+#[derive(Clone, Copy, Debug)]
+struct Above {
+ /// Bottom of the spanning paragraph's last line (the band's top).
+ top: f64,
+ baseline: f64,
+ descent: f64,
+ reference: f64,
+ /// The spanning paragraph's space after.
+ pending: f64,
+}
+
+/// Balancing the columns above a spanning paragraph: a search for the lowest column bottom at
+/// which the band's text still fits its frame, re-laying the band at each trial bottom.
+#[derive(Debug)]
+struct Trial {
+ key: BandKey,
+ /// The spanning paragraph that closes the band.
+ span: usize,
+ /// Highest bottom known not to fit / lowest known to fit.
+ lo: f64,
+ hi: f64,
+ tries: u32,
+ /// The band is laid at `hi` for good.
+ done: bool,
+}
+
+impl Trial {
+ /// The next bottom to try: half way, or `hi` once settled.
+ fn next(&mut self) -> f64 {
+ self.tries += 1;
+ if self.hi - self.lo > 0.5 && self.tries < 16 {
+ (self.lo + self.hi) / 2.0
+ } else {
+ self.done = true;
+ self.hi
+ }
+ }
+}
+
+/// A balancing trial whose band didn't fit its frame: records the next bottom to try in
+/// `limits` (or, when even the settled bottom fails or the budget is spent, leaves the band
+/// unbalanced) and returns the paragraph to re-lay from.
+fn trial_failed(trial: &mut Option, limits: &mut Rc>, budget_left: bool) -> Option {
+ let t = trial.as_mut()?;
+ let key = t.key;
+ let bottom = if t.done || !budget_left {
+ *trial = None;
+ f64::INFINITY
+ } else {
+ t.lo = limit_of(limits, key).unwrap_or(t.lo);
+ t.next()
+ };
+ set_limit(limits, key, Some(bottom));
+ Some(key.1)
+}
+
+fn set_limit(limits: &mut Rc>, key: BandKey, bottom: Option) {
+ let v = Rc::make_mut(limits);
+ v.retain(|(k, _)| *k != key);
+ if let Some(b) = bottom {
+ v.push((key, b));
+ }
+}
+
+fn limit_of(limits: &[(BandKey, f64)], key: BandKey) -> Option {
+ limits.iter().find(|(k, _)| *k == key).map(|&(_, b)| b)
}
impl Cursor {
fn next_column(&mut self, cols: &[Vec]) {
self.col += 1;
if self.fi < cols.len() && self.col >= cols[self.fi].len() {
- self.col = 0;
- self.fi += 1;
+ self.next_frame();
+ } else {
+ self.resume();
}
- self.last_baseline = None;
- self.pending = 0.0;
}
fn next_frame(&mut self) {
self.fi += 1;
self.col = 0;
- self.last_baseline = None;
- self.pending = 0.0;
+ self.band = Band { para: self.pi, ..Band::default() };
+ self.resume();
+ }
+ /// Start the current column at the top of the band.
+ fn resume(&mut self) {
+ match self.band.above {
+ Some(a) => {
+ self.last_baseline = Some(a.baseline);
+ self.last_descent = a.descent;
+ self.last_reference = a.reference;
+ self.pending = a.pending;
+ }
+ None => {
+ self.last_baseline = None;
+ self.pending = 0.0;
+ }
+ }
+ }
+ fn band_key(&self) -> BandKey {
+ (self.fi, self.band.para)
+ }
+ /// Column `col` of the current frame limited to the band: below the spanning paragraph above
+ /// it and, while balancing, above the trial bottom.
+ fn clip(&self, col: Rect) -> Rect {
+ let y0 = self.band.above.map_or(col.y0, |a| a.top.max(col.y0).min(col.y1.max(col.y0)));
+ let y1 = limit_of(&self.limits, self.band_key()).map_or(col.y1, |b| b.min(col.y1).max(y0));
+ Rect::new(col.x0, y0, col.x1, y1)
}
/// Baseline for the next line with leading `lead` and ascent `asc`.
fn next_baseline(&self, f: &FrameSpec, col: Rect, lead: f64, asc: f64, _pp: &ParaProps) -> f64 {
@@ -2080,6 +2339,11 @@ fn hyphenation_points(text: &str, glyphs: &[Glyph], pp: &ParaProps, exceptions:
while j < glyphs.len() && (glyphs[j].is_letter() || glyphs[j].len == 0 || glyphs[j].ch == shape::SOFT_HYPHEN) && !glyphs[j].no_break {
j += 1;
}
+ // A no-break letter can't start a hyphenatable word.
+ if j == i {
+ i += 1;
+ continue;
+ }
// A discretionary hyphen in a word (or right before it) replaces automatic hyphenation.
let discretionary = glyphs[i..j].iter().any(|g| g.ch == shape::SOFT_HYPHEN) || (i > 0 && glyphs[i - 1].ch == shape::SOFT_HYPHEN);
let (a, b) = (glyphs[i].byte, glyphs[j - 1].byte + glyphs[j - 1].len);
@@ -2109,16 +2373,20 @@ fn hyphenation_points(text: &str, glyphs: &[Glyph], pp: &ParaProps, exceptions:
out
}
-fn vertical_justify(ft: &mut FrameText, f: &FrameSpec) {
+/// `spanned`: a paragraph spans the frame's columns, so the lines move together.
+fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool) {
if ft.lines.is_empty() || f.opts.vertical_justification == VerticalJustification::Top {
return;
}
- // Per column.
- let ncols = ft.columns.len().max(1) as u32;
+ if spanned && f.opts.vertical_justification == VerticalJustification::Justify {
+ return;
+ }
+ // Per column (or all lines together).
+ let ncols = if spanned { 1 } else { ft.columns.len().max(1) as u32 };
for c in 0..ncols {
- let idx: Vec = ft.lines.iter().enumerate().filter(|(_, l)| l.column == c).map(|(i, _)| i).collect();
- let (Some(&a), Some(&z)) = (idx.first(), idx.last()) else { continue };
- let bottom = ft.lines[z].baseline + ft.lines[z].descent;
+ let idx: Vec = ft.lines.iter().enumerate().filter(|(_, l)| spanned || l.column == c).map(|(i, _)| i).collect();
+ let (Some(&a), Some(_)) = (idx.first(), idx.last()) else { continue };
+ let bottom = idx.iter().map(|&i| ft.lines[i].baseline + ft.lines[i].descent).fold(f64::NEG_INFINITY, f64::max);
let space = f.area.y1 - bottom;
if space <= 0.0 {
continue;
diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs
index 529c9b777..f1632992e 100644
--- a/crates/compose/src/table.rs
+++ b/crates/compose/src/table.rs
@@ -124,7 +124,7 @@ pub(crate) fn place_table(
if cur.fi >= frames.len() || table.nrows() == 0 || table.ncols() == 0 {
return false;
}
- let col_rect = |cur: &Cursor| cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)];
+ let col_rect = |cur: &Cursor| cur.clip(cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)]);
let mut col = col_rect(cur);
// Column widths: scaled to fit the measure.
let avail = (col.width() - pp.left_indent - pp.right_indent).max(1.0);
diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs
index 549e7bccf..849d3a4e4 100644
--- a/crates/compose/src/tests.rs
+++ b/crates/compose/src/tests.rs
@@ -313,6 +313,18 @@ fn discretionary_hyphen_replaces_automatic_points() {
assert!(lines.iter().filter(|l| l.hyphenated).count() <= 1);
}
+#[test]
+fn hyphenation_skips_a_no_break_word_at_the_start() {
+ // Used to index before the first glyph and panic.
+ let text = "Extraordinarily long words wrap in a narrow column";
+ let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 60.0, 1000.0), ParaAttrs::default());
+ d.story_mut(sid).unwrap().format_chars(0..15, |f| f.over.no_break = Some(true));
+ let cs = compose_story(&d, sid, &ComposeOptions::default());
+ // The no-break word stays whole on the first line.
+ let first = all_lines(&cs)[0];
+ assert!(!first.hyphenated && first.range.end >= 15, "{:?}", &text[first.range.clone()]);
+}
+
/// Story of `n` one-line filler paragraphs followed by `extra` paragraphs, in a 2-column frame.
fn keep_doc(fillers: usize, extra: &[&str], height: f64) -> (Document, StoryId, Vec) {
let mut parts: Vec = (0..fillers).map(|k| format!("Filler {k}")).collect();
@@ -561,6 +573,32 @@ fn column_break_moves_following_text() {
assert!(lines[1].baseline < 20.0, "second column starts at the top");
}
+#[test]
+fn line_before_a_break_character_is_a_last_line() {
+ // As in InDesign: column, frame and page breaks end the paragraph's last line (set with the
+ // last-line alignment), while a forced line break inside a justified paragraph is justified.
+ use designcraft_doc::story::{COLUMN_BREAK, FORCED_LINE_BREAK, FRAME_BREAK, PAGE_BREAK};
+ for (brk, align, justified) in [
+ (COLUMN_BREAK, Align::LeftJustified, false),
+ (FRAME_BREAK, Align::LeftJustified, false),
+ (PAGE_BREAK, Align::LeftJustified, false),
+ (PAGE_BREAK, Align::FullyJustified, true),
+ (FORCED_LINE_BREAK, Align::LeftJustified, true),
+ ] {
+ // A space after the break is skipped and does not keep the break from moving the text.
+ let text = format!("one two three{brk} four five six");
+ let (mut d, sid, fid) = doc_with(&text, Rect::new(0.0, 0.0, 400.0, 300.0), ParaAttrs { align: Some(align), ..Default::default() });
+ d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2;
+ let cs = compose_story(&d, sid, &ComposeOptions::default());
+ let l = &cs.frames[0].lines[0];
+ let full = (l.end_x - l.x1).abs() < 0.6;
+ assert_eq!(full, justified, "{brk:?} {align:?}: line ends at {} of {}", l.end_x, l.x1);
+ if brk == COLUMN_BREAK {
+ assert_eq!(all_lines(&cs)[1].column, 1, "the text after a column break starts the next column");
+ }
+ }
+}
+
#[test]
fn tabs_without_stops_use_default_half_inch() {
let (d, sid, _) = doc_with("A\tB", Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default());
@@ -1877,3 +1915,159 @@ fn vertical_lines_fit_the_em_box() {
assert!((l.baseline - col.y0 - ascent).abs() < 1e-9, "{family}: {} {}", l.baseline, col.y0);
}
}
+
+// ---------- span columns ----------
+
+/// A story of `before` body paragraphs, a heading spanning all columns, and `after` body
+/// paragraphs, in a three-column frame. Returns the heading's paragraph index.
+fn span_doc(before: usize, after: usize, rect: Rect) -> (Document, StoryId, ItemId, usize) {
+ let mut paras: Vec<&str> = vec![LOREM; before];
+ paras.push("A heading across the columns");
+ paras.extend(vec![LOREM; after]);
+ let (mut d, sid, fid) = doc_with(¶s.join("\n"), rect, ParaAttrs::default());
+ d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 3;
+ d.story_mut(sid).unwrap().paras[before].para.span_columns = Some(SpanColumns::Span(0));
+ (d, sid, fid, before)
+}
+
+/// The line's leading slot (glyph ascent and descent may reach into the neighbouring lines).
+fn line_box(l: &Line) -> (f64, f64) {
+ (l.baseline - 0.75 * l.leading, l.baseline + 0.25 * l.leading)
+}
+
+/// No two lines of a frame share any area.
+fn assert_no_overlap(ft: &FrameText) {
+ for (i, a) in ft.lines.iter().enumerate() {
+ for b in &ft.lines[i + 1..] {
+ let ((at, ab), (bt, bb)) = (line_box(a), line_box(b));
+ let x_overlap = a.x0 < b.x1 - 0.5 && b.x0 < a.x1 - 0.5;
+ let y_overlap = at < bb - 0.5 && bt < ab - 0.5;
+ assert!(
+ !(x_overlap && y_overlap),
+ "lines overlap: para {} col {} y {:.1}..{:.1} and para {} col {} y {:.1}..{:.1}",
+ a.para,
+ a.column,
+ at,
+ ab,
+ b.para,
+ b.column,
+ bt,
+ bb
+ );
+ }
+ }
+}
+
+/// The heading spans the frame; text before it sits above it and text after it below it.
+fn assert_span_layout(ft: &FrameText, h: usize) {
+ let heading: Vec<&Line> = ft.lines.iter().filter(|l| l.para == h).collect();
+ assert!(!heading.is_empty());
+ let (c0, cn) = (ft.columns[0], ft.columns[ft.columns.len() - 1]);
+ for l in &heading {
+ assert!((l.x0 - c0.x0.min(cn.x0)).abs() < 1e-6 && (l.x1 - c0.x1.max(cn.x1)).abs() < 1e-6, "heading spans {}..{}", l.x0, l.x1);
+ }
+ let top = heading.iter().map(|l| line_box(l).0).fold(f64::INFINITY, f64::min);
+ let bottom = heading.iter().map(|l| line_box(l).1).fold(f64::NEG_INFINITY, f64::max);
+ for l in ft.lines.iter().filter(|l| l.para < h) {
+ assert!(line_box(l).1 <= top + 0.5, "para {} col {} ends at {} below the heading top {top}", l.para, l.column, line_box(l).1);
+ }
+ for l in ft.lines.iter().filter(|l| l.para > h) {
+ assert!(line_box(l).0 >= bottom - 0.5, "para {} col {} starts at {} above the heading bottom {bottom}", l.para, l.column, line_box(l).0);
+ }
+ assert_no_overlap(ft);
+}
+
+#[test]
+fn text_after_a_spanning_paragraph_flows_below_it() {
+ let (d, sid, _, h) = span_doc(2, 3, Rect::new(0.0, 0.0, 540.0, 300.0));
+ let cs = compose_story(&d, sid, &ComposeOptions::default());
+ assert!(!cs.is_overset());
+ let ft = &cs.frames[0];
+ assert_span_layout(ft, h);
+ // The text before is balanced across the three columns above the heading.
+ let before: Vec<&Line> = ft.lines.iter().filter(|l| l.para < h).collect();
+ let per_col = |c: u32| before.iter().filter(|l| l.column == c).count();
+ let counts = [per_col(0), per_col(1), per_col(2)];
+ assert!(counts.iter().all(|&n| n > 0), "{counts:?}");
+ assert!(counts.iter().max().unwrap() - counts.iter().min().unwrap() <= 1, "{counts:?}");
+ // The text after starts right below the heading in every column, on the same baseline.
+ let bottom = ft.lines.iter().filter(|l| l.para == h).map(|l| l.baseline).fold(f64::NEG_INFINITY, f64::max);
+ let firsts: Vec = (0..3).map(|c| ft.lines.iter().find(|l| l.para > h && l.column == c).map(|l| l.baseline).unwrap()).collect();
+ for b in &firsts {
+ assert!(*b > bottom && *b < bottom + 40.0, "{firsts:?} after {bottom}");
+ }
+ assert!((firsts[1] - firsts[2]).abs() < 1e-6, "{firsts:?}");
+}
+
+#[test]
+fn spanning_heading_kept_with_next_and_spanning_two_of_three_columns() {
+ let (mut d, sid, _, h) = span_doc(2, 3, Rect::new(0.0, 0.0, 540.0, 300.0));
+ d.story_mut(sid).unwrap().paras[h].para.keep_with_next = Some(2);
+ let cs = compose_story(&d, sid, &ComposeOptions::default());
+ assert!(!cs.is_overset());
+ assert_span_layout(&cs.frames[0], h);
+ // Spanning two of three columns: the third column beside the heading stays empty.
+ d.story_mut(sid).unwrap().paras[h].para.span_columns = Some(SpanColumns::Span(2));
+ let cs = compose_story(&d, sid, &ComposeOptions::default());
+ let ft = &cs.frames[0];
+ let heading = ft.lines.iter().find(|l| l.para == h).unwrap();
+ assert!((heading.x0 - ft.columns[0].x0).abs() < 1e-6 && (heading.x1 - ft.columns[1].x1).abs() < 1e-6);
+ assert_no_overlap(ft);
+ let bottom = heading.baseline + 0.25 * heading.leading;
+ assert!(ft.lines.iter().filter(|l| l.para > h).all(|l| line_box(l).0 >= bottom - 0.5));
+ assert!((0..3).all(|c| ft.lines.iter().any(|l| l.para > h && l.column == c)));
+}
+
+#[test]
+fn spanning_paragraph_at_the_top_and_end_of_a_frame() {
+ let (d, sid, _, h) = span_doc(0, 4, Rect::new(0.0, 0.0, 540.0, 300.0));
+ let cs = compose_story(&d, sid, &ComposeOptions::default());
+ assert!(!cs.is_overset());
+ let ft = &cs.frames[0];
+ assert_span_layout(ft, h);
+ assert!(ft.lines[0].para == h && ft.lines[0].baseline < 20.0);
+ assert!((1..3).all(|c| ft.lines.iter().any(|l| l.column == c)));
+
+ let (d, sid, _, h) = span_doc(3, 0, Rect::new(0.0, 0.0, 540.0, 300.0));
+ let cs = compose_story(&d, sid, &ComposeOptions::default());
+ assert!(!cs.is_overset());
+ assert_span_layout(&cs.frames[0], h);
+}
+
+#[test]
+fn text_after_a_spanning_paragraph_threads_into_the_next_frame() {
+ let (mut d, sid, f1, h) = span_doc(2, 8, Rect::new(0.0, 0.0, 540.0, 200.0));
+ let lid = d.default_layer();
+ let (f2, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 300.0, 540.0, 700.0), lid, "", ParaFormat::default()).unwrap();
+ d.item_mut(f2).unwrap().text_frame_mut().unwrap().options.columns = 3;
+ d.thread(f1, f2).unwrap();
+ let cs = compose_story(&d, sid, &ComposeOptions::default());
+ assert!(!cs.is_overset(), "overset at {:?}", cs.overset_at);
+ assert_span_layout(&cs.frames[0], h);
+ let (a, b) = (&cs.frames[0], &cs.frames[1]);
+ assert!((0..3).all(|c| a.lines.iter().any(|l| l.para > h && l.column == c)));
+ assert!(!b.lines.is_empty() && b.lines[0].para > h && b.lines[0].baseline < 320.0);
+ assert_no_overlap(b);
+ // Every line of the story is set once, in order.
+ let lines = all_lines(&cs);
+ for w in lines.windows(2) {
+ assert!(w[1].range.start >= w[0].range.end, "{:?} then {:?}", w[0].range, w[1].range);
+ }
+ assert_eq!(lines.last().unwrap().range.end, d.story(sid).unwrap().text.len());
+}
+
+#[test]
+fn spanning_paragraph_in_rtl_and_vertical_frames() {
+ let (mut d, sid, _, h) = span_doc(2, 4, Rect::new(0.0, 0.0, 540.0, 500.0));
+ d.story_mut(sid).unwrap().direction = designcraft_doc::TextDirection::RightToLeft;
+ let cs = compose_story(&d, sid, &ComposeOptions::default());
+ assert!(!cs.is_overset());
+ assert_span_layout(&cs.frames[0], h);
+
+ let (mut d, sid, _, h) = span_doc(2, 4, Rect::new(0.0, 0.0, 540.0, 540.0));
+ d.story_mut(sid).unwrap().vertical = true;
+ let cs = compose_story(&d, sid, &ComposeOptions::default());
+ assert!(!cs.frames[0].lines.is_empty());
+ assert_no_overlap(&cs.frames[0]);
+ assert!(cs.frames[0].lines.iter().any(|l| l.para == h));
+}
diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs
index 188f514d2..3bb827135 100644
--- a/crates/doc/src/edit.rs
+++ b/crates/doc/src/edit.rs
@@ -171,11 +171,18 @@ impl Document {
Ok(removed)
}
+ /// The column direction a new story starts with: right to left in a document whose binding is
+ /// right to left (Document Setup), else left to right. Stories already made keep theirs.
+ pub fn new_story_direction(&self) -> crate::TextDirection {
+ if self.settings.right_to_left_binding { crate::TextDirection::RightToLeft } else { crate::TextDirection::LeftToRight }
+ }
+
/// Create a text frame with a new (or given) story at `rect` on spread `r`.
pub fn add_text_frame(&mut self, r: SpreadRef, rect: Rect, layer: LayerId, text: &str, para: ParaFormat) -> Result<(ItemId, StoryId)> {
let id = ItemId(self.alloc());
let sid = StoryId(self.alloc());
let mut story = Story::with_text(sid, text, para);
+ story.direction = self.new_story_direction();
story.frames.push(id);
self.stories.insert(sid, Arc::new(story));
let mut item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(rect));
diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml
index ff02d62b8..241fd281a 100644
--- a/crates/engine/Cargo.toml
+++ b/crates/engine/Cargo.toml
@@ -36,3 +36,6 @@ hayro-syntax = "0.7.2"
hayro-interpret = { version = "0.7.0", default-features = false, features = ["embed-fonts"] }
hayro-cmap = "0.1"
kurbo = { workspace = true }
+
+[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
+wasm-bindgen-test = "0.3"
diff --git a/crates/engine/src/cmd/datamerge/tests.rs b/crates/engine/src/cmd/datamerge/tests.rs
index 6153a1d61..64f2f5d08 100644
--- a/crates/engine/src/cmd/datamerge/tests.rs
+++ b/crates/engine/src/cmd/datamerge/tests.rs
@@ -714,7 +714,8 @@ fn relative_path_and_reopen() {
std::fs::create_dir_all(&b).unwrap();
let csv = b.join("data.csv");
write(&csv, "Name\nAda\n");
- assert_eq!(relative_between(&a, &csv), "../b/data.csv");
+ let relative = Path::new("..").join("b").join("data.csv").to_string_lossy().into_owned();
+ assert_eq!(relative_between(&a, &csv), relative);
assert_eq!(relative_between(&b, &csv), "data.csv");
let mut doc = designcraft_doc::Document::new(&designcraft_doc::build::NewDocument::default());
@@ -743,7 +744,7 @@ fn relative_path_and_reopen() {
let mut s2 = Session::new();
exec(&mut s2, "file.open", json!({"path": saved}));
let src = &s2.documents()[s2.active_index().unwrap()].doc.data_merge.sources[0];
- assert_eq!(src.relative_path.as_deref(), Some("../b/data.csv"));
+ assert_eq!(src.relative_path.as_deref(), Some(relative.as_str()));
assert_eq!(src.rows[0][0], "Ada");
}
diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs
index e40000095..d475cafc5 100644
--- a/crates/engine/src/cmd/file.rs
+++ b/crates/engine/src/cmd/file.rs
@@ -124,7 +124,7 @@ fn file_new(s: &mut Session, p: &Value) -> Result {
nd.height = f64_or(p, "height", nd.height);
nd.pages = p.get("pages").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(nd.pages).clamp(1, 9999);
nd.facing_pages = p.get("facingPages").and_then(Value::as_bool).unwrap_or(nd.facing_pages);
- nd.columns = p.get("columns").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(nd.columns);
+ nd.columns = p.get("columns").and_then(Value::as_u64).map(|v| v.clamp(1, 216) as u32).unwrap_or(nd.columns);
nd.gutter = f64_or(p, "gutter", nd.gutter);
nd.primary_text_frame = p.get("primaryTextFrame").and_then(Value::as_bool).unwrap_or(false);
match p.get("margins") {
@@ -164,6 +164,7 @@ fn file_open(s: &mut Session, p: &Value) -> Result {
}
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let mut d = from_bytes(&bytes)?;
+ super::interchange::resolve_packaged_links(&mut d, std::path::Path::new(path).parent());
super::datamerge::resolve_sources_on_open(&mut d, Some(std::path::Path::new(path)));
let (fonts, faces, warnings) = load_document_fonts(&mut d, path);
let mut st = DocState::new(d, Some(path.to_string()));
@@ -185,7 +186,7 @@ pub(crate) fn load_document_fonts(d: &mut Document, path: &str) -> (Option (Option std::path::PathBuf {
+ use designcraft_fonts::DOCUMENT_FONTS_FOLDER;
+ let exact = dir.join(DOCUMENT_FONTS_FOLDER);
+ if exact.is_dir() {
+ return exact;
+ }
+ std::fs::read_dir(dir)
+ .into_iter()
+ .flatten()
+ .flatten()
+ .find(|e| e.file_name().to_str().is_some_and(|n| n.eq_ignore_ascii_case(DOCUMENT_FONTS_FOLDER)) && e.path().is_dir())
+ .map_or(exact, |e| e.path())
+}
+
fn file_open_bytes(s: &mut Session, p: &Value) -> Result {
let b = base64_decode(str_param(p, "base64").unwrap_or(""));
// IDML packages are recognised by their stored `mimetype` first entry.
@@ -795,6 +813,24 @@ mod document_fonts_tests {
let _ = std::fs::remove_dir_all(&dir);
}
+ #[test]
+ fn the_document_fonts_folder_is_found_whatever_its_case() {
+ const FAMILY: &str = "DocFont Case Test";
+ let dir = std::env::temp_dir().join(format!("dc-open-docfonts-case-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ let fonts = dir.join("Document fonts");
+ std::fs::create_dir_all(&fonts).unwrap();
+ std::fs::write(fonts.join("case.ttf"), font_with(FAMILY, &['H']).unwrap()).unwrap();
+ let path = dir.join("Brochure.idml");
+ write_document(&path, FAMILY);
+
+ let mut s = Session::new();
+ let r = open(&mut s, &path);
+ assert_eq!(r["documentFonts"], 1, "{r}");
+ assert_eq!(font_entry(&mut s, FAMILY)["missing"], false);
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
#[test]
fn a_document_without_the_folder_opens_as_before() {
let dir = std::env::temp_dir().join(format!("dc-open-docfonts-none-{}", std::process::id()));
diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs
index ef7eb9313..3b173c199 100644
--- a/crates/engine/src/cmd/fonts.rs
+++ b/crates/engine/src/cmd/fonts.rs
@@ -190,4 +190,39 @@ mod tests {
assert_eq!(r["changed"], 1);
assert_eq!(s.doc().unwrap().doc.styles.para("Odd").unwrap().chars.font_family.as_deref(), Some(avail.as_str()));
}
+
+ /// InDesign names a family installed in several formats `Family (OTF)`; the plain family
+ /// stands for it, and a family that isn't available under either name is still missing.
+ #[test]
+ fn a_family_with_a_format_suffix_uses_the_plain_family() {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({})).unwrap();
+ let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Some text here."})).unwrap();
+ let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
+ s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 15})).unwrap();
+ s.execute("type.char", &json!({"fontFamily": "Source Serif 4 (OTF)", "fontStyle": "Bold"})).unwrap();
+ let l = s.execute("font.list", &json!({})).unwrap();
+ let entry = l.as_array().unwrap().iter().find(|f| f["family"] == "Source Serif 4 (OTF)").unwrap();
+ assert_eq!((&entry["missing"], &entry["styleMissing"], &entry["source"]), (&json!(false), &json!(false), &json!("bundled")), "{l}");
+ let st = s.doc().unwrap();
+ let cs = s.cache.get(&st.doc, sid, None);
+ let faces: Vec<_> =
+ cs.frames.iter().flat_map(|f| &f.lines).flat_map(|l| &l.glyphs).filter(|g| g.visible && g.len > 0).map(|g| g.face.get()).collect();
+ assert!(!faces.is_empty() && faces.iter().all(|f| f.family == "Source Serif 4" && f.style == "Bold"));
+ let kinds = |s: &mut Session| {
+ s.execute("preflight.run", &json!({})).unwrap()["issues"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .map(|i| i["kind"].as_str().unwrap().to_string())
+ .collect::>()
+ };
+ assert!(!kinds(&mut s).iter().any(|k| k == "missingFont"));
+
+ s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 4})).unwrap();
+ s.execute("type.char", &json!({"fontFamily": "Nope (OTF)"})).unwrap();
+ let l = s.execute("font.list", &json!({})).unwrap();
+ assert_eq!((&l[0]["family"], &l[0]["missing"]), (&json!("Nope (OTF)"), &json!(true)), "{l}");
+ assert!(kinds(&mut s).iter().any(|k| k == "missingFont"));
+ }
}
diff --git a/crates/engine/src/cmd/guides.rs b/crates/engine/src/cmd/guides.rs
index df578e30b..8a0fee5e3 100644
--- a/crates/engine/src/cmd/guides.rs
+++ b/crates/engine/src/cmd/guides.rs
@@ -51,7 +51,7 @@ pub fn specs() -> Vec {
"Create Guides…",
["Layout"],
None,
- "{rows?: 0, columns?: 0, rowGutter?: 12, columnGutter?: 12, fitTo?: margins|page, removeExisting?: false, spread?, page? (index in the spread; default all pages)}",
+ "{rows?: 0, columns?: 0 (at most 100 each), rowGutter?: 12, columnGutter?: 12, fitTo?: margins|page, removeExisting?: false, spread?, page? (index in the spread; default all pages)}",
has_doc,
create_guides
),
@@ -157,9 +157,13 @@ pub(crate) fn grid_positions(a: f64, b: f64, n: u32, gutter: f64) -> Vec {
v
}
+/// The most rows or columns Create Guides lays out. The count comes from the caller, and every
+/// guide is stored in the document.
+const MAX_GUIDE_GRID: u32 = 100;
+
fn create_guides(s: &mut Session, p: &Value) -> Result {
- let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(0) as u32;
- let cols = p.get("columns").and_then(Value::as_u64).unwrap_or(0) as u32;
+ let count = |key: &str| p.get(key).and_then(Value::as_u64).unwrap_or(0).min(u64::from(MAX_GUIDE_GRID)) as u32;
+ let (rows, cols) = (count("rows"), count("columns"));
let rg = p.get("rowGutter").and_then(Value::as_f64).unwrap_or(12.0);
let cg = p.get("columnGutter").and_then(Value::as_f64).unwrap_or(12.0);
let to_page = str_param(p, "fitTo") == Some("page");
@@ -238,6 +242,18 @@ mod tests {
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.spreads[0].pages[0].guides.len(), 5);
}
+
+ /// A huge row or column count once made that many guides (2³² − 1 of them exhaust memory, which
+ /// no guard catches), and a count past 2³² wrapped to a small one.
+ #[test]
+ fn create_guides_caps_the_row_and_column_counts() {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({})).unwrap();
+ let args = json!({"spread": 0, "rows": 4_294_967_298_u64, "columns": 3_000_000, "rowGutter": 0.0, "columnGutter": 0.0});
+ let r = s.execute("layout.createGuides", &args).unwrap();
+ let per_page = 2 * (u64::from(MAX_GUIDE_GRID) - 1);
+ assert_eq!(r["guides"], per_page * s.doc().unwrap().doc.spreads[0].pages.len() as u64);
+ }
}
#[cfg(test)]
diff --git a/crates/engine/src/cmd/interchange.rs b/crates/engine/src/cmd/interchange.rs
index 030cf1b6c..8a65ec913 100644
--- a/crates/engine/src/cmd/interchange.rs
+++ b/crates/engine/src/cmd/interchange.rs
@@ -59,13 +59,22 @@ pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result {
std::sync::Arc::make_mut(asset).link = Some(path);
}
}
+ resolve_packaged_links(&mut document, dir);
document
};
Ok(document)
}
#[cfg(not(target_arch = "wasm32"))]
-fn read_packaged_link(link: &str, dir: Option<&std::path::Path>) -> Option<(String, Vec)> {
+const MAX_LINK_PATH_BYTES: usize = 128 * 1024;
+
+#[cfg(not(target_arch = "wasm32"))]
+fn packaged_link_candidates(link: &str, dir: Option<&std::path::Path>) -> Vec {
+ // Enough for a maximum-length Windows path even with four-byte UTF-8 characters.
+ // Bound splitting/allocation for link strings supplied by imported documents.
+ if link.len() > MAX_LINK_PATH_BYTES {
+ return Vec::new();
+ }
let mut candidates = vec![std::path::PathBuf::from(link)];
if let Some(dir) = dir {
// IDML may retain Windows paths even when the package is opened on another platform.
@@ -87,7 +96,29 @@ fn read_packaged_link(link: &str, dir: Option<&std::path::Path>) -> Option<(Stri
candidates.push(dir.join("Links").join(name));
}
}
- for path in candidates {
+ candidates
+}
+
+/// Embedded images do not call the IDML resource loader. Rebind their missing links
+/// beside the opened document too, without replacing their embedded image bytes.
+#[cfg(not(target_arch = "wasm32"))]
+pub(crate) fn resolve_packaged_links(document: &mut Document, dir: Option<&std::path::Path>) {
+ let Some(dir) = dir else { return };
+ for asset in document.assets.values_mut() {
+ let Some(link) = asset.link.as_deref() else { continue };
+ if link.len() > MAX_LINK_PATH_BYTES || std::path::Path::new(link).is_file() {
+ continue;
+ }
+ let Some(path) = packaged_link_candidates(link, Some(dir)).into_iter().find(|p| p.is_file()) else { continue };
+ // Preserve ordinary Windows paths rather than canonicalizing to a verbatim URI.
+ let path = std::path::absolute(&path).unwrap_or(path);
+ std::sync::Arc::make_mut(asset).link = Some(path.to_string_lossy().into_owned());
+ }
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+fn read_packaged_link(link: &str, dir: Option<&std::path::Path>) -> Option<(String, Vec)> {
+ for path in packaged_link_candidates(link, dir) {
if let Ok(bytes) = std::fs::read(&path) {
// Keep ordinary Windows drive/UNC syntax for IDML URI export; canonicalize
// would introduce a verbatim prefix that is not an IDML file URI.
@@ -132,7 +163,7 @@ mod tests {
use super::*;
- fn linked_idml(link: &str) -> Result<(Vec, Vec)> {
+ fn linked_idml(link: &str, embed_images: bool) -> Result<(Vec, Vec)> {
let png = designcraft_render::Rendered { width: 2, height: 2, pixels: vec![255; 16] }.to_png();
let mut session = Session::new();
session.execute("file.new", &json!({}))?;
@@ -142,7 +173,7 @@ mod tests {
Arc::make_mut(asset).link = Some(link.into());
Ok(Value::Null)
})?;
- let bytes = designcraft_idml::export_idml_with(&session.doc()?.doc, &designcraft_idml::ExportOptions { embed_images: false });
+ let bytes = designcraft_idml::export_idml_with(&session.doc()?.doc, &designcraft_idml::ExportOptions { embed_images });
Ok((bytes, png))
}
@@ -151,7 +182,7 @@ mod tests {
let dir = std::env::temp_dir().join(format!("dc-idml-nested-links-{}", std::process::id()));
let path = dir.join("Links").join("illustrations").join("logo.png");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
- let (idml, png) = linked_idml("C:\\original-computer\\project\\Links\\illustrations\\logo.png").unwrap();
+ let (idml, png) = linked_idml("C:\\original-computer\\project\\Links\\illustrations\\logo.png", false).unwrap();
std::fs::write(&path, &png).unwrap();
std::fs::write(dir.join("logo.png"), b"a different file with the same basename").unwrap();
@@ -169,7 +200,7 @@ mod tests {
let dir = std::env::temp_dir().join(format!("dc-idml-flat-links-{}", std::process::id()));
let path = dir.join("Links").join("logo.png");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
- let (idml, png) = linked_idml("/original-computer/project/logo.png").unwrap();
+ let (idml, png) = linked_idml("/original-computer/project/logo.png", false).unwrap();
std::fs::write(&path, &png).unwrap();
let doc = import(&idml, Some(&dir)).unwrap();
@@ -179,4 +210,49 @@ mod tests {
assert_eq!(super::super::links::status(asset), "ok");
std::fs::remove_dir_all(dir).unwrap();
}
+
+ #[test]
+ fn embedded_link_resolution_preserves_originals_and_image_bytes() {
+ let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
+ let dir = std::env::temp_dir().join(format!("dc-embedded-links-{}-{nonce}", std::process::id()));
+ let original = dir.join("original").join("logo.png");
+ let package = dir.join("package");
+ let copy = package.join("Links").join("logo.png");
+ std::fs::create_dir_all(original.parent().unwrap()).unwrap();
+ std::fs::create_dir_all(copy.parent().unwrap()).unwrap();
+ let (idml, png) = linked_idml(original.to_str().unwrap(), true).unwrap();
+ std::fs::write(&original, b"modified original").unwrap();
+ std::fs::write(©, &png).unwrap();
+ let doc = import(&idml, Some(&package)).unwrap();
+ let asset = doc.assets.values().next().unwrap();
+ assert_eq!(std::path::Path::new(asset.link.as_deref().unwrap()), original.as_path(), "an existing original stays authoritative");
+ assert_eq!(*asset.data, png);
+ assert_eq!(super::super::links::status(asset), "modified");
+
+ std::fs::remove_file(&original).unwrap();
+ std::fs::write(©, b"modified packaged copy").unwrap();
+ let doc = import(&idml, Some(&package)).unwrap();
+ let asset = doc.assets.values().next().unwrap();
+ assert_eq!(asset.link.as_deref(), copy.to_str());
+ assert_eq!(*asset.data, png, "rebinding must not replace embedded pixels");
+ assert_eq!(super::super::links::status(asset), "modified");
+
+ std::fs::remove_file(©).unwrap();
+ for folder in [Some(package.as_path()), None] {
+ let doc = import(&idml, folder).unwrap();
+ let asset = doc.assets.values().next().unwrap();
+ assert_eq!(std::path::Path::new(asset.link.as_deref().unwrap()), original.as_path(), "keep a missing link when there is no candidate");
+ assert_eq!(*asset.data, png);
+ assert_eq!(super::super::links::status(asset), "missing");
+ }
+ let oversized = "a/".repeat(MAX_LINK_PATH_BYTES);
+ assert!(packaged_link_candidates(&oversized, Some(&package)).is_empty());
+ let mut doc = import(&idml, None).unwrap();
+ Arc::make_mut(doc.assets.values_mut().next().unwrap()).link = Some(oversized.clone());
+ resolve_packaged_links(&mut doc, Some(&package));
+ let asset = doc.assets.values().next().unwrap();
+ assert_eq!(asset.link.as_deref(), Some(oversized.as_str()));
+ assert_eq!(*asset.data, png);
+ std::fs::remove_dir_all(dir).unwrap();
+ }
}
diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs
index 2a1b0751c..bd5320799 100644
--- a/crates/engine/src/cmd/layout.rs
+++ b/crates/engine/src/cmd/layout.rs
@@ -759,6 +759,35 @@ mod setup_tests {
let d = &s.doc().unwrap().doc;
assert_eq!(d.spreads[1].pages.iter().map(|p| (p.side, p.x)).collect::>(), vec![(Left, 0.0), (Right, w)]);
}
+
+ /// Multi-column frames filled the left column first in a right-to-left document (#100):
+ /// stories made in a right-to-left-bound document start their columns on the right.
+ #[test]
+ fn new_stories_follow_the_binding_direction() {
+ use designcraft_doc::TextDirection::{LeftToRight, RightToLeft};
+ let text = (1..=12).map(|i| format!("الفقرة {i}")).collect::>().join("\n");
+ for (binding, expected) in [("rightToLeft", RightToLeft), ("leftToRight", LeftToRight)] {
+ let mut s = crate::Session::new();
+ s.execute("file.new", &json!({"preset": "A4", "pages": 1})).unwrap();
+ s.execute("layout.documentSetup", &json!({"binding": binding})).unwrap();
+ let f = s.execute("frame.create", &json!({"rect": [40, 170, 555, 260], "content": "text", "text": text})).unwrap();
+ s.execute("object.textFrameOptions", &json!({"ids": [f["id"]], "columns": 2, "gutter": 20})).unwrap();
+ let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap());
+ let st = s.doc().unwrap();
+ assert_eq!(st.doc.story(sid).unwrap().direction, expected, "{binding}");
+ let cs = s.cache.get(&st.doc, sid, None);
+ let fr = &cs.frames[0];
+ let (first, last) = (fr.lines.first().unwrap(), fr.lines.last().unwrap());
+ assert!(fr.columns.len() == 2 && first.x0 != last.x0, "the text fills both columns: {:?}", fr.columns);
+ assert_eq!(first.x0 > last.x0, expected == RightToLeft, "{binding}: the first line is in the first column");
+ // An empty frame given text content starts a story too.
+ let g = s.execute("frame.create", &json!({"rect": [40, 400, 555, 500]})).unwrap();
+ s.execute("object.content", &json!({"ids": [g["id"]], "type": "text"})).unwrap();
+ let d = &s.doc().unwrap().doc;
+ let tf = d.item(designcraft_doc::ItemId(g["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
+ assert_eq!(d.story(tf).unwrap().direction, expected, "{binding}: object.content");
+ }
+ }
}
#[cfg(test)]
diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs
index 40802d548..7e219f29f 100644
--- a/crates/engine/src/cmd/object.rs
+++ b/crates/engine/src/cmd/object.rs
@@ -4,6 +4,7 @@
use std::collections::HashMap;
use std::sync::Arc;
+use designcraft_color::BlendMode;
use designcraft_doc::{
Content, Document, Fill, Item, ItemId, ParaFormat, Selection, Shape, SpreadRef, Story, StoryId, Stroke, TextFrame, TextFrameOptions, TextSel,
};
@@ -219,7 +220,16 @@ pub fn specs() -> Vec {
),
cmd!("object.opacity", "Opacity", [], None, "{opacity: 0..1, blend?: normal|multiply|…, ids?}", has_selection, |s, p| {
let o = f64_or(p, "opacity", 1.0).clamp(0.0, 1.0) as f32;
- let blend = p.get("blend").and_then(|b| serde_json::from_value(b.clone()).ok());
+ // `BlendMode::parse` takes the documented lowercase forms (`multiply`), the panel
+ // labels (`Color Burn`) and the serialized names (`Multiply`). An unknown value is
+ // an error rather than a silent no-op that still reports `changed` (#260).
+ let blend = match p.get("blend") {
+ None | Some(Value::Null) => None,
+ Some(v) => {
+ let raw = v.as_str().ok_or_else(|| bad("object.opacity", "blend must be a string"))?;
+ Some(BlendMode::parse(raw).ok_or_else(|| bad("object.opacity", format!("unknown blend mode `{raw}`")))?)
+ }
+ };
set_flag(
s,
p,
@@ -1379,6 +1389,7 @@ fn content_type(s: &mut Session, p: &Value) -> Result {
("text", Some(Content::Unassigned | Content::Graphic(_))) => {
let sid = StoryId(d.alloc());
let mut st = Story::new(sid);
+ st.direction = d.new_story_direction();
st.frames.push(*id);
d.stories.insert(sid, Arc::new(st));
if let Some(it) = d.item_mut(*id) {
@@ -1918,7 +1929,7 @@ fn step_and_repeat(s: &mut Session, p: &Value) -> Result {
let cols = p.get("columns").and_then(Value::as_u64).map(|v| v as usize);
let count = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000) as usize;
let offsets: Vec = match (rows, cols) {
- (Some(r), Some(c)) if r * c <= 1000 => (0..r)
+ (Some(r), Some(c)) if r.checked_mul(c).is_some_and(|n| n <= 1000) => (0..r)
.flat_map(|i| (0..c).map(move |j| (i, j)))
.filter(|&(i, j)| i + j > 0)
.map(|(i, j)| Vec2::new(j as f64 * dx, i as f64 * dy))
@@ -2421,3 +2432,67 @@ mod named_target_tests {
assert_eq!(s.execute("conveyor.collect", &json!({"id": r["id"]})).unwrap()["count"], 1);
}
}
+
+#[cfg(test)]
+mod blend_mode_tests {
+ use designcraft_color::BlendMode;
+
+ use super::*;
+
+ /// `object.opacity` documents `blend?: normal|multiply|…`, but deserializing the value
+ /// against the enum only accepted the PascalCase variant names, so the documented
+ /// lowercase forms were silently dropped: the command reported `changed: 1` while the
+ /// stored blend stayed as it was (#260).
+ #[test]
+ fn lowercase_documented_blend_modes_apply() {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({})).unwrap();
+ let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "unassigned"})).unwrap();
+ let id = ItemId(r["id"].as_u64().unwrap());
+ let blend = |s: &Session| s.doc().unwrap().doc.item(id).map(|i| i.blend);
+
+ for (given, want) in [
+ ("multiply", BlendMode::Multiply),
+ ("screen", BlendMode::Screen),
+ ("MULTIPLY", BlendMode::Multiply),
+ ("Color Burn", BlendMode::ColorBurn),
+ ("color-burn", BlendMode::ColorBurn),
+ ("soft_light", BlendMode::SoftLight),
+ ("normal", BlendMode::Normal),
+ ] {
+ let out = s.execute("object.opacity", &json!({"opacity": 1, "blend": given})).unwrap();
+ assert_eq!(out["changed"], 1, "{given}");
+ assert_eq!(blend(&s), Some(want), "blend mode {given:?} must be applied");
+ }
+ }
+
+ /// The PascalCase names keep working, so the Properties panel's own submissions are
+ /// unaffected.
+ #[test]
+ fn pascal_case_blend_modes_still_apply() {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({})).unwrap();
+ let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "unassigned"})).unwrap();
+ let id = ItemId(r["id"].as_u64().unwrap());
+ s.execute("object.opacity", &json!({"opacity": 0.5, "blend": "Multiply"})).unwrap();
+ let it = s.doc().unwrap().doc.item(id).cloned().unwrap();
+ assert_eq!(it.blend, BlendMode::Multiply);
+ assert!((it.opacity - 0.5).abs() < 1e-6, "opacity must still be set");
+ }
+
+ /// A blend mode the command doesn't know is an error, not a silent success that leaves
+ /// the previous appearance in place.
+ #[test]
+ fn an_unknown_blend_mode_is_an_error() {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({})).unwrap();
+ let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "unassigned"})).unwrap();
+ let id = ItemId(r["id"].as_u64().unwrap());
+ s.execute("object.opacity", &json!({"opacity": 1, "blend": "Multiply"})).unwrap();
+ let e = s.execute("object.opacity", &json!({"opacity": 1, "blend": "sparkle"})).unwrap_err().to_string();
+ assert!(e.contains("sparkle") && e.contains("blend"), "{e}");
+ let it = s.doc().unwrap().doc.item(id).cloned().unwrap();
+ assert_eq!(it.blend, BlendMode::Multiply, "a rejected value changes nothing");
+ assert_eq!(it.opacity, 1.0);
+ }
+}
diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs
index 5cc4cc860..50556d97f 100644
--- a/crates/engine/src/cmd/package.rs
+++ b/crates/engine/src/cmd/package.rs
@@ -221,6 +221,31 @@ fn copy_links(s: &mut Session, p: &Value) -> Result {
mod tests {
use super::*;
+ #[test]
+ fn relocated_packages_rebind_embedded_image_links_on_open() {
+ let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
+ let dir = std::env::temp_dir().join(format!("dc-package-relocation-{}-{nonce}", std::process::id()));
+ let original = dir.join("original");
+ let moved = dir.join("moved");
+ let png = designcraft_render::Rendered { width: 4, height: 3, pixels: [30, 90, 180, 255].repeat(12) }.to_png();
+ let mut s = Session::new();
+ s.execute("file.new", &json!({"title": "OwnedPackage", "width": 72, "height": 72})).unwrap();
+ s.execute("file.place", &json!({"base64": super::super::base64_encode(&png), "name": "owned.png", "x": 5, "y": 5, "width": 16})).unwrap();
+ s.execute("file.package", &json!({"dir": original.to_string_lossy(), "idml": true, "pdf": false})).unwrap();
+ std::fs::rename(&original, &moved).unwrap();
+ let expected = std::path::absolute(moved.join("Links").join("owned.png")).unwrap();
+ for (command, extension) in [("file.open", "designcraft"), ("file.openIdml", "idml")] {
+ let mut opened = Session::new();
+ opened.execute(command, &json!({"path": moved.join(format!("OwnedPackage.{extension}")).to_string_lossy()})).unwrap();
+ let asset = opened.doc().unwrap().doc.assets.values().next().unwrap();
+ assert_eq!(*asset.data, png, "{extension}: preserve embedded pixels");
+ assert_eq!(asset.link.as_deref(), expected.to_str(), "{extension}: use the relocated Links file");
+ let links = opened.execute("links.list", &json!({})).unwrap();
+ assert_eq!(links[0]["status"], "ok", "{extension}");
+ }
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
#[test]
fn package_writes_document_links_and_report() {
let dir = std::env::temp_dir().join(format!("dc-package-{}", std::process::id()));
diff --git a/crates/engine/src/cmd/path_type.rs b/crates/engine/src/cmd/path_type.rs
index 883f0c20a..77bbf87ab 100644
--- a/crates/engine/src/cmd/path_type.rs
+++ b/crates/engine/src/cmd/path_type.rs
@@ -54,6 +54,7 @@ fn on_path(s: &mut Session, p: &Value) -> Result {
}
let sid = StoryId(d.alloc());
let mut st = Story::with_text(sid, &text, Default::default());
+ st.direction = d.new_story_direction();
st.frames = vec![id];
d.stories.insert(sid, std::sync::Arc::new(st));
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs
index 39805bfb0..97917ee3b 100644
--- a/crates/engine/src/cmd/place_text.rs
+++ b/crates/engine/src/cmd/place_text.rs
@@ -105,27 +105,37 @@ fn strip(story: &mut Story) {
story.format_chars(0..n, |f| *f = Default::default());
}
-/// Is the story overset in its frames, and how much of it do its frames show?
-fn fit(d: &Document, sid: StoryId) -> (Option, usize) {
+/// Is the story overset in its frames, and how much text do its full frames hold (bytes, frames)?
+/// A frame a column, frame or page break ends early is left out: it says nothing about how much
+/// a frame holds.
+fn fit(d: &Document, sid: StoryId) -> (Option, usize, usize) {
let cs = designcraft_compose::compose_story(d, sid, &Default::default());
- let shown = cs.frames.iter().map(|f| f.range.end).max().unwrap_or(0);
- (cs.overset_at, shown)
+ let text = d.story(sid).map_or("", |s| s.text.as_str());
+ let is_break = |c: char| matches!(c, designcraft_doc::COLUMN_BREAK | designcraft_doc::FRAME_BREAK | designcraft_doc::PAGE_BREAK);
+ let (mut held, mut full) = (0, 0);
+ for f in &cs.frames {
+ let Some(shown) = text.get(f.range.clone()) else { continue };
+ if !shown.is_empty() && !shown.chars().any(is_break) {
+ held += shown.len();
+ full += 1;
+ }
+ }
+ (cs.overset_at, held, full)
}
/// Thread new frames on new pages (margin rectangles) until the story fits. Returns pages added.
pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize) -> Result {
let mut added = 0;
loop {
- let (overset, shown) = fit(d, sid);
+ let (overset, held, full) = fit(d, sid);
let Some(at) = overset else { return Ok(added) };
if added >= max_pages {
return Err(bad("file.place", format!("autoflow stopped after {max_pages} pages")));
}
let len = d.story(sid).map_or(0, |s| s.len());
- let frames = d.story(sid).map_or(1, |s| s.frames.len()).max(1);
- // Frames still needed at the rate the existing ones are filled (at least one).
- let per_frame = (shown.max(at) / frames).max(1);
- let want = (len - at).div_ceil(per_frame).clamp(1, max_pages - added);
+ // Frames still needed at the rate the full ones are filled (at least one); with no full
+ // frame to go by yet, one more page.
+ let want = held.checked_div(full).map_or(1, |per_frame| len.saturating_sub(at).div_ceil(per_frame.max(1))).clamp(1, max_pages - added);
let last = *d.story(sid).and_then(|s| s.frames.last()).ok_or_else(|| bad("file.place", "the story has no frame"))?;
let mut page = d.page_of_item(last).unwrap_or(d.page_count().saturating_sub(1));
let parent = d.page_loc(page).and_then(|(si, pi)| d.spreads[si].pages[pi].parent);
@@ -200,6 +210,7 @@ pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -
Some(_) => {
let sid = StoryId(d.alloc());
let mut ns = Story::new(sid);
+ ns.direction = d.new_story_direction();
ns.frames = vec![fid];
d.stories.insert(sid, std::sync::Arc::new(ns));
if let Some(it) = d.item_mut(fid) {
@@ -257,6 +268,29 @@ mod tests {
assert_eq!(st.format_after(6).over.font_style.as_deref(), Some("Bold"));
let _ = std::fs::remove_dir_all(&dir);
}
+
+ #[test]
+ fn autoflow_adds_only_the_pages_breaks_ask_for() {
+ // Regression (#262): a frame a break ends early made autoflow take it for a frame that
+ // holds a few characters, and add a page per few characters left (33 for this text).
+ let dir = std::env::temp_dir().join(format!("dc-place-breaks-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ for (name, rtf, pages) in [
+ ("lead.rtf", r"{\rtf1 \page\par A Short Heading\par First short paragraph.\par Second short paragraph.\par}", 2),
+ ("each.rtf", r"{\rtf1 One\par\page\par Two\par\page\par Three\par\page\par Four\par}", 4),
+ ] {
+ let path = dir.join(name);
+ std::fs::write(&path, rtf).unwrap();
+ let mut s = Session::new();
+ s.execute("file.new", &json!({"pages": 1})).unwrap();
+ let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "autoflow": true})).unwrap();
+ assert_eq!(r["overset"], false, "{name}: {r}");
+ let d = &s.doc().unwrap().doc;
+ assert_eq!(d.page_count(), pages, "{name}: {r}");
+ assert_eq!(d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames.len(), pages, "{name}");
+ }
+ let _ = std::fs::remove_dir_all(&dir);
+ }
}
#[cfg(test)]
diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs
index bbae1b38a..b944b6235 100644
--- a/crates/engine/src/cmd/print.rs
+++ b/crates/engine/src/cmd/print.rs
@@ -2,8 +2,12 @@
use serde_json::{Value, json};
-use super::{CommandSpec, bad, cmd, has_doc, str_param};
-use crate::{EngineError, Result, Session};
+#[cfg(not(target_arch = "wasm32"))]
+use super::str_param;
+use super::{CommandSpec, bad, cmd, has_doc};
+#[cfg(not(target_arch = "wasm32"))]
+use crate::EngineError;
+use crate::{Result, Session};
pub fn specs() -> Vec {
vec![
@@ -39,31 +43,32 @@ fn printers() -> Value {
fn print(s: &mut Session, p: &Value) -> Result {
let d = &s.doc()?.doc;
let opts = super::export::options(p, d.page_count())?;
- let copies = p.get("copies").and_then(Value::as_u64).unwrap_or(1).clamp(1, 999);
- let printer = str_param(p, "printer").map(str::to_string);
- let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false);
- let pdf = designcraft_pdf::export_pdf(d, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
- let file = std::env::temp_dir().join(format!("designcraft-print-{}-{}.pdf", std::process::id(), d.title.replace(['/', '\\'], "_")));
- let mut cmd: Vec = vec!["lpr".into()];
- if let Some(pr) = &printer {
- cmd.extend(["-P".into(), pr.clone()]);
- }
- if copies > 1 {
- cmd.extend(["-#".into(), copies.to_string()]);
- }
- cmd.extend(["-T".into(), d.title.clone(), file.to_string_lossy().to_string()]);
- let pages = opts.pages.as_ref().map_or(d.page_count(), |v| v.len());
- let out = json!({"printer": printer, "copies": copies, "pages": pages, "command": cmd, "bytes": pdf.len()});
- if dry {
- return Ok(out);
- }
#[cfg(target_arch = "wasm32")]
{
- let _ = pdf;
+ // Even a dry run cannot construct a native spool command in the browser.
+ let _ = opts;
Err(bad("file.print", "printing isn't available on the web: export a PDF and print it"))
}
#[cfg(not(target_arch = "wasm32"))]
{
+ let copies = p.get("copies").and_then(Value::as_u64).unwrap_or(1).clamp(1, 999);
+ let printer = str_param(p, "printer").map(str::to_string);
+ let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false);
+ let pdf = designcraft_pdf::export_pdf(d, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
+ let file = std::env::temp_dir().join(format!("designcraft-print-{}-{}.pdf", std::process::id(), d.title.replace(['/', '\\'], "_")));
+ let mut cmd: Vec = vec!["lpr".into()];
+ if let Some(pr) = &printer {
+ cmd.extend(["-P".into(), pr.clone()]);
+ }
+ if copies > 1 {
+ cmd.extend(["-#".into(), copies.to_string()]);
+ }
+ cmd.extend(["-T".into(), d.title.clone(), file.to_string_lossy().to_string()]);
+ let pages = opts.pages.as_ref().map_or(d.page_count(), |v| v.len());
+ let out = json!({"printer": printer, "copies": copies, "pages": pages, "command": cmd, "bytes": pdf.len()});
+ if dry {
+ return Ok(out);
+ }
std::fs::write(&file, &pdf).map_err(|e| EngineError::Other(format!("{}: {e}", file.display())))?;
let st = std::process::Command::new(&cmd[0]).args(&cmd[1..]).output().map_err(|e| bad("file.print", format!("lpr: {e}")))?;
if !st.status.success() {
@@ -73,7 +78,7 @@ fn print(s: &mut Session, p: &Value) -> Result {
}
}
-#[cfg(test)]
+#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use serde_json::json;
diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs
index c2f925c09..6836d1bf8 100644
--- a/crates/engine/src/cmd/table.rs
+++ b/crates/engine/src/cmd/table.rs
@@ -203,6 +203,15 @@ fn table_specs() -> Vec {
cmd!("table.setRowHeight", "Row Height", [], None, "{height, mode?: atLeast|exactly}", in_table, set_row_height),
cmd!("table.setColumnWidth", "Column Width", [], None, "{width}", in_table, set_col_width),
cmd!("table.distributeColumns", "Distribute Columns Evenly", ["Table"], None, "{}", in_table, distribute_cols),
+ cmd!(
+ "table.distributeRows",
+ "Distribute Rows Evenly",
+ ["Table"],
+ None,
+ "{story?, table?, rows?: [a,b]} — equal fixed heights preserving the selected rows' composed total height; may overset cell text; unmerge row-spanning cells first",
+ in_table,
+ distribute_rows
+ ),
cmd!(
"table.options",
"Table Options",
@@ -372,6 +381,14 @@ fn column_width(s: &Session, t: &TextSel) -> f64 {
spec.and_then(|f| f.columns().first().map(|c| c.width())).unwrap_or(300.0)
}
+/// The direction of the paragraph at `offset` in `story`, as composition resolves it (its style
+/// chain, then local overrides). A table created there takes it.
+fn para_direction(d: &Document, story: StoryId, offset: usize) -> designcraft_doc::TextDirection {
+ d.story(story)
+ .and_then(|st| st.paras.get(st.para_at(offset)))
+ .map_or(designcraft_doc::TextDirection::LeftToRight, |pf| d.styles.resolve_para(pf).0.direction)
+}
+
fn insert(s: &mut Session, p: &Value) -> Result {
let t = s.doc()?.selection.text.ok_or_else(|| bad("table.insert", "no insertion point"))?;
if t.cell.is_some() {
@@ -385,6 +402,7 @@ fn insert(s: &mut Session, p: &Value) -> Result {
s.edit(|d, sel| {
let id = d.alloc();
let mut table = Table::new(id, rows, cols, header, footer, width);
+ table.options.direction = para_direction(d, t.story, t.range().start);
let st = d.story_mut(t.story).ok_or_else(|| bad("table.insert", "no story"))?;
// Cells start with the paragraph format at the caret.
let pf = st.paras[st.para_at(t.range().start)].clone();
@@ -410,6 +428,7 @@ fn convert_from_text(s: &mut Session, p: &Value) -> Result {
let width = p.get("width").and_then(Value::as_f64).unwrap_or_else(|| column_width(s, &t));
s.edit(|d, sel| {
let id = d.alloc();
+ let direction = para_direction(d, t.story, t.range().start);
let st = d.story_mut(t.story).ok_or_else(|| bad("table.convertFromText", "no story"))?;
let ranges = st.para_ranges();
let (pa, pb) = (st.para_at(t.range().start), st.para_at(t.range().end));
@@ -420,7 +439,8 @@ fn convert_from_text(s: &mut Session, p: &Value) -> Result {
let data: Vec> = st.text[a..b].split('\n').map(|line| line.split(sep).map(|c| c.trim().to_string()).collect()).collect();
let pf = st.paras[pa].clone();
let cf = st.format_after(a).clone();
- let table = Table::from_strings(id, &data, width, &pf, &cf);
+ let mut table = Table::from_strings(id, &data, width, &pf, &cf);
+ table.options.direction = direction;
st.delete(a..b);
let anchor = st.insert_table(a, table);
*sel = Selection::text(TextSel::caret(t.story, anchor));
@@ -732,6 +752,63 @@ fn set_col_width(s: &mut Session, p: &Value) -> Result {
})
}
+fn distribute_rows(s: &mut Session, p: &Value) -> Result {
+ const ID: &str = "table.distributeRows";
+ if !p.is_object() {
+ return Err(bad(ID, "parameters must be an object"));
+ }
+ if let Some(rows) = p.get("rows") {
+ let valid = rows.as_array().is_some_and(|a| a.len() == 2 && a.iter().all(|v| v.as_u64().is_some_and(|n| usize::try_from(n).is_ok())));
+ if !valid {
+ return Err(bad(ID, "rows must be two non-negative row indices"));
+ }
+ }
+ for key in ["row", "story", "table"] {
+ if let Some(v) = p.get(key)
+ && !v.as_u64().is_some_and(|n| usize::try_from(n).is_ok())
+ {
+ return Err(bad(ID, format!("{key} must be a non-negative integer")));
+ }
+ }
+ let g = target(s, p, ID)?;
+ let st = s.doc()?;
+ let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad(ID, "no table"))?;
+ if t.cells.iter().any(|c| c.row_span > 1) {
+ return Err(bad(ID, "unmerge cells that span rows first"));
+ }
+ let cs = s.cache.get(&st.doc, g.story, None);
+ let mut total = 0.0;
+ for row in g.range.r0..=g.range.r1 {
+ let height = cs
+ .frames
+ .iter()
+ .flat_map(|f| &f.tables)
+ .filter(|f| f.table == g.table)
+ .flat_map(|f| &f.cells)
+ .filter(|c| c.row == row)
+ .map(|c| c.rect.height())
+ .reduce(f64::max)
+ .ok_or_else(|| bad(ID, "all selected rows must be laid out; enlarge or thread the text frame first"))?;
+ if !height.is_finite() || height <= 0.0 {
+ return Err(bad(ID, "invalid composed row height"));
+ }
+ total += height;
+ }
+ let count = g.range.r1 - g.range.r0 + 1;
+ let height = total / count as f64;
+ if !height.is_finite() {
+ return Err(bad(ID, "invalid total row height"));
+ }
+ edit_table(s, &g, ID, |t| {
+ let rows = t.rows.get_mut(g.range.r0..=g.range.r1).ok_or_else(|| bad(ID, "invalid row range"))?;
+ for row in rows {
+ row.height = height;
+ row.mode = RowHeightMode::Exactly;
+ }
+ Ok(json!({"rows": count, "height": height}))
+ })
+}
+
fn distribute_cols(s: &mut Session, p: &Value) -> Result {
let g = target(s, p, "table.distributeColumns")?;
edit_table(s, &g, "table.distributeColumns", |t| {
@@ -1315,6 +1392,105 @@ mod sort_tests {
use crate::Session;
+ fn distribution_session() -> Session {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({})).unwrap();
+ let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 700], "content": "text", "text": ""})).unwrap();
+ s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap();
+ s.execute("table.insert", &json!({"rows": 3, "cols": 2, "headerRows": 1})).unwrap();
+ for (row, height) in [(0, 25), (1, 40), (2, 80), (3, 30)] {
+ s.execute("table.setRowHeight", &json!({"row": row, "height": height, "mode": "exactly"})).unwrap();
+ s.execute("table.setCell", &json!({"row": row, "col": 0, "text": format!("row {row}")})).unwrap();
+ }
+ s
+ }
+
+ fn table(s: &Session) -> designcraft_doc::Table {
+ (**s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap()).clone()
+ }
+
+ #[test]
+ fn distribute_selected_rows_preserves_height_content_and_undo() {
+ let mut s = distribution_session();
+ s.execute("table.select", &json!({"rows": [1, 2], "what": "row"})).unwrap();
+ let before = table(&s);
+ let selection = s.doc().unwrap().selection.cells;
+ let result = s.execute("table.distributeRows", &json!({})).unwrap();
+ assert_eq!(result, json!({"rows": 2, "height": 60.0}));
+ let after = table(&s);
+ assert_eq!(after.rows.iter().map(|r| r.height).collect::>(), [25.0, 60.0, 60.0, 30.0]);
+ assert_eq!(after.cells, before.cells);
+ assert_eq!(after.rows[0].kind, before.rows[0].kind);
+ assert_eq!(s.doc().unwrap().selection.cells, selection);
+ let st = s.doc().unwrap();
+ let sid = st.doc.stories.values().find(|st| !st.tables.is_empty()).unwrap().id;
+ let cs = s.cache.get(&st.doc, sid, None);
+ let heights: Vec<_> =
+ cs.frames.iter().flat_map(|f| &f.tables).flat_map(|f| &f.cells).filter(|c| c.col == 0).map(|c| c.rect.height()).collect();
+ assert_eq!(heights, [25.0, 60.0, 60.0, 30.0]);
+ s.execute("edit.undo", &json!({})).unwrap();
+ assert_eq!(table(&s), before);
+ s.execute("edit.redo", &json!({})).unwrap();
+ assert_eq!(table(&s), after);
+ }
+
+ #[test]
+ fn distribution_uses_content_grown_heights_and_explicit_table() {
+ let mut s = distribution_session();
+ s.execute("table.setRowHeight", &json!({"rows": [1, 2], "height": 3, "mode": "atLeast"})).unwrap();
+ s.execute("table.setCell", &json!({"row": 2, "col": 0, "text": "one\ntwo\nthree"})).unwrap();
+ let st = s.doc().unwrap();
+ let story = st.doc.stories.values().find(|st| !st.tables.is_empty()).unwrap();
+ let (sid, tid) = (story.id, *story.tables.keys().next().unwrap());
+ let cs = s.cache.get(&st.doc, sid, None);
+ let total: f64 = cs
+ .frames
+ .iter()
+ .flat_map(|f| &f.tables)
+ .flat_map(|f| &f.cells)
+ .filter(|c| c.col == 0 && (1..=2).contains(&c.row))
+ .map(|c| c.rect.height())
+ .sum();
+ let result = s.execute("table.distributeRows", &json!({"story": sid.0, "table": tid, "rows": [1, 2]})).unwrap();
+ assert!((result["height"].as_f64().unwrap() * 2.0 - total).abs() < 1e-6);
+ assert!(table(&s).rows[1].height > 3.0);
+ assert_eq!(table(&s).rows[1].mode, designcraft_doc::RowHeightMode::Exactly);
+ }
+
+ #[test]
+ fn distribution_keeps_column_spans_and_caret() {
+ let mut s = distribution_session();
+ s.execute("table.merge", &json!({"rows": [1, 1], "cols": [0, 1]})).unwrap();
+ let before = table(&s);
+ let caret = s.doc().unwrap().selection.text;
+ let result = s.execute("table.distributeRows", &json!({"rows": [2, 1]})).unwrap();
+ assert_eq!(result["height"], 60.0);
+ assert_eq!(table(&s).cell(1, 0).unwrap().col_span, 2);
+ assert_eq!(table(&s).cells, before.cells);
+ assert_eq!(s.doc().unwrap().selection.text, caret);
+ }
+
+ #[test]
+ fn distribution_rejects_merges_and_missing_rows_without_changes() {
+ let mut s = distribution_session();
+ s.execute("table.merge", &json!({"rows": [1, 2], "cols": [0, 0]})).unwrap();
+ let before = table(&s);
+ assert!(s.execute("table.distributeRows", &json!({"rows": [1, 2]})).is_err());
+ assert_eq!(table(&s), before);
+ s.execute("edit.undo", &json!({})).unwrap();
+ s.execute("table.setRowHeight", &json!({"rows": [0, 3], "height": 10000, "mode": "exactly"})).unwrap();
+ let before = table(&s);
+ assert!(s.execute("table.distributeRows", &json!({"rows": [0, 3]})).is_err());
+ assert_eq!(table(&s), before);
+ assert!(s.execute("table.distributeRows", &json!({"rows": [0, 99]})).is_err());
+ for params in
+ [json!({"rows": "all"}), json!({"rows": [0]}), json!({"rows": [-1, 2]}), json!({"row": 1.5}), json!({"table": "bad"}), json!([])]
+ {
+ assert!(s.execute("table.distributeRows", ¶ms).is_err(), "{params}");
+ assert_eq!(table(&s), before);
+ }
+ }
+
#[test]
fn sort_body_rows_by_a_column() {
let mut s = Session::new();
@@ -1435,4 +1611,46 @@ mod arabic_tests {
assert_eq!(t.ncols(), 2);
assert_eq!(t.options.direction, designcraft_doc::TextDirection::RightToLeft);
}
+
+ /// A text frame whose paragraphs have a paragraph style with `direction`, with the story's
+ /// text selected. Returns the session and the story id.
+ fn styled_frame(direction: &str, text: &str) -> (Session, u64) {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({"preset": "A4", "pages": 1})).unwrap();
+ s.execute("style.paragraph.create", &json!({"name": "Dir", "para": {"direction": direction}})).unwrap();
+ let r = s.execute("frame.create", &json!({"rect": [40, 330, 555, 420], "content": "text", "text": text, "caret": true})).unwrap();
+ s.execute("edit.selectAll", &json!({})).unwrap();
+ s.execute("style.paragraph.apply", &json!({"name": "Dir"})).unwrap();
+ s.execute("edit.selectAll", &json!({})).unwrap();
+ (s, r["story"].as_u64().unwrap())
+ }
+
+ fn only_table(s: &Session) -> std::sync::Arc {
+ s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap().clone()
+ }
+
+ /// Text converted to a table in a right-to-left paragraph laid its columns out left to right
+ /// (#100): the first column belongs on the right.
+ #[test]
+ fn tables_take_the_direction_of_their_paragraph() {
+ use designcraft_doc::TextDirection::{LeftToRight, RightToLeft};
+ let (mut s, sid) = styled_frame("rightToLeft", "١\t٢\t٣\nأ\tب\tج");
+ s.execute("table.convertFromText", &json!({"columnSeparator": "tab"})).unwrap();
+ assert_eq!(only_table(&s).options.direction, RightToLeft);
+ let st = s.doc().unwrap();
+ let cs = s.cache.get(&st.doc, designcraft_doc::StoryId(sid), None);
+ let placed = &cs.frames[0].tables[0];
+ let (first, last) = (placed.cell(0, 0).unwrap().rect, placed.cell(0, 2).unwrap().rect);
+ assert!(first.x0 > last.x0, "cell ١ is the rightmost: {first:?} {last:?}");
+
+ let (mut s, _) = styled_frame("leftToRight", "1\t2\t3\na\tb\tc");
+ s.execute("table.convertFromText", &json!({"columnSeparator": "tab"})).unwrap();
+ assert_eq!(only_table(&s).options.direction, LeftToRight);
+
+ for (direction, expected) in [("rightToLeft", RightToLeft), ("leftToRight", LeftToRight)] {
+ let (mut s, _) = styled_frame(direction, "x");
+ s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap();
+ assert_eq!(only_table(&s).options.direction, expected, "table.insert in a {direction} paragraph");
+ }
+ }
}
diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs
index a75d6d473..552ce519a 100644
--- a/crates/engine/src/cmd/text.rs
+++ b/crates/engine/src/cmd/text.rs
@@ -114,8 +114,8 @@ pub fn specs() -> Vec {
cmd!(query "story.get", "Get Story", [], None, "{story? | frame?} → text, frames, paragraphs, vertical, overset", has_doc, |s, p| {
let st = s.doc()?;
let sid = story_of(s, p).ok_or_else(|| bad("story.get", "no story"))?;
+ let mut v = st.doc.story_summary(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
let cs = s.cache.get(&st.doc, sid, None);
- let mut v = st.doc.story_summary(sid).unwrap_or_default();
v["overset"] = json!(cs.overset_at);
v["lines"] = json!(cs.line_count());
Ok(v)
@@ -1638,3 +1638,19 @@ mod vertical_caret_tests {
assert_eq!(mv(&mut s, "up", false, false), 3 * c);
}
}
+
+#[cfg(test)]
+mod story_query_contract_tests {
+ use serde_json::json;
+ #[test]
+ fn missing_story_errors_but_valid_empty_and_selected_stories_succeed() {
+ let mut s = crate::Session::new();
+ s.execute("file.new", &json!({})).unwrap();
+ assert!(s.execute("story.get", &json!({"story":999})).unwrap_err().to_string().contains("no such story"));
+ let f = s.execute("frame.create", &json!({"rect":[36,36,300,200],"content":"text"})).unwrap();
+ assert_eq!(s.execute("story.get", &json!({"story":f["story"]})).unwrap()["text"], "");
+ assert_eq!(s.execute("story.get", &json!({"frame":f["id"]})).unwrap()["text"], "");
+ s.execute("text.select", &json!({"story":f["story"],"anchor":0})).unwrap();
+ assert_eq!(s.execute("story.get", &json!({})).unwrap()["text"], "");
+ }
+}
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index 8f2f39b0f..1f5f7189e 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -372,6 +372,28 @@ fn step_and_repeat_grid() {
assert_eq!(last.y0, 36.0 + 2.0 * 40.0);
}
+/// rows × columns once overflowed: a panic in debug builds, and in release builds a product that
+/// wrapped to 0 passed the 1000-copy limit and started a grid of 2⁶⁴ copies.
+#[test]
+fn step_and_repeat_with_a_huge_grid_makes_no_grid() {
+ let mut s = session();
+ s.execute("frame.create", &json!({"rect": [36, 36, 66, 66], "content": "unassigned"})).unwrap();
+ let r = s.execute("edit.stepAndRepeat", &json!({"rows": 4_294_967_296_u64, "columns": 4_294_967_296_u64})).unwrap();
+ // As for any grid of more than 1000 copies: `count` (default 1) copies instead.
+ assert_eq!(r["created"], 1);
+}
+
+/// The column count is the caller's and sizes a list every time the page's columns are laid out.
+#[test]
+fn new_document_caps_the_column_count() {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({"columns": 4_000_000_000_u64})).unwrap();
+ assert_eq!(s.doc().unwrap().doc.spreads[0].pages[0].columns.count, 216);
+ // Past u32 the count wrapped (2³² + 3 became 3).
+ s.execute("file.new", &json!({"columns": 4_294_967_299_u64})).unwrap();
+ assert_eq!(s.doc().unwrap().doc.spreads[0].pages[0].columns.count, 216);
+}
+
#[test]
fn snippets_roundtrip_between_documents() {
let mut s = Session::new();
diff --git a/crates/engine/tests/browser_print.rs b/crates/engine/tests/browser_print.rs
new file mode 100644
index 000000000..fda224b90
--- /dev/null
+++ b/crates/engine/tests/browser_print.rs
@@ -0,0 +1,31 @@
+//! Browser Print must reject native spooler operations without trapping the editor.
+#![cfg(target_arch = "wasm32")]
+
+use designcraft_engine::Session;
+use serde_json::json;
+use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure};
+
+wasm_bindgen_test_configure!(run_in_browser);
+
+fn print_is_safe(dry_run: bool) {
+ let mut session = Session::new();
+ session.execute("file.new", &json!({"pages": 1})).unwrap();
+ let error = session.execute("file.print", &json!({"dryRun": dry_run})).unwrap_err().to_string();
+ assert!(error.contains("printing isn't available on the web"), "{error}");
+ assert!(error.contains("export a PDF"), "{error}");
+ session.execute("frame.create", &json!({"rect": [36, 36, 100, 100], "content": "text"})).unwrap();
+ let exported = session.execute("file.exportPdf", &json!({})).unwrap();
+ assert!(exported["bytes"].as_u64().unwrap() > 500);
+ assert_eq!(exported["pages"], 1);
+ assert!(session.execute("file.print", &json!({"pages": "9", "dryRun": dry_run})).is_err());
+}
+
+#[wasm_bindgen_test]
+fn print_confirmation_preserves_editing_and_pdf_export() {
+ print_is_safe(false);
+}
+
+#[wasm_bindgen_test]
+fn print_dry_run_is_also_unsupported_without_native_operations() {
+ print_is_safe(true);
+}
diff --git a/crates/engine/tests/text_decorations.rs b/crates/engine/tests/text_decorations.rs
new file mode 100644
index 000000000..cd3388f76
--- /dev/null
+++ b/crates/engine/tests/text_decorations.rs
@@ -0,0 +1,165 @@
+//! The same synthetic overlapping runs must paint correctly on screen and in exported PDF.
+use designcraft_color::{Color, Swatch};
+use designcraft_compose::{Cache, compose_story};
+use designcraft_doc::{CharAttrs, Document, ParaFormat, SpreadRef, build::NewDocument};
+use designcraft_geom::{Affine, Rect};
+use designcraft_pdf::{PdfOptions, export_pdf};
+use designcraft_render::{Placed, RenderOptions, Rendered, Renderer};
+
+fn document(decoration: u8, hide_first: bool, hide_second: bool) -> Document {
+ let mut d = Document::new(&NewDocument { width: 240.0, height: 240.0, facing_pages: false, ..Default::default() });
+ d.swatches.push(Swatch::color("Cyan rule", Color::rgb(0.0, 1.0, 1.0)));
+ let (_, sid) = d
+ .add_text_frame(
+ SpreadRef::Doc(0),
+ Rect::new(0.0, 0.0, 240.0, 180.0),
+ d.default_layer(),
+ "HI",
+ ParaFormat {
+ chars: CharAttrs { font_family: Some("Source Sans 3".into()), size: Some(72.0), fill: Some("[Black]".into()), ..Default::default() },
+ ..Default::default()
+ },
+ )
+ .unwrap();
+ d.story_mut(sid).unwrap().format_chars(0..1, |f| {
+ f.over.skew = Some(45.0);
+ if hide_first {
+ f.over.fill = Some("[None]".into());
+ }
+ });
+ d.story_mut(sid).unwrap().format_chars(1..2, |f| {
+ if hide_second {
+ f.over.fill = Some("[None]".into());
+ }
+ f.over.underline = Some(decoration == 1 || decoration == 3);
+ f.over.strikethrough = Some(decoration == 2);
+ f.over.underline_color = Some(if decoration == 3 { "[None]" } else { "Cyan rule" }.into());
+ f.over.strikethrough_color = Some("Cyan rule".into());
+ f.over.underline_weight = Some(Some(18.0));
+ f.over.strikethrough_weight = Some(Some(18.0));
+ f.over.underline_offset = Some(Some(-24.0));
+ // Strike offsets are positive above the baseline in the document model.
+ f.over.strikethrough_offset = Some(Some(24.0));
+ });
+ d
+}
+
+fn raster(d: &Document, threads: u16) -> Rendered {
+ let cache = Cache::new();
+ let mut renderer = Renderer::new();
+ renderer.threads = threads;
+ let placed = [Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }];
+ let options = RenderOptions { paper: false, background: Some([255; 4]), greek_below_px: 0.0, ..Default::default() };
+ let first = renderer.render(d, &cache, &placed, 480, 480, Affine::scale(2.0), &options);
+ let cached = renderer.render(d, &cache, &placed, 480, 480, Affine::scale(2.0), &options);
+ assert_eq!(first.pixels, cached.pixels, "decoration cache hits must retain paint order");
+ first
+}
+
+fn pdf(d: &Document) -> Rendered {
+ let bytes = export_pdf(d, &Cache::new(), &PdfOptions::default()).unwrap();
+ let image = designcraft_render::render_pdf_page(&bytes, 0, 480).unwrap();
+ assert_eq!((image.width(), image.height()), (480, 480));
+ // hayro renders on transparent paper; composite onto the same white backdrop as the canvas.
+ let pixels = image
+ .data()
+ .iter()
+ .flat_map(|p| {
+ let white = 255 - p.a;
+ [p.r.saturating_add(white), p.g.saturating_add(white), p.b.saturating_add(white), 255]
+ })
+ .collect();
+ Rendered { width: 480, height: 480, pixels }
+}
+
+fn dark(p: [u8; 4]) -> bool {
+ // Process black is color managed (about 52/51/52 with the bundled profile).
+ p[0] < 80 && p[1] < 80 && p[2] < 80
+}
+fn white(p: [u8; 4]) -> bool {
+ p[0] > 245 && p[1] > 245 && p[2] > 245
+}
+fn cyan(p: [u8; 4]) -> bool {
+ p[0] < 35 && p[1] > 220 && p[2] > 220
+}
+
+fn check_paint_order(render: impl Fn(&Document) -> Rendered) {
+ let plain_doc = document(0, false, false);
+ let plain = render(&plain_doc);
+ let first_only = render(&document(0, false, true));
+ let second_only = render(&document(0, true, false));
+ let under = render(&document(1, false, false));
+ let strike = render(&document(2, false, false));
+ assert_eq!(render(&document(3, false, false)).pixels, plain.pixels, "explicit None must draw no rule");
+ let sid = *plain_doc.stories.keys().next().unwrap();
+ let cs = compose_story(&plain_doc, sid, &Default::default());
+ let line = &cs.frames[0].lines[0];
+ let g = line.glyphs.iter().find(|g| g.byte == 1).unwrap();
+ let rect = Rect::new(g.x, line.baseline - 24.0 - 9.0, g.x + g.adv, line.baseline - 24.0 + 9.0);
+ let mut cross_run = 0;
+ let mut exposed = 0;
+ for y in (rect.y0 * 2.0).ceil() as u32 + 2..(rect.y1 * 2.0).floor() as u32 - 2 {
+ for x in (rect.x0 * 2.0).ceil() as u32 + 2..(rect.x1 * 2.0).floor() as u32 - 2 {
+ // The first run's skewed H overlaps the SECOND run's rule. A per-run under/glyph
+ // loop would still cover these pixels, so the witness is stronger than one run.
+ if dark(first_only.pixel(x, y)) && white(second_only.pixel(x, y)) {
+ cross_run += 1;
+ assert!(dark(under.pixel(x, y)), "underline covered an earlier run at {x},{y}: {:?}", under.pixel(x, y));
+ assert!(cyan(strike.pixel(x, y)), "strike must remain above glyphs at {x},{y}");
+ }
+ if white(plain.pixel(x, y)) {
+ exposed += 1;
+ assert!(cyan(under.pixel(x, y)), "the visible underline must retain its fill");
+ }
+ }
+ }
+ assert!(cross_run > 20, "fixture needs cross-run interior-ink witnesses, got {cross_run}");
+ assert!(exposed > 20, "fixture needs visible rule pixels, got {exposed}");
+}
+
+#[test]
+fn raster_underlines_are_behind_all_runs_and_strikes_remain_above() {
+ for threads in [0, 2] {
+ check_paint_order(|d| raster(d, threads));
+ }
+}
+
+#[test]
+fn pdf_underlines_are_behind_all_runs_and_strikes_remain_above() {
+ check_paint_order(pdf);
+}
+
+#[test]
+fn decoration_offsets_are_centers_for_explicit_and_automatic_rules() {
+ let mut d = document(1, false, false);
+ let sid = *d.stories.keys().next().unwrap();
+ for (automatic, size) in [(false, 72.0), (true, 28.0)] {
+ d.story_mut(sid).unwrap().format_chars(0..2, |f| {
+ f.over.size = Some(size);
+ f.over.underline = Some(true);
+ f.over.strikethrough = Some(true);
+ f.over.underline_color = Some(String::new());
+ f.over.strikethrough_color = Some(String::new());
+ f.over.fill_tint = Some(0.4);
+ f.over.underline_tint = Some(0.9);
+ f.over.strikethrough_tint = Some(0.8);
+ f.over.underline_weight = Some(if automatic { None } else { Some(12.0) });
+ f.over.strikethrough_weight = Some(if automatic { None } else { Some(6.0) });
+ f.over.underline_offset = Some(if automatic { None } else { Some(-3.0) });
+ f.over.strikethrough_offset = Some(if automatic { None } else { Some(8.0) });
+ });
+ let cs = compose_story(&d, sid, &Default::default());
+ for style in &cs.styles {
+ let baseline = 84.222687;
+ let rules: Vec<_> = style.rules(10.0, 50.0, baseline).collect();
+ assert_eq!(rules.len(), 2);
+ let expected = if automatic { [(size * 0.12, size / 14.0), (-size * 0.3, size / 14.0)] } else { [(-3.0, 12.0), (-8.0, 6.0)] };
+ for ((rule, rect), (offset, weight)) in rules.iter().zip(expected) {
+ assert!((rect.center().y - (baseline + offset)).abs() < 1e-9, "offset must locate the center: {rect:?}");
+ assert!((rect.height() - weight).abs() < 1e-9);
+ assert_eq!(rule.color, style.fill);
+ assert_eq!(rule.tint, 0.4);
+ }
+ }
+ }
+}
diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs
index 3a61b509e..643f59341 100644
--- a/crates/epub/src/lib.rs
+++ b/crates/epub/src/lib.rs
@@ -39,7 +39,7 @@ pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions
let io = |e: std::io::Error| EpubError(e.to_string());
let zerr = |e: zip::result::ZipError| EpubError(e.to_string());
let title = opts.title.clone().unwrap_or_else(|| doc.title.clone());
- let lang = &opts.language;
+ let lang = language_tag(&opts.language);
let id = opts.identifier.clone().unwrap_or_else(|| format!("urn:designcraft:{}", slug(&title)));
let mut manifest = String::from("- \n");
let mut spine = String::new();
@@ -50,8 +50,8 @@ pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions
let (w, h) = (p.width.round().max(1.0) as u32, p.height.round().max(1.0) as u32);
let xhtml = format!(
"\n\n\n
{} — {n} \n\n{}
\n\n",
- esc(&title),
- esc(&p.text).replace('\n', " ")
+ xml_text(&title),
+ story_markup(&p.text).replace('\n', " ")
);
let _ = writeln!(
manifest,
@@ -66,13 +66,13 @@ pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions
let (vw, vh) = pages.first().map_or((612, 792), |p| (p.width.round() as u32, p.height.round() as u32));
let nav = format!(
"\n\n\n{} \nPages {nav_items} \n\n",
- esc(&title)
+ xml_text(&title)
);
- let author = opts.author.as_deref().map(|a| format!("{} ", esc(a))).unwrap_or_default();
+ let author = opts.author.as_deref().map(|a| format!("{} ", xml_text(a))).unwrap_or_default();
let opf = format!(
"\n\n\n{} \n{} \n{lang} {author}\n 2026-01-01T00:00:00Z\n pre-paginated\n auto\n \n \n \n\n{manifest} \n\n{spine} \n \n",
- esc(&id),
- esc(&title)
+ xml_text(&id),
+ xml_text(&title)
);
let container = "\n \n";
let mut buf = std::io::Cursor::new(Vec::new());
@@ -114,7 +114,11 @@ impl std::fmt::Display for EpubError {
}
impl std::error::Error for EpubError {}
-fn esc(s: &str) -> String {
+fn valid_xml_char(c: char) -> bool {
+ matches!(c, '\u{9}' | '\u{A}' | '\u{D}' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}')
+}
+
+fn escape_markup(s: &str, attr: bool, story_breaks: bool) -> String {
let mut o = String::with_capacity(s.len());
for c in s.chars() {
match c {
@@ -122,15 +126,81 @@ fn esc(s: &str) -> String {
'<' => o.push_str("<"),
'>' => o.push_str(">"),
'"' => o.push_str("""),
+ '\'' if attr => o.push_str("'"),
'\u{E000}'..='\u{E1FF}' => {}
- '\u{2028}' => o.push_str(" "),
+ '\u{2028}' if story_breaks => o.push_str(" "),
'\u{AD}' => o.push_str(""),
- c => o.push(c),
+ c if valid_xml_char(c) => o.push(c),
+ _ => {}
}
}
o
}
+fn xml_text(s: &str) -> String {
+ escape_markup(s, false, false)
+}
+
+fn xml_attr(s: &str) -> String {
+ escape_markup(s, true, false)
+}
+
+/// Story content permits the document's discretionary line-separator marker to become markup.
+/// Other contexts always serialize it as ordinary text.
+fn story_markup(s: &str) -> String {
+ escape_markup(s, false, true)
+}
+
+/// A conservative BCP 47 subset suitable for both HTML and EPUB language attributes.
+fn language_tag(s: &str) -> &str {
+ if s.len() > 63 {
+ return "und";
+ }
+ let mut parts = s.split('-');
+ let Some(first) = parts.next() else { return "und" };
+ if !(2..=8).contains(&first.len()) || !first.bytes().all(|b| b.is_ascii_alphabetic()) {
+ return "und";
+ }
+ if parts.any(|p| p.is_empty() || p.len() > 8 || !p.bytes().all(|b| b.is_ascii_alphanumeric())) {
+ return "und";
+ }
+ s
+}
+
+/// Return a MIME type that is safe to place in an attribute and a data URL. Parameters are omitted
+/// because exported assets are always represented as their original binary bytes.
+fn safe_mime(mime: &str) -> &str {
+ fn token(s: &str) -> bool {
+ !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'+' | b'-'))
+ }
+
+ let mime = mime.split_once(';').map_or(mime, |(essence, _)| essence).trim();
+ if mime.len() <= 127
+ && let Some((kind, subtype)) = mime.split_once('/')
+ && !subtype.contains('/')
+ && token(kind)
+ && token(subtype)
+ {
+ mime
+ } else {
+ "image/png"
+ }
+}
+
+/// Quote arbitrary document metadata as one CSS string token. Escaping `<` is important because
+/// HTML parses the contents of a `style` element before CSS parsing sees it.
+fn css_string(s: &str) -> String {
+ let mut out = String::with_capacity(s.len());
+ for c in s.chars() {
+ if matches!(c, '"' | '\\' | '<' | '>' | '&') || c.is_control() {
+ let _ = write!(out, "\\{:x} ", c as u32);
+ } else {
+ out.push(c);
+ }
+ }
+ out
+}
+
/// CSS class name for a style name.
pub fn slug(name: &str) -> String {
let s: String = name.chars().map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' }).collect();
@@ -177,7 +247,7 @@ fn hex(doc: &Document, swatch: &str, tint: f32) -> Option {
}
fn char_css(doc: &Document, c: &CharProps, base_size: f64, out: &mut String) {
- let _ = write!(out, "font-family: \"{}\", serif; ", c.font_family);
+ let _ = write!(out, "font-family: \"{}\", serif; ", css_string(&c.font_family));
let st = c.font_style.to_lowercase();
if st.contains("bold") {
out.push_str("font-weight: bold; ");
@@ -330,7 +400,7 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String {
et.map(|e| e.class.clone()).filter(|c| !c.is_empty()).or_else(|| pc.get(pf.style.as_str()).cloned()).unwrap_or_else(|| slug(&pf.style));
let text_empty = st.text[r.clone()].trim().is_empty();
let rtl = doc.styles.resolve_para(pf).0.direction == designcraft_doc::TextDirection::RightToLeft;
- let _ = write!(out, "<{tag} class=\"{class}\"{}>", if rtl { " dir=\"rtl\"" } else { "" });
+ let _ = write!(out, "<{tag} class=\"{}\"{}>", xml_attr(&class), if rtl { " dir=\"rtl\"" } else { "" });
if text_empty {
out.push_str(" ");
}
@@ -353,9 +423,9 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String {
None => c.to_string(),
})
.collect();
- esc(&sub)
+ story_markup(&sub)
} else {
- esc(raw)
+ story_markup(raw)
};
let et = (f.style != story::NO_CHAR_STYLE).then(|| doc.styles.export_tag(&f.style, true)).flatten();
let ctag = et.map(|e| e.tag.as_str()).filter(|t| is_tag(t, true)).unwrap_or("span");
@@ -365,12 +435,12 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String {
.filter(|c| !c.is_empty())
.or_else(|| cc.get(f.style.as_str()).cloned())
.unwrap_or_else(|| slug(&f.style));
- format!(" class=\"{class}\"")
+ format!(" class=\"{}\"", xml_attr(&class))
} else {
String::new()
};
let style = override_css(doc, &f.over);
- let style_attr = if style.is_empty() { String::new() } else { format!(" style=\"{style}\"") };
+ let style_attr = if style.is_empty() { String::new() } else { format!(" style=\"{}\"", xml_attr(&style)) };
if cls.is_empty() && style_attr.is_empty() {
out.push_str(&t);
} else {
@@ -495,10 +565,11 @@ fn body_html<'a>(
if a.data.is_empty() {
continue;
}
+ let mime = safe_mime(&a.mime);
// Video and sound: an HTML5 player (with the poster, when there is one).
- if let Some(kind) = designcraft_doc::media_kind(&a.mime) {
- let name = format!("media/{}.{}", g.asset.0, ext(&a.mime));
- images.insert(name.clone(), (a.mime.as_str(), a.data.as_slice()));
+ if let Some(kind) = designcraft_doc::media_kind(mime) {
+ let name = format!("media/{}.{}", g.asset.0, ext(mime));
+ images.insert(name.clone(), (mime, a.data.as_slice()));
let m = doc.item(*iid).and_then(|i| i.media.clone()).unwrap_or_default();
let mut attrs = String::new();
for (on, attr) in
@@ -509,17 +580,23 @@ fn body_html<'a>(
}
}
if let Some(pa) = m.poster.and_then(|p| doc.assets.get(&p)) {
- let pn = format!("images/{}.{}", pa.id.0, ext(&pa.mime));
- images.insert(pn.clone(), (pa.mime.as_str(), pa.data.as_slice()));
+ let poster_mime = safe_mime(&pa.mime);
+ let pn = format!("images/{}.{}", pa.id.0, ext(poster_mime));
+ images.insert(pn.clone(), (poster_mime, pa.data.as_slice()));
if kind == "video" {
- attrs.push_str(&format!(" poster=\"{}\"", img_src(&pn, &pa.mime, &pa.data)));
+ attrs.push_str(&format!(" poster=\"{}\"", xml_attr(&img_src(&pn, poster_mime, &pa.data))));
}
}
- let _ = writeln!(body, "<{kind} src=\"{}\"{attrs}>{}{kind}> ", img_src(&name, &a.mime, &a.data), esc(&a.name));
+ let _ = writeln!(
+ body,
+ "<{kind} src=\"{}\"{attrs}>{}{kind}> ",
+ xml_attr(&img_src(&name, mime, &a.data)),
+ xml_text(&a.name)
+ );
continue;
}
- let name = format!("images/{}.{}", g.asset.0, ext(&a.mime));
- images.insert(name.clone(), (a.mime.as_str(), a.data.as_slice()));
+ let name = format!("images/{}.{}", g.asset.0, ext(mime));
+ images.insert(name.clone(), (mime, a.data.as_slice()));
// Alt text from Object Export Options, else the file name.
let alt = doc.item(*iid).map(|i| i.alt_text.as_str()).filter(|t| !t.is_empty()).unwrap_or(&a.name);
// Object Export Options: alignment and a page break before.
@@ -531,8 +608,9 @@ fn body_html<'a>(
if eo.page_break_before {
css.push_str("page-break-before:always;break-before:page;");
}
- let style = if css.is_empty() { String::new() } else { format!(" style=\"{css}\"") };
- let _ = writeln!(body, " ", img_src(&name, &a.mime, &a.data), esc(alt));
+ let style = if css.is_empty() { String::new() } else { format!(" style=\"{}\"", xml_attr(&css)) };
+ let _ =
+ writeln!(body, " ", xml_attr(&img_src(&name, mime, &a.data)), xml_attr(alt));
}
}
}
@@ -550,11 +628,12 @@ pub struct HtmlOptions {
/// document's stories and graphics in reading order.
pub fn export_html(doc: &Document, opts: &HtmlOptions) -> String {
let title = opts.title.clone().unwrap_or_else(|| doc.title.clone());
- let lang = opts.language.clone().unwrap_or_else(|| "en".into());
+ let language = opts.language.as_deref().unwrap_or("en");
+ let lang = language_tag(language);
let (body, _, _) = body_html(doc, &|_, mime, data| format!("data:{mime};base64,{}", base64(data)));
format!(
"\n\n\n \n \n \n{} \n\n\n\n{body}\n\n",
- esc(&title),
+ xml_text(&title),
stylesheet(doc)
)
}
@@ -578,38 +657,38 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubEr
let title = opts.title.clone().unwrap_or_else(|| doc.title.clone());
let id = opts.identifier.clone().unwrap_or_else(|| format!("urn:designcraft:{}", slug(&title)));
let (body, images, toc) = body_html(doc, &|name, _, _| name.to_string());
- let lang = &opts.language;
+ let lang = language_tag(&opts.language);
let chapter = format!(
"\n\n\n{} \n\n{body}\n\n",
- esc(&title)
+ xml_text(&title)
);
let mut nav_items = String::new();
for (a, label) in &toc {
- let _ = write!(nav_items, "{} ", esc(label));
+ let _ = write!(nav_items, "{} ", xml_text(label));
}
if nav_items.is_empty() {
- nav_items = format!("{} ", esc(&title));
+ nav_items = format!("{} ", xml_text(&title));
}
let nav = format!(
"\n\n\n{} \nContents {nav_items} \n\n",
- esc(&title)
+ xml_text(&title)
);
let mut manifest = String::from(
"- \n
- \n
- \n",
);
for (k, (name, (mime, _))) in images.iter().enumerate() {
- let _ = writeln!(manifest, "
- ");
+ let _ = writeln!(manifest, "
- ", xml_attr(name), xml_attr(mime));
}
// Cover: an image page first in reading order.
if opts.cover.is_some() {
manifest.push_str("
- \n
- \n");
}
let cover_spine = if opts.cover.is_some() { "
\n" } else { "" };
- let author = opts.author.as_deref().map(|a| format!("{} ", esc(a))).unwrap_or_default();
+ let author = opts.author.as_deref().map(|a| format!("{} ", xml_text(a))).unwrap_or_default();
let opf = format!(
"\n\n\n{} \n{} \n{lang} {author}\n 2026-01-01T00:00:00Z\n \n \n\n{manifest} \n\n{cover_spine}\n \n \n",
- esc(&id),
- esc(&title)
+ xml_text(&id),
+ xml_text(&title)
);
let container = "\n \n";
let mut buf = std::io::Cursor::new(Vec::new());
@@ -638,8 +717,8 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubEr
z.write_all(c).map_err(io)?;
let xhtml = format!(
"\n\n\n{} \n \n\n",
- esc(&title),
- esc(&title)
+ xml_text(&title),
+ xml_attr(&title)
);
z.start_file("OEBPS/cover.xhtml", deflate).map_err(zerr)?;
z.write_all(xhtml.as_bytes()).map_err(io)?;
@@ -653,9 +732,10 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubEr
mod tests {
use super::*;
use designcraft_doc::build::NewDocument;
- use designcraft_doc::geom::Rect;
- use designcraft_doc::{ParaFormat, SpreadRef};
+ use designcraft_doc::geom::{Affine, Rect, shapes};
+ use designcraft_doc::{Asset, AssetId, Content, Graphic, Item, ItemId, ParaFormat, Shape, SpreadRef};
use std::io::Read;
+ use std::sync::Arc;
#[test]
fn exports_valid_package_in_reading_order() {
@@ -734,4 +814,73 @@ mod tests {
assert_eq!(base64(b"Ma"), "TWE=");
assert_eq!(base64(b"M"), "TQ==");
}
+
+ #[test]
+ fn metadata_is_serialized_for_its_output_context() {
+ let mut d = Document::new(&NewDocument::default());
+ d.title = "Field Notes & ".into();
+ let lid = d.default_layer();
+ let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "A&B \"D\"", ParaFormat::default()).unwrap();
+ d.styles_mut()
+ .export_tags
+ .insert(format!("p:{}", story::BASIC_PARAGRAPH), designcraft_doc::ExportTag { tag: "h2".into(), class: "Primary \"Copy\"".into() });
+ if let Some(basic) = d.styles_mut().paragraph.iter_mut().find(|s| s.name == story::BASIC_PARAGRAPH) {
+ basic.chars.font_family = Some("Quoted \"Serif\" ".into());
+ }
+
+ let aid = AssetId(d.alloc());
+ d.assets.insert(
+ aid,
+ Arc::new(Asset {
+ page: 0,
+ id: aid,
+ name: "Cover & Notes".into(),
+ mime: "image/png; profile=screen".into(),
+ link: None,
+ data: Arc::new(vec![1]),
+ pixels: Some((1, 1)),
+ }),
+ );
+ let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(36.0, 120.0, 72.0, 156.0)));
+ item.alt_text = "Cover \"A\" & B".into();
+ item.content = Content::Graphic(Graphic {
+ asset: aid,
+ size: (1.0, 1.0),
+ xf: Affine::IDENTITY,
+ auto_fit: Default::default(),
+ fit_align: 4,
+ crop: [0.0; 4],
+ });
+ d.insert_item(SpreadRef::Doc(0), item, None).unwrap();
+
+ let story = story_html(&d, sid);
+ assert!(story.contains("A&B <C> "D" "));
+ let html = export_html(&d, &HtmlOptions { title: None, language: Some("English (US)".into()) });
+ assert!(html.contains(""));
+ assert!(html.contains("Field Notes & <Edition> "));
+ assert!(html.contains("alt=\"Cover "A" & B\""));
+ assert!(html.contains("src=\"data:image/png;base64,AQ==\""));
+ assert!(!html.contains("image/png; profile=screen"));
+ assert!(!stylesheet(&d).contains(""));
+ assert!(stylesheet(&d).contains("\\3c Alt\\3e "));
+
+ let bytes = export_epub(
+ &d,
+ &EpubOptions {
+ title: None,
+ author: Some("A & B".into()),
+ language: "English (US)".into(),
+ identifier: Some("notes&edition".into()),
+ cover: None,
+ },
+ )
+ .unwrap();
+ let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
+ let mut opf = String::new();
+ z.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut opf).unwrap();
+ assert!(opf.contains("xml:lang=\"und\""));
+ assert!(opf.contains("notes&edition "));
+ assert!(opf.contains("A & B "));
+ assert!(opf.contains("media-type=\"image/png\""));
+ }
}
diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs
index 4177f941c..14441e144 100644
--- a/crates/fonts/src/fontdb.rs
+++ b/crates/fonts/src/fontdb.rs
@@ -712,6 +712,15 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s
})
}
+/// `family` without the font-format suffix layout apps append when a family is installed in
+/// several formats (`Minion Pro (OTF)` → `Minion Pro`), if it has one.
+fn without_format_suffix(family: &str) -> Option<&str> {
+ const FORMATS: [&str; 5] = ["OTF", "TT", "TTF", "TTC", "T1"];
+ let (base, suffix) = family.trim_end().strip_suffix(')')?.rsplit_once('(')?;
+ let base = base.trim_end();
+ (!base.is_empty() && FORMATS.iter().any(|f| f.eq_ignore_ascii_case(suffix.trim()))).then_some(base)
+}
+
/// The named style part of a style with axis settings (`Bold {wght:650}` → `Bold`).
pub fn base_style(style: &str) -> &str {
style.split('{').next().unwrap_or(style).trim()
@@ -1218,6 +1227,14 @@ impl ScopedFonts<'_> {
/// Style names available for `family` (Regular first, then by weight).
pub fn styles(&self, family: &str) -> Vec {
+ let v = self.exact_styles(family);
+ match without_format_suffix(family) {
+ Some(base) if v.is_empty() => self.exact_styles(base),
+ _ => v,
+ }
+ }
+
+ fn exact_styles(&self, family: &str) -> Vec {
let mut v: Vec<(bool, f32, String)> = self
.own()
.iter()
@@ -1237,15 +1254,10 @@ impl ScopedFonts<'_> {
}
/// Resolve a family + style to a face, falling back to the closest style of the family, then to
- /// Source Sans 3 Regular. Installed system fonts are found by name whatever ran before.
+ /// Source Sans 3 Regular. Installed system fonts are found by name whatever ran before. A
+ /// family with a format suffix (`Minion Pro (OTF)`) that isn't found is looked up without it.
pub fn face(&self, family: &str, style: &str) -> Arc {
- if let Some(f) = self.find(family, style) {
- return f;
- }
- #[cfg(not(target_arch = "wasm32"))]
- if self.db.load_cataloged(family)
- && let Some(f) = self.find(family, style)
- {
+ if let Some(f) = self.find_or_load(family, style).or_else(|| without_format_suffix(family).and_then(|base| self.find_or_load(base, style))) {
return f;
}
self.find(FALLBACK_FAMILY, style)
@@ -1254,8 +1266,24 @@ impl ScopedFonts<'_> {
.unwrap_or_else(last_resort_face)
}
- /// Is `family` available (the document's, loaded, or installed on the system)?
+ fn find_or_load(&self, family: &str, style: &str) -> Option> {
+ if let Some(f) = self.find(family, style) {
+ return Some(f);
+ }
+ #[cfg(not(target_arch = "wasm32"))]
+ if self.db.load_cataloged(family) {
+ return self.find(family, style);
+ }
+ None
+ }
+
+ /// Is `family` available (the document's, loaded, or installed on the system), as named or
+ /// without a format suffix (see [`ScopedFonts::face`])?
pub fn has_family(&self, family: &str) -> bool {
+ self.has_exact_family(family) || without_format_suffix(family).is_some_and(|base| self.has_exact_family(base))
+ }
+
+ fn has_exact_family(&self, family: &str) -> bool {
if self.own().iter().any(|f| f.family.eq_ignore_ascii_case(family)) || self.db.is_loaded(family) {
return true;
}
diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs
index 8f901e96d..c1d744780 100644
--- a/crates/fonts/src/lib.rs
+++ b/crates/fonts/src/lib.rs
@@ -425,6 +425,18 @@ mod tests {
assert_eq!(static_face.style, "Regular", "static fonts ignore axis settings");
}
+ #[test]
+ fn an_exact_family_name_wins_over_the_name_without_its_format_suffix() {
+ let db = FontDb::with_font_dirs(Vec::new());
+ db.set_system_fallback(false);
+ db.add_font(testing::font_with("DC Test Suffix (OTF)", &['a']).unwrap());
+ assert_eq!(db.face("DC Test Suffix (OTF)", "Regular").family, "DC Test Suffix (OTF)");
+ assert_eq!(db.face(&format!("{DEFAULT_FAMILY} (TT)"), "Regular").family, DEFAULT_FAMILY);
+ assert!(db.has_family(&format!("{DEFAULT_FAMILY} (T1)")));
+ assert!(!db.has_family(&format!("{DEFAULT_FAMILY} (Bold)")));
+ assert!(!db.has_family("DC Test Suffix"));
+ }
+
#[test]
fn outlines_and_metrics() {
let db = FontDb::global();
diff --git a/crates/fonts/src/testing.rs b/crates/fonts/src/testing.rs
index e03ad1b6f..830f3d358 100644
--- a/crates/fonts/src/testing.rs
+++ b/crates/fonts/src/testing.rs
@@ -29,14 +29,15 @@ pub fn font_mapping(family: &str, glyphs: &[(char, char)]) -> Option> {
let mut tables = tables_of(base)?;
tables.retain(|(tag, _)| tag != b"name" && tag != b"cmap");
- // `name`: format 0, family (1) and subfamily (2), Windows Unicode English.
- let strings: Vec> = [family, "Regular"].iter().map(|s| s.encode_utf16().flat_map(u16::to_be_bytes).collect()).collect();
+ // `name`: format 0, family (1), subfamily (2) and PostScript name (6), Windows Unicode English.
+ let ps: String = family.chars().filter(char::is_ascii_alphanumeric).chain("-Regular".chars()).collect();
+ let strings: Vec> = [family, "Regular", &ps].iter().map(|s| s.encode_utf16().flat_map(u16::to_be_bytes).collect()).collect();
let mut name = Vec::new();
- for v in [0u16, 2, 6 + 2 * 12] {
+ for v in [0u16, 3, 6 + 3 * 12] {
name.extend_from_slice(&v.to_be_bytes());
}
let mut at = 0usize;
- for (id, s) in [1u16, 2].iter().zip(&strings) {
+ for (id, s) in [1u16, 2, 6].iter().zip(&strings) {
for v in [3u16, 1, 0x409, *id, u16::try_from(s.len()).ok()?, u16::try_from(at).ok()?] {
name.extend_from_slice(&v.to_be_bytes());
}
diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs
index 919991eb0..09fa22f39 100644
--- a/crates/idml/src/export.rs
+++ b/crates/idml/src/export.rs
@@ -346,7 +346,8 @@ impl<'a> Ex<'a> {
let n = format!("TV {name}");
root.push(El::new("TextVariable").attr("Self", format!("dTextVariablen{n}")).attr("Name", &n).attr("VariableType", ty));
}
- for l in &d.layers {
+ // IDML lists layers back to front.
+ for l in d.layers.iter().rev() {
let color = match names::layer_color_out(l.color) {
Some(n) => p("LayerColor", "enumeration", n),
None => {
@@ -1098,8 +1099,8 @@ impl<'a> Ex<'a> {
if let Some(o) = o {
el.set(&format!("{k}Offset"), num(o.unwrap_or(-9999.0)));
}
- if let Some(c) = c.as_ref().filter(|c| !c.is_empty()) {
- el.set(&format!("{k}Color"), self.sw(c));
+ if let Some(c) = c {
+ el.set(&format!("{k}Color"), if c.is_empty() { "Text Color".into() } else { self.sw(c) });
}
if let Some(t) = t {
el.set(&format!("{k}Tint"), num(t as f64 * 100.0));
diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs
index 3e1c17e28..a958e6594 100644
--- a/crates/idml/src/import.rs
+++ b/crates/idml/src/import.rs
@@ -356,6 +356,8 @@ impl<'r> Importer<'r> {
self.layer_ids.insert(s.to_string(), id);
}
}
+ // IDML lists layers back to front; `Document::layers` is front to back.
+ self.layers.reverse();
if self.layers.is_empty() {
let id = LayerId(self.alloc());
self.layers.push(Layer {
@@ -1095,7 +1097,9 @@ impl<'r> Importer<'r> {
// Underline / strikethrough options (-9999 = automatic).
for k in ["Underline", "StrikeThru"] {
let num = |n: &str| e.num(&format!("{k}{n}")).filter(|v| *v > -9000.0);
- let color = e.prop(&format!("{k}Color")).map(|r| self.swatch_ref(r.trim())).filter(|r| r != "Text Color" && !r.is_empty());
+ // An explicit Text Color resets an inherited swatch; absence still inherits.
+ // Resolve the sentinel before swatch_ref, which maps unknown names to [None].
+ let color = e.prop(&format!("{k}Color")).map(|r| if r.trim() == "Text Color" { String::new() } else { self.swatch_ref(r.trim()) });
let tint = e.num(&format!("{k}Tint")).filter(|v| *v >= 0.0).map(|v| (v / 100.0) as f32);
if k == "Underline" {
a.underline_weight = num("Weight").map(Some);
diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs
index b636a2ac1..e6308e897 100644
--- a/crates/idml/src/tests.rs
+++ b/crates/idml/src/tests.rs
@@ -7,6 +7,8 @@ use designcraft_geom::{Rect, shapes};
use super::*;
+mod decorations;
+
fn zip_files(files: &[(&str, &str)]) -> Vec {
use zip::write::SimpleFileOptions;
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
@@ -857,3 +859,38 @@ fn arabic_story_and_table_directions_survive_idml_export() {
assert_eq!(st.direction, designcraft_doc::TextDirection::RightToLeft);
assert_eq!(st.tables.values().next().unwrap().options.direction, designcraft_doc::TextDirection::RightToLeft);
}
+
+/// InDesign's designmap lists layers back to front; `Document::layers[0]` is the front layer.
+#[test]
+fn layers_import_and_export_in_stacking_order() {
+ let designmap = r#"
+
+
+
+ "#;
+ let spread = r#"
+
+
+
+
+
+
+
+
+
+ "#;
+ let d = import_idml(&zip_files(&[("designmap.xml", designmap), ("Spreads/Spread_s.xml", spread)])).unwrap();
+ let names = |d: &Document| d.layers.iter().map(|l| l.name.clone()).collect::>();
+ assert_eq!(names(&d), ["Content", "Background"]);
+ assert_eq!(d.spreads[0].items[0].layer, d.layers[1].id, "the rectangle stays on Background");
+
+ let bytes = export_idml(&d);
+ let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes.as_slice())).unwrap();
+ let mut map = String::new();
+ std::io::Read::read_to_string(&mut zip.by_name("designmap.xml").unwrap(), &mut map).unwrap();
+ let (bg, content) = (map.find(r#"Name="Background""#).unwrap(), map.find(r#"Name="Content""#).unwrap());
+ assert!(bg < content, "designmap lists the back layer first");
+ let back = import_idml(&bytes).unwrap();
+ assert_eq!(names(&back), ["Content", "Background"]);
+ assert_eq!(back.spreads[0].items[0].layer, back.layers[1].id);
+}
diff --git a/crates/idml/src/tests/decorations.rs b/crates/idml/src/tests/decorations.rs
new file mode 100644
index 000000000..338acffca
--- /dev/null
+++ b/crates/idml/src/tests/decorations.rs
@@ -0,0 +1,83 @@
+use super::*;
+
+fn fixture(local: &str) -> Document {
+ let styles = r#"
+
+
+ ParagraphStyle/PBase
+ ParagraphStyle/PReset
+
+
+
+ CharacterStyle/CBase Text Color Text Color
+ CharacterStyle/CReset
+ CharacterStyle/CBase
+
+ "#;
+ let story = format!(
+ r#"Ink "#
+ );
+ let map = r#" "#;
+ let spread = r#" "#;
+ import_idml(&zip_files(&[("designmap.xml", map), ("Resources/Styles.xml", styles), ("Stories/s.xml", &story), ("Spreads/s.xml", spread)]))
+ .unwrap()
+}
+
+fn assert_style_resets(d: &Document) {
+ let p = d.styles.para("PReset").unwrap();
+ let c = d.styles.char_style("CReset").unwrap();
+ for attrs in [&p.chars, &c.chars] {
+ assert_eq!(attrs.underline_color.as_deref(), Some(""), "an explicit reset must remain a sparse value");
+ assert_eq!(attrs.strikethrough_color.as_deref(), Some(""));
+ }
+ let (_, base) = d.styles.resolve_para_style("PBase");
+ let (_, reset) = d.styles.resolve_para_style("PLeaf");
+ assert_eq!(base.underline_color, "[Black]");
+ assert_eq!(base.strikethrough_color, "[Black]");
+ assert!(reset.underline_color.is_empty() && reset.strikethrough_color.is_empty());
+ let reset = d.styles.resolve_char(&base, &designcraft_doc::CharFormat { style: "CLeaf".into(), ..Default::default() });
+ assert!(reset.underline_color.is_empty() && reset.strikethrough_color.is_empty());
+ let inherited = d.styles.resolve_char(&base, &designcraft_doc::CharFormat { style: "CInherit".into(), ..Default::default() });
+ assert_eq!(inherited.underline_color, "[Black]");
+ assert_eq!(inherited.strikethrough_color, "[Black]");
+ assert!(d.styles.char_style("CInherit").unwrap().chars.underline_color.is_none());
+}
+
+#[test]
+fn text_color_resets_paragraph_and_character_style_chains_through_roundtrip() {
+ let d = fixture("");
+ assert_style_resets(&d);
+ assert_style_resets(&import_idml(&export_idml(&d)).unwrap());
+}
+
+#[test]
+fn local_text_color_and_explicit_none_remain_distinct_through_roundtrip() {
+ for (local, expected) in [
+ (r#"UnderlineColor="Text Color" StrikeThruColor="Text Color""#, ""),
+ (r#"UnderlineColor="n" StrikeThruColor="n""#, "[None]"),
+ (r#"UnderlineColor="" StrikeThruColor="""#, "[None]"),
+ ("", "[Black]"),
+ ] {
+ let imported = fixture(local);
+ let reopened = import_idml(&export_idml(&imported)).unwrap();
+ for d in [&imported, &reopened] {
+ let story = d.stories.values().next().unwrap();
+ let (_, para) = d.styles.resolve_para(&story.paras[0]);
+ let actual = d.styles.resolve_char(¶, story.char_format_at(0));
+ assert_eq!(actual.underline_color, expected);
+ assert_eq!(actual.strikethrough_color, expected);
+ }
+ }
+}
+
+#[test]
+fn exporting_native_text_color_reset_writes_the_sentinel() {
+ let mut d = fixture("");
+ let attrs = &mut d.styles_mut().para_mut("PReset").unwrap().chars;
+ attrs.underline_color = Some(String::new());
+ attrs.strikethrough_color = Some(String::new());
+ let attrs = &mut d.styles_mut().char_style_mut("CReset").unwrap().chars;
+ attrs.underline_color = Some(String::new());
+ attrs.strikethrough_color = Some(String::new());
+ assert_style_resets(&import_idml(&export_idml(&d)).unwrap());
+}
diff --git a/crates/images/Cargo.toml b/crates/images/Cargo.toml
index de878d596..0db9d190c 100644
--- a/crates/images/Cargo.toml
+++ b/crates/images/Cargo.toml
@@ -14,3 +14,6 @@ designcraft-fonts = { workspace = true }
image = { workspace = true }
tiff = { workspace = true }
resvg = { workspace = true }
+
+[dev-dependencies]
+designcraft-fonts = { workspace = true, features = ["testing"] }
diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs
index b770140fd..1e81115c4 100644
--- a/crates/images/src/lib.rs
+++ b/crates/images/src/lib.rs
@@ -1,7 +1,7 @@
//! Placed graphics: format sniffing, sizes and decoding for the renderer and PDF export.
//! Rasters go through `image` (PNG, JPEG, GIF, WebP, TIFF, BMP) or [`psd`] (Photoshop's merged
-//! composite); SVG is parsed with usvg (text set in the bundled fonts) and rasterised with resvg
-//! for the screen — PDF export draws the same tree as vectors.
+//! composite); SVG is parsed with usvg (text set in the bundled fonts, then the shared ones) and
+//! rasterised with resvg for the screen — PDF export draws the same tree as vectors.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::sync::{Arc, OnceLock};
@@ -67,10 +67,67 @@ fn svg_options() -> &'static usvg::Options<'static> {
db.set_serif_family("Source Serif 4");
db.set_sans_serif_family("Source Sans 3");
db.set_monospace_family("JetBrains Mono");
- usvg::Options { fontdb: Arc::new(db), font_family: "Source Sans 3".into(), ..Default::default() }
+ let font_resolver = usvg::FontResolver { select_font: select_font(), select_fallback: select_fallback() };
+ usvg::Options { fontdb: Arc::new(db), font_family: "Source Sans 3".into(), font_resolver, ..Default::default() }
})
}
+/// A family the SVG names that isn't bundled comes from the shared fonts (installed ones too),
+/// as it would in a text frame.
+fn select_font() -> usvg::FontSelectionFn<'static> {
+ let bundled = usvg::FontResolver::default_font_selector();
+ Box::new(move |font, db| {
+ let fonts = designcraft_fonts::FontDb::global();
+ for family in font.families() {
+ if let usvg::FontFamily::Named(name) = family
+ && !db.faces().any(|f| f.families.iter().any(|(n, _)| n.eq_ignore_ascii_case(name)))
+ && fonts.has_family(name)
+ {
+ for style in fonts.styles(name) {
+ add_face(db, fonts.face(name, &style));
+ }
+ }
+ }
+ bundled(font, db)
+ })
+}
+
+/// Characters the chosen fonts lack (Korean, Arabic…) fall back to a shared face that has them.
+fn select_fallback() -> usvg::FallbackSelectionFn<'static> {
+ let loaded = usvg::FontResolver::default_fallback_selector();
+ Box::new(move |c, exclude, db| {
+ loaded(c, exclude, db).or_else(|| {
+ let id = add_face(db, designcraft_fonts::FontDb::global().fallback_for(c, 0, None)?)?;
+ // usvg asks again until we run out: a face it already tried ends the search.
+ (!exclude.contains(&id)).then_some(id)
+ })
+ })
+}
+
+/// A shared face's bytes, handed to usvg without a copy.
+struct FaceBytes(Arc);
+
+impl AsRef<[u8]> for FaceBytes {
+ fn as_ref(&self) -> &[u8] {
+ self.0.data()
+ }
+}
+
+/// Add `face` to the SVG's font database (once) and return its id there.
+fn add_face(db: &mut Arc, face: Arc) -> Option {
+ let same = |f: &usvg::fontdb::FaceInfo| {
+ f.index == face.index()
+ && matches!(&f.source, usvg::fontdb::Source::Binary(b) if std::ptr::eq(b.as_ref().as_ref().as_ptr(), face.data().as_ptr()))
+ };
+ if let Some(f) = db.faces().find(|f| same(f)) {
+ return Some(f.id);
+ }
+ let index = face.index();
+ let ids = Arc::make_mut(db).load_font_source(usvg::fontdb::Source::Binary(Arc::new(FaceBytes(face))));
+ // A collection loads all its faces (skipping any it can't read): pick ours by its index.
+ ids.into_iter().find(|id| db.face(*id).is_some_and(|f| f.index == index))
+}
+
/// The parsed SVG.
pub fn svg_tree(bytes: &[u8]) -> Option {
usvg::Tree::from_data(bytes, svg_options()).ok()
@@ -211,6 +268,43 @@ mod tests {
assert!(!is_svg(b"\x89PNG"));
}
+ /// The family of the font that sets each text glyph of `svg`, and the glyph's id.
+ fn glyph_families(svg: &str) -> Vec<(String, u16)> {
+ fn walk(g: &usvg::Group, tree: &usvg::Tree, out: &mut Vec<(String, u16)>) {
+ for n in g.children() {
+ match n {
+ usvg::Node::Group(g) => walk(g, tree, out),
+ usvg::Node::Text(t) => {
+ for g in t.layouted().iter().flat_map(|s| &s.positioned_glyphs) {
+ out.extend(tree.fontdb().face(g.font).map(|f| (f.families[0].0.clone(), g.id.0)));
+ }
+ }
+ _ => {}
+ }
+ }
+ }
+ let tree = svg_tree(svg.as_bytes()).unwrap();
+ let mut out = Vec::new();
+ walk(tree.root(), &tree, &mut out);
+ out
+ }
+
+ #[test]
+ fn svg_text_uses_the_shared_fonts() {
+ let font = designcraft_fonts::testing::font_with("DC Test SVG Hangul", &['한', '글']).unwrap();
+ designcraft_fonts::FontDb::global().add_font(font);
+ let text = |attrs: &str, body: &str| {
+ format!(r#"{body} "#)
+ };
+ let named = r#" font-family="DC Test SVG Hangul""#;
+ // A family named in the SVG.
+ let set = glyph_families(&text(named, "한글"));
+ assert!(set.len() == 2 && set.iter().all(|(f, gid)| f == "DC Test SVG Hangul" && *gid != 0), "{set:?}");
+ // Characters the bundled default lacks: a shared face that has them, not the default.
+ let set = glyph_families(&text("", "한글"));
+ assert!(set.len() == 2 && set.iter().all(|(f, gid)| f != "Source Sans 3" && *gid != 0), "{set:?}");
+ }
+
/// A minimal Photoshop file: 2×1 RGB, no layers, raw merged image.
fn tiny_psd() -> Vec {
let mut b = Vec::new();
diff --git a/crates/images/src/psd.rs b/crates/images/src/psd.rs
index 7da9e3ac4..182005274 100644
--- a/crates/images/src/psd.rs
+++ b/crates/images/src/psd.rs
@@ -144,8 +144,7 @@ pub(crate) fn decode(bytes: &[u8]) -> Option {
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
let byte = |v: f32| v.round().clamp(0.0, 255.0) as u8;
let white = mode.white();
- for (i, px) in rgba.chunks_exact_mut(4).enumerate() {
- let Ok(px) = <&mut [u8; 4]>::try_from(px) else { continue };
+ for (i, px) in rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() {
let a = alpha.get(i).copied().unwrap_or(255);
let mut v = px.map(f32::from);
// The composite is matted against white where it's partly transparent: un-matte it.
@@ -314,7 +313,7 @@ fn fill(dst: &mut [u8], slot: usize, step: usize, src: &[u8], depth: u16) {
match depth {
1 => dst.zip(src.iter().flat_map(|&b| (0..8).rev().map(move |i| if (b >> i) & 1 == 1 { 0 } else { 255 }))).for_each(|(d, s)| *d = s),
16 => dst
- .zip(src.chunks_exact(2).map(|c| c.iter().fold(0u32, |n, &b| n << 8 | u32::from(b))))
+ .zip(src.as_chunks::<2>().0.iter().map(|c| c.iter().fold(0u32, |n, &b| n << 8 | u32::from(b))))
.for_each(|(d, s)| *d = ((s * 255 + 32_767) / 65_535) as u8),
_ => dst.zip(src).for_each(|(d, &s)| *d = s),
}
diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs
index ee9d11d19..aea3e8763 100644
--- a/crates/mcp/src/headless.rs
+++ b/crates/mcp/src/headless.rs
@@ -180,7 +180,7 @@ impl Headless {
fn text(&mut self, p: &Value) -> Result {
let t = s(p, "text").ok_or("missing `text`")?;
- if !self.session.wants_text() {
+ if self.session.active().is_none_or(|st| st.selection.text.is_none_or(|t| st.doc.text_story(t.story, t.cell).is_none())) {
return Err("no text insertion point: click into a text frame with the type tool first (pointer with tool \"type\"), \
or select text with execute text.select, or use set_story_text"
.into());
@@ -191,7 +191,7 @@ impl Headless {
fn render(&mut self, p: &Value) -> Result {
let st = self.session.active().ok_or("no document open")?;
- let page = p.get("page").and_then(Value::as_u64).unwrap_or(0) as usize;
+ let page = page_index(p, st.doc.page_count())?;
let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0).clamp(0.05, 16.0);
let bleed = p.get("bleed").and_then(Value::as_bool).unwrap_or(false);
let img = self
@@ -213,7 +213,7 @@ impl Headless {
fn export(&mut self, p: &Value) -> Result {
let path = s(p, "path").ok_or("missing `path`")?;
let st = self.session.active().ok_or("no document open")?;
- let page = p.get("page").and_then(Value::as_u64).unwrap_or(0) as usize;
+ let page = page_index(p, st.doc.page_count())?;
let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(2.0).clamp(0.05, 16.0);
let img = self
.renderer
@@ -263,3 +263,15 @@ impl Backend for Headless {
"headless (in-process engine, no window)".into()
}
}
+
+/// A supplied page must be a representable unsigned index; only omission selects page zero.
+fn page_index(p: &Value, count: usize) -> Result {
+ let page = match p.get("page") {
+ None => 0,
+ Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or("`page` must be a nonnegative integer representable as a page index")?,
+ };
+ if page >= count {
+ return Err(format!("no page {page} (the document has {count} pages, indices are 0-based)"));
+ }
+ Ok(page)
+}
diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs
index 2804d13fb..3051a5ad4 100644
--- a/crates/mcp/src/tests.rs
+++ b/crates/mcp/src/tests.rs
@@ -416,3 +416,87 @@ fn batch_steps_use_earlier_results() {
);
assert_eq!(v["completed"], 2);
}
+
+#[test]
+fn explicit_typing_honors_caret_range_and_undo_without_changing_shortcuts() {
+ let mut s = server();
+ assert_eq!(call(&mut s, "type_text", json!({"text":"X"}))["isError"], true);
+ let f = ok(&mut s, "execute", json!({"command":"frame.create","params":{"rect":[36,36,300,200],"content":"text","text":"Hello"}}));
+ let story = f["story"].as_u64().unwrap();
+ for (anchor, focus, expected) in [(5, 5, "HelloX"), (0, 5, "X")] {
+ ok(&mut s, "set_story_text", json!({"story":story,"text":"Hello"}));
+ ok(&mut s, "execute", json!({"command":"text.select","params":{"story":story,"anchor":anchor,"focus":focus}}));
+ ok(&mut s, "type_text", json!({"text":"X"}));
+ assert_eq!(ok(&mut s, "get_story", json!({"story":story}))["text"], expected);
+ ok(&mut s, "execute", json!({"command":"edit.undo"}));
+ assert_eq!(ok(&mut s, "get_story", json!({"story":story}))["text"], "Hello");
+ }
+ ok(&mut s, "execute", json!({"command":"text.placeCaret","params":{"frame":f["id"],"point":[100,50]}}));
+ ok(&mut s, "type_text", json!({"text":"X"}));
+ assert_eq!(ok(&mut s, "get_story", json!({"story":story}))["text"], "HelloX");
+ assert_eq!(ok(&mut s, "key", json!({"key":"T"}))["handledBy"], "tool.select");
+ assert_eq!(ok(&mut s, "get_story", json!({"story":story}))["text"], "HelloX");
+}
+
+#[test]
+fn story_queries_distinguish_missing_from_empty() {
+ let mut s = server();
+ assert_eq!(call(&mut s, "get_story", json!({"story":999}))["isError"], true);
+ let f = ok(&mut s, "execute", json!({"command":"frame.create","params":{"rect":[36,36,300,200],"content":"text"}}));
+ assert_eq!(ok(&mut s, "get_story", json!({"story":f["story"]}))["text"], "");
+ assert_eq!(ok(&mut s, "get_story", json!({"frame":f["id"]}))["text"], "");
+ ok(&mut s, "execute", json!({"command":"text.select","params":{"story":f["story"],"anchor":0}}));
+ assert_eq!(ok(&mut s, "get_story", json!({}))["text"], "");
+}
+
+#[test]
+fn page_contract_rejects_supplied_invalid_values_without_writes() {
+ let mut s = server();
+ ok(&mut s, "new_document", json!({"sample":true}));
+ let path = tmp("page-contract.png");
+ for tool in ["export_png", "render_page"] {
+ for page in
+ [json!(-1), json!(-2), json!(1.5), Value::Null, json!("0"), json!(true), json!(u64::MAX), json!(18446744073709551616_f64), json!(99)]
+ {
+ std::fs::write(&path, b"KEEP").unwrap();
+ let args = json!({"page":page,"path":path,"scale":0.1});
+ assert_eq!(call(&mut s, tool, args)["isError"], true, "{tool}: {page}");
+ assert_eq!(std::fs::read(&path).unwrap(), b"KEEP");
+ }
+ }
+ let first = image_of(&call(&mut s, "render_page", json!({"page":0,"scale":0.1})));
+ let next = image_of(&call(&mut s, "render_page", json!({"page":1,"scale":0.1})));
+ assert_ne!(first, next);
+ assert_eq!(first, image_of(&call(&mut s, "render_page", json!({"scale":0.1}))));
+ ok(&mut s, "export_png", json!({"page":1,"path":path,"scale":0.1}));
+ assert_eq!(std::fs::read(&path).unwrap(), next);
+ ok(&mut s, "export_png", json!({"path":path,"scale":0.1}));
+ assert_eq!(std::fs::read(&path).unwrap(), first);
+ for tool in ["render_page", "export_png"] {
+ let definition = tool_definitions().into_iter().find(|v| v["name"] == tool).unwrap();
+ assert_eq!(definition["inputSchema"]["properties"]["page"]["minimum"], 0);
+ }
+ let mut b = Headless::with_document();
+ for method in ["ui.render", "app.export"] {
+ for page in [json!(-1), Value::Null, json!("0"), json!(99)] {
+ std::fs::write(&path, b"KEEP").unwrap();
+ assert!(b.call(method, json!({"page":page,"path":path,"scale":0.1})).is_err());
+ assert_eq!(std::fs::read(&path).unwrap(), b"KEEP");
+ }
+ std::fs::remove_file(&path).unwrap();
+ assert!(b.call(method, json!({"page":-1,"path":path,"scale":0.1})).is_err());
+ assert!(!path.exists());
+ }
+}
+
+#[test]
+fn explicit_typing_rejects_stale_selection_and_closed_document() {
+ let mut b = Headless::with_document();
+ let f = b.session.execute("frame.create", &json!({"rect":[36,36,300,200],"content":"text","text":"Hello"})).unwrap();
+ b.session.execute("text.select", &json!({"story":f["story"],"anchor":5})).unwrap();
+ b.session.active_mut().unwrap().selection.text.as_mut().unwrap().story.0 = 999;
+ assert!(b.call("ui.text", json!({"text":"X"})).is_err());
+ assert_eq!(b.session.execute("story.get", &json!({"story":f["story"]})).unwrap()["text"], "Hello");
+ b.session.execute("file.close", &json!({})).unwrap();
+ assert!(b.call("ui.text", json!({"text":"X"})).is_err());
+}
diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs
index dc2604c5b..802f615dc 100644
--- a/crates/mcp/src/tools.rs
+++ b/crates/mcp/src/tools.rs
@@ -106,7 +106,8 @@ pub fn tool_definitions() -> Vec {
"Run any DesignCraft command by id with JSON params (ids and params from list_commands). Examples: \
{\"command\":\"frame.create\",\"params\":{\"rect\":[36,36,576,300],\"content\":\"text\",\"text\":\"Hello\"}} → {id, story}; \
{\"command\":\"type.char\",\"params\":{\"size\":24}}; {\"command\":\"edit.undo\"}. Coordinates are points in spread space. \
- In the desktop app UI-only commands (view.*, window.*, app.*) work too.",
+ In the desktop app the UI-only commands (view.*, window.*, help.*, edit.dynamicSpelling, and app.* except app.links, \
+ which works everywhere) work too.",
obj(json!({"command": string("Command id, e.g. frame.create"), "params": params_schema()}), &["command"]),
false,
),
@@ -137,16 +138,17 @@ pub fn tool_definitions() -> Vec {
"inspect_document",
"Inspect document",
"Summary of the active document: settings, pages (index, name, bounds, margins, columns), spreads with items (id, kind, \
- bounds, fill, stroke, story), stories (id, frames, length, overset, preview), layers, paragraph/character styles, \
- swatches, selection, active tool.",
+ bounds, fill, stroke, story), stories (id, frames, length in UTF-8 bytes, overset, preview), layers, \
+ paragraph/character styles, swatches, selection, active tool.",
empty(),
true,
),
tool(
"get_story",
"Get story",
- "Full text of a story plus its frames, paragraph count, line count and overset position. Identify it by `story` id or \
- by a text `frame` id (default: the selection).",
+ "Full text of a story plus its length, frames, paragraph count, line count and overset position (length and positions \
+ are UTF-8 byte offsets, the unit text.select takes). Identify it by `story` id or by a text `frame` id (default: the \
+ selection).",
obj(json!({"story": int("Story id"), "frame": int("Text frame id")}), &[]),
true,
),
@@ -223,7 +225,7 @@ pub fn tool_definitions() -> Vec {
also write it to `path`.",
obj(
json!({
- "page": int("Page index, 0-based (default 0; in the desktop app default = the current page)"),
+ "page": {"type": "integer", "minimum": 0, "description": "Page index, 0-based (default 0; in the desktop app default = the current page)"},
"scale": num("Pixels per point (default 1 = 72 ppi)"),
"bleed": boolean("Include the bleed area"),
"path": string("Also write the PNG here"),
@@ -237,7 +239,7 @@ pub fn tool_definitions() -> Vec {
"Export PNG",
"Export one page as a PNG (or JPEG if the path ends in .jpg) file. Returns the path and pixel size.",
obj(
- json!({"path": string("Destination file"), "page": int("Page index, 0-based (default 0)"), "scale": num("Pixels per point (default 2)")}),
+ json!({"path": string("Destination file"), "page": {"type": "integer", "minimum": 0, "description": "Page index, 0-based (default 0; in the desktop app default = the current page)"}, "scale": num("Pixels per point (default 2)")}),
&["path"],
),
false,
@@ -303,7 +305,7 @@ pub fn tool_definitions() -> Vec {
tool(
"type_text",
"Type text",
- "Type text at the text insertion point (as the keyboard would with the type tool), or into the focused dialog field in \
+ "Type text at the text insertion point, independently of the active tool, or into the focused dialog field in \
the desktop app. Put a caret first with pointer (tool \"type\") or execute text.placeCaret / text.select.",
obj(json!({"text": string("Text to type (\\n = new paragraph)")}), &["text"]),
false,
@@ -409,6 +411,15 @@ fn pick(a: &Args, keys: &[&str]) -> Value {
Value::Object(keys.iter().filter_map(|k| a.get(*k).filter(|v| !v.is_null()).map(|v| (k.to_string(), v.clone()))).collect())
}
+/// Page omission selects a default, but an explicitly supplied null must reach validation.
+fn page_params(a: &Args, keys: &[&str]) -> Value {
+ let mut p = pick(a, keys);
+ if let Some(page) = a.get("page") {
+ p["page"] = page.clone();
+ }
+ p
+}
+
fn command_params(v: Option<&Value>) -> Result {
match v {
None | Some(Value::Null) => Ok(json!({})),
@@ -463,7 +474,7 @@ fn story_id(b: &mut dyn Backend, a: &Args) -> Result {
}
fn render_page(b: &mut dyn Backend, a: &Args) -> Result {
- let r = b.call("ui.render", pick(a, &["page", "scale", "bleed"]))?;
+ let r = b.call("ui.render", page_params(a, &["page", "scale", "bleed"]))?;
let b64 = r.get("pngBase64").and_then(Value::as_str).ok_or("renderer returned no image")?.to_string();
let path = a.get("path").and_then(Value::as_str);
if let Some(path) = path {
@@ -550,7 +561,7 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result render_page(b, a),
"export_png" => {
req_str(a, "path")?;
- j(b.call("app.export", pick(a, &["path", "page", "scale"]))?)
+ j(b.call("app.export", page_params(a, &["path", "page", "scale"]))?)
}
"screenshot" => screenshot(b, a),
"select_tool" => j(b.call("ui.tool.select", json!({"tool": req_str(a, "tool")?}))?),
diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs
index 0c77e69b9..72b378a97 100644
--- a/crates/pdf/src/export.rs
+++ b/crates/pdf/src/export.rs
@@ -154,6 +154,7 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions)
fonts: HashMap::new(),
reverse_cmaps: HashMap::new(),
rgb_only: archival.is_some(),
+ interpolate: archival.is_none(),
tags: Vec::new(),
story_tags: HashMap::new(),
tag_story: None,
@@ -327,6 +328,7 @@ pub fn export_booklet(doc: &Document, cache: &Cache, opts: &BookletOptions) -> R
fonts: HashMap::new(),
reverse_cmaps: HashMap::new(),
rgb_only: false,
+ interpolate: true,
tags: Vec::new(),
story_tags: HashMap::new(),
tag_story: None,
@@ -421,6 +423,8 @@ pub(crate) struct Exporter<'a> {
pub reverse_cmaps: HashMap>>,
/// Convert CMYK to RGB (PDF/A: krilla needs a CMYK output profile we don't ship yet).
pub rgb_only: bool,
+ /// Ask viewers to smooth upscaled images (`/Interpolate`). Off for PDF/A, which forbids it.
+ interpolate: bool,
/// Tagged PDF: the structure in reading order (stories gather their frames' content).
tags: Vec,
story_tags: HashMap>,
@@ -937,22 +941,43 @@ impl Exporter<'_> {
}
/// A swatch fill (solid or gradient) in the item's inner space. `None` for [None]/unknown.
- fn fill_paint(&self, fill: &designcraft_doc::Fill, bounds: Rect) -> Option {
+ fn fill_paint(&mut self, fill: &designcraft_doc::Fill, bounds: Rect) -> Option {
let (swatch, tint, angle) = (fill.swatch.as_str(), fill.tint, fill.gradient_angle);
if let Some(g) = designcraft_color::swatch::resolve_gradient(&self.doc.swatches, swatch) {
+ let kind = g.kind;
+ let expanded = g.expanded_stops();
+ // A PDF shading needs one colour space. Midpoint expansion can introduce RGB even
+ // when every authored stop is CMYK/Gray; PDF/A can also change the output space.
+ let components = |c: &Color| match c {
+ Color::Cmyk { .. } if !self.rgb_only => 4,
+ Color::Gray { .. } => 1,
+ _ => 3,
+ };
+ let mixed = expanded.first().is_some_and(|(_, first, _)| expanded.iter().any(|(_, c, _)| components(c) != components(first)));
+ if mixed {
+ self.warn(format!("gradient '{swatch}': mixed color spaces converted to RGB (colour appearance and separations may change)"));
+ }
let mut stops: Vec = Vec::new();
let mut last = 0.0f32;
- for (o, c, a) in g.expanded_stops() {
+ for (o, c, a) in expanded {
let o = o.clamp(last, 1.0);
last = o;
- stops.push(Stop { offset: norm(o), color: device(&c, self.rgb_only), opacity: norm(a) });
+ let color = if mixed && !matches!(c, Color::Rgb { .. }) {
+ let [r, g, b] = c.to_rgb();
+ rgb::Color::new(q(r), q(g), q(b)).into()
+ } else {
+ // Preserve RGB values, including already-interpolated display RGB, and
+ // leave every homogeneous gradient on its existing device-space path.
+ device(&c, self.rgb_only)
+ };
+ stops.push(Stop { offset: norm(o), color, opacity: norm(a) });
}
if stops.is_empty() {
return None;
}
let c = bounds.center();
let v = fill.gradient_vector;
- return Some(match g.kind {
+ return Some(match kind {
GradientKind::Radial => {
let (c, r) = match v {
Some([x0, y0, x1, y1]) => (designcraft_geom::Point::new(x0, y0), Vec2::new(x1 - x0, y1 - y0).hypot()),
@@ -1168,21 +1193,21 @@ impl Exporter<'_> {
let data = asset.data.clone();
let fmt = image::guess_format(&data).ok();
let img = match fmt {
- Some(image::ImageFormat::Jpeg) => Image::from_jpeg(data.clone().into(), true).ok(),
+ Some(image::ImageFormat::Jpeg) => Image::from_jpeg(data.clone().into(), self.interpolate).ok(),
// A CMYK TIFF keeps its ink values (`image` decodes it to RGB, and 100% K would print
// as four-colour black). PDF/A exports are RGB only.
- Some(image::ImageFormat::Tiff) if !self.rgb_only && designcraft_images::is_cmyk_tiff(&data) => match cmyk_tiff(&data) {
+ Some(image::ImageFormat::Tiff) if !self.rgb_only && designcraft_images::is_cmyk_tiff(&data) => match cmyk_tiff(&data, self.interpolate) {
Some(img) => Some(img),
None => {
self.warn(format!(
"{}: converted to RGB (a CMYK TIFF that is planar, has premultiplied alpha, uses other inks or is over 256 MiB)",
asset.name
));
- lossless(&data, fmt)
+ lossless(&data, fmt, self.interpolate)
}
},
- _ if self.opts.compress_images => recompress(&data).or_else(|| lossless(&data, fmt)),
- _ => lossless(&data, fmt),
+ _ if self.opts.compress_images => recompress(&data, self.interpolate).or_else(|| lossless(&data, fmt, self.interpolate)),
+ _ => lossless(&data, fmt, self.interpolate),
};
if img.is_none() {
self.warn(format!("image `{}` could not be decoded and was skipped", asset.name));
@@ -1276,16 +1301,19 @@ fn relabel_pdf_header(data: &[u8], from: &[u8], to: &[u8]) -> Option> {
Some(out)
}
-fn lossless(data: &Arc>, fmt: Option) -> Option {
+fn lossless(data: &Arc>, fmt: Option, interpolate: bool) -> Option {
+ // krilla reads PNG, GIF and WebP lazily: a damaged file is accepted here and fails the whole
+ // export when the PDF is written. Decoding it first means it is skipped, like any other
+ // image that can't be decoded.
+ let rgba = designcraft_images::decode_rgba(data)?;
let direct = match fmt {
- Some(image::ImageFormat::Png) => Image::from_png(data.clone().into(), true).ok(),
- Some(image::ImageFormat::Gif) => Image::from_gif(data.clone().into(), true).ok(),
- Some(image::ImageFormat::WebP) => Image::from_webp(data.clone().into(), true).ok(),
+ Some(image::ImageFormat::Png) => Image::from_png(data.clone().into(), interpolate).ok(),
+ Some(image::ImageFormat::Gif) => Image::from_gif(data.clone().into(), interpolate).ok(),
+ Some(image::ImageFormat::WebP) => Image::from_webp(data.clone().into(), interpolate).ok(),
_ => None,
};
direct.or_else(|| {
// TIFF, BMP, PSD composites…
- let rgba = designcraft_images::decode_rgba(data)?;
let (w, h) = rgba.dimensions();
Some(Image::from_rgba8(rgba.into_raw(), w, h))
})
@@ -1320,18 +1348,18 @@ impl krilla::image::CustomImage for CmykImage {
}
/// A CMYK TIFF as a DeviceCMYK image. `None` when it can't be read as CMYK.
-fn cmyk_tiff(data: &[u8]) -> Option {
+fn cmyk_tiff(data: &[u8], interpolate: bool) -> Option {
let r = designcraft_images::decode_cmyk_tiff(data)?;
// krilla panics when the channel lengths don't match the size.
let pixels = usize::try_from(r.width).ok()?.checked_mul(usize::try_from(r.height).ok()?)?;
if r.cmyk.len() != pixels.checked_mul(4)? || r.alpha.as_ref().is_some_and(|a| a.len() != pixels) {
return None;
}
- Image::from_custom(CmykImage(Arc::new(r)), true).ok()
+ Image::from_custom(CmykImage(Arc::new(r)), interpolate).ok()
}
/// Opaque raster → JPEG (quality 90). `None` when the image has transparency or can't be decoded.
-fn recompress(data: &[u8]) -> Option {
+fn recompress(data: &[u8], interpolate: bool) -> Option {
let img = image::load_from_memory(data).ok()?;
if img.color().has_alpha() && img.to_rgba8().pixels().any(|p| p[3] < 255) {
return None;
@@ -1340,5 +1368,5 @@ fn recompress(data: &[u8]) -> Option {
let mut buf = Vec::new();
let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, 90);
image::ImageEncoder::write_image(enc, rgb.as_raw(), rgb.width(), rgb.height(), image::ExtendedColorType::Rgb8).ok()?;
- Image::from_jpeg(buf.into(), true).ok()
+ Image::from_jpeg(buf.into(), interpolate).ok()
}
diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs
index b03d8dbe7..d2fbba7ba 100644
--- a/crates/pdf/src/lib.rs
+++ b/crates/pdf/src/lib.rs
@@ -12,15 +12,16 @@
//! - **Colour:** CMYK as DeviceCMYK, RGB as DeviceRGB, Gray as DeviceGray, spot swatches (and their
//! tints) as `/Separation` with the swatch's own values as the alternate space, `[Registration]`
//! as `/Separation /All`.
+//! Gradients with mixed output colour spaces are converted to RGB with an export warning;
+//! homogeneous gradients keep their existing colour space (subject to PDF/A conversion).
//! - **Images:** clipped to their frame; JPEG data is passed through, PNG/GIF/WebP are embedded
//! losslessly (or re-encoded as JPEG with [`PdfOptions::compress_images`]).
//! - **Text as real text:** the composed glyph runs are emitted with embedded, subsetted fonts and a
//! Unicode mapping taken from the story text (ligatures, page numbers and inserted hyphens get the
//! right characters), so text is selectable and searchable.
//! - **Marks:** crop marks, bleed marks and a page-information line, drawn in `[Registration]`.
-//! - **Standards:** PDF/A-2b through krilla's validator. PDF/X-4 writes PDF 1.6 with trim/bleed
-//! boxes, but the PDF/X output intent and identification are not written yet (krilla 0.8 has no
-//! PDF/X support) — the export report says so.
+//! - **Standards:** PDF/A-2b through krilla's validator and PDF/X-4 output intent/identification
+//! with built-in conformance checks. `designcraft-cli validate-pdf` exposes those checks for CI.
//!
//! Not yet: tagged PDF, bookmarks, hyperlinks, overprint, layers as optional content.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
@@ -44,8 +45,7 @@ pub enum Standard {
/// Plain PDF 1.7.
#[default]
None,
- /// PDF/X-4 (PDF 1.6, trim/bleed boxes). TODO: OutputIntent + GTS_PDFXVersion once krilla
- /// supports PDF/X.
+ /// PDF/X-4 (PDF 1.6, trim/bleed boxes, output intent and identification).
PdfX4,
/// PDF/A-2b (archival; validated by krilla).
PdfA2b,
diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs
index 1771c4e4f..18aa5b418 100644
--- a/crates/pdf/src/tests.rs
+++ b/crates/pdf/src/tests.rs
@@ -240,6 +240,31 @@ fn place(d: &mut Document, data: Vec, px: (u32, u32)) {
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
}
+/// krilla reads PNG, GIF and WebP lazily, so a damaged one passed the exporter's "does it decode"
+/// check and failed the whole export when the PDF was written ("PDF writer error: … unexpected
+/// end of file"). A cut-off download must be skipped with a warning, as a damaged TIFF is.
+#[test]
+fn a_cut_off_image_is_skipped_not_fatal() {
+ let px = image::RgbaImage::from_fn(64, 64, |x, y| image::Rgba([(x * 37 + y * 11) as u8, (x * 5) as u8, (y * 91) as u8, 255]));
+ for format in [image::ImageFormat::Png, image::ImageFormat::Gif, image::ImageFormat::WebP] {
+ let mut whole = Vec::new();
+ px.write_to(&mut std::io::Cursor::new(&mut whole), format).unwrap();
+ let cut = whole[..whole.len() * 9 / 10].to_vec();
+ let mut d = doc_with_text("still here");
+ place(&mut d, cut, (64, 64));
+ let r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions::default()).unwrap_or_else(|e| panic!("{format:?}: {e}"));
+ assert!(r.warnings.iter().any(|w| w.contains("could not be decoded and was skipped")), "{format:?}: {:?}", r.warnings);
+ assert!(image_xobjects(&r.bytes).is_empty(), "{format:?}");
+ assert!(extract_text(&r.bytes).concat().contains("still"), "{format:?}: the rest of the page is exported");
+ // The undamaged file still goes in.
+ let mut d = doc_with_text("x");
+ place(&mut d, whole, (64, 64));
+ let r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions::default()).unwrap();
+ assert!(r.warnings.is_empty(), "{format:?}: {:?}", r.warnings);
+ assert_eq!(image_xobjects(&r.bytes).len(), 1, "{format:?}");
+ }
+}
+
/// Every image XObject in a PDF: its /ColorSpace name (`?` when not a name) and decoded samples.
fn image_xobjects(bytes: &[u8]) -> Vec<(String, Vec)> {
use hayro_syntax::object::Name;
@@ -271,6 +296,55 @@ fn cmyk_tiff_keeps_its_inks() {
}
}
+/// An 8×8 raster encoded as `fmt`; `alpha` gives it a transparent corner.
+fn raster(fmt: image::ImageFormat, alpha: bool) -> Vec {
+ let img =
+ image::RgbaImage::from_fn(8, 8, |x, y| image::Rgba([(x * 30) as u8, (y * 30) as u8, 128, if alpha && x == 0 && y == 0 { 0 } else { 255 }]));
+ let img = if fmt == image::ImageFormat::Jpeg {
+ image::DynamicImage::ImageRgb8(image::DynamicImage::ImageRgba8(img).to_rgb8())
+ } else {
+ image::DynamicImage::ImageRgba8(img)
+ };
+ let mut b = std::io::Cursor::new(Vec::new());
+ img.write_to(&mut b, fmt).unwrap();
+ b.into_inner()
+}
+
+/// Each image XObject's /Interpolate value (`None` when absent).
+fn interpolate_flags(bytes: &[u8]) -> Vec> {
+ use hayro_syntax::object::Name;
+ let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).expect("parse");
+ pdf.objects()
+ .into_iter()
+ .filter_map(|o| o.into_stream())
+ .filter(|s| s.dict().get::("Subtype").is_some_and(|n| n.as_str() == "Image"))
+ .map(|s| s.dict().get::("Interpolate"))
+ .collect()
+}
+
+/// PDF/A-2b rejected placed PNG, JPEG, GIF and WebP images (#71): they were written with
+/// /Interpolate true, which PDF/A forbids. Ordinary exports keep interpolation.
+#[test]
+fn archival_exports_keep_placed_images_without_interpolation() {
+ use image::ImageFormat as F;
+ let mut d = doc_with_text("x");
+ for (fmt, alpha) in [(F::Png, true), (F::Png, false), (F::Jpeg, false), (F::Gif, true), (F::WebP, true)] {
+ place(&mut d, raster(fmt, alpha), (8, 8));
+ }
+ for compress_images in [false, true] {
+ let opts = PdfOptions { compress_images, created: Some(1_700_000_000), ..Default::default() };
+ let a = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { standard: Standard::PdfA2b, ..opts.clone() });
+ let a = a.unwrap_or_else(|e| panic!("PDF/A-2b with images (compress {compress_images}): {e:?}"));
+ let flags = interpolate_flags(&a.bytes);
+ assert!(flags.len() >= 5, "every placed image is in the PDF/A file: {flags:?}");
+ assert!(flags.iter().all(|f| *f != Some(true)), "{flags:?}");
+ let plain = export_pdf(&d, &Cache::new(), &opts).unwrap();
+ let flags = interpolate_flags(&plain);
+ assert!(flags.len() >= 5, "{flags:?}");
+ assert!(flags.contains(&Some(true)), "ordinary exports interpolate: {flags:?}");
+ }
+}
+
/// What a page draws: each glyph (its text and outline bounds) and each filled path's bounds.
fn drawn(bytes: &[u8]) -> (Vec<(String, kurbo::Rect)>, Vec) {
use hayro_interpret::font::Glyph;
diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs
index 7fd8e3654..fb2370cb5 100644
--- a/crates/pdf/src/text.rs
+++ b/crates/pdf/src/text.rs
@@ -39,6 +39,28 @@ fn same_run(a: &PlacedGlyph, b: &PlacedGlyph) -> bool {
&& (a.y - b.y).abs() < 1e-6
}
+/// Merge bars of one paint layer; underlines and strikes must never be merged together.
+fn text_rules(cs: &ComposedStory, ft: &FrameText, underline: bool) -> Vec<(designcraft_compose::Rule, Rect)> {
+ let mut bars: Vec<(designcraft_compose::Rule, Rect)> = Vec::new();
+ for line in &ft.lines {
+ for g in line.glyphs.iter().filter(|g| g.visible) {
+ let style = &cs.styles[g.style as usize];
+ let (on, rule) = if underline { (style.underline, &style.underline_rule) } else { (style.strikethrough, &style.strike_rule) };
+ if !on {
+ continue;
+ }
+ let rect = rule.rect(g.x, g.x + g.adv, line.baseline);
+ match bars.last_mut() {
+ Some((last_rule, last)) if last_rule == rule && (last.y0 - rect.y0).abs() < 1e-6 && (rect.x0 - last.x1).abs() < 0.5 => {
+ last.x1 = rect.x1;
+ }
+ _ => bars.push((rule.clone(), rect)),
+ }
+ }
+ }
+ bars
+}
+
impl Exporter<'_> {
pub(crate) fn font(&mut self, face: &FontFace) -> Option {
self.fonts
@@ -108,7 +130,17 @@ impl Exporter<'_> {
me.tables(s, ft);
}
});
- let mut deco: Vec<(designcraft_compose::Rule, Rect)> = Vec::new();
+ // All underlines precede all glyph runs, including ink overlapping from another run.
+ let underlines = text_rules(cs, ft, true);
+ if !underlines.is_empty() {
+ self.as_artifact(s, |me, s| {
+ for (rule, r) in underlines {
+ if let Some(c) = me.swatch_color(&rule.color, rule.tint) {
+ me.fill_rect(s, r, c);
+ }
+ }
+ });
+ }
for l in &ft.lines {
let gs = &l.glyphs;
let mut i = 0;
@@ -135,21 +167,9 @@ impl Exporter<'_> {
self.tagged_run(s, cs, &gs[i..j], l.baseline, story);
i = j;
}
- // Underline / strikethrough, merged per style along the line.
- for g in gs.iter().filter(|g| g.visible) {
- let st = &cs.styles[g.style as usize];
- for (rule, r) in st.rules(g.x, g.x + g.adv, l.baseline) {
- match deco.last_mut() {
- Some((last_rule, last)) if *last_rule == *rule && (last.y0 - r.y0).abs() < 1e-6 && (r.x0 - last.x1).abs() < 0.5 => {
- last.x1 = r.x1
- }
- _ => deco.push((rule.clone(), r)),
- }
- }
- }
}
self.as_artifact(s, |me, s| {
- for (rule, r) in deco {
+ for (rule, r) in text_rules(cs, ft, false) {
if let Some(c) = me.swatch_color(&rule.color, rule.tint) {
me.fill_rect(s, r, c);
}
diff --git a/crates/pdf/tests/gradient_color_spaces.rs b/crates/pdf/tests/gradient_color_spaces.rs
new file mode 100644
index 000000000..c523a4187
--- /dev/null
+++ b/crates/pdf/tests/gradient_color_spaces.rs
@@ -0,0 +1,145 @@
+//! Synthetic regressions for gradients that previously aborted inside the PDF writer.
+use designcraft_color::{Color, Gradient, GradientKind, GradientStop, Swatch, SwatchValue};
+use designcraft_compose::Cache;
+use designcraft_doc::{Document, Fill, Item, ItemId, Shape, SpreadRef, build::NewDocument};
+use designcraft_geom::{Rect, shapes};
+use designcraft_pdf::{PdfOptions, Standard, export_pdf_with_report};
+use hayro_syntax::{
+ Pdf,
+ object::{Array, Dict, Name},
+};
+
+fn document(colors: &[Color], kind: GradientKind, midpoint: f32, opacity: f32) -> Document {
+ let mut doc = Document::new(&NewDocument { width: 120.0, height: 120.0, facing_pages: false, ..Default::default() });
+ let stops = colors
+ .iter()
+ .enumerate()
+ .map(|(i, &color)| GradientStop { offset: i as f32 / (colors.len() - 1) as f32, color, opacity, midpoint })
+ .collect();
+ doc.swatches.push(Swatch {
+ name: "Mixed inks".into(),
+ value: SwatchValue::Gradient { gradient: Gradient { kind, stops } },
+ locked: false,
+ named: true,
+ hidden: false,
+ });
+ // Two uses of the same swatch must not duplicate the warning.
+ for x in [10.0, 65.0] {
+ let mut item = Item::new(ItemId(doc.alloc()), doc.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(x, 10.0, x + 40.0, 100.0)));
+ item.fill = Fill::swatch("Mixed inks");
+ doc.insert_item(SpreadRef::Doc(0), item, None).unwrap();
+ }
+ doc
+}
+
+fn conversion_warnings(warnings: &[String]) -> usize {
+ warnings.iter().filter(|w| w.contains("Mixed inks") && w.contains("converted to RGB")).count()
+}
+
+/// A subprocess is essential: the original assertion panicked again in Surface::drop and aborted.
+#[test]
+fn mixed_gradient_export_survives_in_subprocess() {
+ const CHILD: &str = "DESIGNCRAFT_TEST_MIXED_GRADIENT_CHILD";
+ if std::env::var_os(CHILD).is_none() {
+ let out = std::process::Command::new(std::env::current_exe().unwrap())
+ .args(["--exact", "mixed_gradient_export_survives_in_subprocess", "--nocapture"])
+ .env(CHILD, "1")
+ .output()
+ .unwrap();
+ assert!(out.status.success(), "gradient export child failed: {}\n{}", out.status, String::from_utf8_lossy(&out.stderr));
+ assert!(String::from_utf8_lossy(&out.stderr).contains("gradient matrix completed"), "child must execute the regression matrix");
+ return;
+ }
+ let rgb = Color::rgb(1.0, 0.0, 0.0);
+ let cmyk = Color::cmyk(1.0, 0.0, 0.0, 0.0);
+ let gray = Color::gray(0.4);
+ for colors in [vec![cmyk, rgb], vec![rgb, cmyk], vec![rgb, gray], vec![gray, cmyk], vec![gray, rgb, cmyk]] {
+ for kind in [GradientKind::Linear, GradientKind::Radial] {
+ for standard in [Standard::None, Standard::PdfX4, Standard::PdfA2b] {
+ let d = document(&colors, kind, 0.5, 0.7);
+ let report = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { standard, ..Default::default() }).unwrap();
+ assert_eq!(Pdf::new(report.bytes.clone()).unwrap().pages().len(), 1);
+ let archival_rgb = standard == Standard::PdfA2b && !colors.iter().any(|c| matches!(c, Color::Gray { .. }));
+ assert_eq!(conversion_warnings(&report.warnings), usize::from(!archival_rgb), "{standard:?}: {:?}", report.warnings);
+ if standard == Standard::PdfX4 {
+ assert!(
+ report.warnings.iter().any(|w| w.starts_with("PDF/X-4:") && w.contains("RGB")),
+ "RGB conformance warning must survive: {:?}",
+ report.warnings
+ );
+ }
+ if standard == Standard::PdfA2b {
+ assert!(
+ report.warnings.iter().any(|w| w.contains("PDF/A") && w.contains("RGB")),
+ "archival policy warning must survive: {:?}",
+ report.warnings
+ );
+ }
+ }
+ }
+ }
+ // Expansion inserts an RGB midpoint even when the authoring stops share CMYK or Gray.
+ for colors in [[cmyk, Color::cmyk(0.0, 1.0, 0.0, 0.0)], [gray, Color::gray(0.8)]] {
+ for kind in [GradientKind::Linear, GradientKind::Radial] {
+ for standard in [Standard::None, Standard::PdfX4, Standard::PdfA2b] {
+ let report =
+ export_pdf_with_report(&document(&colors, kind, 0.25, 1.0), &Cache::new(), &PdfOptions { standard, ..Default::default() })
+ .unwrap();
+ let archival_cmyk = standard == Standard::PdfA2b && matches!(colors[0], Color::Cmyk { .. });
+ assert_eq!(conversion_warnings(&report.warnings), usize::from(!archival_cmyk), "{standard:?}: {:?}", report.warnings);
+ assert_eq!(Pdf::new(report.bytes).unwrap().pages().len(), 1);
+ }
+ }
+ }
+ eprintln!("gradient matrix completed");
+}
+
+fn endpoint(function: &Dict<'_>, first: bool) -> Vec {
+ if let Some(functions) = function.get::>(b"Functions") {
+ let functions: Vec<_> = functions.iter::>().collect();
+ return endpoint(if first { functions.first().unwrap() } else { functions.last().unwrap() }, first);
+ }
+ function.get::>(if first { b"C0" } else { b"C1" }).unwrap().iter::().collect()
+}
+
+#[test]
+fn homogeneous_gradients_keep_device_spaces_and_components() {
+ for (colors, space, start, end) in [
+ ([Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)], "DeviceRGB", vec![1.0, 0.0, 0.0], vec![0.0, 0.0, 1.0]),
+ ([Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 1.0, 0.0, 0.0)], "DeviceCMYK", vec![1.0, 0.0, 0.0, 0.0], vec![0.0, 1.0, 0.0, 0.0]),
+ ([Color::gray(0.2), Color::gray(0.8)], "DeviceGray", vec![0.8], vec![0.2]),
+ ] {
+ for standard in [Standard::None, Standard::PdfX4] {
+ let report = export_pdf_with_report(
+ &document(&colors, GradientKind::Linear, 0.5, 1.0),
+ &Cache::new(),
+ &PdfOptions { standard, ..Default::default() },
+ )
+ .unwrap();
+ assert_eq!(conversion_warnings(&report.warnings), 0);
+ let pdf = Pdf::new(report.bytes).unwrap();
+ let patterns = &pdf.pages()[0].resources().patterns;
+ let mut count = 0;
+ for (name, _) in patterns.entries() {
+ let pattern = patterns.get::>(name.as_ref()).unwrap();
+ let shading = pattern.get::>(b"Shading").unwrap();
+ assert_eq!(shading.get::>(b"ColorSpace").unwrap().as_ref(), space.as_bytes());
+ let function = shading.get::>(b"Function").unwrap();
+ for (actual, expected) in [(endpoint(&function, true), &start), (endpoint(&function, false), &end)] {
+ assert_eq!(actual.len(), expected.len());
+ assert!(actual.iter().zip(expected).all(|(a, b)| (a - b).abs() < 0.005), "{space}: {actual:?} vs {expected:?}");
+ }
+ count += 1;
+ }
+ assert!(count > 0, "inspect actual shading resources, not unrelated blend spaces");
+ }
+ }
+}
+
+#[test]
+fn mixed_gradient_booklet_reports_conversion() {
+ let d = document(&[Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::rgb(1.0, 0.0, 0.0)], GradientKind::Linear, 0.5, 1.0);
+ let report = designcraft_pdf::export_booklet(&d, &Cache::new(), &Default::default()).unwrap();
+ assert_eq!(conversion_warnings(&report.warnings), 1);
+ assert!(!Pdf::new(report.bytes).unwrap().pages().is_empty());
+}
diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs
index 165366eb7..854681642 100644
--- a/crates/render/src/text.rs
+++ b/crates/render/src/text.rs
@@ -25,8 +25,9 @@ pub(crate) struct LineGlyphs {
pub bounds: Option,
/// Glyph outlines batched per run style.
pub runs: Vec<(u32, BezPath)>,
- /// Underline / strikethrough bars: colour swatch, tint, rectangle.
- pub decos: Vec<(String, f32, Rect)>,
+ /// Bars below/above the glyph paint pass: colour swatch, tint, rectangle.
+ pub underlines: Vec<(String, f32, Rect)>,
+ pub strikes: Vec<(String, f32, Rect)>,
pub glyphs: usize,
}
@@ -116,15 +117,18 @@ fn union(r: Option, b: Rect) -> Option {
fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line, vertical: bool) -> LineGlyphs {
let mut runs: Vec<(u32, BezPath)> = Vec::new();
- let mut decos = Vec::new();
+ let mut underlines = Vec::new();
+ let mut strikes = Vec::new();
let mut glyphs = 0;
for g in &l.glyphs {
if !g.visible {
continue;
}
let style = &cs.styles[g.style as usize];
- for (rule, r) in style.rules(g.x, g.x + g.adv, l.baseline) {
- decos.push((rule.color.clone(), rule.tint, r));
+ for (on, rule, bars) in [(style.underline, &style.underline_rule, &mut underlines), (style.strikethrough, &style.strike_rule, &mut strikes)] {
+ if on {
+ bars.push((rule.color.clone(), rule.tint, rule.rect(g.x, g.x + g.adv, l.baseline)));
+ }
}
let outline = db.outline(&g.face, g.gid);
if outline.elements().is_empty() {
@@ -155,10 +159,10 @@ fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line, vertical: bool) -> Line
let sw = if st.stroke != designcraft_color::swatch::NONE { st.stroke_weight } else { 0.0 };
bounds = union(bounds, bp.bounding_box().inflate(sw, sw));
}
- for (_, _, r) in &decos {
+ for (_, _, r) in underlines.iter().chain(&strikes) {
bounds = union(bounds, *r);
}
- LineGlyphs { bounds, runs, decos, glyphs }
+ LineGlyphs { bounds, runs, underlines, strikes, glyphs }
}
fn build(cs: &ComposedStory, ft: &FrameText) -> FrameGlyphs {
@@ -339,12 +343,19 @@ impl Renderer {
doc.resolve_color(&st.fill, st.fill_tint).map(|c| color_of(&c, 1.0))
})
};
- for i in shown {
- let lg = &fg.lines[i];
- let Some(b) = lg.bounds else { continue };
- if !rect_overlaps(b, vis) {
- continue;
+ shown.retain(|&i| fg.lines[i].bounds.is_some_and(|b| rect_overlaps(b, vis)));
+ // Paint all underlines before any glyphs: skewed runs and tightly spaced lines
+ // can overlap a later run's rule. A per-run paint pass would still cover ink.
+ for &i in &shown {
+ for (sw, tint, r) in &fg.lines[i].underlines {
+ if let Some(c) = doc.resolve_color(sw, *tint) {
+ ctx.set_paint(color_of(&c, 1.0));
+ ctx.fill_rect(r);
+ }
}
+ }
+ for &i in &shown {
+ let lg = &fg.lines[i];
self.stats.glyphs += lg.glyphs;
for (si, bp) in &lg.runs {
if let Some(c) = fill_of(*si) {
@@ -374,7 +385,9 @@ impl Renderer {
ctx.stroke_path(bp);
}
}
- for (sw, tint, r) in &lg.decos {
+ }
+ for &i in &shown {
+ for (sw, tint, r) in &fg.lines[i].strikes {
if let Some(c) = doc.resolve_color(sw, *tint) {
ctx.set_paint(color_of(&c, 1.0));
ctx.fill_rect(r);
diff --git a/crates/textimport/src/docx.rs b/crates/textimport/src/docx.rs
index 563eb7289..670f8a0fa 100644
--- a/crates/textimport/src/docx.rs
+++ b/crates/textimport/src/docx.rs
@@ -276,7 +276,9 @@ impl Ctx {
for e in p.els() {
match e.name.as_str() {
"r" => self.run(e, st, allow_notes),
- "hyperlink" | "smartTag" | "ins" | "fldSimple" | "customXml" => self.inline(e, st, allow_notes),
+ // Tracked changes import as accepted: insertions and move destinations are kept,
+ // deletions (`del`) and move sources (`moveFrom`) are dropped.
+ "hyperlink" | "smartTag" | "ins" | "moveTo" | "fldSimple" | "customXml" => self.inline(e, st, allow_notes),
_ => {}
}
}
@@ -469,4 +471,23 @@ mod tests {
assert_eq!(i.para_styles[0].para.space_before, Some(12.0));
assert_eq!(i.char_styles[0].chars.font_style.as_deref(), Some("Italic"));
}
+
+ #[test]
+ fn tracked_changes_import_as_accepted_including_moves() {
+ // Regression (#256): text in a tracked move destination (`w:moveTo`) vanished on import.
+ let document = format!(
+ r#"
+ Kept text.
+ MOVED HERE.
+ MOVED THEN DELETED
+ Plain
+ INSERTED
+ DELETED
+ MOVED HERE. End.
+ "#
+ );
+ let i = import(&docx(&document, "", "")).unwrap();
+ i.story.check().unwrap();
+ assert_eq!(i.story.text, "Kept text. MOVED HERE. Plain INSERTED\nEnd.");
+ }
}
diff --git a/crates/textimport/src/lib.rs b/crates/textimport/src/lib.rs
index 8dc50ea7e..22f79c513 100644
--- a/crates/textimport/src/lib.rs
+++ b/crates/textimport/src/lib.rs
@@ -28,6 +28,33 @@ pub fn xlsx_records(bytes: &[u8], sheet: Option<&str>) -> Result
use designcraft_doc::{CharAttrs, ParaAttrs, Story};
+/// Joins UTF-16 code units that arrive one at a time (RTF `\uN`, Tagged Text `<0xNNNN>`) into
+/// characters. A character past U+FFFF comes as two units, a surrogate pair: the first half
+/// waits for the second.
+#[derive(Default)]
+pub(crate) struct Utf16Units {
+ high: Option,
+}
+
+impl Utf16Units {
+ /// The character that `unit` is or completes. Half a pair without its partner is no character.
+ pub(crate) fn push(&mut self, unit: u32) -> Option {
+ match (self.high.take(), unit) {
+ (Some(high), 0xDC00..=0xDFFF) => char::from_u32(0x10000 + ((high - 0xD800) << 10) + (unit - 0xDC00)),
+ (_, 0xD800..=0xDBFF) => {
+ self.high = Some(unit);
+ None
+ }
+ _ => char::from_u32(unit),
+ }
+ }
+
+ /// Other text came in between: a waiting first half has no partner.
+ pub(crate) fn reset(&mut self) {
+ self.high = None;
+ }
+}
+
/// A paragraph or character style found in the source.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ImportedStyle {
diff --git a/crates/textimport/src/rtf.rs b/crates/textimport/src/rtf.rs
index c02e5e551..9189b52d2 100644
--- a/crates/textimport/src/rtf.rs
+++ b/crates/textimport/src/rtf.rs
@@ -70,6 +70,7 @@ pub fn import(bytes: &[u8]) -> Result {
let mut st = Story::new(StoryId(0));
let mut stack: Vec = vec![State { uc: 1, ..Default::default() }];
let mut pending = 0usize; // characters still to skip after \uN
+ let mut units = crate::Utf16Units::default();
let mut buf = String::new();
let mut fmt = stack[0].format();
let mut warnings = Vec::new();
@@ -124,6 +125,7 @@ pub fn import(bytes: &[u8]) -> Result {
if let Ok(b) = u8::from_str_radix(&hex, 16)
&& !stack.last().is_some_and(|s| s.skip)
{
+ units.reset();
buf.push(cp1252(b));
}
continue;
@@ -225,8 +227,8 @@ pub fn import(bytes: &[u8]) -> Result {
}
"u" => {
if let Some(n) = param {
- let cp = if n < 0 { (n + 65536) as u32 } else { n as u32 };
- if let Some(ch) = char::from_u32(cp) {
+ let unit = if n < 0 { (n + 65536) as u32 } else { n as u32 };
+ if let Some(ch) = units.push(unit) {
buf.push(ch);
}
pending = stack.last().map_or(1, |s| s.uc);
@@ -243,6 +245,7 @@ pub fn import(bytes: &[u8]) -> Result {
continue;
}
if !stack.last().is_some_and(|s| s.skip) {
+ units.reset();
buf.push(c);
}
}
@@ -287,4 +290,19 @@ Caf\'e9 \u8364? price\tab tab\line next\par
assert_eq!(st.format_after(0).over.size, Some(12.0));
assert!(!st.text.contains("Times"), "font table skipped");
}
+
+ /// Characters past U+FFFF are written as two `\uN` words, a UTF-16 surrogate pair (the form
+ /// our own exporter and Word write). Each half was converted alone and dropped.
+ #[test]
+ fn surrogate_pairs_become_one_character() {
+ let i = import(br"{\rtf1\ansi\uc1 A\u-10179?\u-8704?B \u-10174?\u-8265?\u30000?}").unwrap();
+ assert_eq!(i.story.text, "A\u{1F600}B \u{20BB7}\u{7530}");
+ // What the exporter writes comes back.
+ let mut st = Story::new(StoryId(1));
+ st.insert(0, "x \u{1F600} \u{20BB7}\u{7530} y");
+ let doc = designcraft_doc::Document::new(&designcraft_doc::build::NewDocument::default());
+ assert_eq!(import(crate::export::rtf(&doc, &st).as_bytes()).unwrap().story.text, st.text);
+ // Half a pair is not a character: it is still left out.
+ assert_eq!(import(br"{\rtf1 A\u-10179?B\u-8704?C}").unwrap().story.text, "ABC");
+ }
}
diff --git a/crates/textimport/src/tagged.rs b/crates/textimport/src/tagged.rs
index ca78bc4b1..cfd08eea7 100644
--- a/crates/textimport/src/tagged.rs
+++ b/crates/textimport/src/tagged.rs
@@ -110,12 +110,14 @@ pub fn import(bytes: &[u8]) -> Result {
_ => runs.push((c.to_string(), fmt.clone())),
}
};
+ let mut units = crate::Utf16Units::default();
let mut i = 0;
let mut skipped = 0;
while i < chars.len() {
let c = chars[i];
match c {
'\\' if i + 1 < chars.len() => {
+ units.reset();
push(&mut paras, chars[i + 1], &fmt);
i += 2;
}
@@ -143,7 +145,7 @@ pub fn import(bytes: &[u8]) -> Result {
match name {
n if n.starts_with("0x") => {
if let Ok(u) = u32::from_str_radix(&n[2..], 16)
- && let Some(ch) = char::from_u32(u)
+ && let Some(ch) = units.push(u)
{
push(&mut paras, ch, &fmt);
}
@@ -193,6 +195,7 @@ pub fn import(bytes: &[u8]) -> Result {
i += 1;
}
c => {
+ units.reset();
push(&mut paras, c, &fmt);
i += 1;
}
@@ -226,6 +229,20 @@ pub fn import(bytes: &[u8]) -> Result {
mod tests {
use super::*;
+ /// Characters past U+FFFF are written as two tags, a UTF-16 surrogate pair. Each half was
+ /// converted alone and dropped.
+ #[test]
+ fn surrogate_pairs_become_one_character() {
+ let i = import("\r\n\r\nA<0xD83D><0xDE00>B <0xD842><0xDFB7><0x7530>".as_bytes()).unwrap();
+ assert_eq!(i.story.text, "A\u{1F600}B \u{20BB7}\u{7530}");
+ // What the exporter writes comes back.
+ let doc = Document::new(&designcraft_doc::build::NewDocument::default());
+ let back = import(tagged_text(&doc, &i.story).as_bytes()).unwrap();
+ assert_eq!(back.story.text, i.story.text);
+ // Half a pair is not a character: it is still left out.
+ assert_eq!(import("\r\nA<0xD83D>B<0xDE00>C".as_bytes()).unwrap().story.text, "ABC");
+ }
+
#[test]
fn tagged_text_round_trips() {
let src = "\r\n\r\nCaf<0x00E9> \\<1\\>\r\nA word and big text";
diff --git a/crates/ui-egui/src/about.rs b/crates/ui-egui/src/about.rs
index a3875609a..4eae2e7c6 100644
--- a/crates/ui-egui/src/about.rs
+++ b/crates/ui-egui/src/about.rs
@@ -20,7 +20,7 @@ const MARK_PATH: &str = "M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,7
/// The other ArtCraft apps (app-page slug, name, what it is) — same list as the README.
pub const SIBLINGS: &[(&str, &str, &str)] = &[
("photocraft", "PhotoCraft", "image editing"),
- ("drawcraft", "VectorCraft", "vector illustration"),
+ ("vectorcraft", "VectorCraft", "vector illustration"),
("filmcraft", "FilmCraft", "video editing, color and sound"),
("lightcraft", "LightCraft", "photo library and raw development"),
("pdfcraft", "PdfCraft", "reading, organizing and protecting PDFs"),
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index 8288dd35c..b4b17d3ab 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -318,6 +318,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
draw_tool_overlays(app, &painter, &xf);
if rulers {
draw_rulers(app, ui, full, rect, &xf, &doc, &layout, &t);
+ ruler_units_menus(app, &resp, full, rect, &doc);
}
if let Some(r) = sel_rect
&& !preview
@@ -1247,8 +1248,7 @@ fn draw_tool_overlays(app: &mut DesignApp, painter: &egui::Painter, xf: &Xf) {
#[allow(clippy::too_many_arguments)]
fn draw_rulers(app: &DesignApp, ui: &egui::Ui, full: Rect, rect: Rect, xf: &Xf, doc: &Document, layout: &CanvasLayout, t: &Tokens) {
let painter = ui.painter_at(full);
- let top = Rect::from_min_max(pos2(rect.min.x, full.min.y), pos2(full.max.x, rect.min.y));
- let left = Rect::from_min_max(pos2(full.min.x, rect.min.y), pos2(rect.min.x, full.max.y));
+ let (top, left) = ruler_rects(full, rect);
painter.rect_filled(top, 0.0, t.ruler);
painter.rect_filled(left, 0.0, t.ruler);
painter.rect_filled(Rect::from_min_max(full.min, rect.min), 0.0, t.ruler);
@@ -1262,11 +1262,11 @@ fn draw_rulers(app: &DesignApp, ui: &egui::Ui, full: Rect, rect: Rect, xf: &Xf,
let Some(i) = current_slot(app, layout) else { return };
let slot = &layout.slots[i];
let origin = Point::new(slot.bounds.x0, slot.bounds.y0);
- let unit = doc.settings.horizontal_units;
- let (major, sub) = unit.ruler_ticks(xf.zoom);
let font = egui::FontId::proportional(9.0);
let tick = Stroke::new(1.0, t.ruler_tick);
// Horizontal.
+ let unit = doc.settings.horizontal_units;
+ let (major, sub) = unit.ruler_ticks(xf.zoom);
let c0 = xf.to_canvas(top.min).x - origin.x;
let c1 = xf.to_canvas(pos2(top.max.x, 0.0)).x - origin.x;
let mut k = (c0 / major).floor() as i64;
@@ -1291,6 +1291,8 @@ fn draw_rulers(app: &DesignApp, ui: &egui::Ui, full: Rect, rect: Rect, xf: &Xf,
k += 1;
}
// Vertical.
+ let unit = doc.settings.vertical_units;
+ let (major, sub) = unit.ruler_ticks(xf.zoom);
let c0 = xf.to_canvas(pos2(0.0, left.min.y)).y - origin.y;
let c1 = xf.to_canvas(pos2(0.0, left.max.y)).y - origin.y;
let mut k = (c0 / major).floor() as i64;
@@ -1325,6 +1327,43 @@ fn draw_rulers(app: &DesignApp, ui: &egui::Ui, full: Rect, rect: Rect, xf: &Xf,
}
}
+/// The horizontal (top) and vertical (left) rulers around the view `rect` in `full`.
+fn ruler_rects(full: Rect, rect: Rect) -> (Rect, Rect) {
+ (
+ Rect::from_min_max(pos2(rect.min.x, full.min.y), pos2(full.max.x, rect.min.y)),
+ Rect::from_min_max(pos2(full.min.x, rect.min.y), pos2(rect.min.x, full.max.y)),
+ )
+}
+
+/// Right-clicking a ruler opens a menu of units for that ruler.
+fn ruler_units_menus(app: &mut DesignApp, resp: &egui::Response, full: Rect, rect: Rect, doc: &Document) {
+ let (top, left) = ruler_rects(full, rect);
+ let at = resp.interact_pointer_pos();
+ for (ruler, key, cur) in [(top, "horizontalUnits", doc.settings.horizontal_units), (left, "verticalUnits", doc.settings.vertical_units)] {
+ let open = if resp.secondary_clicked() {
+ Some(egui::SetOpenCommand::Bool(at.is_some_and(|p| ruler.contains(p))))
+ } else if resp.clicked() {
+ Some(egui::SetOpenCommand::Bool(false))
+ } else {
+ None
+ };
+ egui::Popup::context_menu(resp).id(resp.id.with(("ruler_units", key))).open_memory(open).show(|ui| ruler_units_menu(app, ui, key, cur));
+ }
+}
+
+/// Every unit, the current one checked; picking one sets `key` (`horizontalUnits` or
+/// `verticalUnits`) through `document.preferences`.
+fn ruler_units_menu(app: &mut DesignApp, ui: &mut egui::Ui, key: &str, cur: designcraft_geom::Unit) {
+ for u in designcraft_geom::Unit::ALL {
+ let label = crate::i18n::tr(&app.ui.language, u.label());
+ let text = if u == cur { format!("✓ {label}") } else { format!(" {label}") };
+ if ui.button(crate::rtl::widget(ui, text)).clicked() {
+ let _ = app.run("document.preferences", json!({ key: u }));
+ ui.close();
+ }
+ }
+}
+
fn fmt_tick(v: f64) -> String {
let r = v.round();
if (v - r).abs() < 1e-6 { format!("{}", r as i64) } else { format!("{v:.1}") }
@@ -1726,3 +1765,72 @@ fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rec
}
true
}
+
+#[cfg(test)]
+mod tests {
+ use designcraft_geom::Unit;
+ use egui_kittest::{Harness, kittest::Queryable};
+
+ use super::*;
+
+ fn harness(app: DesignApp) -> Harness<'static, DesignApp> {
+ let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state(
+ |ui, app: &mut DesignApp| {
+ let ctx = ui.ctx().clone();
+ app.logic(&ctx);
+ app.ui(ui);
+ },
+ app,
+ );
+ h.run_steps(4);
+ h
+ }
+
+ fn right_click(h: &mut Harness<'_, DesignApp>, p: Pos2) {
+ h.hover_at(p);
+ h.step();
+ for pressed in [true, false] {
+ h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() });
+ }
+ h.run_steps(3);
+ }
+
+ fn units(h: &Harness<'_, DesignApp>) -> (Unit, Unit) {
+ let s = &h.state().session.doc().unwrap().doc.settings;
+ (s.horizontal_units, s.vertical_units)
+ }
+
+ #[test]
+ fn right_clicking_a_ruler_sets_that_rulers_units() {
+ let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+ app.run("file.new", json!({})).unwrap();
+ let mut h = harness(app);
+ let rect = h.state().canvas_rect.unwrap();
+ assert_eq!(units(&h), (Unit::Picas, Unit::Picas));
+ // Vertical ruler: the current unit is checked; picking one sets only the vertical units.
+ right_click(&mut h, pos2(rect.min.x - RULER / 2.0, rect.center().y));
+ h.get_by_label("✓ Picas");
+ h.get_by_label(" Millimeters").click();
+ h.run_steps(3);
+ assert_eq!(units(&h), (Unit::Picas, Unit::Millimeters));
+ // Horizontal ruler.
+ right_click(&mut h, pos2(rect.center().x, rect.min.y - RULER / 2.0));
+ h.get_by_label("✓ Picas");
+ h.get_by_label(" Inches").click();
+ h.run_steps(3);
+ assert_eq!(units(&h), (Unit::Inches, Unit::Millimeters));
+ // The page and the ruler corner open no units menu.
+ for p in [rect.center(), rect.min - vec2(RULER / 2.0, RULER / 2.0)] {
+ right_click(&mut h, p);
+ assert!(h.query_by_label(" Points").is_none());
+ }
+ }
+
+ #[test]
+ fn rulers_open_no_units_menu_without_a_document() {
+ let mut h = harness(DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()));
+ let Some(rect) = h.state().canvas_rect else { return };
+ right_click(&mut h, pos2(rect.center().x, rect.min.y - RULER / 2.0));
+ assert!(h.query_by_label(" Points").is_none());
+ }
+}
diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs
index dfb5e699b..d27390bc9 100644
--- a/crates/ui-egui/src/chrome.rs
+++ b/crates/ui-egui/src/chrome.rs
@@ -21,86 +21,105 @@ pub fn app_bar(app: &mut DesignApp, ui: &mut egui::Ui) {
let full = ui.max_rect();
let mut menus_end = full.min.x;
let mut tools_start = full.max.x;
- ui.horizontal_centered(|ui| {
- if icons::button(ui, "home", 24.0, app.session.active().is_none(), crate::i18n::tr(&app.ui.language, "Home")).clicked() {
- app.session_home();
- }
- ui.add_space(6.0);
- if !app.native_menu {
- ui.style_mut().visuals.widgets.inactive.weak_bg_fill = Color32::TRANSPARENT;
- ui.style_mut().visuals.widgets.inactive.bg_stroke = Stroke::NONE;
- ui.style_mut().visuals.widgets.hovered.bg_stroke = Stroke::NONE;
- ui.style_mut().spacing.button_padding = vec2(7.0, 3.0);
- crate::menus::menu_bar(app, ui);
- }
- menus_end = ui.min_rect().max.x;
- ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
- ui.add_space(4.0);
- // Search field.
- let (r, _) = ui.allocate_exact_size(vec2(125.0, 18.0), Sense::click());
- ui.painter().rect(r, 1.0, t.input, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
- icons::paint(ui.painter(), egui::Rect::from_min_size(r.min + vec2(3.0, 2.0), vec2(14.0, 14.0)), "search", t.icon);
- crate::rtl::paint(
- ui.painter(),
- r.min + vec2(20.0, 9.0),
- egui::Align2::LEFT_CENTER,
- crate::i18n::tr(&app.ui.language, "Search"),
- egui::FontId::proportional(11.0),
- t.text_dim,
- );
- ui.add_space(8.0);
- let current = app.ui.workspace.clone();
- let shown = crate::i18n::workspace_name(&app.ui.language, ¤t);
- ui.menu_button(crate::rtl::widget(ui, egui::RichText::new(format!("{shown} ▾")).font(semibold(11.5)).color(t.text)), |ui| {
- let customs: Vec = app.ui.custom_workspaces.iter().map(|w| w.name.clone()).collect();
- for w in
- ["Essentials", "Advanced", "Book", "Digital Publishing", "Interactive for PDF", "Printing and Proofing", "Typography"]
- .into_iter()
- .map(str::to_string)
- .chain(customs.iter().cloned())
- {
- if ui.selectable_label(w == current, crate::rtl::widget(ui, crate::i18n::workspace_name(&app.ui.language, &w))).clicked()
+ // The right-hand controls' width, measured last frame: in a window too narrow for them
+ // and the menus they follow the menus, and the bar scrolls sideways.
+ let tools_w_id = ui.id().with("tools_width");
+ let tools_w: f32 = ui.data(|d| d.get_temp(tools_w_id)).unwrap_or(0.0);
+ crate::widgets::overflow_scrolling(ui);
+ egui::ScrollArea::horizontal().id_salt("app_bar_scroll").auto_shrink([false, false]).show(ui, |ui| {
+ ui.horizontal_centered(|ui| {
+ if icons::button(ui, "home", 24.0, app.session.active().is_none(), crate::i18n::tr(&app.ui.language, "Home")).clicked() {
+ app.session_home();
+ }
+ ui.add_space(6.0);
+ if !app.native_menu {
+ ui.style_mut().visuals.widgets.inactive.weak_bg_fill = Color32::TRANSPARENT;
+ ui.style_mut().visuals.widgets.inactive.bg_stroke = Stroke::NONE;
+ ui.style_mut().visuals.widgets.hovered.bg_stroke = Stroke::NONE;
+ ui.style_mut().spacing.button_padding = vec2(7.0, 3.0);
+ crate::menus::menu_bar(app, ui);
+ }
+ menus_end = ui.min_rect().max.x;
+ let size = vec2(ui.available_width().max(tools_w), ui.available_height());
+ let tools = ui.allocate_ui_with_layout(size, egui::Layout::right_to_left(egui::Align::Center), |ui| {
+ ui.add_space(4.0);
+ // Search field.
+ let (r, _) = ui.allocate_exact_size(vec2(125.0, 18.0), Sense::click());
+ ui.painter().rect(r, 1.0, t.input, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
+ icons::paint(ui.painter(), egui::Rect::from_min_size(r.min + vec2(3.0, 2.0), vec2(14.0, 14.0)), "search", t.icon);
+ crate::rtl::paint(
+ ui.painter(),
+ r.min + vec2(20.0, 9.0),
+ egui::Align2::LEFT_CENTER,
+ crate::i18n::tr(&app.ui.language, "Search"),
+ egui::FontId::proportional(11.0),
+ t.text_dim,
+ );
+ ui.add_space(8.0);
+ let current = app.ui.workspace.clone();
+ let shown = crate::i18n::workspace_name(&app.ui.language, ¤t);
+ ui.menu_button(crate::rtl::widget(ui, egui::RichText::new(format!("{shown} ▾")).font(semibold(11.5)).color(t.text)), |ui| {
+ let customs: Vec = app.ui.custom_workspaces.iter().map(|w| w.name.clone()).collect();
+ for w in
+ ["Essentials", "Advanced", "Book", "Digital Publishing", "Interactive for PDF", "Printing and Proofing", "Typography"]
+ .into_iter()
+ .map(str::to_string)
+ .chain(customs.iter().cloned())
{
- let _ = app.run("window.workspace", json!({"name": w}));
+ if ui
+ .selectable_label(w == current, crate::rtl::widget(ui, crate::i18n::workspace_name(&app.ui.language, &w)))
+ .clicked()
+ {
+ let _ = app.run("window.workspace", json!({"name": w}));
+ ui.close();
+ }
+ }
+ ui.separator();
+ if ui
+ .button(crate::rtl::widget(
+ ui,
+ format!(
+ "{} {}",
+ crate::i18n::tr(&app.ui.language, "Reset"),
+ crate::i18n::workspace_name(&app.ui.language, ¤t)
+ ),
+ ))
+ .clicked()
+ {
+ let _ = app.run("window.resetWorkspace", json!({}));
ui.close();
}
- }
- ui.separator();
- if ui
- .button(crate::rtl::widget(
- ui,
- format!("{} {}", crate::i18n::tr(&app.ui.language, "Reset"), crate::i18n::workspace_name(&app.ui.language, ¤t)),
- ))
- .clicked()
- {
- let _ = app.run("window.resetWorkspace", json!({}));
- ui.close();
- }
- if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Workspace…"))).clicked() {
- let _ = app.run("window.newWorkspace", json!({}));
- ui.close();
- }
- if !customs.is_empty() {
- ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete Workspace")), |ui| {
- for w in &customs {
- if ui.button(w).clicked() {
- let _ = app.run("window.deleteWorkspace", json!({"name": w}));
- ui.close();
+ if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Workspace…"))).clicked() {
+ let _ = app.run("window.newWorkspace", json!({}));
+ ui.close();
+ }
+ if !customs.is_empty() {
+ ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete Workspace")), |ui| {
+ for w in &customs {
+ if ui.button(w).clicked() {
+ let _ = app.run("window.deleteWorkspace", json!({"name": w}));
+ ui.close();
+ }
}
- }
- });
+ });
+ }
+ });
+ ui.add_space(6.0);
+ if icons::button(ui, "share", 22.0, false, crate::i18n::tr(&app.ui.language, "Share")).clicked() {
+ app.status("Export a PDF, IDML or package to share — no cloud account needed.");
}
+ ui.add_space(8.0);
+ // Always one click away: the ArtCraft community Discord.
+ if crate::about::discord_button(ui, "Discord", vec2(78.0, 22.0)) {
+ let _ = app.run("help.discord", json!({}));
+ }
+ tools_start = ui.min_rect().min.x;
+ ui.min_rect().width()
});
- ui.add_space(6.0);
- if icons::button(ui, "share", 22.0, false, crate::i18n::tr(&app.ui.language, "Share")).clicked() {
- app.status("Export a PDF, IDML or package to share — no cloud account needed.");
+ if (tools.inner - tools_w).abs() > 0.5 {
+ ui.data_mut(|d| d.insert_temp(tools_w_id, tools.inner));
+ ui.ctx().request_discard("app bar: the right-hand controls changed width");
}
- ui.add_space(8.0);
- // Always one click away: the ArtCraft community Discord.
- if crate::about::discord_button(ui, "Discord", vec2(78.0, 22.0)) {
- let _ = app.run("help.discord", json!({}));
- }
- tools_start = ui.min_rect().min.x;
});
});
// Centred title.
@@ -142,14 +161,18 @@ pub fn control_bar(app: &mut DesignApp, ui: &mut egui::Ui) {
egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin { left: 8, right: 8, top: 5, bottom: 3 }).stroke(Stroke::new(1.0, t.divider)),
)
.show(ui, |ui| {
- ui.horizontal(|ui| {
- let text_mode =
- matches!(app.session.tool_id(), "type" | "verticalType") || app.session.active().is_some_and(|d| d.selection.text.is_some());
- if text_mode {
- control_text(app, ui);
- } else {
- control_object(app, ui);
- }
+ // Wider than the window: the bar scrolls sideways.
+ crate::widgets::overflow_scrolling(ui);
+ egui::ScrollArea::horizontal().id_salt("control_bar_scroll").auto_shrink([false, true]).show(ui, |ui| {
+ ui.horizontal_top(|ui| {
+ let text_mode =
+ matches!(app.session.tool_id(), "type" | "verticalType") || app.session.active().is_some_and(|d| d.selection.text.is_some());
+ if text_mode {
+ control_text(app, ui);
+ } else {
+ control_object(app, ui);
+ }
+ });
});
});
}
@@ -566,7 +589,14 @@ pub fn status_bar(app: &mut DesignApp, ui: &mut egui::Ui) {
egui::RichText::new(if errors == 0 {
format!("{} ▾", crate::i18n::tr(&app.ui.language, "No errors"))
} else {
- format!("{errors} {} ▾", crate::i18n::tr(&app.ui.language, if errors == 1 { "error" } else { "errors" }))
+ format!(
+ "{} ▾",
+ crate::i18n::count_label(
+ &app.ui.language,
+ if errors == 1 && app.ui.language != "uk" { "error" } else { "errors" },
+ errors
+ )
+ )
})
.font(small.clone()),
);
@@ -716,3 +746,86 @@ fn community_card(app: &mut DesignApp, ui: &mut egui::Ui) {
});
});
}
+
+#[cfg(test)]
+mod tests {
+ use egui::{pos2, vec2};
+ use egui_kittest::kittest::Queryable;
+
+ use crate::test_window::{self, wheel};
+
+ /// The Control panel's last control with nothing selected.
+ const LAST_CONTROL: &str = "Jump to Next Column";
+ const FIRST_CONTROL: &str = "Rotate 90° Counterclockwise";
+
+ fn app() -> crate::DesignApp {
+ let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+ app.ui.control_bar = true;
+ app
+ }
+
+ fn rect(h: &egui_kittest::Harness<'static, test_window::Window>, label: &str) -> egui::Rect {
+ h.get_by_label(label).rect()
+ }
+
+ #[test]
+ fn a_narrow_window_scrolls_the_control_bar() {
+ let w = 700.0;
+ let mut h = test_window::open(app(), vec2(w, 600.0));
+ let bar = test_window::panel_rect(&h, "control_bar");
+ assert!(rect(&h, LAST_CONTROL).max.x > w, "the controls are wider than the window");
+ // A plain mouse wheel scrolls the bar sideways.
+ let first = rect(&h, FIRST_CONTROL).center();
+ wheel(&mut h, first, vec2(0.0, -3000.0));
+ let last = rect(&h, LAST_CONTROL);
+ let window = egui::Rect::from_min_size(pos2(0.0, 0.0), vec2(w, 600.0));
+ assert!(window.contains_rect(last) && bar.contains_rect(last), "{last:?} outside the bar {bar:?}");
+ assert_eq!(test_window::panel_rect(&h, "control_bar").height(), bar.height(), "the bar keeps its height");
+ // A popup opened from a scrolled control opens at that control.
+ let fill = h.get_by_label("Fill").rect();
+ let chip = pos2(fill.max.x + 15.0, fill.center().y);
+ h.hover_at(chip);
+ h.drag_at(chip);
+ h.drop_at(chip);
+ h.run_steps(4);
+ let item = rect(&h, "[Black]");
+ assert!(item.min.y > fill.max.y && (item.min.x - chip.x).abs() < 120.0, "popup item at {item:?}, control at {chip:?}");
+ }
+
+ #[test]
+ fn a_narrow_window_scrolls_the_app_bar() {
+ let w = 640.0;
+ let mut h = test_window::open(app(), vec2(w, 600.0));
+ let (help, share) = (rect(&h, "Help"), rect(&h, "Share"));
+ assert!(help.max.x <= share.min.x, "the menus ({help:?}) and the workspace controls ({share:?}) overlap");
+ wheel(&mut h, help.center(), vec2(0.0, -3000.0));
+ let share = rect(&h, "Share");
+ let window = egui::Rect::from_min_size(pos2(0.0, 0.0), vec2(w, 600.0));
+ assert!(window.contains_rect(share), "{share:?} outside the window");
+ }
+
+ #[test]
+ fn a_wide_window_does_not_scroll_the_bars() {
+ let mut h = test_window::open(app(), vec2(1400.0, 900.0));
+ let before = [rect(&h, FIRST_CONTROL), rect(&h, LAST_CONTROL), rect(&h, "Help"), rect(&h, "Share")];
+ assert!(before.iter().all(|r| r.max.x <= 1400.0), "{before:?}");
+ wheel(&mut h, before[0].center(), vec2(0.0, -3000.0));
+ wheel(&mut h, before[2].center(), vec2(0.0, -3000.0));
+ let after = [rect(&h, FIRST_CONTROL), rect(&h, LAST_CONTROL), rect(&h, "Help"), rect(&h, "Share")];
+ assert_eq!(after, before);
+ }
+
+ #[test]
+ fn a_scrolled_bar_keeps_its_captions_on_one_line() {
+ let mut app = app();
+ app.run("file.new", serde_json::json!({})).unwrap();
+ let f = app.run("frame.create", serde_json::json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hello"})).unwrap();
+ app.run("selection.set", serde_json::json!({"ids": [f["id"]]})).unwrap();
+ let mut h = test_window::open(app, vec2(700.0, 600.0));
+ let bar = test_window::panel_rect(&h, "control_bar").center();
+ wheel(&mut h, bar, vec2(0.0, -3000.0));
+ // The text frame's last group: its caption gets only what is left of the row.
+ let columns = h.get_by_label("Columns").rect();
+ assert!(columns.height() < 20.0 && columns.width() > columns.height(), "Columns wraps: {columns:?}");
+ }
+}
diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs
index 32450259c..e81e4c190 100644
--- a/crates/ui-egui/src/control.rs
+++ b/crates/ui-egui/src/control.rs
@@ -8,7 +8,8 @@
//! - `ui.tool.select {tool}`, `ui.tool.list`
//! - `ui.pointer {events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen"}], mods?}`:
//! drive the active tool through the same path as the mouse
-//! - `ui.key {key, shift?, alt?, cmd?}` / `ui.text {text}`: synthetic keyboard input
+//! - `ui.key {key, shift?, alt?, cmd?}`: synthetic keyboard input
+//! - `ui.text {text}`: insert at the explicit caret, or type into a focused UI field
//! - `ui.move {x, y}` / `ui.click {x, y, button?, count?, shift?…}` / `ui.drag {x, y, toX, toY, steps?}`:
//! real egui pointer input in screen points (reaches every widget: panels, flyouts, dialogs)
//! - `ui.set {brightness?, panel?, dockTab?, rulers?, outline?, …}`
@@ -216,9 +217,19 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) ->
ok(Value::Null)
}
"ui.text" => {
- app.synthetic.push(egui::Event::Text(s("text").unwrap_or("").to_string()));
- ctx.request_repaint();
- ok(Value::Null)
+ let Some(text) = s("text") else { return err("missing `text`") };
+ // Focused widgets retain the synthetic keyboard route. Document automation uses
+ // the explicit caret independently of the active tool, unlike ordinary typing.
+ if ctx.text_edit_focused() || app.ui.dialog.is_some() || app.ui.palette.is_some() {
+ app.synthetic.push(egui::Event::Text(text.to_string()));
+ ctx.request_repaint();
+ ok(Value::Null)
+ } else {
+ if app.session.active().is_none_or(|st| st.selection.text.is_none_or(|t| st.doc.text_story(t.story, t.cell).is_none())) {
+ return err("no valid text insertion point: use text.select or text.placeCaret first");
+ }
+ wrap(app.run("text.insert", json!({"text": text})))
+ }
}
"ui.set" => {
let mut r = Ok(Value::Null);
@@ -313,7 +324,10 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) ->
}
"ui.render" => {
let Some(st) = app.session.active() else { return err("no document") };
- let page = p.get("page").and_then(Value::as_u64).map(|v| v as usize).or_else(|| crate::canvas::current_page(app)).unwrap_or(0);
+ let page = match page_index(p, crate::canvas::current_page(app).unwrap_or(0), st.doc.page_count()) {
+ Ok(page) => page,
+ Err(e) => return err(e),
+ };
let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0);
let Some(img) = app.canvas.renderer.render_page(
&st.doc,
@@ -336,7 +350,7 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) ->
}
"app.open" => wrap(app.run("file.open", json!({"path": s("path")}))),
"app.save" => wrap(app.run("file.save", json!({"path": s("path")}))),
- "app.export" => wrap(app.run("app.exportPng", json!({"path": s("path"), "page": p.get("page"), "scale": p.get("scale")}))),
+ "app.export" => wrap(app.run("app.exportPng", p.clone())),
"app.quit" => {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
ok(Value::Null)
@@ -361,3 +375,102 @@ pub fn save_screenshot(app: &mut DesignApp, image: &egui::ColorImage, path: Opti
None => json!({"ok": false, "error": "no writer configured"}),
}
}
+
+/// Parse explicit page indices without treating invalid values as omission.
+pub(crate) fn page_index(p: &Value, default: usize, count: usize) -> Result {
+ let page = match p.get("page") {
+ None => default,
+ Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or("`page` must be a nonnegative integer representable as a page index")?,
+ };
+ if page >= count {
+ return Err(format!("no page {page} (the document has {count} pages, indices are 0-based)"));
+ }
+ Ok(page)
+}
+
+#[cfg(test)]
+mod automation_contract_tests {
+ use super::*;
+ fn app() -> DesignApp {
+ let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+ app.session.execute("file.new", &json!({"pages":2})).unwrap();
+ app
+ }
+ fn call(app: &mut DesignApp, ctx: &egui::Context, method: &str, params: Value) -> Value {
+ let (req, _) = ControlRequest::new(method, params);
+ match handle(app, ctx, &req) {
+ Outcome::Done(v) => v,
+ Outcome::Screenshot { .. } => panic!("unexpected screenshot"),
+ }
+ }
+ fn frame(app: &mut DesignApp, ctx: &egui::Context, events: Vec) {
+ let mut output = ctx.run_ui(
+ egui::RawInput {
+ screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))),
+ events,
+ ..Default::default()
+ },
+ |ui| {
+ app.logic(ui.ctx());
+ app.ui(ui);
+ },
+ );
+ output.textures_delta.clear();
+ }
+ #[test]
+ fn control_typing_uses_explicit_caret_but_retains_widget_events() {
+ let mut app = app();
+ let ctx = egui::Context::default();
+ assert_eq!(call(&mut app, &ctx, "ui.text", json!({"text":"X"}))["ok"], false);
+ let f = app.session.execute("frame.create", &json!({"rect":[36,36,300,200],"content":"text","text":"Hello"})).unwrap();
+ app.session.execute("text.select", &json!({"story":f["story"],"anchor":5})).unwrap();
+ assert_eq!(app.session.tool_id(), "selection");
+ assert_eq!(call(&mut app, &ctx, "ui.text", json!({"text":"X"}))["ok"], true);
+ assert_eq!(app.session.execute("story.get", &json!({"story":f["story"]})).unwrap()["text"], "HelloX");
+ app.session.execute("edit.undo", &json!({})).unwrap();
+ assert_eq!(app.session.execute("story.get", &json!({"story":f["story"]})).unwrap()["text"], "Hello");
+ app.select_tool("type");
+ assert!(app.session.wants_text(), "the document would receive ordinary typing without widget focus");
+ app.ui.palette = Some(String::new());
+ for _ in 0..3 {
+ frame(&mut app, &ctx, vec![]);
+ }
+ assert!(ctx.text_edit_focused());
+ assert_eq!(call(&mut app, &ctx, "ui.text", json!({"text":"field"}))["ok"], true);
+ let events = std::mem::take(&mut app.synthetic);
+ frame(&mut app, &ctx, events);
+ assert_eq!(app.ui.palette.as_deref(), Some("field"));
+ assert_eq!(app.session.execute("story.get", &json!({"story":f["story"]})).unwrap()["text"], "Hello");
+ }
+ #[test]
+ fn control_pages_validate_before_writer_and_preserve_omission() {
+ let mut app = app();
+ let ctx = egui::Context::default();
+ let writes = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
+ let sink = writes.clone();
+ app.services.write = Some(Box::new(move |_, bytes| {
+ sink.lock().unwrap().push(bytes.to_vec());
+ Ok(())
+ }));
+ app.session.execute("frame.create", &json!({"rect":[36,36,300,200]})).unwrap();
+ app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0)));
+ crate::canvas::go_to_page(&mut app, 1);
+ assert_eq!(crate::canvas::current_page(&app), Some(1));
+ for method in ["ui.render", "app.export"] {
+ for page in
+ [json!(-1), json!(-2), json!(1.5), Value::Null, json!("0"), json!(true), json!(u64::MAX), json!(18446744073709551616_f64), json!(99)]
+ {
+ assert_eq!(call(&mut app, &ctx, method, json!({"page":page,"path":"unchanged.png","scale":0.1}))["ok"], false, "{method}: {page}");
+ assert!(writes.lock().unwrap().is_empty());
+ }
+ assert_eq!(call(&mut app, &ctx, method, json!({"path":"omitted.png","scale":0.1}))["ok"], true);
+ assert_eq!(call(&mut app, &ctx, method, json!({"page":1,"path":"explicit.png","scale":0.1}))["ok"], true);
+ let mut bytes = writes.lock().unwrap();
+ assert_eq!(bytes.len(), 2);
+ assert_eq!(bytes[0], bytes[1]);
+ bytes.clear();
+ }
+ assert!(app.run("app.exportPng", json!({"page":Value::Null,"path":"unchanged.png"})).is_err());
+ assert!(writes.lock().unwrap().is_empty());
+ }
+}
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index 7a426adc8..7af1a32b9 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -144,6 +144,11 @@ fn user_dictionary(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
});
}
+fn is_modifier_key(key: egui::Key) -> bool {
+ use egui::Key::*;
+ matches!(key, ShiftLeft | ShiftRight | ControlLeft | ControlRight | AltLeft | AltRight | SuperLeft | SuperRight)
+}
+
fn keyboard_shortcuts(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
let mut q = d.s("query");
ui.add(
@@ -153,11 +158,12 @@ fn keyboard_shortcuts(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
);
d.fields.insert("query".into(), json!(q));
let recording = d.s("recording");
- // Capture the next key press for the command being recorded.
+ // Capture the next key press for the command being recorded. Pressing a modifier
+ // reports the modifier itself as a key; skip it and wait for the key it modifies.
if !recording.is_empty() {
let pressed = ui.input(|i| {
i.events.iter().find_map(|e| match e {
- egui::Event::Key { key, pressed: true, modifiers, .. } => Some((*key, *modifiers)),
+ egui::Event::Key { key, pressed: true, modifiers, .. } if !is_modifier_key(*key) => Some((*key, *modifiers)),
_ => None,
})
});
@@ -803,7 +809,9 @@ fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
let Some(mut d) = app.ui.dialog.clone() else { return };
let mut result: Option = None;
+ let alert_title = d.s("title");
let title = match d.id.as_str() {
+ "alert" => crate::i18n::tr(&app.ui.language, &alert_title),
"ruby" => crate::i18n::tr(&app.ui.language, "Ruby"),
"newDocument" => crate::i18n::tr(&app.ui.language, "New Document"),
"frameSize" => crate::i18n::tr(&app.ui.language, "Rectangle"),
@@ -837,7 +845,11 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
};
egui::Modal::new(egui::Id::new("dialog")).show(ctx, |ui| {
ui.set_min_width(380.0);
- ui.set_max_width(if d.id == "newDocument" && crate::i18n::is_rtl(&app.ui.language) { 380.0 } else { 640.0 });
+ ui.set_max_width(match d.id.as_str() {
+ "alert" => 440.0,
+ "newDocument" if crate::i18n::is_rtl(&app.ui.language) => 380.0,
+ _ => 640.0,
+ });
if crate::i18n::is_rtl(&app.ui.language) {
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
@@ -1332,7 +1344,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
egui::Grid::new("ins_table").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Body Rows"));
text_field(ui, &mut d, "bodyRows", 60.0);
- crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Columns"));
+ crate::rtl::label(ui, crate::i18n::tr_context(&app.ui.language, "Columns", "table"));
text_field(ui, &mut d, "columns", 60.0);
ui.end_row();
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Header Rows"));
@@ -1366,6 +1378,14 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
});
}
"documentSetup" => document_setup(app, ui, &mut d),
+ "alert" => {
+ let file = d.s("file");
+ if !file.is_empty() {
+ crate::rtl::label(ui, egui::RichText::new(crate::rtl::isolate(&file)).font(semibold(13.0)));
+ ui.add_space(4.0);
+ }
+ crate::rtl::label(ui, d.s("message"));
+ }
_ => {}
}
ui.add_space(12.0);
@@ -1381,7 +1401,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
{
result = Some(true);
}
- if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Cancel"))).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
+ // An alert only has OK.
+ let cancel = d.id != "alert" && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Cancel"))).clicked();
+ if cancel || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
result = Some(false);
}
});
@@ -1498,7 +1520,7 @@ pub fn confirm(app: &mut DesignApp) -> Result {
}
"pdfImport" => {
let crop = d.s("crop");
- app.run("file.place", json!({"path": d.s("path"), "pdfPage": d.n("page").unwrap_or(1.0).max(1.0) as u64, "pdfCrop": if crop.is_empty() { "crop".to_string() } else { crop }}))
+ app.open_file("file.place", json!({"path": d.s("path"), "pdfPage": d.n("page").unwrap_or(1.0).max(1.0) as u64, "pdfCrop": if crop.is_empty() { "crop".to_string() } else { crop }}))
}
"print" => {
let pages = if d.s("range") == "all" { Value::Null } else { json!(d.s("pages")) };
@@ -1601,7 +1623,7 @@ pub fn confirm(app: &mut DesignApp) -> Result {
app.run("document.preferences", doc)
}
"newWorkspace" => app.run("window.newWorkspace", json!({"name": d.s("name")})),
- "importOptions" => app.run(
+ "importOptions" => app.open_file(
"file.place",
json!({"path": d.s("path"), "removeStyles": d.b("removeStyles"), "styleConflicts": d.s("styleConflicts"), "styleMap": d.fields.get("map").cloned().unwrap_or(json!({}))}),
),
@@ -1669,6 +1691,7 @@ pub fn confirm(app: &mut DesignApp) -> Result {
}
app.run("footnote.options", p)
}
+ "alert" => Ok(Value::Null),
"findChange" if d.b("objectMode") => Ok(Value::Null),
"findChange" => app.run("find.change", json!({"find": d.s("find"), "change": d.s("change"), "grep": d.b("grep"), "caseSensitive": d.b("caseSensitive"), "wholeWord": d.b("wholeWord"), "scope": d.s("scope")})),
id if id.starts_with("cmd:") => {
@@ -1683,10 +1706,12 @@ pub fn confirm(app: &mut DesignApp) -> Result {
let parsed = serde_json::from_str::(v).ok().filter(|x| !x.is_string());
p.insert(f.key, parsed.unwrap_or_else(|| json!(v)));
}
- Some(Value::Bool(b)) => {
- p.insert(f.key, json!(b));
+ Some(Value::String(_)) | None => {}
+ Some(value) => {
+ // Control/MCP callers can supply JSON directly. Preserve its type,
+ // including explicit null, for the command to interpret.
+ p.insert(f.key, value.clone());
}
- _ => {}
}
}
let r = app.run(cid, Value::Object(p));
@@ -1812,13 +1837,25 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
}
});
let mut n = ns["count"].as_u64().unwrap_or(1) as u32;
- if ui.add(egui::DragValue::new(&mut n).range(1..=999)).changed() {
- ns["count"] = json!(n);
- changed = true;
+ let mut count_value = |ui: &mut egui::Ui, ns: &mut Value, changed: &mut bool| {
+ if ui.add(egui::DragValue::new(&mut n).range(1..=999)).changed() {
+ ns["count"] = json!(n);
+ *changed = true;
+ }
+ };
+ if app.ui.language != "uk" {
+ count_value(ui, ns, &mut changed);
}
let kind = ns["until"]["kind"].as_str().unwrap_or("words").to_string();
egui::ComboBox::from_id_salt(("ns_until", i))
- .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, &kind)))
+ .selected_text(crate::rtl::widget(
+ ui,
+ if app.ui.language == "uk" {
+ format!("{}:", crate::i18n::tr("uk", &kind))
+ } else {
+ crate::i18n::tr(&app.ui.language, &kind).to_owned()
+ },
+ ))
.width(100.0)
.show_ui(ui, |ui| {
for k in
@@ -1830,6 +1867,9 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
}
}
});
+ if app.ui.language == "uk" {
+ count_value(ui, ns, &mut changed);
+ }
if kind == "chars" {
let mut c = ns["until"]["chars"].as_str().unwrap_or("").to_string();
if ui.add(egui::TextEdit::singleline(&mut c).desired_width(40.0)).changed() {
@@ -1881,13 +1921,22 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
}
}
});
- crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "for"));
+ crate::rtl::label(
+ ui,
+ if app.ui.language == "uk" {
+ format!("{}:", crate::i18n::tr("uk", "lines"))
+ } else {
+ crate::i18n::tr(&app.ui.language, "for").to_owned()
+ },
+ );
let mut n = l["lines"].as_u64().unwrap_or(1) as u32;
if ui.add(egui::DragValue::new(&mut n).range(1..=999)).changed() {
l["lines"] = json!(n);
changed = true;
}
- crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, if n == 1 { "line" } else { "lines" }));
+ if app.ui.language != "uk" {
+ crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, if n == 1 { "line" } else { "lines" }));
+ }
if ui
.small_button("×")
.on_hover_ui(|ui| {
@@ -2650,6 +2699,73 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
mod tests {
use super::*;
+ fn command_app(id: &str, fields: Value) -> DesignApp {
+ let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+ app.ui.dialog = Some(Dialog::new(&format!("cmd:{id}"), fields));
+ app
+ }
+
+ #[test]
+ fn generic_new_document_preserves_typed_values_and_text_entry() {
+ for pages in [json!(2), json!(" 2 ")] {
+ let mut app = command_app(
+ "file.new",
+ json!({
+ "preset": "A4", "pages": pages, "facingPages": false, "gutter": 0,
+ "margins": {"top": 10, "bottom": 20, "inside": 30, "outside": 40},
+ "title": " ", "status": "internal", "primaryTextFrame": true
+ }),
+ );
+ confirm(&mut app).unwrap();
+ let doc = &app.session.active().unwrap().doc;
+ assert_eq!(doc.page_count(), 2);
+ assert!(!doc.settings.facing_pages);
+ assert!(doc.title.starts_with("Untitled-"));
+ for page in doc.spreads.iter().flat_map(|s| &s.pages) {
+ assert!((page.width - 595.2755905511812).abs() < 0.01);
+ assert!((page.height - 841.8897637795277).abs() < 0.01);
+ assert_eq!(page.columns.gutter, 0.0);
+ assert_eq!(page.margins.top, 10.0);
+ assert_eq!(page.margins.outside, 40.0);
+ }
+ assert!(doc.spreads.iter().all(|s| s.items.is_empty()), "undocumented fields stay excluded");
+ assert!(app.ui.dialog.is_none());
+ }
+ }
+
+ #[test]
+ fn generic_command_preserves_array_geometry() {
+ let mut app = command_app("file.new", json!({}));
+ confirm(&mut app).unwrap();
+ app.ui.dialog = Some(Dialog::new("cmd:frame.create", json!({"rect": [10, 20, 110, 220]})));
+ confirm(&mut app).unwrap();
+ let doc = &app.session.active().unwrap().doc;
+ let item = doc.spreads.iter().flat_map(|s| &s.items).next().unwrap();
+ assert_eq!(item.bounds(), designcraft_geom::Rect::new(10.0, 20.0, 110.0, 220.0));
+ }
+
+ #[test]
+ fn generic_explicit_null_uses_the_commands_existing_semantics() {
+ // file.new accepts null width by using the selected preset's width.
+ for width in [Value::Null, json!("null")] {
+ let mut app = command_app("file.new", json!({"preset": "A4", "width": width, "title": " Sample layout "}));
+ confirm(&mut app).unwrap();
+ let doc = &app.session.active().unwrap().doc;
+ assert_eq!(doc.title, "Sample layout");
+ assert!((doc.settings.page_width - 595.2755905511812).abs() < 0.01);
+ }
+ }
+
+ #[test]
+ fn generic_invalid_number_retains_dialog_without_creating_document() {
+ let mut app = command_app("file.new", json!({"width": 0}));
+ assert!(confirm(&mut app).is_err());
+ assert!(app.session.active().is_none());
+ let dialog = app.ui.dialog.as_ref().unwrap();
+ assert_eq!(dialog.fields["width"], 0);
+ assert!(dialog.fields["status"].as_str().unwrap().contains("page size out of range"));
+ }
+
#[test]
fn parses_command_params() {
let f = command_fields("{name, type: custom|lastPageNumber|chapterNumber, text?, rule?: {on, weight}, flag?: bool} — creates");
@@ -2677,4 +2793,19 @@ mod tests {
let _ = command_fields(c.params);
}
}
+
+ #[test]
+ fn shortcut_recorder_waits_for_the_key_after_its_modifiers() {
+ let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+ let mut d = Dialog::new("keyboardShortcuts", json!({"query": "", "recording": "app.palette"}));
+ let ctx = egui::Context::default();
+ let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
+ // Holding Ctrl reports Ctrl itself as a key press, then Ctrl+K arrives.
+ for event in [key(egui::Key::ControlLeft, egui::Modifiers::COMMAND), key(egui::Key::K, egui::Modifiers::COMMAND)] {
+ let input = egui::RawInput { events: vec![event], ..Default::default() };
+ ctx.run_ui(input, |ui| keyboard_shortcuts(&mut app, ui, &mut d)).textures_delta.clear();
+ }
+ assert_eq!(crate::menus::shortcut_of(&app, "app.palette").as_deref(), Some("Cmd+K"));
+ assert_eq!(d.s("recording"), "", "recording ends with the recorded shortcut");
+ }
}
diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs
index 878657a5f..04f1c8889 100644
--- a/crates/ui-egui/src/i18n.rs
+++ b/crates/ui-egui/src/i18n.rs
@@ -4,7 +4,9 @@
use std::{collections::HashMap, sync::OnceLock};
mod ar;
+mod ja;
mod pt_br;
+mod uk;
/// Supported interface languages: (code, name in that language).
pub const LANGUAGES: &[(&str, &str)] = &[
@@ -16,6 +18,7 @@ pub const LANGUAGES: &[(&str, &str)] = &[
("zh", "简体中文"),
("ar", "العربية"),
("pt-br", "Português (Brasil)"),
+ ("uk", "Українська"),
];
/// English → [German, French, Spanish, Japanese, Simplified Chinese].
@@ -230,6 +233,26 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Business Card", ["Business Card", "Business Card", "Business Card", "Business Card", "名片"]),
("CMYK", ["CMYK", "CMYK", "CMYK", "CMYK", "CMYK"]),
("Cancel", ["Cancel", "Cancel", "Cancel", "Cancel", "取消"]),
+ (
+ "Can't Open the File",
+ [
+ "Datei kann nicht geöffnet werden",
+ "Impossible d'ouvrir le fichier",
+ "No se puede abrir el archivo",
+ "ファイルを開けません",
+ "无法打开文件",
+ ],
+ ),
+ (
+ "Can't Place the File",
+ [
+ "Datei kann nicht platziert werden",
+ "Impossible d'importer le fichier",
+ "No se puede colocar el archivo",
+ "ファイルを配置できません",
+ "无法置入文件",
+ ],
+ ),
("Center Content", ["Center Content", "Center Content", "Center Content", "Center Content", "内容居中"]),
("Change All", ["Change All", "Change All", "Change All", "Change All", "全部更改"]),
("Change to:", ["Change to:", "Change to:", "Change to:", "Change to:", "更改为:"]),
@@ -2730,19 +2753,50 @@ fn column(lang: &str) -> Option {
/// `s` in `lang` (English, or the string itself, when there's no translation).
pub fn tr<'a>(lang: &str, s: &'a str) -> &'a str {
+ static JAPANESE: OnceLock> = OnceLock::new();
static TRANSLATIONS: OnceLock> = OnceLock::new();
static ARABIC: OnceLock> = OnceLock::new();
+ static UKRAINIAN: OnceLock> = OnceLock::new();
static PORTUGUESE_BR: OnceLock> = OnceLock::new();
if lang == "ar" {
return ARABIC.get_or_init(|| ar::TABLE.iter().copied().collect()).get(s).copied().unwrap_or(s);
}
+ // The Japanese table covers the whole interface; the shared table's Japanese column is the fallback
+ // for keys added there after it.
+ if lang == "ja"
+ && let Some(text) = JAPANESE.get_or_init(|| ja::TABLE.iter().copied().collect()).get(s).copied()
+ {
+ return text;
+ }
if lang == "pt-br" {
return PORTUGUESE_BR.get_or_init(|| pt_br::TABLE.iter().copied().collect()).get(s).copied().unwrap_or(s);
}
+ if lang == "uk" {
+ return UKRAINIAN.get_or_init(|| uk::TABLE.iter().copied().collect()).get(s).copied().unwrap_or(s);
+ }
let Some(c) = column(lang) else { return s };
TRANSLATIONS.get_or_init(|| TABLE.iter().copied().collect()).get(s).and_then(|row| row.get(c)).copied().unwrap_or(s)
}
+/// Context-sensitive UI captions; unsupported contexts keep the shared translation.
+pub fn tr_context<'a>(lang: &str, s: &'a str, context: &str) -> &'a str {
+ if lang == "uk" {
+ return uk::CONTEXT.iter().find(|(key, scope, _)| *key == s && *scope == context).map_or_else(|| tr(lang, s), |(_, _, text)| text);
+ }
+ tr(lang, s)
+}
+
+#[cfg(test)]
+pub(crate) fn ukrainian_catalog() -> Vec<&'static str> {
+ uk::TABLE.iter().map(|(_, text)| *text).chain(uk::CONTEXT.iter().map(|(_, _, text)| *text)).collect()
+}
+
+/// Count captions in Ukrainian put the category before the value so every count uses
+/// a grammatical form; other languages keep the existing value-first caption.
+pub fn count_label(lang: &str, noun: &str, count: usize) -> String {
+ if lang == "uk" { format!("{}: {count}", tr(lang, noun)) } else { format!("{count} {}", tr(lang, noun)) }
+}
+
/// Localize reserved built-in style names only; user-defined names are document data.
pub fn style_name<'a>(lang: &str, name: &'a str) -> &'a str {
match name {
@@ -2767,7 +2821,10 @@ mod tests {
fn cached_lookup_preserves_every_translation_across_language_switches() {
for (key, translations) in TABLE {
for (lang, expected) in ["de", "fr", "es", "ja", "zh"].into_iter().zip(translations) {
- assert_eq!(tr(lang, key), *expected, "{lang}: {key}");
+ // `ja::TABLE` translates the whole interface and wins over the shared Japanese column.
+ if lang != "ja" {
+ assert_eq!(tr(lang, key), *expected, "{lang}: {key}");
+ }
}
assert_eq!(tr("", key), *key);
}
@@ -2777,8 +2834,11 @@ mod tests {
for (key, expected) in pt_br::TABLE {
assert_eq!(tr("pt-br", key), *expected, "pt-br: {key}");
}
+ for (key, expected) in uk::TABLE {
+ assert_eq!(tr("uk", key), *expected, "uk: {key}");
+ }
let unknown = String::from("A user-defined untranslated label");
- for lang in ["ar", "pt-br", "zh", "ja", "de", "fr", "es", "", "unknown"] {
+ for lang in ["uk", "ar", "pt-br", "zh", "ja", "de", "fr", "es", "", "unknown"] {
assert!(std::ptr::eq(tr(lang, &unknown), unknown.as_str()));
}
}
@@ -2810,6 +2870,56 @@ mod tests {
}
}
+ #[test]
+ fn ukrainian_catalog_covers_existing_keys_and_preserves_placeholders() {
+ let catalog: std::collections::HashMap<_, _> = uk::TABLE.iter().copied().collect();
+ assert_eq!(catalog.len(), uk::TABLE.len(), "duplicate Ukrainian keys");
+ let mut keys = std::collections::HashSet::new();
+ keys.extend(TABLE.iter().map(|(key, _)| *key));
+ keys.extend(ar::TABLE.iter().map(|(key, _)| *key));
+ keys.extend(pt_br::TABLE.iter().map(|(key, _)| *key));
+ assert_eq!(keys, catalog.keys().copied().collect());
+ let placeholders =
+ |s: &str| s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::>();
+ for (key, translation) in uk::TABLE {
+ assert!(!translation.trim().is_empty(), "empty {key}");
+ let mut source = placeholders(key);
+ let mut translated = placeholders(translation);
+ source.sort();
+ translated.sort();
+ assert_eq!(source, translated, "placeholders: {key}");
+ }
+ assert_eq!(tr_context("uk", "Columns", "table"), "Стовпці");
+ assert_eq!(tr("uk", "Columns"), "Колонки");
+ assert_eq!(tr_context("uk", "Group", "selection"), "Група");
+ assert_eq!(tr("uk", "Group"), "Згрупувати");
+ assert_eq!(tr_context("", "Columns", "table"), "Columns");
+ assert_eq!(tr_context("uk", "Custom caption", "custom"), "Custom caption");
+ assert_eq!(tr("uk", "through"), "включно");
+ assert_eq!(tr("uk", "up to"), "не включно");
+ assert!(!is_rtl("uk"));
+ assert_eq!(tr("uk", "Type"), "Текст");
+ assert_eq!(tr("uk", "Spread"), "Розворот");
+ assert_eq!(tr("uk", "Story"), "Текстовий матеріал");
+ assert_eq!(tr("uk", "Footer Rows"), "Рядки нижнього колонтитула");
+ assert_eq!(style_name("uk", "[Basic Paragraph]"), "[Основний абзац]");
+ assert_eq!(style_name("uk", "My layout style"), "My layout style");
+ assert_eq!(workspace_name("uk", "Essentials"), "Основне");
+ assert_eq!(workspace_name("uk", "My workspace"), "My workspace");
+ }
+
+ #[test]
+ fn ukrainian_counts_use_category_captions_for_every_number() {
+ for n in [0, 1, 2, 4, 5, 11, 21, 22, 25] {
+ assert_eq!(count_label("uk", "pages", n), format!("сторінок: {n}"));
+ assert_eq!(count_label("uk", "objects", n), format!("об’єктів: {n}"));
+ assert_eq!(count_label("uk", "errors", n), format!("помилок: {n}"));
+ let pages = tr("uk", "{pages} Pages in {spreads} Spreads").replace("{pages}", &n.to_string()).replace("{spreads}", &n.to_string());
+ assert_eq!(pages, format!("Сторінок: {n}; розворотів: {n}"));
+ }
+ assert_eq!(count_label("", "objects", 2), "2 objects");
+ }
+
#[test]
fn japanese_vertical_type_commands_are_translated() {
for (en, ja) in [
diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs
index 783f33e5e..34a50b57e 100644
--- a/crates/ui-egui/src/i18n/ar.rs
+++ b/crates/ui-egui/src/i18n/ar.rs
@@ -167,6 +167,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Dock panel", "إرساء اللوحة"),
("OK", "موافق"),
("Cancel", "إلغاء"),
+ ("Can't Open the File", "تعذّر فتح الملف"),
+ ("Can't Place the File", "تعذّر إدراج الملف"),
("Apply", "تطبيق"),
("Done", "تم"),
("Reset", "إعادة ضبط"),
diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs
new file mode 100644
index 000000000..be6e2c4da
--- /dev/null
+++ b/crates/ui-egui/src/i18n/ja.rs
@@ -0,0 +1,1725 @@
+//! Original Japanese interface translations, written from the English source using ordinary Japanese
+//! computing and print-layout vocabulary. Every key of the shared table and of the Arabic and Brazilian
+//! Portuguese tables has a row here; the menu titles and panel names that were already Japanese in the
+//! shared table are carried over unchanged.
+pub(super) const TABLE: &[(&str, &str)] = &[
+ ("File", "ファイル"),
+ ("Edit", "編集"),
+ ("Layout", "レイアウト"),
+ ("Type", "書式"),
+ ("Object", "オブジェクト"),
+ ("Table", "表"),
+ ("View", "表示"),
+ ("Window", "ウィンドウ"),
+ ("Help", "ヘルプ"),
+ ("New", "新規"),
+ ("New Document…", "新規ドキュメント…"),
+ ("Open…", "開く…"),
+ ("Close", "閉じる"),
+ ("Save", "保存"),
+ ("Save As…", "別名で保存…"),
+ ("Save a Copy…", "コピーを保存…"),
+ ("Revert", "復帰"),
+ ("Place…", "配置…"),
+ ("Export PDF…", "PDF を書き出し…"),
+ ("Print…", "プリント…"),
+ ("Document Setup…", "ドキュメント設定…"),
+ ("Package…", "パッケージ…"),
+ ("Undo", "取り消し"),
+ ("Redo", "やり直し"),
+ ("Cut", "カット"),
+ ("Copy", "コピー"),
+ ("Paste", "ペースト"),
+ ("Clear", "消去"),
+ ("Duplicate", "複製"),
+ ("Select All", "すべてを選択"),
+ ("Deselect All", "すべての選択を解除"),
+ ("Find/Change…", "検索と置換…"),
+ ("Check Spelling…", "スペルチェック…"),
+ ("Preferences…", "環境設定…"),
+ ("Keyboard Shortcuts…", "キーボードショートカット…"),
+ ("Interface Language", "インターフェイスの言語"),
+ ("Pages", "ページ"),
+ ("Margins and Columns…", "マージン・段組…"),
+ ("Create Guides…", "ガイドを作成…"),
+ ("Numbering & Section Options…", "ノンブルとセクション設定…"),
+ ("Story Direction", "組み方向"),
+ ("Horizontal", "横書き"),
+ ("Vertical", "縦書き"),
+ ("Tate-Chu-Yoko", "縦中横"),
+ ("Ruby…", "ルビ…"),
+ ("Kenten", "圏点"),
+ ("Font", "フォント"),
+ ("Size", "サイズ"),
+ ("Character", "文字"),
+ ("Paragraph", "段落"),
+ ("Tabs", "タブ"),
+ ("Glyphs", "字形"),
+ ("Story", "ストーリー"),
+ ("Character Styles", "文字スタイル"),
+ ("Paragraph Styles", "段落スタイル"),
+ ("Create Outlines", "アウトラインを作成"),
+ ("Show Hidden Characters", "制御文字を表示"),
+ ("Transform", "変形"),
+ ("Arrange", "重ね順"),
+ ("Group", "グループ"),
+ ("Ungroup", "グループ解除"),
+ ("Lock", "ロック"),
+ ("Hide", "隠す"),
+ ("Effects", "効果"),
+ ("Fitting", "オブジェクトサイズの調整"),
+ ("Text Frame Options…", "テキストフレーム設定…"),
+ ("Insert Table…", "表を挿入…"),
+ ("Merge Cells", "セルを結合"),
+ ("Unmerge Cells", "セルの結合解除"),
+ ("Insert", "挿入"),
+ ("Delete", "削除"),
+ ("Zoom In", "ズームイン"),
+ ("Zoom Out", "ズームアウト"),
+ ("Fit Page in Window", "ページ全体"),
+ ("Fit Spread in Window", "スプレッド全体"),
+ ("Actual Size", "実際のサイズ"),
+ ("Screen Mode", "スクリーンモード"),
+ ("Grids & Guides", "グリッドとガイド"),
+ ("Show Rulers", "定規を表示"),
+ ("Snap to Guides", "ガイドにスナップ"),
+ ("Snap to Document Grid", "ドキュメントグリッドにスナップ"),
+ ("Smart Guides", "スマートガイド"),
+ ("Align to Object Edges", "オブジェクトの端に揃える"),
+ ("Align to Object Centers", "オブジェクトの中心に揃える"),
+ ("Smart Dimensions", "スマートサイズ"),
+ ("Smart Spacing", "スマート間隔"),
+ ("Snap to Zone", "スナップゾーン"),
+ ("Properties", "プロパティ"),
+ ("Layers", "レイヤー"),
+ ("Links", "リンク"),
+ ("Swatches", "スウォッチ"),
+ ("Color", "カラー"),
+ ("Stroke", "線"),
+ ("Gradient", "グラデーション"),
+ ("Align", "整列"),
+ ("Pathfinder", "パスファインダー"),
+ ("Info", "情報"),
+ ("Library", "ライブラリ"),
+ ("Book", "ブック"),
+ ("Index", "索引"),
+ ("Hyperlinks", "ハイパーリンク"),
+ ("Bookmarks", "ブックマーク"),
+ ("Articles", "アーティクル"),
+ ("Tags", "タグ"),
+ ("Media", "メディア"),
+ ("Object States", "オブジェクトステート"),
+ ("Buttons and Forms", "ボタンとフォーム"),
+ ("Liquid Layout", "リキッドレイアウト"),
+ ("Workspace", "ワークスペース"),
+ ("Split Window", "ウィンドウを分割"),
+ ("New Window", "新規ウィンドウ"),
+ ("Show All Menu Items", "すべてのメニュー項目を表示"),
+ ("Accept All", "すべて採用"),
+ ("Add", "追加"),
+ ("Add Document…", "ドキュメントを追加…"),
+ ("Add to Previous Spread", "前のスプレッドに追加"),
+ ("Align From:", "揃えの基準点:"),
+ ("Align to baseline grid", "ベースライングリッドに揃える"),
+ ("Alignment:", "揃え:"),
+ ("All Spots to Process", "すべての特色をプロセスカラーに変換"),
+ ("Allow Spread to Shuffle", "スプレッドのページ再配置を許可"),
+ ("Alternating Row Fills", "行の交互塗り"),
+ ("Appearance", "外観"),
+ ("Apply Gradient", "グラデーションを適用"),
+ ("Apply Parent", "親ページを適用"),
+ ("Apply Style:", "適用するスタイル:"),
+ ("Apply to All Spreads", "すべてのスプレッドに適用"),
+ ("Apply to Stroke", "線に適用"),
+ ("Art", "アートボックス"),
+ ("Based On:", "基になるスタイル:"),
+ ("Bleed", "裁ち落とし"),
+ ("Body Rows", "本体行"),
+ ("Bottom", "下"),
+ ("Break Link to Style", "スタイルへのリンクを解除"),
+ ("Bullets and Numbering", "箇条書き記号と番号付け"),
+ ("Business Card", "名刺"),
+ ("CMYK", "CMYK"),
+ ("Cancel", "キャンセル"),
+ ("Center Content", "内容を中央に配置"),
+ ("Change All", "すべてを置換"),
+ ("Change to:", "置換後の文字列:"),
+ ("Character Style:", "文字スタイル:"),
+ ("Checked by default", "デフォルトでチェック済み"),
+ ("Choices", "選択肢"),
+ ("Choose Image…", "画像を選択…"),
+ ("Class:", "クラス:"),
+ ("Color Picker", "カラーピッカー"),
+ ("Color Settings", "カラー設定"),
+ ("Color:", "カラー:"),
+ ("Columns", "列数"),
+ ("Composer:", "組版方式:"),
+ ("Copies:", "部数:"),
+ ("Count", "件数を集計"),
+ ("Create", "作成"),
+ ("Create Table", "表を作成"),
+ ("Create Table…", "表を作成…"),
+ ("Crop", "切り抜き範囲"),
+ ("Crop to:", "切り抜き範囲:"),
+ ("Cursor Key:", "カーソルキー:"),
+ ("Default", "デフォルト"),
+ ("Default View:", "デフォルトの表示:"),
+ ("Delete Column", "列を削除"),
+ ("Delete Condition", "条件を削除"),
+ ("Delete Layer", "レイヤーを削除"),
+ ("Delete Page", "ページを削除"),
+ ("Delete Row", "行を削除"),
+ ("Delete Swatch", "スウォッチを削除"),
+ ("Delete Unused Layers", "未使用のレイヤーを削除"),
+ ("Desired", "目標値"),
+ ("Dialog", "ダイアログ"),
+ ("Document", "ドキュメント"),
+ ("Document Setup", "ドキュメント設定"),
+ ("Drop Shadow", "ドロップシャドウ"),
+ ("Duplicate Spread", "スプレッドを複製"),
+ ("Email", "メール"),
+ ("Embed", "埋め込み"),
+ ("Export XML…", "XML を書き出し…"),
+ ("Fast", "高速"),
+ ("Fill Frame Proportionally", "縦横比を保持してフレームを塗りつぶす"),
+ ("Find All", "すべてを検索"),
+ ("Find Next", "次を検索"),
+ ("Find what:", "検索する文字列:"),
+ ("Find/Change", "検索と置換"),
+ ("Find/Replace Font", "フォントの検索と置換"),
+ ("Fit Content Proportionally", "縦横比を保持して内容を合わせる"),
+ ("Fit Content to Frame", "内容をフレームに合わせる"),
+ ("Fitting:", "オブジェクトサイズの調整:"),
+ ("Font Family:", "フォントファミリー:"),
+ ("Font Size:", "フォントサイズ:"),
+ ("Font Style:", "フォントスタイル:"),
+ ("Footer Rows", "フッター行"),
+ ("Footnote Options", "脚注オプション"),
+ ("Form Field", "フォームフィールド"),
+ ("Format:", "書式:"),
+ ("Frame Fitting", "フレーム内のオブジェクトサイズの調整"),
+ ("Frame Fitting Options", "フレームのオブジェクトサイズの調整オプション"),
+ ("Generate QR Code", "QR コードを生成"),
+ ("Go To", "移動"),
+ ("Go to Page", "ページへ移動"),
+ ("Gutter", "段間"),
+ ("Header Rows", "ヘッダー行"),
+ ("Height", "高さ"),
+ ("Height:", "高さ:"),
+ ("High Quality", "高品質"),
+ ("Horizontal Gridline Every:", "水平グリッド線の間隔:"),
+ ("Horizontal Margins:", "左右のマージン:"),
+ ("Hyphenate", "ハイフネーション"),
+ ("Import Options", "読み込みオプション"),
+ ("Import XML…", "XML を読み込み…"),
+ ("Increment Every:", "グリッドの間隔:"),
+ ("Ink Manager", "インキ管理"),
+ ("Insert Column", "列を挿入"),
+ ("Insert Page After", "後ろにページを挿入"),
+ ("Insert Row", "行を挿入"),
+ ("Inset spacing", "内側の余白"),
+ ("Inside", "内側"),
+ ("Intent:", "目的:"),
+ ("Keyboard Shortcuts", "キーボードショートカット"),
+ ("Kind:", "種類:"),
+ ("Lab", "Lab"),
+ ("Leading:", "行送り:"),
+ ("Left Indent:", "左インデント:"),
+ ("Link To:", "リンク先:"),
+ ("Load Profile…", "プロファイルを読み込み…"),
+ ("Load Swatches…", "スウォッチを読み込み…"),
+ ("Load…", "読み込み…"),
+ ("Make Compound", "複合パスを作成"),
+ ("Margins", "マージン"),
+ ("Maximum", "最大"),
+ ("Menu Customization", "メニューのカスタマイズ"),
+ ("Merge into Active Layer", "アクティブなレイヤーに結合"),
+ ("Min:", "最小:"),
+ ("Minimum", "最小"),
+ ("Minimum Space Before First Footnote:", "最初の脚注の前の最小間隔:"),
+ ("Misspelled word → Correction (one per line):", "綴りの誤った単語 → 修正後の単語 (1行に1件):"),
+ ("Mobile", "モバイル"),
+ ("Move Selection Here", "選択範囲をここへ移動"),
+ ("Move to Color Group", "カラーグループへ移動"),
+ ("Move to Group", "グループへ移動"),
+ ("Multiline", "複数行"),
+ ("Name", "名前"),
+ ("Name:", "名前:"),
+ ("New Bookmark for This Page", "このページの新規ブックマーク"),
+ ("New Book…", "新規ブック…"),
+ ("New Color Group", "新規カラーグループ"),
+ ("New Color Group with Swatch", "スウォッチを含む新規カラーグループ"),
+ ("New Cross-Reference", "新規相互参照"),
+ ("New Document", "新規ドキュメント"),
+ ("New GREP Style", "新規 GREP スタイル"),
+ ("New Library…", "新規ライブラリ…"),
+ ("New Line Style", "新規行スタイル"),
+ ("New Nested Style", "新規入れ子スタイル"),
+ ("New Workspace", "新規ワークスペース"),
+ ("No spot inks", "特色インキはありません"),
+ ("None", "なし"),
+ ("Number of Pages:", "ページ数:"),
+ ("Number of Sides:", "辺の数:"),
+ ("Number of columns", "段数"),
+ ("Object Layer Options", "オブジェクトのレイヤーオプション"),
+ ("Offset:", "オフセット:"),
+ ("On Release", "ボタンを離したとき"),
+ ("On Screen:", "画面上:"),
+ ("OpenType", "OpenType"),
+ ("Orientation:", "向き:"),
+ ("Outside", "外側"),
+ ("Pages:", "ページ:"),
+ ("Paragraph Composer", "段落単位の組版方式"),
+ ("Paragraph Style Options", "段落スタイルのオプション"),
+ ("Paragraph Style:", "段落スタイル:"),
+ ("Place PDF", "PDF を配置"),
+ ("Plain Text", "プレーンテキスト"),
+ ("Polygon Settings", "多角形の設定"),
+ ("Position", "位置"),
+ ("Position:", "位置:"),
+ ("Poster", "ポスター画像"),
+ ("Preferences", "環境設定"),
+ ("Prefix:", "前置文字:"),
+ ("Preset", "プリセット"),
+ ("Print", "プリント"),
+ ("Printer:", "プリンター:"),
+ ("Printing / Exporting:", "プリント / 書き出し:"),
+ ("Quick Actions", "クイック操作"),
+ ("RGB", "RGB"),
+ ("Rectangle", "長方形"),
+ ("Reject All", "すべて却下"),
+ ("Relative To:", "基準位置:"),
+ ("Release", "解除"),
+ ("Relink to Folder…", "フォルダーに再リンク…"),
+ ("Relink…", "再リンク…"),
+ ("Remove Conditions from Selection", "選択範囲から条件を解除"),
+ ("Repeat Header Rows", "ヘッダー行の繰り返し"),
+ ("Required", "必須"),
+ ("Reset All to Defaults", "すべてをデフォルトにリセット"),
+ ("Restart Numbering Every:", "番号を振り直す単位:"),
+ ("Reverse", "反転"),
+ ("Save Swatches for Exchange…", "交換用スウォッチを保存…"),
+ ("Save recovery data every:", "復旧データの保存間隔:"),
+ ("Search:", "検索範囲:"),
+ ("Select an object.", "オブジェクトを選択してください。"),
+ ("Select text or a text frame.", "テキストまたはテキストフレームを選択してください。"),
+ ("Separator:", "区切り文字:"),
+ ("Show Prefix/Suffix in:", "前置文字・後置文字の表示箇所:"),
+ ("Show Tag Markers", "タグマーカーを表示"),
+ ("Single-line", "1行単位"),
+ ("Single-line Composer", "1行単位の組版方式"),
+ ("Size:", "サイズ:"),
+ ("Space Between Footnotes:", "脚注間の間隔:"),
+ ("Speed", "速度"),
+ ("Star Inset:", "星形のくぼみ:"),
+ ("Start Page #:", "開始ページ番号:"),
+ ("Start at:", "開始番号:"),
+ ("Start:", "開始位置:"),
+ ("Style Name:", "スタイル名:"),
+ ("Style:", "スタイル:"),
+ ("Stylistic Sets", "字形セット"),
+ ("Subdivisions:", "分割数:"),
+ ("Suffix:", "後置文字:"),
+ ("Synchronize", "同期"),
+ ("Tag:", "タグ:"),
+ ("Text Frame", "テキストフレーム"),
+ ("Text Frame Options", "テキストフレーム設定"),
+ ("Text Message", "テキストメッセージ"),
+ ("Text Style", "テキストスタイル"),
+ ("Text Wrap", "テキストの回り込み"),
+ ("This PDF has no layers.", "この PDF にはレイヤーがありません。"),
+ ("To Text:", "適用対象のテキスト:"),
+ ("Top", "上"),
+ ("Track Changes in All Stories", "すべてのストーリーの変更を記録"),
+ ("Trim", "仕上がり"),
+ ("Type:", "種類:"),
+ ("Typical", "標準"),
+ ("UI Size:", "UI サイズ:"),
+ ("Ungroup Color Group", "カラーグループを解除"),
+ ("Unmerge", "セルの結合解除"),
+ ("Update", "更新"),
+ ("Update Numbering", "ノンブルを更新"),
+ ("Use Global Light", "共通光源を使用"),
+ ("User Dictionary", "ユーザー辞書"),
+ ("Vertical Gridline Every:", "垂直グリッド線の間隔:"),
+ ("Vertical Margins:", "上下のマージン:"),
+ ("Vertical justification", "縦方向の揃え"),
+ ("View Threshold:", "表示する最小倍率:"),
+ ("Web", "ウェブ"),
+ ("Web Hyperlink", "ウェブハイパーリンク"),
+ ("Weight:", "線の太さ:"),
+ ("Width", "幅"),
+ ("Width:", "幅:"),
+ ("Words, one per line:", "単語 (1行に1語):"),
+ ("Multiple Objects", "複数のオブジェクト"),
+ ("Ellipse", "楕円"),
+ ("Polygon", "多角形"),
+ ("Characters", "文字"),
+ ("Image", "画像"),
+ ("Line", "直線"),
+ ("Path", "パス"),
+ ("No Selection", "選択なし"),
+ ("Adjust Layout", "レイアウトを調整"),
+ ("Edit Page", "ページを編集"),
+ ("Import File", "ファイルを読み込み"),
+ ("Insert Page", "ページを挿入"),
+ ("Hide Bar", "バーを隠す"),
+ ("Properties Panel", "プロパティパネル"),
+ ("New Workspace…", "新規ワークスペース…"),
+ ("Float panel", "パネルをフローティング表示"),
+ ("Dock panel", "パネルをドックに戻す"),
+ ("Fit frame height to text", "フレームの高さをテキストに合わせる"),
+ ("More Options", "その他のオプション"),
+ ("Flip Horizontal", "左右反転"),
+ ("Rotate 90° Clockwise", "時計回りに90°回転"),
+ ("Flip Vertical", "上下反転"),
+ ("Default Fill and Stroke (D)", "塗りと線をデフォルトに戻す (D)"),
+ ("Swap Fill and Stroke (Shift+X)", "塗りと線を入れ替え (Shift+X)"),
+ ("Fill (X)", "塗り (X)"),
+ ("Stroke (X)", "線 (X)"),
+ ("Apply None (/)", "カラーなしを適用 (/)"),
+ ("Reverse the gradient", "グラデーションを反転"),
+ ("Style source", "スタイルの同期元"),
+ ("Open", "開く"),
+ ("All Caps", "すべて大文字"),
+ ("Underline", "下線"),
+ ("Strikethrough", "取り消し線"),
+ ("Superscript", "上付き文字"),
+ ("Pick a file in the folder holding the moved graphics", "移動した画像のあるフォルダー内のファイルを選んでください"),
+ ("Show Favorites Only", "お気に入りのみ表示"),
+ ("Select", "選択"),
+ ("Hyperlink from the selected text or frames", "選択したテキストやフレームからハイパーリンクを作成"),
+ ("Include when exporting", "書き出しに含める"),
+ ("Add the selection", "選択範囲を追加"),
+ ("Tag the selection", "選択範囲にタグを付ける"),
+ ("Separated by commas", "コンマで区切る"),
+ ("Show in the text", "テキスト内で表示"),
+ ("Show / hide text with this condition", "この条件のテキストの表示・非表示を切り替え"),
+ ("Spreads of up to 10 pages", "最大10ページのスプレッド"),
+ ("for", "に対して"),
+ ("About DesignCraft", "DesignCraft について"),
+ ("Accept All Changes", "すべての変更を採用"),
+ ("Activate Document", "ドキュメントをアクティブ化"),
+ ("Add Anchor Point", "アンカーポイントを追加"),
+ ("Add Document", "ドキュメントを追加"),
+ ("Add Hyphenation Exception", "ハイフネーションの例外を追加"),
+ ("Add Item", "項目を追加"),
+ ("Add Page to Spread", "スプレッドにページを追加"),
+ ("Add to Swatches", "スウォッチに追加"),
+ ("Allow Selected Spread to Shuffle", "選択したスプレッドのページの再配置を許可"),
+ ("Anchored Object Options…", "アンカー付きオブジェクトのオプション…"),
+ ("Anchored Objects", "アンカー付きオブジェクト"),
+ ("Appearance of Black", "黒の表現"),
+ ("Apply Attributes (Eyedropper)", "属性を適用 (スポイト)"),
+ ("Apply Cell Style", "セルスタイルを適用"),
+ ("Apply Color", "カラーを適用"),
+ ("Apply Condition", "条件を適用"),
+ ("Apply Parent to Pages…", "ページに親ページを適用…"),
+ ("ArtCraft App Page…", "ArtCraft アプリページ…"),
+ ("ArtCraft Website…", "ArtCraft ウェブサイト…"),
+ ("Attributes", "属性"),
+ ("Bevel and Emboss", "ベベルとエンボス"),
+ ("Buttons", "ボタン"),
+ ("Cell Options", "セルオプション"),
+ ("Cell Style Options…", "セルスタイルのオプション…"),
+ ("Change All (Object)", "すべてを置換 (オブジェクト)"),
+ ("Change Case", "大文字・小文字を変更"),
+ ("Changes", "変更"),
+ ("Character Formatting", "文字の書式"),
+ ("Clear Conveyor", "コンベヤーを消去"),
+ ("Clear Transformations", "変形を消去"),
+ ("Clipping Path…", "クリッピングパス…"),
+ ("Close Book", "ブックを閉じる"),
+ ("Close Library", "ライブラリを閉じる"),
+ ("Collect", "収集"),
+ ("Color Settings…", "カラー設定…"),
+ ("Community & Project Links", "コミュニティとプロジェクトのリンク"),
+ ("Conditions", "条件"),
+ ("Container", "コンテナー"),
+ ("Content", "内容"),
+ ("Contextual Task Bar", "状況に応じたタスクバー"),
+ ("Control", "コントロール"),
+ ("Convert Cell to Graphic Cell", "セルをグラフィックセルに変換"),
+ ("Convert Cell to Graphic Cell…", "セルをグラフィックセルに変換…"),
+ ("Convert Cell to Text Cell", "セルをテキストセルに変換"),
+ ("Convert Color", "カラーを変換"),
+ ("Convert Direction Point", "方向点を変換"),
+ ("Convert Note to Text", "注釈をテキストに変換"),
+ ("Convert Selection to Multi-State Object", "選択範囲を複数ステートのオブジェクトに変換"),
+ ("Convert Shape", "図形を変換"),
+ ("Convert Table to Text", "表をテキストに変換"),
+ ("Convert Text to Table", "テキストを表に変換"),
+ ("Convert to Button", "ボタンに変換"),
+ ("Convert to Form Field", "フォームフィールドに変換"),
+ ("Conveyor", "コンベヤー"),
+ ("Copy Link(s) To…", "リンクを指定先にコピー…"),
+ ("Corner Options…", "角のオプション…"),
+ ("Create Alternate Layout…", "別レイアウトを作成…"),
+ ("Create Frame", "フレームを作成"),
+ ("Create Merged Document…", "結合ドキュメントを作成…"),
+ ("Cross-Reference Formats", "相互参照の書式"),
+ ("Cross-References", "相互参照"),
+ ("Data Merge Fields", "データ結合フィールド"),
+ ("Define Cross-Reference Format…", "相互参照の書式を定義…"),
+ ("Define Lists…", "リストを定義…"),
+ ("Define Text Variable…", "テキスト変数を定義…"),
+ ("Delete All Guides on Spread", "スプレッド上のガイドをすべて削除"),
+ ("Delete Anchor Point", "アンカーポイントを削除"),
+ ("Delete Item", "項目を削除"),
+ ("Delete Text Variable", "テキスト変数を削除"),
+ ("Delete Workspace…", "ワークスペースを削除…"),
+ ("DesignCraft Website…", "DesignCraft ウェブサイト…"),
+ ("DesignCraft on GitHub…", "GitHub の DesignCraft…"),
+ ("Detach All Objects from Parent", "すべてのオブジェクトを親ページから分離"),
+ ("Directional Feather", "方向別ぼかし"),
+ ("Distribute", "均等に配置"),
+ ("Dock Panel", "パネルをドックに戻す"),
+ ("Document Endnote Options…", "ドキュメントの文末脚注オプション…"),
+ ("Document Footnote Options…", "ドキュメントの脚注オプション…"),
+ ("Document Preferences", "ドキュメントの環境設定"),
+ ("Document Presets", "ドキュメントプリセット"),
+ ("Document…", "ドキュメント…"),
+ ("Dynamic Spelling", "自動スペルチェック"),
+ ("Edit Parents", "親ページを編集"),
+ ("Edit Story", "ストーリーを編集"),
+ ("Edit in Story Editor", "ストーリーエディターで編集"),
+ ("Endnotes", "文末脚注"),
+ ("Entire Pasteboard", "ペーストボード全体"),
+ ("Erase", "消去"),
+ ("Export Book to PDF…", "ブックを PDF に書き出し…"),
+ ("Export EPUB (Fixed Layout)…", "EPUB を書き出し (固定レイアウト)…"),
+ ("Export EPUB (Reflowable)…", "EPUB を書き出し (リフロー)…"),
+ ("Export EPUB…", "EPUB を書き出し…"),
+ ("Export HTML…", "HTML を書き出し…"),
+ ("Export IDML…", "IDML を書き出し…"),
+ ("Export PDF (Interactive)…", "インタラクティブ PDF を書き出し…"),
+ ("Export Page as PNG…", "ページを PNG で書き出し…"),
+ ("Export Selection as Snippet", "選択範囲をスニペットとして書き出し"),
+ ("Export Tagging", "書き出し用タグ設定"),
+ ("Export Text…", "テキストを書き出し…"),
+ ("Extend Text Selection", "テキストの選択範囲を拡張"),
+ ("Fast Display", "高速表示"),
+ ("Find", "検索"),
+ ("Find Object", "オブジェクトを検索"),
+ ("Find by Script Label", "スクリプトラベルで検索"),
+ ("Find/Replace Font…", "フォントの検索と置換…"),
+ ("First Object Above", "最前面のオブジェクト"),
+ ("Fit Selection in Window", "選択範囲全体"),
+ ("Flattener Preview", "透明の統合プレビュー"),
+ ("Float Panel", "パネルをフローティング表示"),
+ ("Fonts in Document", "ドキュメント内のフォント"),
+ ("Footnotes", "脚注"),
+ ("Form Fields", "フォームフィールド"),
+ ("Frame Fitting Options…", "フレームのオブジェクトサイズの調整オプション…"),
+ ("Generate Index…", "索引を生成…"),
+ ("Generate QR Code…", "QR コードを生成…"),
+ ("Generate Static Caption", "静的キャプションを生成"),
+ ("Get Story", "ストーリーを取得"),
+ ("Get Table", "表を取得"),
+ ("Global Light…", "共通光源…"),
+ ("Go To Link", "リンクへ移動"),
+ ("Go To Source", "リンク元へ移動"),
+ ("Go to Footnote Reference", "脚注参照番号へ移動"),
+ ("Go to Page…", "ページへ移動…"),
+ ("Gradient Feather", "グラデーションぼかし"),
+ ("Gridify", "グリッド化"),
+ ("Group Transparency", "グループの透明"),
+ ("Guides", "ガイド"),
+ ("Hide Menu Item", "メニュー項目を隠す"),
+ ("Hide/Lock Others", "他のレイヤーを隠す・ロック"),
+ ("High Quality Display", "高品質表示"),
+ ("History", "履歴"),
+ ("Hyphenation Exceptions", "ハイフネーションの例外"),
+ ("Import Options: Styles", "読み込みオプション: スタイル"),
+ ("Index Page References", "索引のページ参照"),
+ ("Inner Glow", "内側の光彩"),
+ ("Inner Shadow", "内側のシャドウ"),
+ ("Insert Anchored Object…", "アンカー付きオブジェクトを挿入…"),
+ ("Insert Cross-Reference…", "相互参照を挿入…"),
+ ("Insert Endnote", "文末脚注を挿入"),
+ ("Insert Footnote", "脚注を挿入"),
+ ("Insert Math Expression…", "数式を挿入…"),
+ ("Insert Pages…", "ページを挿入…"),
+ ("Insert Variable", "変数を挿入"),
+ ("Inspect Document", "ドキュメントを調査"),
+ ("Interface Color Theme", "インターフェイスのカラーテーマ"),
+ ("Join the ArtCraft Discord…", "ArtCraft の Discord に参加…"),
+ ("Last Object Below", "最背面のオブジェクト"),
+ ("Library Contents", "ライブラリの内容"),
+ ("Library Items", "ライブラリの項目"),
+ ("Linked Stories", "リンクされたストーリー"),
+ ("Liquid Page Rule", "リキッドページルール"),
+ ("List Documents", "ドキュメントの一覧"),
+ ("List Styles", "スタイルの一覧"),
+ ("List Tools", "ツールの一覧"),
+ ("Load Place Cursor", "配置カーソルに読み込み"),
+ ("Make Compound Path", "複合パスを作成"),
+ ("Map Styles to Tags", "スタイルをタグに対応付け"),
+ ("Math Expression SVG", "数式の SVG"),
+ ("Media Options", "メディアのオプション"),
+ ("Menus…", "メニュー…"),
+ ("Merge Layers", "レイヤーを結合"),
+ ("Move Caret", "挿入位置を移動"),
+ ("Move Gap", "すき間を移動"),
+ ("Move Row", "行を移動"),
+ ("New Book", "新規ブック"),
+ ("New Cell Style…", "新規セルスタイル…"),
+ ("New Color Swatch…", "新規カラースウォッチ…"),
+ ("New Condition…", "新規条件…"),
+ ("New Guide", "新規ガイド"),
+ ("New Hyperlink Destination…", "新規ハイパーリンク先…"),
+ ("New Hyperlink…", "新規ハイパーリンク…"),
+ ("New Library", "新規ライブラリ"),
+ ("New Page Reference…", "新規ページ参照…"),
+ ("New Paragraph Style…", "新規段落スタイル…"),
+ ("New Stroke Style…", "新規線スタイル…"),
+ ("New Table Style…", "新規表スタイル…"),
+ ("Next Cell", "次のセル"),
+ ("Next Document", "次のドキュメント"),
+ ("Next Object Above", "1つ前面のオブジェクト"),
+ ("Next Object Below", "1つ背面のオブジェクト"),
+ ("Next Object in Group", "グループ内の次のオブジェクト"),
+ ("Notes", "注釈"),
+ ("Object Export Options", "オブジェクトの書き出しオプション"),
+ ("Object Export Options…", "オブジェクトの書き出しオプション…"),
+ ("Object Layer Options…", "オブジェクトのレイヤーオプション…"),
+ ("Object Layers", "オブジェクトのレイヤー"),
+ ("Object Liquid Settings", "オブジェクトのリキッド設定"),
+ ("Open Book", "ブックを開く"),
+ ("Open Bytes", "バイトデータを開く"),
+ ("Open IDML", "IDML を開く"),
+ ("Open Library", "ライブラリを開く"),
+ ("Outer Glow", "外側の光彩"),
+ ("Overprint Preview", "オーバープリントプレビュー"),
+ ("Override All Parent Page Items", "親ページの全項目をオーバーライド"),
+ ("Page Size", "ページサイズ"),
+ ("Page Transition", "ページ切り替え効果"),
+ ("Page Transitions", "ページ切り替え効果"),
+ ("Paragraph Formatting", "段落の書式"),
+ ("Paragraph Style Options…", "段落スタイルのオプション…"),
+ ("Paste Into", "内部にペースト"),
+ ("Paste without Formatting", "書式なしでペースト"),
+ ("Place", "配置"),
+ ("Place Caret", "挿入位置を指定"),
+ ("Place Item", "項目を配置"),
+ ("Place Snippet", "スニペットを配置"),
+ ("Place and Link", "配置してリンク"),
+ ("Place with Import Options…", "読み込みオプションを指定して配置…"),
+ ("Polygon Settings…", "多角形の設定…"),
+ ("Preflight Document", "ドキュメントのプリフライト"),
+ ("Previous Cell", "前のセル"),
+ ("Previous Document", "前のドキュメント"),
+ ("Previous Object in Group", "グループ内の前のオブジェクト"),
+ ("Primary Text Frame", "主テキストフレーム"),
+ ("Print Booklet…", "小冊子をプリント…"),
+ ("Printers", "プリンター"),
+ ("Proof Colors", "カラー校正"),
+ ("Proof Setup", "カラー校正設定"),
+ ("Quick Apply…", "クイック適用…"),
+ ("Recover Documents", "ドキュメントを復旧"),
+ ("Recovery Data", "復旧データ"),
+ ("Reject All Changes", "すべての変更を却下"),
+ ("Release Compound Path", "複合パスを解除"),
+ ("Remove All Local Overrides", "ローカルのオーバーライドをすべて解除"),
+ ("Remove Document", "ドキュメントを除外"),
+ ("Report an Issue…", "問題を報告…"),
+ ("Reset Shortcuts", "ショートカットをリセット"),
+ ("Reset Workspace", "ワークスペースをリセット"),
+ ("Resize", "サイズを変更"),
+ ("Rotate Spread", "スプレッドを回転"),
+ ("Run Script", "スクリプトを実行"),
+ ("Sample Document", "サンプルドキュメント"),
+ ("Satin", "サテン"),
+ ("Save Recovery Data", "復旧データを保存"),
+ ("Script Label", "スクリプトラベル"),
+ ("Select All on Layer", "レイヤー上のすべてを選択"),
+ ("Select Cells", "セルを選択"),
+ ("Select Column", "列を選択"),
+ ("Select Frame", "フレームを選択"),
+ ("Select Row", "行を選択"),
+ ("Select Table", "表を選択"),
+ ("Select Text", "テキストを選択"),
+ ("Select Tool", "ツールを選択"),
+ ("Select Word", "単語を選択"),
+ ("Selection Attributes", "選択範囲の属性"),
+ ("Separations Preview", "分版プレビュー"),
+ ("Serialize", "シリアライズ"),
+ ("Set Active Layer", "アクティブレイヤーを設定"),
+ ("Set Shortcut", "ショートカットを設定"),
+ ("Set Visibility / Lock", "表示・ロックを設定"),
+ ("Show Panel", "パネルを表示"),
+ ("Show/Hide Baseline Grid", "ベースライングリッドを表示・非表示"),
+ ("Show/Hide Frame Edges", "フレームの枠を表示・非表示"),
+ ("Show/Hide Guides", "ガイドを表示・非表示"),
+ ("Show/Hide Hidden Characters", "制御文字を表示・非表示"),
+ ("Show/Hide Panels", "パネルを表示・非表示"),
+ ("Show/Hide Panels Except Tools", "ツール以外のパネルを表示・非表示"),
+ ("Show/Hide Rulers", "定規を表示・非表示"),
+ ("Show/Hide Tagged Frames", "タグ付きフレームを表示・非表示"),
+ ("Show/Hide Text Threads", "テキストフレームの連結を表示・非表示"),
+ ("Smooth", "平滑化"),
+ ("Sort", "並べ替え"),
+ ("Split Cell Horizontally", "セルを横方向に分割"),
+ ("Split Cell Vertically", "セルを縦方向に分割"),
+ ("Split Path", "パスを分割"),
+ ("Step and Repeat…", "間隔を指定して複製…"),
+ ("Stroke Styles", "線スタイル"),
+ ("Structure", "構造"),
+ ("Style Source", "スタイルの同期元"),
+ ("Synchronize Book", "ブックを同期"),
+ ("Table Options", "表オプション"),
+ ("Table Panel", "表パネル"),
+ ("Table Style Options…", "表スタイルのオプション…"),
+ ("Table of Contents Entries", "目次の項目"),
+ ("Table of Contents…", "目次…"),
+ ("Text Anchors", "テキストアンカー"),
+ ("Text Variables", "テキスト変数"),
+ ("Toggle Normal/Preview", "通常表示・プレビューを切り替え"),
+ ("Toggle Selection", "選択範囲への追加・解除"),
+ ("Tools: Double Column", "ツール: 2列"),
+ ("Transform Again", "変形を再適用"),
+ ("Transform Panel", "変形パネル"),
+ ("Transform Values", "変形の値"),
+ ("Transparency Blend Space", "透明合成のカラースペース"),
+ ("Transparency Flattener Presets", "透明の統合プリセット"),
+ ("Type on a Path", "パス上のテキスト"),
+ ("Type on a Path Options…", "パス上のテキストのオプション…"),
+ ("Typical Display", "標準表示"),
+ ("UI Scaling", "UI の拡大・縮小"),
+ ("Update Link", "リンクを更新"),
+ ("User Dictionary Words", "ユーザー辞書の単語"),
+ ("User Dictionary…", "ユーザー辞書…"),
+ ("Zoom To", "ズーム倍率を指定"),
+ ("⇧", "⇧"),
+ ("⌃", "⌃"),
+ ("⌘", "⌘"),
+ ("⌥", "⌥"),
+ ("Accept Change", "変更を採用"),
+ ("Add Anchor", "アンカーポイントを追加"),
+ ("Add Selection to Article", "選択範囲をアーティクルに追加"),
+ ("Add Unnamed Colors", "名前のないカラーを追加"),
+ ("Add to Dictionary", "辞書に追加"),
+ ("Align Center", "中央揃え"),
+ ("Align Left", "左揃え"),
+ ("Align Right", "右揃え"),
+ ("Apply Character Style", "文字スタイルを適用"),
+ ("Apply Object Style", "オブジェクトスタイルを適用"),
+ ("Apply Paragraph Style", "段落スタイルを適用"),
+ ("Apply Table Style", "表スタイルを適用"),
+ ("Article Options", "アーティクルのオプション"),
+ ("Basic Feather", "基本ぼかし"),
+ ("Bold", "太字"),
+ ("Bring Forward", "1つ前面へ"),
+ ("Bring to Front", "最前面へ"),
+ ("Change", "変更"),
+ ("Character Style Options…", "文字スタイルのオプション…"),
+ ("Close Path", "パスを閉じる"),
+ ("Color Group Options", "カラーグループのオプション"),
+ ("Column Width", "列の幅"),
+ ("Condition Options…", "条件オプション…"),
+ ("Convert to Liquid Guide", "リキッドガイドに変換"),
+ ("Convert to Object", "オブジェクトに変換"),
+ ("Create Line", "線を作成"),
+ ("Create Path", "パスを作成"),
+ ("Decrease Point Size", "文字サイズを縮小"),
+ ("Delete Article", "アーティクルを削除"),
+ ("Delete Bookmark", "ブックマークを削除"),
+ ("Delete Endnote", "文末脚注を削除"),
+ ("Delete Footnote", "脚注を削除"),
+ ("Delete Guide", "ガイドを削除"),
+ ("Delete Hyperlink", "ハイパーリンクを削除"),
+ ("Delete List", "リストを削除"),
+ ("Delete Note", "注釈を削除"),
+ ("Delete Pages", "ページを削除"),
+ ("Delete Paragraph Style", "段落スタイルを削除"),
+ ("Delete Stroke Style", "線スタイルを削除"),
+ ("Delete Table", "表を削除"),
+ ("Delete Tag", "タグを削除"),
+ ("Delete Text", "テキストを削除"),
+ ("Distribute Columns Evenly", "列幅を均等化"),
+ ("Edit Endnote", "文末脚注を編集"),
+ ("Edit Note", "注釈を編集"),
+ ("Embed Link", "リンクを埋め込み"),
+ ("Fill", "塗り"),
+ ("Fill with Placeholder Text", "仮テキストで埋める"),
+ ("Flip", "反転"),
+ ("Hyperlink Options…", "ハイパーリンクのオプション…"),
+ ("Increase Point Size", "文字サイズを拡大"),
+ ("Insert Column Left", "左に列を挿入"),
+ ("Insert Column Right", "右に列を挿入"),
+ ("Insert Row Above", "上に行を挿入"),
+ ("Insert Row Below", "下に行を挿入"),
+ ("Italic", "斜体"),
+ ("Justify Left", "両端揃え (最終行は左揃え)"),
+ ("Layer Options…", "レイヤーのオプション…"),
+ ("Move", "移動"),
+ ("Move Anchors", "アンカーポイントを移動"),
+ ("Move Column", "列を移動"),
+ ("Move Guide", "ガイドを移動"),
+ ("Move Layer", "レイヤーを移動"),
+ ("Move Pages…", "ページを移動…"),
+ ("Move to Layer", "レイヤーへ移動"),
+ ("New Article", "新規アーティクル"),
+ ("New Bookmark", "新規ブックマーク"),
+ ("New Character Style…", "新規文字スタイル…"),
+ ("New Layer…", "新規レイヤー…"),
+ ("New Note", "新規注釈"),
+ ("New Object Style…", "新規オブジェクトスタイル…"),
+ ("New Parent…", "新規親ページ…"),
+ ("New Tag", "新規タグ"),
+ ("Opacity", "不透明度"),
+ ("Paste in Place", "同じ位置にペースト"),
+ ("Place Loaded Graphic", "読み込み済みのグラフィックを配置"),
+ ("Reject Change", "変更を却下"),
+ ("Release State to Objects", "ステートを解除してオブジェクトに戻す"),
+ ("Remove Hyphenation Exception", "ハイフネーションの例外を削除"),
+ ("Remove from Article", "アーティクルから削除"),
+ ("Rename", "名前を変更"),
+ ("Rename Bookmark", "ブックマークの名前を変更"),
+ ("Rotate", "回転"),
+ ("Row Height", "行の高さ"),
+ ("Scale", "拡大・縮小"),
+ ("Send Backward", "1つ背面へ"),
+ ("Send to Back", "最背面へ"),
+ ("Set Cross-Reference Format", "相互参照の書式を設定"),
+ ("Set Footnote Text", "脚注のテキストを設定"),
+ ("Set Story Text", "ストーリーのテキストを設定"),
+ ("Shear", "傾斜"),
+ ("Show All on Spread", "スプレッド内のすべてを表示"),
+ ("Show State", "ステートを表示"),
+ ("State Options", "ステートのオプション"),
+ ("Tag Frame", "フレームにタグを付与"),
+ ("Tag Text", "テキストにタグを付与"),
+ ("Track Changes", "変更を記録"),
+ ("Unlink", "リンクを解除"),
+ ("Unlock All on Spread", "スプレッド内のすべてのロックを解除"),
+ ("Update Index", "索引を更新"),
+ ("Update Table of Contents", "目次を更新"),
+ ("Bulleted and Numbered Lists", "箇条書きと番号付きリスト"),
+ ("Insert Special Character", "特殊文字を挿入"),
+ ("Insert White Space", "空白文字を挿入"),
+ ("Insert Break Character", "改行・改ページ文字を挿入"),
+ ("Hyperlinks Cross-References", "ハイパーリンク・相互参照"),
+ ("Document Footnote Options...", "ドキュメントの脚注オプション…"),
+ (" Close ", " 閉じる "),
+ (" New file ", " 新規ファイル "),
+ (" OK ", " OK "),
+ (" Open ", " 開く "),
+ (" Open sample magazine ", " サンプルマガジンを開く "),
+ ("(Top Level)", "(最上位)"),
+ ("(import)", "(読み込み)"),
+ ("Anchored Object", "アンカー付きオブジェクト"),
+ ("Apply Attributes (Eyedropper", "属性を適用 (スポイト"),
+ ("Baseline Grid", "ベースライングリッド"),
+ ("Bleed and Slug", "裁ち落としとスラッグ"),
+ ("Buttons act in interactive PDF export.", "ボタンは書き出したインタラクティブ PDF 内で機能します。"),
+ ("Captions", "キャプション"),
+ ("Change All (Object", "すべてを置換 (オブジェクト"),
+ ("Character Settings", "文字設定"),
+ ("Choose a link.", "リンクを選択してください。"),
+ ("Clipping Path", "クリッピングパス"),
+ (
+ "Collect objects with the Content Collector (B); place them with the Content Placer.",
+ "コンテンツ収集ツール (B) でオブジェクトを収集し、コンテンツ配置ツールで配置します。",
+ ),
+ ("Content Fitting", "内容のサイズ調整"),
+ ("Convert Cell Type", "セルの種類を変換"),
+ ("Copy Link(s", "リンクをコピー ("),
+ ("Create Alternate Layout", "別レイアウトを作成"),
+ ("Crop Amount", "トリミング量"),
+ ("Cross-Reference Format", "相互参照の書式"),
+ ("Delete Workspace", "ワークスペースを削除"),
+ ("DesignCraft", "DesignCraft"),
+ ("Display Performance", "表示品質"),
+ ("Document Grid", "ドキュメントグリッド"),
+ ("Document Recovery Data", "ドキュメント復旧データ"),
+ ("EPUB and HTML", "EPUB と HTML"),
+ ("Export", "書き出し"),
+ ("Export EPUB (Fixed Layout", "EPUB を書き出し (固定レイアウト"),
+ ("Export EPUB (Reflowable", "EPUB を書き出し (リフロー"),
+ ("Facing Pages", "見開きページ"),
+ ("GREP Styles", "GREP スタイル"),
+ ("Get help, share your layouts and shape what we build next.", "サポートを受け、レイアウトを共有し、今後の開発に参加できます。"),
+ ("Highlight", "強調表示"),
+ ("Hyperlinks & Cross-References", "ハイパーリンクと相互参照"),
+ ("Hyphens and Dashes", "ハイフンとダッシュ"),
+ ("Interactive", "インタラクティブ"),
+ ("Invert", "反転"),
+ ("Join the ArtCraft community", "ArtCraft コミュニティに参加"),
+ ("Keyboard Increments", "キーボードの移動量"),
+ ("Liquid guides", "リキッドガイド"),
+ ("Markers", "マーカー"),
+ ("More ArtCraft apps", "他の ArtCraft アプリ"),
+ ("Nested Line Styles", "入れ子行スタイル"),
+ ("Nested Styles", "入れ子スタイル"),
+ ("No articles: exports follow page order.", "アーティクルがありません。ページ順に書き出します。"),
+ ("No book open.", "開いているブックがありません。"),
+ ("No bookmarks. They become the PDF outline.", "ブックマークがありません。ブックマークは PDF のしおりになります。"),
+ ("No changes.", "変更がありません。"),
+ (
+ "No conditions. Create one, then select text and click it to apply.",
+ "条件がありません。条件を作成し、テキストを選択してから条件名をクリックすると適用できます。",
+ ),
+ ("No document open", "開いているドキュメントなし"),
+ ("No document.", "ドキュメントがありません。"),
+ (
+ "No hyperlinks. Select text or a frame, type a URL and click New.",
+ "ハイパーリンクがありません。テキストまたはフレームを選択し、URL を入力して「新規」をクリックします。",
+ ),
+ ("No library open.", "開いているライブラリがありません。"),
+ ("No notes in this document.", "このドキュメントには注釈がありません。"),
+ ("No placed graphics.", "配置された画像がありません。"),
+ ("No scripts. Add one below.", "スクリプトがありません。下で追加してください。"),
+ ("No text anchors in this document.", "このドキュメントにはテキストアンカーがありません。"),
+ ("OK changes all: text and paragraph/character styles.", "OK でテキストと段落スタイル・文字スタイルをすべて変更します。"),
+ ("OVERSET", "オーバーセット"),
+ ("Open source. No telemetry.", "オープンソースです。利用情報は収集しません。"),
+ ("Options", "オプション"),
+ ("Options for Black on RGB and Grayscale Devices", "RGB とグレースケール機器での黒のオプション"),
+ ("Other", "その他"),
+ ("Output", "出力"),
+ ("Page Numbering", "ノンブル"),
+ (
+ "Page layout for print and screen — fast, open and scriptable.",
+ "印刷・画面向けの高速なページレイアウトツールです。オープンで、スクリプトにも対応します。",
+ ),
+ ("Page · PPI", "ページ・PPI"),
+ ("Part of the ArtCraft suite of open creative tools.", "オープンな制作ツール群 ArtCraft の一部です。"),
+ ("Pasteboard Options", "ペーストボードのオプション"),
+ ("Paths", "パス"),
+ ("Place the insertion point in a table, or create one.", "表内に挿入位置を置くか、表を作成してください。"),
+ ("Plays in EPUB export; print and PDF show the poster.", "書き出した EPUB では再生され、プリントと PDF ではポスター画像が表示されます。"),
+ ("Quotation Marks", "引用符"),
+ ("Recently Used", "最近使用した項目"),
+ ("Replace With", "置換後"),
+ ("Right-click: stroke, groups", "右クリック: 線・グループ"),
+ ("Ruler Units", "定規の単位"),
+ ("Saves which bars show and where the panels are.", "バーの表示状態とパネルの位置を保存します。"),
+ (
+ "Select a placed video or sound (File › Place a .mp4, .mov, .mp3, .wav …).",
+ "配置した動画または音声を選択します (ファイル › 配置で .mp4、.mov、.mp3、.wav などを配置)。",
+ ),
+ ("Select an object to color its fill or stroke.", "塗りや線にカラーを設定するオブジェクトを選択してください。"),
+ ("Select an object to give its fill a gradient.", "塗りにグラデーションを適用するオブジェクトを選択してください。"),
+ ("Select an object to make it a button.", "ボタンにするオブジェクトを選択します。"),
+ (
+ "Select two or more objects (or a group) to make a multi-state object.",
+ "複数のステートを持つオブジェクトにするには、2つ以上のオブジェクト (またはグループ) を選択します。",
+ ),
+ ("Show", "表示"),
+ ("Smart Text Reflow", "テキスト量に応じたページの自動調整"),
+ ("Spelling", "スペルチェック"),
+ ("Start a new document", "新規ドキュメントを作成"),
+ ("Status", "状態"),
+ ("Style Mapping", "スタイルの対応付け"),
+ ("Style Name Conflicts", "スタイル名の競合"),
+ ("Symbols", "記号"),
+ ("System default", "システムのデフォルト"),
+ ("Table Dimensions", "表の行数・列数"),
+ ("Text Display Options", "テキスト表示オプション"),
+ ("The fill isn't a gradient.", "塗りはグラデーションではありません。"),
+ ("The library is empty. Select objects and Add Selection.", "ライブラリは空です。オブジェクトを選択して「選択範囲を追加」を押してください。"),
+ ("Transitions play in interactive PDF (full-screen mode).", "ページ切り替え効果はインタラクティブ PDF (全画面モード) で再生されます。"),
+ ("Type & Tables", "書式と表"),
+ ("Type Options", "書式オプション"),
+ (
+ "Type a word with ~ at each allowed break (ex~am~ple); without ~ the word is never hyphenated.",
+ "ハイフネーション可能な位置に ~ を入れて単語を入力します (例: ex~am~ple)。~ がなければ、その単語はハイフネーションされません。",
+ ),
+ ("User Dictionary (this document)", "ユーザー辞書 (このドキュメント)"),
+ ("Utilities", "ユーティリティ"),
+ ("Variable Font", "バリアブルフォント"),
+ ("Welcome to DesignCraft", "DesignCraft へようこそ"),
+ ("When Scaling", "拡大・縮小時"),
+ ("[Basic] (working) ▾", "[基本] (作業中) ▾"),
+ ("[Black] Overprint", "「黒」のオーバープリント"),
+ ("[No Group]", "[グループなし]"),
+ ("[None]", "[なし]"),
+ ("[None] — choose a colour on the ramp.", "[なし] — カラーバーでカラーを選択してください。"),
+ ("discord.gg/artcraft — help, feedback and show-and-tell", "discord.gg/artcraft — サポート・ご意見・作品紹介"),
+ ("⌥-click clears overrides", "⌥ を押しながらクリックするとオーバーライドを消去します"),
+ ("⚠ out of date", "⚠ 要更新"),
+ ("General", "一般"),
+ ("Basic Character Formats", "文字の基本書式"),
+ ("Indents and Spacing", "インデントと間隔"),
+ ("Hyphenation", "ハイフネーション"),
+ ("Justification", "両端揃え"),
+ ("Drop Caps and Nested Styles", "ドロップキャップと入れ子"),
+ ("GREP Style", "GREP スタイル"),
+ ("Character Color", "文字カラー"),
+ ("Numbering and Formatting", "番号付けと書式"),
+ ("Spacing Options", "間隔オプション"),
+ ("First Baseline", "最初のベースライン"),
+ ("Rule Above", "上の罫線"),
+ ("Numbering", "番号付け"),
+ ("Footnote Reference Number in Text", "本文中の脚注参照番号"),
+ ("Footnote Formatting", "脚注の書式"),
+ ("Span Footnotes Across Columns", "脚注を段にまたがって配置"),
+ ("Rule On", "罫線を表示"),
+ ("Ascent", "アセント"),
+ ("Cap Height", "大文字の高さ"),
+ ("Leading", "行送り"),
+ ("x Height", "x の高さ"),
+ ("Fixed", "固定"),
+ ("Never (continuous)", "振り直しなし (通し番号)"),
+ ("Page", "ページ"),
+ ("Spread", "スプレッド"),
+ ("Section", "セクション"),
+ ("Footnote Reference", "脚注参照番号"),
+ ("Footnote Text", "脚注テキスト"),
+ ("Both", "両方"),
+ ("Apply Superscript", "上付きを適用"),
+ ("Apply Subscript", "下付きを適用"),
+ ("Apply Normal", "通常の位置を適用"),
+ ("OpenType Superscript", "OpenType の上付き"),
+ ("through", "終端を含む"),
+ ("up to", "終端の手前まで"),
+ ("line", "行"),
+ ("lines", "行"),
+ ("sentences", "文"),
+ ("words", "単語"),
+ ("characters", "文字"),
+ ("letters", "英字"),
+ ("digits", "数字"),
+ ("tab", "タブ"),
+ ("forcedLineBreak", "強制改行"),
+ ("emSpace", "全角スペース"),
+ ("enSpace", "二分スペース"),
+ ("chars", "指定文字"),
+ ("[Automatic]", "[自動]"),
+ ("Tracking:", "トラッキング:"),
+ ("Right Indent:", "右インデント:"),
+ ("First Line Indent:", "最初の行のインデント:"),
+ ("Space Before:", "段落前の間隔:"),
+ ("Space After:", "段落後の間隔:"),
+ ("Words with at Least:", "単語の最小文字数:"),
+ ("After First:", "単語先頭の最小文字数:"),
+ ("Before Last:", "単語末尾の最小文字数:"),
+ ("Hyphen Limit:", "ハイフネーションの連続行数上限:"),
+ ("Word Spacing:", "単語間隔:"),
+ ("Letter Spacing:", "字間:"),
+ ("Glyph Scaling:", "グリフの拡大・縮小:"),
+ ("Insert Kashidas:", "カシーダの挿入:"),
+ ("In justified Arabic text", "両端揃えのアラビア語テキストで使用"),
+ ("Korean Line Breaks:", "韓国語の改行:"),
+ ("Between syllables (not only at spaces)", "音節間で改行 (スペース以外も可)"),
+ ("Binding:", "綴じ方向:"),
+ ("Ruby:", "ルビ:"),
+ ("Set over the selected text; empty removes it.", "選択したテキストの上にルビを付けます。空欄にすると削除します。"),
+ ("[Basic Paragraph]", "[基本段落]"),
+ ("[No Paragraph Style]", "[段落スタイルなし]"),
+ ("Align left", "左揃え"),
+ ("Align center", "中央揃え"),
+ ("Align right", "右揃え"),
+ ("Justify with last line aligned left", "両端揃え (最終行は左揃え)"),
+ ("Justify with last line aligned center", "両端揃え (最終行は中央揃え)"),
+ ("Justify with last line aligned right", "両端揃え (最終行は右揃え)"),
+ ("Justify all lines", "全行を両端揃え"),
+ ("Align towards spine", "ノド側揃え"),
+ ("Align away from spine", "小口側揃え"),
+ ("Create New Style from Selection", "選択範囲から新規スタイルを作成"),
+ ("No text selected", "テキストが選択されていません"),
+ ("Search", "検索"),
+ ("Essentials", "基本"),
+ ("Advanced", "詳細"),
+ ("Digital Publishing", "デジタル出版"),
+ ("Interactive for PDF", "PDF のインタラクティブ機能"),
+ ("Printing and Proofing", "プリントとカラー校正"),
+ ("Typography", "組版"),
+ ("Rulers & Grids", "定規とグリッド"),
+ ("No errors", "エラーなし"),
+ ("error", "エラー"),
+ ("errors", "エラー"),
+ ("Reset", "リセット"),
+ ("Right to Left", "右から左"),
+ ("Left to Right", "左から右"),
+ ("Portrait", "縦"),
+ ("Landscape", "横"),
+ ("Display All Blacks as Rich Black", "すべての黒をリッチブラックで表示"),
+ ("Display All Blacks Accurately", "すべての黒を正確に表示"),
+ ("Output All Blacks as Rich Black", "すべての黒をリッチブラックで出力"),
+ ("Output All Blacks Accurately", "すべての黒を正確に出力"),
+ ("Top Margin", "上マージン"),
+ ("Top of Page", "ページ上端"),
+ ("Paragraph Styles panel", "段落スタイルパネル"),
+ ("Character Styles panel", "文字スタイルパネル"),
+ ("Create new style", "新規スタイルを作成"),
+ ("Clear overrides", "個別の変更を消去"),
+ ("Apply", "適用"),
+ ("Accept", "採用"),
+ ("Reject", "却下"),
+ ("Add Selection", "選択範囲を追加"),
+ ("Align Stroke:", "線の位置:"),
+ ("Angle", "角度"),
+ ("Angle:", "角度:"),
+ ("Baseline", "ベースライン"),
+ ("Cap:", "線端:"),
+ ("Cell Text", "セル内のテキスト"),
+ ("Cell Type", "セルの種類"),
+ ("Composer", "組版方式"),
+ ("Convert to Text", "テキストに変換"),
+ ("Create New Layer", "新規レイヤーを作成"),
+ ("Delete DTD", "DTD を削除"),
+ ("Digits", "数字"),
+ ("Direction", "方向"),
+ ("Distance", "距離"),
+ ("Distribute ↔", "均等に配置 ↔"),
+ ("Distribute ↕", "均等に配置 ↕"),
+ ("Effects: Drop Shadow", "効果: ドロップシャドウ"),
+ ("Favorites", "お気に入り"),
+ ("First Spread", "最初のスプレッド"),
+ ("Gap Color:", "すき間のカラー:"),
+ ("Graphic…", "グラフィック…"),
+ ("Home", "ホーム"),
+ ("Join:", "角の形状:"),
+ ("Language", "言語"),
+ ("Last Spread", "最後のスプレッド"),
+ ("Load DTD…", "DTD を読み込み…"),
+ ("Location:", "位置:"),
+ ("Lock Guides", "ガイドをロック"),
+ ("Miter Limit:", "マイターの上限:"),
+ ("New Article from Selection", "選択範囲から新規アーティクルを作成"),
+ ("New Note at Cursor", "カーソル位置に新規注釈"),
+ ("New Swatch", "新規スウォッチ"),
+ ("New condition name", "新規条件名"),
+ ("New group…", "新規グループ…"),
+ ("New tag", "新規タグ"),
+ ("Next Spread", "次のスプレッド"),
+ ("Note text", "注釈のテキスト"),
+ ("Offset", "オフセット"),
+ ("Offsets", "オフセット"),
+ ("Paragraph alignment", "段落の揃え"),
+ ("Previous Spread", "前のスプレッド"),
+ ("Release to Objects", "個別のオブジェクトに分離"),
+ ("Remove", "削除"),
+ ("Rotate 90° Counterclockwise", "反時計回りに90°回転"),
+ ("Rotation Angle", "回転角度"),
+ ("Rows", "行数"),
+ ("Run", "実行"),
+ ("Scale X Percentage", "X 方向の拡大・縮小率"),
+ ("Scale Y Percentage", "Y 方向の拡大・縮小率"),
+ ("Search commands", "コマンドを検索"),
+ ("Search fonts", "フォントを検索"),
+ ("Search styles and commands…", "スタイルとコマンドを検索…"),
+ ("Search: character or U+code", "検索: 文字または U+ コード"),
+ ("Share", "共有"),
+ ("Shear X Angle", "X 方向の傾斜角度"),
+ ("Show Baseline Grid", "ベースライングリッドを表示"),
+ ("Show Document Grid", "ドキュメントグリッドを表示"),
+ ("Show Guides", "ガイドを表示"),
+ ("Stop Color:", "分岐点のカラー:"),
+ ("Style", "スタイル"),
+ ("Styles panel", "スタイルパネル"),
+ ("Text", "テキスト"),
+ ("The structure is valid.", "構造は有効です。"),
+ ("Toggle double column", "ツールバーの2列表示を切り替え"),
+ ("Tracking", "トラッキング"),
+ ("Untag Selection", "選択範囲のタグを解除"),
+ ("Validate", "検証"),
+ ("Weight", "太さ"),
+ ("Subtract", "前面で型抜き"),
+ ("Intersect", "共通部分を残す"),
+ ("Exclude Overlap", "重なりを除外"),
+ ("Minus Back", "背面で型抜き"),
+ ("Align left edges", "左端を揃える"),
+ ("Align horizontal centers", "水平方向の中心を揃える"),
+ ("Align right edges", "右端を揃える"),
+ ("Align top edges", "上端を揃える"),
+ ("Align vertical centers", "垂直方向の中心を揃える"),
+ ("Align bottom edges", "下端を揃える"),
+ ("Align to Selection", "選択範囲に揃える"),
+ ("Align to Key Object", "基準オブジェクトに揃える"),
+ ("Align to Margins", "マージンに揃える"),
+ ("Align to Page", "ページに揃える"),
+ ("Align to Spread", "スプレッドに揃える"),
+ ("No Text Wrap", "テキストの回り込みなし"),
+ ("Wrap Around Bounding Box", "外接矩形に沿って回り込み"),
+ ("Wrap Around Object Shape", "オブジェクトの形状に沿って回り込み"),
+ ("Jump Object", "オブジェクトを飛び越す"),
+ ("Jump to Next Column", "次の段へ移動"),
+ ("Bulleted List", "箇条書き"),
+ ("Numbered List", "番号付きリスト"),
+ ("UPPERCASE", "すべて大文字"),
+ ("Small Caps", "スモールキャップ"),
+ ("Normal", "通常"),
+ ("Butt", "平型"),
+ ("Round", "丸型"),
+ ("Projecting", "突出型"),
+ ("Miter", "マイター"),
+ ("Bevel", "面取り"),
+ ("Center", "中央"),
+ ("Start/End:", "始点/終点:"),
+ ("Left", "左"),
+ ("Right", "右"),
+ ("First", "最初"),
+ ("Last", "最後"),
+ ("Before", "段落前"),
+ ("After", "段落後"),
+ ("Shading", "背景色"),
+ ("Border", "囲み線"),
+ ("Overprint Fill", "塗りをオーバープリント"),
+ ("Overprint Stroke", "線をオーバープリント"),
+ ("Overprint Gap", "すき間をオーバープリント"),
+ ("Nonprinting", "プリントしない"),
+ ("At Least", "最小値"),
+ ("Exactly", "固定値"),
+ ("Justify", "両端揃え"),
+ ("Underline Options", "下線オプション"),
+ ("Strikethrough Options", "取り消し線オプション"),
+ ("Discretionary Ligatures", "任意の合字"),
+ ("Fractions", "分数"),
+ ("Ordinal", "序数"),
+ ("Swash", "スワッシュ"),
+ ("Titling Alternates", "見出し用代替グリフ"),
+ ("Contextual Alternates", "文脈に応じた代替グリフ"),
+ ("Slashed Zero", "斜線付きゼロ"),
+ ("Tabular Lining", "等幅ライニング数字"),
+ ("Proportional Oldstyle", "可変幅オールドスタイル数字"),
+ ("Proportional Lining", "可変幅ライニング数字"),
+ ("Tabular Oldstyle", "等幅オールドスタイル数字"),
+ ("Default Figure Style", "デフォルトの数字スタイル"),
+ ("Not a button", "ボタンなし"),
+ ("Go To Next Page", "次のページへ移動"),
+ ("Go To Previous Page", "前のページへ移動"),
+ ("Go To First Page", "最初のページへ移動"),
+ ("Go To Last Page", "最後のページへ移動"),
+ ("Go To Page", "ページへ移動"),
+ ("Go To URL", "URL へ移動"),
+ ("Not a form field", "フォームフィールドなし"),
+ ("Text Field", "テキストフィールド"),
+ ("Check Box", "チェックボックス"),
+ ("Radio Button", "ラジオボタン"),
+ ("Combo Box", "コンボボックス"),
+ ("List Box", "リストボックス"),
+ ("Signature", "署名"),
+ ("Resize width", "幅を変更"),
+ ("Resize height", "高さを変更"),
+ ("Pin top", "上に固定"),
+ ("Pin bottom", "下に固定"),
+ ("Pin left", "左に固定"),
+ ("Pin right", "右に固定"),
+ ("Inward", "内向き"),
+ ("Metrics", "メトリクス"),
+ ("Optical", "オプティカル"),
+ ("Applies to fill", "適用先: 塗り"),
+ ("Applies to text", "適用先: テキスト"),
+ ("Gray", "グレー"),
+ ("Linear", "線形"),
+ ("Radial", "放射状"),
+ ("Arabic", "アラビア語"),
+ ("Beveled Rectangle", "角を面取りした長方形"),
+ ("Chinese", "中国語"),
+ ("Constrain proportions for width and height", "幅と高さの比率を固定"),
+ ("Corner", "角"),
+ ("Custom", "カスタム"),
+ ("Czech", "チェコ語"),
+ ("Danish", "デンマーク語"),
+ ("Dashed", "破線"),
+ ("Dotted", "点線"),
+ ("Dutch: 2005 Reform", "オランダ語: 2005年正書法改訂"),
+ ("Embedded", "埋め込み済み"),
+ ("English: Canadian", "英語: カナダ"),
+ ("English: UK", "英語: 英国"),
+ ("English: USA", "英語: 米国"),
+ ("Farsi", "ペルシア語"),
+ ("Finnish", "フィンランド語"),
+ ("Fit Frame to Content", "フレームを内容に合わせる"),
+ ("French", "フランス語"),
+ ("French: Canadian", "フランス語: カナダ"),
+ ("German: 2006 Reform", "ドイツ語: 2006年正書法改訂"),
+ ("German: Swiss 2006 Reform", "ドイツ語: スイス 2006年正書法改訂"),
+ ("Greek", "ギリシャ語"),
+ ("Hebrew", "ヘブライ語"),
+ ("Hindi", "ヒンディー数字"),
+ ("Hungarian", "ハンガリー語"),
+ ("Inverse Rounded Rectangle", "内向き角丸長方形"),
+ ("Italian", "イタリア語"),
+ ("Japanese", "日本語"),
+ ("Korean", "韓国語"),
+ ("Make all settings the same", "すべての設定を統一"),
+ ("Native", "言語固有の数字"),
+ ("Norwegian: Bokmål", "ノルウェー語: ブークモール"),
+ ("Orthogonal Line", "直交線"),
+ ("Polish", "ポーランド語"),
+ ("Portuguese", "ポルトガル語"),
+ ("Portuguese: Brazilian", "ポルトガル語: ブラジル"),
+ ("Rounded Rectangle", "角丸長方形"),
+ ("Russian", "ロシア語"),
+ ("Solid", "実線"),
+ ("Spanish", "スペイン語"),
+ ("Story Editor", "ストーリーエディター"),
+ ("Swedish", "スウェーデン語"),
+ ("Thick - Thick", "太線・太線"),
+ ("Thick - Thin", "太線・細線"),
+ ("Thin - Thick", "細線・太線"),
+ ("Thin - Thin", "細線・細線"),
+ ("Thin - Thick - Thin", "細線・太線・細線"),
+ ("Thick - Thin - Thick", "太線・細線・太線"),
+ ("Wavy", "波線"),
+ ("Straight Hash", "直線ハッチ"),
+ ("Stripes", "縞模様"),
+ ("Triangle", "三角形"),
+ ("Turkish", "トルコ語"),
+ ("Ukrainian", "ウクライナ語"),
+ ("Cell Style", "セルスタイル"),
+ ("Table Style", "表スタイル"),
+ ("Col ←", "列 ←"),
+ ("Col →", "列 →"),
+ ("Row ↑", "行 ↑"),
+ ("Row ↓", "行 ↓"),
+ ("Hide Others", "他のレイヤーを隠す"),
+ ("Lock Others", "他のレイヤーをロック"),
+ ("Show All Layers", "すべてのレイヤーを表示"),
+ ("Unlock", "ロック解除"),
+ ("Unlock All Layers", "すべてのレイヤーのロック解除"),
+ ("Added", "追加済み"),
+ ("Deleted", "削除済み"),
+ ("Guide-based", "ガイド基準"),
+ ("Loop", "繰り返し再生"),
+ ("Object-based", "オブジェクト基準"),
+ ("Off", "オフ"),
+ ("Play on Page Load", "ページ読み込み時に再生"),
+ ("Re-center", "中央に再配置"),
+ ("Show Controls", "再生コントロールを表示"),
+ ("Signature Field", "署名フィールド"),
+ ("Select text to apply a condition", "条件を適用するテキストを選択してください"),
+ ("Blinds", "ブラインド"),
+ ("Box", "ボックス"),
+ ("Comb", "くし状"),
+ ("Cover", "カバー"),
+ ("Dissolve", "ディゾルブ"),
+ ("Fade", "フェード"),
+ ("Push", "押し出し"),
+ ("Split", "分割"),
+ ("Uncover", "アンカバー"),
+ ("Wipe", "ワイプ"),
+ ("Simple", "シンプル"),
+ ("Simple - Wide", "シンプル (幅広)"),
+ ("Triangle - Wide", "三角形 (幅広)"),
+ ("Barbed", "かぎ付き"),
+ ("Curved", "曲線"),
+ ("Circle", "円"),
+ ("Circle - Solid", "塗りつぶしの円"),
+ ("Square", "正方形"),
+ ("Square - Solid", "塗りつぶしの正方形"),
+ ("Bar", "バー"),
+ ("Fit Text", "テキストに合わせる"),
+ ("Preview (W)", "プレビュー (W)"),
+ ("Normal (W)", "通常 (W)"),
+ ("objects", "オブジェクト"),
+ ("pages", "ページ"),
+ ("stories", "ストーリー"),
+ ("items", "項目"),
+ ("warnings", "警告"),
+ ("Render", "描画"),
+ ("Set", "セット"),
+ ("unused", "未使用"),
+ ("⚠ Missing", "⚠ リンク切れ"),
+ ("▲ Modified", "▲ 変更済み"),
+ ("(Text Color)", "(テキストカラー)"),
+ ("{count} glyphs — click to insert at the text cursor", "{count}グリフ — クリックでテキストカーソル位置に挿入"),
+ ("{pages} Pages in {spreads} Spreads", "{pages}ページ ({spreads}スプレッド)"),
+ (" (auto)", " (自動)"),
+ ("Absolute Numbering (instead of Section Numbering)", "通しノンブル (セクションノンブルの代わり)"),
+ ("Add and remove pages as the primary text frame's story grows and shrinks", "主テキストフレームのストーリーの長さに応じてページを追加・削除"),
+ ("Added Text (highlighted)", "追加されたテキスト (強調表示)"),
+ ("Adjust Layout (objects follow the new page size)", "レイアウトを調整 (新しいページサイズに合わせてオブジェクトを調整)"),
+ ("Auto-Fit", "自動サイズ調整"),
+ ("Case sensitive", "大文字と小文字を区別"),
+ ("Custom Tracking/Kerning", "カスタムのトラッキング・カーニング"),
+ ("Dimensions Include Stroke Weight", "寸法に線の太さを含める"),
+ ("Enable Autocorrect", "自動修正を有効化"),
+ ("Enable Dynamic Spelling (underline misspelled words)", "自動スペルチェックを有効化 (スペルミスのある単語に下線)"),
+ ("Grids in Back", "グリッドを背面に表示"),
+ ("H&J Violations", "H&J の制約違反"),
+ ("Include Bleed", "裁ち落としを含める"),
+ ("Include Stroke Weight", "線の太さを含める"),
+ ("Join our Discord", "Discord に参加"),
+ ("Keep Violations", "段落保持の制約違反"),
+ ("Overprint [Black] Swatch at 100%", "100% の「黒」スウォッチをオーバープリント"),
+ ("Printer's Marks", "プリント用マーク"),
+ ("Remove Styles and Formatting from Text and Tables", "テキストと表からスタイルと書式を削除"),
+ ("Ruby", "ルビ"),
+ ("Show Note Anchors in Layout View", "レイアウト表示で注釈アンカーを表示"),
+ ("Spreads", "スプレッド"),
+ ("Substituted Fonts", "代替フォント"),
+ ("Transformations are Totals", "変形の累積値を表示"),
+ ("Use Black Point Compensation", "黒点補正を使用"),
+ ("Use Typographer's Quotes", "組版用の引用符を使用"),
+ ("Whole word", "単語全体に一致"),
+ ("Interface", "インターフェイス"),
+ ("Advanced Type", "高度な書式"),
+ ("Composition", "組版"),
+ ("Show Font Names in English", "フォント名を英語で表示"),
+ ("Missing Glyphs", "欠落グリフ"),
+ ("Draw Missing Glyphs from Fallback Fonts", "欠落グリフを代替フォントで表示"),
+ ("Units & Increments", "単位と増分"),
+ ("Grids", "グリッド"),
+ ("Guides & Pasteboard", "ガイドとペーストボード"),
+ ("Dictionary", "辞書"),
+ ("Autocorrect", "自動修正"),
+ ("Story Editor Display", "ストーリーエディターの表示"),
+ ("File Handling", "ファイルの取り扱い"),
+ ("Formatting affects container (J)", "書式をフレームに適用 (J)"),
+ ("Formatting affects text (J)", "書式をテキストに適用 (J)"),
+ ("Apply None", "カラーなしを適用"),
+ ("Height Only", "高さのみ"),
+ ("Width Only", "幅のみ"),
+ ("Height and Width", "高さと幅"),
+ ("Height only", "高さのみ"),
+ ("Width only", "幅のみ"),
+ ("Height and width", "高さと幅"),
+ ("Version", "バージョン"),
+ ("{count} characters don't fit", "{count}文字が収まりません"),
+ ("document", "ドキュメント"),
+ ("story", "ストーリー"),
+ ("selection", "選択範囲"),
+ ("top", "上"),
+ ("center", "中央揃え"),
+ ("bottom", "下"),
+ ("justify", "両端揃え"),
+ ("text", "テキスト"),
+ ("graphic", "グラフィック"),
+ ("unassigned", "未指定"),
+ ("Perceptual", "知覚的"),
+ ("Relative Colorimetric", "相対比色"),
+ ("Saturation", "彩度"),
+ ("Absolute Colorimetric", "絶対比色"),
+ ("No conflicts", "競合なし"),
+ ("Page (1–{count}):", "ページ (1–{count}):"),
+ ("Fonts in Document: {count} Missing: {missing}", "ドキュメント内のフォント: {count} 欠落: {missing}"),
+ ("No data source", "データソースがありません"),
+ ("Select Data Source…", "データソースを選択…"),
+ ("Data Merge", "データ結合"),
+ ("Preview", "プレビュー"),
+ ("Previous", "前へ"),
+ ("Next", "次へ"),
+ ("Record", "レコード"),
+ ("Merge options", "結合オプション"),
+ ("All records", "すべてのレコード"),
+ ("One record", "1件のレコード"),
+ ("Range", "範囲"),
+ ("Records per page", "ページあたりのレコード"),
+ ("Single record", "単一レコード"),
+ ("Multiple records", "複数レコード"),
+ ("Rows first", "行を優先"),
+ ("Columns first", "列を優先"),
+ ("Column spacing", "列の間隔"),
+ ("Row spacing", "行の間隔"),
+ ("Fit proportionally", "縦横比を保持して合わせる"),
+ ("Fill proportionally", "縦横比を保持して塗りつぶす"),
+ ("Fit content to frame", "内容をフレームに合わせる"),
+ ("Center in frame", "フレーム内で中央"),
+ ("Link images", "画像をリンク"),
+ ("Limit", "上限"),
+ ("Bind to frame", "フレームにバインド"),
+ ("Insert field", "フィールドを挿入"),
+ ("Pass csv, rows, or bytes from the control channel.", "制御チャネルから csv、rows または bytes を渡します。"),
+ ("Panels", "パネル"),
+ ("Next Page", "次のページ"),
+ ("Previous Page", "前のページ"),
+ ("First Page", "最初のページ"),
+ ("Last Page", "最後のページ"),
+ ("Object Styles", "オブジェクトスタイル"),
+ ("Row", "行"),
+ ("Column", "列"),
+ ("Rulers", "定規"),
+ ("Hide Guides", "ガイドを隠す"),
+ ("Hide Rulers", "定規を隠す"),
+ ("Extras", "表示補助"),
+ ("Colour", "カラー"),
+ ("Transparency", "透明"),
+ ("Community", "コミュニティ"),
+ ("Join the community", "コミュニティに参加"),
+ ("OK", "OK"),
+ ("Done", "完了"),
+ ("Orientation", "向き"),
+ ("Units", "単位"),
+ ("Number of Pages", "ページ数"),
+ ("Start Page #", "開始ページ番号"),
+ ("Slug", "スラッグ"),
+ ("Binding", "綴じ方向"),
+ ("Rotation", "回転"),
+ ("Flex Layout", "フレックスレイアウト"),
+ ("Auto Size", "自動サイズ"),
+ ("Kerning", "カーニング"),
+ ("Alignment", "揃え"),
+ ("Indent", "インデント"),
+ ("Space Before", "段落前の間隔"),
+ ("Space After", "段落後の間隔"),
+ ("New Layer", "新規レイヤー"),
+ ("Colour Picker", "カラーピッカー"),
+ ("Find What", "検索する文字列"),
+ ("Change To", "置換後の文字列"),
+ ("Selection", "選択範囲"),
+ ("All Documents", "すべてのドキュメント"),
+ ("On", "オン"),
+ // Labels the code passes to `tr` that the other tables have no row for: command labels without their
+ // trailing `…` (the shortcut list), menu paths (the menu list), special characters, profile names and a
+ // few field labels.
+ ("Anchored Object Options", "アンカー付きオブジェクトのオプション"),
+ ("Apply Parent to Pages", "ページに親ページを適用"),
+ ("ArtCraft App Page", "ArtCraft アプリページ"),
+ ("ArtCraft Website", "ArtCraft ウェブサイト"),
+ ("Check Spelling", "スペルチェック"),
+ ("Corner Options", "角のオプション"),
+ ("Create Guides", "ガイドを作成"),
+ ("Define Cross-Reference Format", "相互参照の書式を定義"),
+ ("Define Lists", "リストを定義"),
+ ("Define Text Variable", "テキスト変数を定義"),
+ ("DesignCraft on GitHub", "GitHub の DesignCraft"),
+ ("DesignCraft Website", "DesignCraft ウェブサイト"),
+ ("Document Endnote Options", "ドキュメントの文末脚注オプション"),
+ ("Document Footnote Options", "ドキュメントの脚注オプション"),
+ ("Export EPUB", "EPUB を書き出し"),
+ ("Export EPUB (Fixed Layout)", "EPUB を書き出し (固定レイアウト)"),
+ ("Export EPUB (Reflowable)", "EPUB を書き出し (リフロー)"),
+ ("Export HTML", "HTML を書き出し"),
+ ("Export IDML", "IDML を書き出し"),
+ ("Export Page as PNG", "ページを PNG で書き出し"),
+ ("Export PDF", "PDF を書き出し"),
+ ("Export PDF (Interactive)", "インタラクティブ PDF を書き出し"),
+ ("Export Text", "テキストを書き出し"),
+ ("Export XML", "XML を書き出し"),
+ ("Generate Index", "索引を生成"),
+ ("Global Light", "共通光源"),
+ ("Graphic", "グラフィック"),
+ ("Import XML", "XML を読み込み"),
+ ("Insert Anchored Object", "アンカー付きオブジェクトを挿入"),
+ ("Insert Cross-Reference", "相互参照を挿入"),
+ ("Insert Math Expression", "数式を挿入"),
+ ("Insert Pages", "ページを挿入"),
+ ("Join the ArtCraft Discord", "ArtCraft の Discord に参加"),
+ ("Load DTD", "DTD を読み込み"),
+ ("Load Swatches", "スウォッチを読み込み"),
+ ("Margins and Columns", "マージン・段組"),
+ ("Menus", "メニュー"),
+ ("Move Pages", "ページを移動"),
+ ("New Condition", "新規条件"),
+ ("New Hyperlink", "新規ハイパーリンク"),
+ ("New Hyperlink Destination", "新規ハイパーリンク先"),
+ ("New Page Reference", "新規ページ参照"),
+ ("New Parent", "新規親ページ"),
+ ("New Stroke Style", "新規線スタイル"),
+ ("Numbering & Section Options", "ノンブルとセクション設定"),
+ ("Package", "パッケージ"),
+ ("Place with Import Options", "読み込みオプションを指定して配置"),
+ ("Print Booklet", "小冊子をプリント"),
+ ("Quick Apply", "クイック適用"),
+ ("Relink", "再リンク"),
+ ("Relink to Folder", "フォルダーに再リンク"),
+ ("Report an Issue", "問題を報告"),
+ ("Save a Copy", "コピーを保存"),
+ ("Save As", "別名で保存"),
+ ("Save Swatches for Exchange", "交換用スウォッチを保存"),
+ ("Step and Repeat", "間隔を指定して複製"),
+ ("Table of Contents", "目次"),
+ ("Type on a Path Options", "パス上のテキストのオプション"),
+ ("Edit/Interface Language", "編集/インターフェイスの言語"),
+ ("Edit/Spelling", "編集/スペルチェック"),
+ ("Edit/Transparency Blend Space", "編集/透明合成のカラースペース"),
+ ("Edit/Transparency Flattener Presets", "編集/透明の統合プリセット"),
+ ("File/Export", "ファイル/書き出し"),
+ ("Layout/Index", "レイアウト/索引"),
+ ("Layout/Pages", "レイアウト/ページ"),
+ ("Object/Anchored Object", "オブジェクト/アンカー付きオブジェクト"),
+ ("Object/Arrange", "オブジェクト/重ね順"),
+ ("Object/Captions", "オブジェクト/キャプション"),
+ ("Object/Clipping Path", "オブジェクト/クリッピングパス"),
+ ("Object/Content", "オブジェクト/内容"),
+ ("Object/Convert Shape", "オブジェクト/図形を変換"),
+ ("Object/Effects", "オブジェクト/効果"),
+ ("Object/Fitting", "オブジェクト/オブジェクトサイズの調整"),
+ ("Object/Pathfinder", "オブジェクト/パスファインダー"),
+ ("Object/Paths", "オブジェクト/パス"),
+ ("Object/Select", "オブジェクト/選択"),
+ ("Object/Transform", "オブジェクト/変形"),
+ ("Object/Transform Again", "オブジェクト/変形を再適用"),
+ ("Table/Convert Cell Type", "表/セルの種類を変換"),
+ ("Table/Delete", "表/削除"),
+ ("Table/Insert", "表/挿入"),
+ ("Table/Select", "表/選択"),
+ ("Type/Bulleted and Numbered Lists", "書式/箇条書きと番号付きリスト"),
+ ("Type/Change Case", "書式/大文字・小文字を変更"),
+ ("Type/Hyperlinks & Cross-References", "書式/ハイパーリンクと相互参照"),
+ ("Type/Insert Break Character", "書式/改行・改ページ文字を挿入"),
+ ("Type/Insert Special Character/Hyphens and Dashes", "書式/特殊文字を挿入/ハイフンとダッシュ"),
+ ("Type/Insert Special Character/Markers", "書式/特殊文字を挿入/マーカー"),
+ ("Type/Insert Special Character/Other", "書式/特殊文字を挿入/その他"),
+ ("Type/Insert Special Character/Quotation Marks", "書式/特殊文字を挿入/引用符"),
+ ("Type/Insert Special Character/Symbols", "書式/特殊文字を挿入/記号"),
+ ("Type/Insert White Space", "書式/空白文字を挿入"),
+ ("Type/Proof Setup", "書式/カラー校正設定"),
+ ("Type/Rotate Spread", "書式/スプレッドを回転"),
+ ("Type/Separations Preview", "書式/分版プレビュー"),
+ ("Type/Story Direction", "書式/組み方向"),
+ ("Type/Text Variables", "書式/テキスト変数"),
+ ("Type/Track Changes", "書式/変更を記録"),
+ ("Type/Type on a Path", "書式/パス上のテキスト"),
+ ("View/Display Performance", "表示/表示品質"),
+ ("View/Grids & Guides", "表示/グリッドとガイド"),
+ ("Window/Arrange", "ウィンドウ/配置"),
+ ("Window/Interactive", "ウィンドウ/インタラクティブ"),
+ ("Window/Interface Color Theme", "ウィンドウ/インターフェイスのカラーテーマ"),
+ ("Window/Output", "ウィンドウ/出力"),
+ ("Window/Panels", "ウィンドウ/パネル"),
+ ("Window/Type & Tables", "ウィンドウ/書式と表"),
+ ("Window/Utilities", "ウィンドウ/ユーティリティ"),
+ ("90° CCW", "90° 反時計回り"),
+ ("90° CW", "90° 時計回り"),
+ ("a, b, c, d...", "a, b, c, d..."),
+ ("A, B, C, D...", "A, B, C, D..."),
+ ("Alpha Channel", "アルファチャンネル"),
+ ("Black", "ブラック"),
+ ("Bleed:", "裁ち落とし:"),
+ ("Bottom:", "下:"),
+ ("Bullet Character", "箇条書き記号"),
+ ("Clear Rotation", "回転を解除"),
+ ("Closed Path", "閉じたパス"),
+ ("CMYK:", "CMYK:"),
+ ("Color Blindness – Deuteranopia", "色覚シミュレーション (2型2色覚)"),
+ ("Color Blindness – Protanopia", "色覚シミュレーション (1型2色覚)"),
+ ("Column Break", "改段"),
+ ("Columns:", "段:"),
+ ("Conditional Text", "条件付きテキスト"),
+ ("Copyright Symbol", "著作権記号"),
+ ("Current Page Number", "現在のページ番号"),
+ ("Cyan", "シアン"),
+ ("Dark", "暗い"),
+ ("Delete Type from Path", "パス上のテキストを削除"),
+ ("Detect Edges", "輪郭を検出"),
+ ("Discretionary Hyphen", "任意ハイフン"),
+ ("Document CMYK", "ドキュメントの CMYK"),
+ ("Document RGB", "ドキュメントの RGB"),
+ ("Double Left Quotation Marks", "左二重引用符"),
+ ("Double Right Quotation Marks", "右二重引用符"),
+ ("Ellipsis", "三点リーダー"),
+ ("Em Dash", "全角ダッシュ"),
+ ("Em Space", "全角スペース"),
+ ("En Dash", "二分ダッシュ"),
+ ("En Space", "二分スペース"),
+ ("Figure Space", "数字幅スペース"),
+ ("Flush Space", "行末調整スペース"),
+ ("Forced Line Break", "強制改行"),
+ ("Frame Break", "改フレーム"),
+ ("Generate Live Caption", "ライブキャプションを生成"),
+ ("GREP", "GREP"),
+ ("Hair Space", "極細スペース"),
+ ("High Contrast", "高コントラスト"),
+ ("High Resolution", "高解像度"),
+ ("Horizontal:", "水平方向:"),
+ ("i, ii, iii, iv...", "i, ii, iii, iv..."),
+ ("I, II, III, IV...", "I, II, III, IV..."),
+ ("Indent to Here", "ここまでインデント"),
+ ("Ink Limit 300%", "インキ量の上限 300%"),
+ ("Internet Standard RGB (sRGB)", "インターネット標準 RGB (sRGB)"),
+ ("Left:", "左:"),
+ ("Legacy Macintosh RGB", "旧 Macintosh RGB"),
+ ("Light", "明るい"),
+ ("Low Resolution", "低解像度"),
+ ("lowercase", "すべて小文字"),
+ ("Magenta", "マゼンタ"),
+ ("Margins:", "マージン:"),
+ ("Medium Dark", "やや暗い"),
+ ("Medium Light", "やや明るい"),
+ ("Medium Resolution", "中解像度"),
+ ("Monitor RGB", "モニター RGB"),
+ ("Next Page Number", "次のページ番号"),
+ ("Nonbreaking Hyphen", "改行しないハイフン"),
+ ("Nonbreaking Space", "改行しないスペース"),
+ ("None (keep transparency)", "なし (透明を保持)"),
+ ("Open Path", "開いたパス"),
+ ("Page Break", "改ページ"),
+ ("Paragraph Return", "改段落"),
+ ("Paragraph Symbol", "段落記号"),
+ ("Picas", "パイカ"),
+ ("Preflight", "プリフライト"),
+ ("Previous Page Number", "前のページ番号"),
+ ("Punctuation Space", "句読点幅スペース"),
+ ("Quarter Space", "四分スペース"),
+ ("Registered Trademark Symbol", "登録商標記号"),
+ ("RGB:", "RGB:"),
+ ("Right Indent Tab", "右インデントタブ"),
+ ("Right:", "右:"),
+ ("Scripts", "スクリプト"),
+ ("Section Marker", "セクションマーカー"),
+ ("Section Symbol", "節記号"),
+ ("Sentence case", "文頭を大文字に"),
+ ("Simulate Paper Color", "用紙カラーをシミュレート"),
+ ("Single Left Quotation Mark", "左一重引用符"),
+ ("Single Right Quotation Mark", "右一重引用符"),
+ ("Sixth Space", "六分スペース"),
+ ("Slug:", "スラッグ:"),
+ ("Smart Guide Options", "スマートガイドのオプション"),
+ ("Story Column Direction", "ストーリーの段の方向"),
+ ("Straight Double Quotation Marks", "直線型二重引用符"),
+ ("Straight Single Quotation Mark (Apostrophe)", "直線型一重引用符 (アポストロフィー)"),
+ ("Subscript:", "下付き:"),
+ ("Superscript:", "上付き:"),
+ ("Tab", "タブ"),
+ ("Thin Space", "細いスペース"),
+ ("Third Space", "三分スペース"),
+ ("Title Case", "各単語の先頭を大文字に"),
+ ("Top:", "上:"),
+ ("Trademark Symbol", "商標記号"),
+ ("Transform Again Individually", "個別に再変形"),
+ ("Transform Sequence Again", "一連の変形を再実行"),
+ ("Transform Sequence Again Individually", "一連の変形を個別に再実行"),
+ ("Unassigned", "未指定"),
+ ("URL:", "URL:"),
+ ("Vertical:", "垂直方向:"),
+ ("Working CMYK", "作業用 CMYK"),
+ ("Yellow", "イエロー"),
+];
+
+#[cfg(test)]
+mod tests {
+ use std::collections::HashSet;
+
+ use super::TABLE;
+ use crate::i18n::tr;
+
+ fn placeholders(text: &str) -> Vec<&str> {
+ let mut found = Vec::new();
+ let mut rest = text;
+ while let Some(start) = rest.find('{') {
+ let Some(len) = rest[start..].find('}') else { break };
+ found.push(&rest[start..start + len + 1]);
+ rest = &rest[start + len + 1..];
+ }
+ found.sort_unstable();
+ found
+ }
+
+ #[test]
+ fn rows_are_unique_and_complete() {
+ let mut seen = HashSet::new();
+ for (en, ja) in TABLE {
+ assert!(seen.insert(*en), "duplicate {en}");
+ assert!(!ja.is_empty(), "{en}");
+ }
+ }
+
+ #[test]
+ fn rows_keep_placeholders_padding_and_endings() {
+ let ellipsis = |text: &str| text.trim_end().ends_with('…') || text.trim_end().ends_with("...");
+ for (en, ja) in TABLE {
+ assert_eq!(placeholders(en), placeholders(ja), "placeholders of {en}");
+ assert_eq!(en.len() - en.trim_start().len(), ja.len() - ja.trim_start().len(), "leading space of {en}");
+ assert_eq!(en.len() - en.trim_end().len(), ja.len() - ja.trim_end().len(), "trailing space of {en}");
+ assert_eq!(ellipsis(en), ellipsis(ja), "ellipsis of {en}");
+ // The numbering samples (`a, b, c, d...`) stay as they are; everything else uses `…`.
+ assert!(!ja.contains("...") || ja == en, "ASCII ellipsis in {en}");
+ assert_eq!(en.trim_end().ends_with(':'), ja.trim_end().ends_with(':'), "colon of {en}");
+ }
+ }
+
+ #[test]
+ fn lookup_uses_this_table_and_leaves_unknown_text_alone() {
+ assert_eq!(tr("ja", "File"), "ファイル");
+ assert_eq!(tr("ja", "Cancel"), "キャンセル");
+ assert_eq!(tr("ja", "{count} glyphs — click to insert at the text cursor").matches("{count}").count(), 1);
+ let unknown = String::from("A user-defined untranslated label");
+ assert!(std::ptr::eq(tr("ja", &unknown), unknown.as_str()));
+ }
+}
diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs
new file mode 100644
index 000000000..9a9fb89ba
--- /dev/null
+++ b/crates/ui-egui/src/i18n/uk.rs
@@ -0,0 +1,1461 @@
+//! Ukrainian interface translations; publishing terms are reviewed in context.
+//! Common phrases are adapted from the MIT OR Apache-2.0 ArtCraft sibling catalogs
+//! (VectorCraft, PhotoCraft, WordCraft and DeckCraft); see ASSETS.md for pinned sources.
+pub(super) const TABLE: &[(&str, &str)] = &[
+ ("File", "Файл"),
+ ("Edit", "Редагування"),
+ ("Can't Open the File", "Не вдалося відкрити файл"),
+ ("Can't Place the File", "Не вдалося розмістити файл"),
+ ("Layout", "Макет"),
+ ("Type", "Текст"),
+ ("Object", "Об’єкт"),
+ ("Table", "Таблиця"),
+ ("View", "Перегляд"),
+ ("Window", "Вікно"),
+ ("Help", "Довідка"),
+ ("New", "Створити"),
+ ("New Document…", "Новий документ…"),
+ ("Open…", "Відкрити…"),
+ ("Close", "Закрити"),
+ ("Save", "Зберегти"),
+ ("Save As…", "Зберегти як…"),
+ ("Save a Copy…", "Зберегти копію…"),
+ ("Revert", "Повернути збережену версію"),
+ ("Place…", "Розмістити…"),
+ ("Export PDF…", "Експортувати PDF…"),
+ ("Print…", "Друк…"),
+ ("Document Setup…", "Параметри документа…"),
+ ("Package…", "Зібрати пакет…"),
+ ("Undo", "Скасувати"),
+ ("Redo", "Повторити"),
+ ("Cut", "Вирізати"),
+ ("Copy", "Копіювати"),
+ ("Paste", "Вставити"),
+ ("Clear", "Очистити"),
+ ("Duplicate", "Дублювати"),
+ ("Select All", "Виділити все"),
+ ("Deselect All", "Зняти всі виділення"),
+ ("Find/Change…", "Знайти й замінити…"),
+ ("Check Spelling…", "Перевірити орфографію…"),
+ ("Preferences…", "Налаштування…"),
+ ("Keyboard Shortcuts…", "Клавіатурні скорочення…"),
+ ("Interface Language", "Мова інтерфейсу"),
+ ("Pages", "Сторінки"),
+ ("Margins and Columns…", "Поля та колонки…"),
+ ("Create Guides…", "Створити напрямні…"),
+ ("Numbering & Section Options…", "Параметри нумерації та розділів…"),
+ ("Story Direction", "Напрямок тексту"),
+ ("Horizontal", "Горизонтально"),
+ ("Vertical", "Вертикально"),
+ ("Tate-Chu-Yoko", "Tate-Chu-Yoko"),
+ ("Ruby…", "Фонетичні підписи…"),
+ ("Kenten", "Позначки наголосу"),
+ ("Font", "Шрифт"),
+ ("Size", "Розмір"),
+ ("Character", "Символ"),
+ ("Paragraph", "Абзац"),
+ ("Tabs", "Табуляція"),
+ ("Glyphs", "Гліфи"),
+ ("Story", "Текстовий матеріал"),
+ ("Character Styles", "Стилі символів"),
+ ("Paragraph Styles", "Стилі абзаців"),
+ ("Create Outlines", "Створити контури"),
+ ("Show Hidden Characters", "Показати приховані символи"),
+ ("Transform", "Перетворення"),
+ ("Arrange", "Упорядкувати"),
+ ("Group", "Згрупувати"),
+ ("Ungroup", "Розгрупувати"),
+ ("Lock", "Заблокувати"),
+ ("Hide", "Приховати"),
+ ("Effects", "Ефекти"),
+ ("Fitting", "Припасування"),
+ ("Text Frame Options…", "Параметри текстового фрейму…"),
+ ("Insert Table…", "Вставити таблицю…"),
+ ("Merge Cells", "Об’єднати клітинки"),
+ ("Unmerge Cells", "Роз’єднати клітинки"),
+ ("Insert", "Вставити"),
+ ("Delete", "Видалити"),
+ ("Zoom In", "Збільшити"),
+ ("Zoom Out", "Зменшити"),
+ ("Fit Page in Window", "Умістити сторінку у вікні"),
+ ("Fit Spread in Window", "Умістити розворот у вікні"),
+ ("Actual Size", "Реальний розмір"),
+ ("Screen Mode", "Режим екрана"),
+ ("Grids & Guides", "Сітки та напрямні"),
+ ("Show Rulers", "Показати лінійки"),
+ ("Snap to Guides", "Прив’язувати до напрямних"),
+ ("Snap to Document Grid", "Прив’язувати до сітки документа"),
+ ("Smart Guides", "Розумні напрямні"),
+ ("Align to Object Edges", "Вирівнювати за краями об’єктів"),
+ ("Align to Object Centers", "Вирівнювати за центрами об’єктів"),
+ ("Smart Dimensions", "Розумні розміри"),
+ ("Smart Spacing", "Розумні інтервали"),
+ ("Snap to Zone", "Прив’язувати до зони"),
+ ("Properties", "Властивості"),
+ ("Layers", "Шари"),
+ ("Links", "Зв’язки"),
+ ("Swatches", "Зразки"),
+ ("Color", "Колір"),
+ ("Stroke", "Обведення"),
+ ("Gradient", "Градієнт"),
+ ("Align", "Вирівнювання"),
+ ("Pathfinder", "Обробка контурів"),
+ ("Info", "Інформація"),
+ ("Library", "Бібліотека"),
+ ("Book", "Книга"),
+ ("Index", "Покажчик"),
+ ("Hyperlinks", "Гіперпосилання"),
+ ("Bookmarks", "Закладки"),
+ ("Articles", "Статті"),
+ ("Tags", "Теги"),
+ ("Media", "Медіа"),
+ ("Object States", "Стани об’єкта"),
+ ("Buttons and Forms", "Кнопки та форми"),
+ ("Liquid Layout", "Гнучкий макет"),
+ ("Workspace", "Робоче середовище"),
+ ("Split Window", "Розділити вікно"),
+ ("New Window", "Нове вікно"),
+ ("Show All Menu Items", "Показати всі пункти меню"),
+ ("Accept All", "Прийняти все"),
+ ("Add", "Додати"),
+ ("Add Document…", "Додати документ…"),
+ ("Add to Previous Spread", "Додати до попереднього розвороту"),
+ ("Align From:", "Вирівнювати від:"),
+ ("Align to baseline grid", "Вирівнювати за сіткою базових ліній"),
+ ("Alignment:", "Вирівнювання:"),
+ ("All Spots to Process", "Перетворити всі плашкові кольори на тріадні"),
+ ("Allow Spread to Shuffle", "Дозволити перерозподіл сторінок розвороту"),
+ ("Alternating Row Fills", "Чергування заливок рядків"),
+ ("Appearance", "Оформлення"),
+ ("Apply Gradient", "Застосувати градієнт"),
+ ("Apply Parent", "Застосувати шаблон"),
+ ("Apply Style:", "Застосувати стиль:"),
+ ("Apply to All Spreads", "Застосувати до всіх розворотів"),
+ ("Apply to Stroke", "Застосувати до обведення"),
+ ("Art", "Ілюстрація"),
+ ("Based On:", "На основі:"),
+ ("Bleed", "Виліт"),
+ ("Body Rows", "Рядки основного вмісту"),
+ ("Bottom", "Знизу"),
+ ("Break Link to Style", "Розірвати зв’язок зі стилем"),
+ ("Bullets and Numbering", "Маркери та нумерація"),
+ ("Business Card", "Візитівка"),
+ ("CMYK", "CMYK"),
+ ("Cancel", "Скасувати"),
+ ("Center Content", "Центрувати вміст"),
+ ("Change All", "Змінити все"),
+ ("Change to:", "Замінити на:"),
+ ("Character Style:", "Стиль символів:"),
+ ("Checked by default", "Позначено за замовчуванням"),
+ ("Choices", "Варіанти"),
+ ("Choose Image…", "Вибрати зображення…"),
+ ("Class:", "Клас:"),
+ ("Color Picker", "Палітра кольорів"),
+ ("Color Settings", "Налаштування кольору"),
+ ("Color:", "Колір:"),
+ ("Columns", "Колонки"),
+ ("Composer:", "Компонувальник:"),
+ ("Copies:", "Копії:"),
+ ("Count", "Кількість"),
+ ("Create", "Створити"),
+ ("Create Table", "Створити таблицю"),
+ ("Create Table…", "Створити таблицю…"),
+ ("Crop", "Обрізати"),
+ ("Crop to:", "Обрізати до:"),
+ ("Cursor Key:", "Клавіші курсора:"),
+ ("Default", "За замовчуванням"),
+ ("Default View:", "Типовий вигляд:"),
+ ("Delete Column", "Видалити стовпець"),
+ ("Delete Condition", "Видалити умову"),
+ ("Delete Layer", "Видалити шар"),
+ ("Delete Page", "Видалити сторінку"),
+ ("Delete Row", "Видалити рядок"),
+ ("Delete Swatch", "Видалити зразок"),
+ ("Delete Unused Layers", "Видалити невикористані шари"),
+ ("Desired", "Бажане"),
+ ("Dialog", "Діалогове вікно"),
+ ("Document", "Документ"),
+ ("Document Setup", "Параметри документа"),
+ ("Drop Shadow", "Тінь"),
+ ("Duplicate Spread", "Дублювати розворот"),
+ ("Email", "Електронна пошта"),
+ ("Embed", "Вбудувати"),
+ ("Export XML…", "Експортувати XML…"),
+ ("Fast", "Швидко"),
+ ("Fill Frame Proportionally", "Пропорційно заповнити фрейм"),
+ ("Find All", "Знайти все"),
+ ("Find Next", "Знайти далі"),
+ ("Find what:", "Знайти:"),
+ ("Find/Change", "Знайти й замінити"),
+ ("Find/Replace Font", "Знайти й замінити шрифт"),
+ ("Fit Content Proportionally", "Пропорційно припасувати вміст"),
+ ("Fit Content to Frame", "Припасувати вміст до фрейму"),
+ ("Fitting:", "Припасування:"),
+ ("Font Family:", "Гарнітура:"),
+ ("Font Size:", "Розмір шрифту:"),
+ ("Font Style:", "Накреслення:"),
+ ("Footer Rows", "Рядки нижнього колонтитула"),
+ ("Footnote Options", "Параметри виносок"),
+ ("Form Field", "Поле форми"),
+ ("Format:", "Формат:"),
+ ("Frame Fitting", "Припасування фрейму"),
+ ("Frame Fitting Options", "Параметри припасування фрейму"),
+ ("Generate QR Code", "Створити QR-код"),
+ ("Go To", "Перейти"),
+ ("Go to Page", "Перейти до сторінки"),
+ ("Gutter", "Проміжок"),
+ ("Header Rows", "Рядки заголовка"),
+ ("Height", "Висота"),
+ ("Height:", "Висота:"),
+ ("High Quality", "Висока якість"),
+ ("Horizontal Gridline Every:", "Горизонтальні лінії сітки через:"),
+ ("Horizontal Margins:", "Горизонтальні поля:"),
+ ("Hyphenate", "Розставляти переноси"),
+ ("Import Options", "Параметри імпорту"),
+ ("Import XML…", "Імпортувати XML…"),
+ ("Increment Every:", "Крок збільшення:"),
+ ("Ink Manager", "Керування фарбами"),
+ ("Insert Column", "Вставити стовпець"),
+ ("Insert Page After", "Вставити сторінку після"),
+ ("Insert Row", "Вставити рядок"),
+ ("Inset spacing", "Внутрішні відступи"),
+ ("Inside", "Усередині"),
+ ("Intent:", "Призначення:"),
+ ("Keyboard Shortcuts", "Клавіатурні скорочення"),
+ ("Kind:", "Різновид:"),
+ ("Lab", "Lab"),
+ ("Leading:", "Міжрядковий інтервал:"),
+ ("Left Indent:", "Лівий відступ:"),
+ ("Link To:", "Посилання на:"),
+ ("Load Profile…", "Завантажити профіль…"),
+ ("Load Swatches…", "Завантажити зразки…"),
+ ("Load…", "Завантажити…"),
+ ("Make Compound", "Створити складений об’єкт"),
+ ("Margins", "Поля"),
+ ("Maximum", "Максимум"),
+ ("Menu Customization", "Налаштування меню"),
+ ("Merge into Active Layer", "Об’єднати з активним шаром"),
+ ("Min:", "Мін.:"),
+ ("Minimum", "Мінімум"),
+ ("Minimum Space Before First Footnote:", "Мінімальний інтервал перед першою виноскою:"),
+ ("Misspelled word → Correction (one per line):", "Помилкове слово → виправлення (по одному в рядку):"),
+ ("Mobile", "Мобільні пристрої"),
+ ("Move Selection Here", "Перемістити виділення сюди"),
+ ("Move to Color Group", "Перемістити до колірної групи"),
+ ("Move to Group", "Перемістити до групи"),
+ ("Multiline", "Багаторядкове"),
+ ("Name", "Назва"),
+ ("Name:", "Назва:"),
+ ("New Bookmark for This Page", "Нова закладка для цієї сторінки"),
+ ("New Book…", "Нова книга…"),
+ ("New Color Group", "Нова колірна група"),
+ ("New Color Group with Swatch", "Нова колірна група зі зразком"),
+ ("New Cross-Reference", "Нове перехресне посилання"),
+ ("New Document", "Новий документ"),
+ ("New GREP Style", "Новий стиль GREP"),
+ ("New Library…", "Нова бібліотека…"),
+ ("New Line Style", "Новий стиль рядка"),
+ ("New Nested Style", "Новий вкладений стиль"),
+ ("New Workspace", "Нове робоче середовище"),
+ ("No spot inks", "Немає плашкових фарб"),
+ ("None", "Немає"),
+ ("Number of Pages:", "Кількість сторінок:"),
+ ("Number of Sides:", "Кількість сторін:"),
+ ("Number of columns", "Кількість колонок"),
+ ("Object Layer Options", "Параметри шарів об’єкта"),
+ ("Offset:", "Зміщення:"),
+ ("On Release", "Після відпускання"),
+ ("On Screen:", "На екрані:"),
+ ("OpenType", "OpenType"),
+ ("Orientation:", "Орієнтація:"),
+ ("Outside", "Зовні"),
+ ("Pages:", "Сторінки:"),
+ ("Paragraph Composer", "Компонувальник абзаців"),
+ ("Paragraph Style Options", "Параметри стилю абзацу"),
+ ("Paragraph Style:", "Стиль абзацу:"),
+ ("Place PDF", "Розмістити PDF"),
+ ("Plain Text", "Звичайний текст"),
+ ("Polygon Settings", "Параметри багатокутника"),
+ ("Position", "Розташування"),
+ ("Position:", "Розташування:"),
+ ("Poster", "Постер"),
+ ("Preferences", "Налаштування"),
+ ("Prefix:", "Префікс:"),
+ ("Preset", "Набір"),
+ ("Print", "Друк"),
+ ("Printer:", "Принтер:"),
+ ("Printing / Exporting:", "Друк / експорт:"),
+ ("Quick Actions", "Швидкі дії"),
+ ("RGB", "RGB"),
+ ("Rectangle", "Прямокутник"),
+ ("Reject All", "Відхилити все"),
+ ("Relative To:", "Відносно:"),
+ ("Release", "Звільнити"),
+ ("Relink to Folder…", "Відновити зв’язок із папкою…"),
+ ("Relink…", "Відновити зв’язок…"),
+ ("Remove Conditions from Selection", "Зняти умови з виділення"),
+ ("Repeat Header Rows", "Повторювати рядки заголовків"),
+ ("Required", "Обов’язкове"),
+ ("Reset All to Defaults", "Скинути все до типових значень"),
+ ("Restart Numbering Every:", "Починати нумерацію заново для:"),
+ ("Reverse", "Змінити напрямок"),
+ ("Save Swatches for Exchange…", "Зберегти зразки для обміну…"),
+ ("Save recovery data every:", "Зберігати дані відновлення через:"),
+ ("Search:", "Пошук:"),
+ ("Select an object.", "Виділіть об’єкт."),
+ ("Select text or a text frame.", "Виділіть текст або текстовий фрейм."),
+ ("Separator:", "Роздільник:"),
+ ("Show Prefix/Suffix in:", "Показувати префікс/суфікс у:"),
+ ("Show Tag Markers", "Показувати позначки тегів"),
+ ("Single-line", "Однорядкове"),
+ ("Single-line Composer", "Однорядковий компонувальник"),
+ ("Size:", "Розмір:"),
+ ("Space Between Footnotes:", "Інтервал між виносками:"),
+ ("Speed", "Швидкість"),
+ ("Star Inset:", "Глибина променів зірки:"),
+ ("Start Page #:", "Початкова сторінка №:"),
+ ("Start at:", "Починати з:"),
+ ("Start:", "Початок:"),
+ ("Style Name:", "Назва стилю:"),
+ ("Style:", "Стиль:"),
+ ("Stylistic Sets", "Стилістичні набори"),
+ ("Subdivisions:", "Поділки:"),
+ ("Suffix:", "Суфікс:"),
+ ("Synchronize", "Синхронізувати"),
+ ("Tag:", "Тег:"),
+ ("Text Frame", "Текстовий фрейм"),
+ ("Text Frame Options", "Параметри текстового фрейму"),
+ ("Text Message", "Текстове повідомлення"),
+ ("Text Style", "Стиль тексту"),
+ ("Text Wrap", "Обтікання текстом"),
+ ("This PDF has no layers.", "Цей PDF не містить шарів."),
+ ("To Text:", "До тексту:"),
+ ("Top", "Зверху"),
+ ("Track Changes in All Stories", "Відстежувати зміни в усіх текстових матеріалах"),
+ ("Trim", "Підрізати"),
+ ("Type:", "Тип:"),
+ ("Typical", "Типове"),
+ ("UI Size:", "Розмір інтерфейсу:"),
+ ("Ungroup Color Group", "Розгрупувати колірну групу"),
+ ("Unmerge", "Роз’єднати"),
+ ("Update", "Оновити"),
+ ("Update Numbering", "Оновити нумерацію"),
+ ("Use Global Light", "Використовувати глобальне освітлення"),
+ ("User Dictionary", "Словник користувача"),
+ ("Vertical Gridline Every:", "Вертикальні лінії сітки через:"),
+ ("Vertical Margins:", "Вертикальні поля:"),
+ ("Vertical justification", "Вертикальне вирівнювання"),
+ ("View Threshold:", "Поріг видимості:"),
+ ("Web", "Веб"),
+ ("Web Hyperlink", "Посилання на сайт"),
+ ("Weight:", "Товщина:"),
+ ("Width", "Ширина"),
+ ("Width:", "Ширина:"),
+ ("Words, one per line:", "Слова, по одному в рядку:"),
+ ("Multiple Objects", "Кілька об’єктів"),
+ ("Ellipse", "Еліпс"),
+ ("Polygon", "Багатокутник"),
+ ("Characters", "Символи"),
+ ("Image", "Зображення"),
+ ("Line", "Лінія"),
+ ("Path", "Контур"),
+ ("No Selection", "Немає виділення"),
+ ("Adjust Layout", "Адаптувати макет"),
+ ("Edit Page", "Редагувати сторінку"),
+ ("Import File", "Імпортувати файл"),
+ ("Insert Page", "Вставити сторінку"),
+ ("Hide Bar", "Сховати панель"),
+ ("Properties Panel", "Панель властивостей"),
+ ("New Workspace…", "Нове робоче середовище…"),
+ ("Float panel", "Від’єднати панель"),
+ ("Dock panel", "Прикріпити панель"),
+ ("Fit frame height to text", "Припасувати висоту фрейму до тексту"),
+ ("More Options", "Додаткові параметри"),
+ ("Flip Horizontal", "Відобразити по горизонталі"),
+ ("Rotate 90° Clockwise", "Повернути на 90° за годинниковою стрілкою"),
+ ("Flip Vertical", "Відобразити по вертикалі"),
+ ("Default Fill and Stroke (D)", "Заливка та обведення за замовчуванням (D)"),
+ ("Swap Fill and Stroke (Shift+X)", "Змінити заливку та обведення (Shift+X)"),
+ ("Fill (X)", "Заливка (X)"),
+ ("Stroke (X)", "Обведення (X)"),
+ ("Apply None (/)", "Без кольору (/)"),
+ ("Reverse the gradient", "Змінити напрямок градієнта"),
+ ("Style source", "Джерело стилів"),
+ ("Open", "Відкрити"),
+ ("All Caps", "Усі великі"),
+ ("Underline", "Підкреслення"),
+ ("Strikethrough", "Закреслення"),
+ ("Superscript", "Верхній індекс"),
+ ("Pick a file in the folder holding the moved graphics", "Виберіть файл у папці з переміщеними зображеннями"),
+ ("Show Favorites Only", "Показувати лише вибране"),
+ ("Select", "Виділення"),
+ ("Hyperlink from the selected text or frames", "Гіперпосилання з виділеного тексту або фреймів"),
+ ("Include when exporting", "Додавати під час експорту"),
+ ("Add the selection", "Додати виділення"),
+ ("Tag the selection", "Позначити виділення тегом"),
+ ("Separated by commas", "Через кому"),
+ ("Show in the text", "Показувати в тексті"),
+ ("Show / hide text with this condition", "Показати / приховати текст із цією умовою"),
+ ("Spreads of up to 10 pages", "Розвороти до 10 сторінок"),
+ ("for", "для"),
+ ("About DesignCraft", "Про DesignCraft"),
+ ("Accept All Changes", "Прийняти всі зміни"),
+ ("Activate Document", "Активувати документ"),
+ ("Add Anchor Point", "Додати опорну точку"),
+ ("Add Document", "Додати документ"),
+ ("Add Hyphenation Exception", "Додати виняток переносу"),
+ ("Add Item", "Додати елемент"),
+ ("Add Page to Spread", "Додати сторінку до розвороту"),
+ ("Add to Swatches", "Додати до зразків"),
+ ("Allow Selected Spread to Shuffle", "Дозволити перерозподіл сторінок вибраного розвороту"),
+ ("Anchored Object Options…", "Параметри прив’язаного об’єкта…"),
+ ("Anchored Objects", "Прив’язані об’єкти"),
+ ("Appearance of Black", "Відображення чорного"),
+ ("Apply Attributes (Eyedropper)", "Застосувати атрибути (піпетка)"),
+ ("Apply Cell Style", "Застосувати стиль клітинки"),
+ ("Apply Color", "Застосувати колір"),
+ ("Apply Condition", "Застосувати умову"),
+ ("Apply Parent to Pages…", "Застосувати шаблон до сторінок…"),
+ ("ArtCraft App Page…", "Сторінка програми ArtCraft…"),
+ ("ArtCraft Website…", "Вебсайт ArtCraft…"),
+ ("Attributes", "Атрибути"),
+ ("Bevel and Emboss", "Скіс і рельєф"),
+ ("Buttons", "Кнопки"),
+ ("Cell Options", "Параметри клітинки"),
+ ("Cell Style Options…", "Параметри стилю клітинки…"),
+ ("Change All (Object)", "Замінити все (об’єкти)"),
+ ("Change Case", "Змінити регістр"),
+ ("Changes", "Зміни"),
+ ("Character Formatting", "Форматування символів"),
+ ("Clear Conveyor", "Очистити конвеєр"),
+ ("Clear Transformations", "Скинути перетворення"),
+ ("Clipping Path…", "Відсічний контур…"),
+ ("Close Book", "Закрити книгу"),
+ ("Close Library", "Закрити бібліотеку"),
+ ("Collect", "Зібрати"),
+ ("Color Settings…", "Налаштування кольору…"),
+ ("Community & Project Links", "Спільнота та посилання на проєкт"),
+ ("Conditions", "Умови"),
+ ("Container", "Контейнер"),
+ ("Content", "Вміст"),
+ ("Contextual Task Bar", "Контекстна панель завдань"),
+ ("Control", "Панель керування"),
+ ("Convert Cell to Graphic Cell", "Перетворити клітинку на графічну"),
+ ("Convert Cell to Graphic Cell…", "Перетворити клітинку на графічну…"),
+ ("Convert Cell to Text Cell", "Перетворити клітинку на текстову"),
+ ("Convert Color", "Перетворити колір"),
+ ("Convert Direction Point", "Перетворити напрямну точку"),
+ ("Convert Note to Text", "Перетворити примітку на текст"),
+ ("Convert Selection to Multi-State Object", "Перетворити виділення на об’єкт із кількома станами"),
+ ("Convert Shape", "Перетворити фігуру"),
+ ("Convert Table to Text", "Перетворити таблицю на текст"),
+ ("Convert Text to Table", "Перетворити текст на таблицю"),
+ ("Convert to Button", "Перетворити на кнопку"),
+ ("Convert to Form Field", "Перетворити на поле форми"),
+ ("Conveyor", "Конвеєр"),
+ ("Copy Link(s) To…", "Копіювати пов’язані файли до…"),
+ ("Corner Options…", "Параметри кутів…"),
+ ("Create Alternate Layout…", "Створити альтернативний макет…"),
+ ("Create Frame", "Створити фрейм"),
+ ("Create Merged Document…", "Створити об’єднаний документ…"),
+ ("Cross-Reference Formats", "Формати перехресних посилань"),
+ ("Cross-References", "Перехресні посилання"),
+ ("Data Merge Fields", "Поля об’єднання даних"),
+ ("Define Cross-Reference Format…", "Визначити формат перехресного посилання…"),
+ ("Define Lists…", "Визначити списки…"),
+ ("Define Text Variable…", "Визначити текстову змінну…"),
+ ("Delete All Guides on Spread", "Видалити всі напрямні розвороту"),
+ ("Delete Anchor Point", "Видалити опорну точку"),
+ ("Delete Item", "Видалити елемент"),
+ ("Delete Text Variable", "Видалити текстову змінну"),
+ ("Delete Workspace…", "Видалити робоче середовище…"),
+ ("DesignCraft Website…", "Вебсайт DesignCraft…"),
+ ("DesignCraft on GitHub…", "DesignCraft на GitHub…"),
+ ("Detach All Objects from Parent", "Від’єднати всі об’єкти від шаблону"),
+ ("Directional Feather", "Спрямоване розтушовування"),
+ ("Distribute", "Розподілити"),
+ ("Dock Panel", "Прикріпити панель"),
+ ("Document Endnote Options…", "Параметри кінцевих виносок документа…"),
+ ("Document Footnote Options…", "Параметри виносок документа…"),
+ ("Document Preferences", "Налаштування документа"),
+ ("Document Presets", "Набори параметрів документа"),
+ ("Document…", "Документ…"),
+ ("Dynamic Spelling", "Динамічна перевірка правопису"),
+ ("Edit Parents", "Редагувати шаблони"),
+ ("Edit Story", "Редагувати текстовий матеріал"),
+ ("Edit in Story Editor", "Редагувати в текстовому редакторі"),
+ ("Endnotes", "Кінцеві виноски"),
+ ("Entire Pasteboard", "Увесь монтажний стіл"),
+ ("Erase", "Стерти"),
+ ("Export Book to PDF…", "Експортувати книгу в PDF…"),
+ ("Export EPUB (Fixed Layout)…", "Експортувати EPUB (фіксований макет)…"),
+ ("Export EPUB (Reflowable)…", "Експортувати EPUB (адаптивний макет)…"),
+ ("Export EPUB…", "Експортувати EPUB…"),
+ ("Export HTML…", "Експортувати HTML…"),
+ ("Export IDML…", "Експортувати IDML…"),
+ ("Export PDF (Interactive)…", "Експортувати PDF (інтерактивний)…"),
+ ("Export Page as PNG…", "Експортувати сторінку в PNG…"),
+ ("Export Selection as Snippet", "Експортувати виділення як фрагмент"),
+ ("Export Tagging", "Теги експорту"),
+ ("Export Text…", "Експортувати текст…"),
+ ("Extend Text Selection", "Розширити виділення тексту"),
+ ("Fast Display", "Швидке відображення"),
+ ("Find", "Знайти"),
+ ("Find Object", "Знайти об’єкт"),
+ ("Find by Script Label", "Знайти за міткою сценарію"),
+ ("Find/Replace Font…", "Знайти й замінити шрифт…"),
+ ("First Object Above", "Перший об’єкт вище"),
+ ("Fit Selection in Window", "Умістити виділення у вікні"),
+ ("Flattener Preview", "Попередній перегляд зведення"),
+ ("Float Panel", "Від’єднати панель"),
+ ("Fonts in Document", "Шрифти у документі"),
+ ("Footnotes", "Виноски"),
+ ("Form Fields", "Поля форми"),
+ ("Frame Fitting Options…", "Параметри припасування фрейму…"),
+ ("Generate Index…", "Створити покажчик…"),
+ ("Generate QR Code…", "Створити QR-код…"),
+ ("Generate Static Caption", "Створити статичний підпис"),
+ ("Get Story", "Отримати текстовий матеріал"),
+ ("Get Table", "Отримати таблицю"),
+ ("Global Light…", "Глобальне освітлення…"),
+ ("Go To Link", "Перейти за посиланням"),
+ ("Go To Source", "Перейти до джерела"),
+ ("Go to Footnote Reference", "Перейти до посилання на виноску"),
+ ("Go to Page…", "Перейти до сторінки…"),
+ ("Gradient Feather", "Градієнтне розтушовування"),
+ ("Gridify", "Розмістити сіткою"),
+ ("Group Transparency", "Прозорість групи"),
+ ("Guides", "Напрямні"),
+ ("Hide Menu Item", "Приховати пункт меню"),
+ ("Hide/Lock Others", "Приховати/заблокувати решту"),
+ ("High Quality Display", "Відображення високої якості"),
+ ("History", "Історія"),
+ ("Hyphenation Exceptions", "Винятки переносу"),
+ ("Import Options: Styles", "Параметри імпорту: стилі"),
+ ("Index Page References", "Посилання покажчика на сторінки"),
+ ("Inner Glow", "Внутрішнє світіння"),
+ ("Inner Shadow", "Внутрішня тінь"),
+ ("Insert Anchored Object…", "Вставити прив’язаний об’єкт…"),
+ ("Insert Cross-Reference…", "Вставити перехресне посилання…"),
+ ("Insert Endnote", "Вставити кінцеву виноску"),
+ ("Insert Footnote", "Вставити виноску"),
+ ("Insert Math Expression…", "Вставити математичний вираз…"),
+ ("Insert Pages…", "Вставити сторінки…"),
+ ("Insert Variable", "Вставити змінну"),
+ ("Inspect Document", "Перевірити документ"),
+ ("Interface Color Theme", "Колірна тема інтерфейсу"),
+ ("Join the ArtCraft Discord…", "Приєднатися до Discord ArtCraft…"),
+ ("Last Object Below", "Останній об’єкт нижче"),
+ ("Library Contents", "Вміст бібліотеки"),
+ ("Library Items", "Елементи бібліотеки"),
+ ("Linked Stories", "Пов’язані текстові матеріали"),
+ ("Liquid Page Rule", "Правило гнучкого макета сторінки"),
+ ("List Documents", "Перелік документів"),
+ ("List Styles", "Перелік стилів"),
+ ("List Tools", "Перелік інструментів"),
+ ("Load Place Cursor", "Завантажити курсор розміщення"),
+ ("Make Compound Path", "Створити складений контур"),
+ ("Map Styles to Tags", "Зіставити стилі з тегами"),
+ ("Math Expression SVG", "Математичний вираз SVG"),
+ ("Media Options", "Параметри медіа"),
+ ("Menus…", "Меню…"),
+ ("Merge Layers", "Об’єднати шари"),
+ ("Move Caret", "Перемістити текстовий курсор"),
+ ("Move Gap", "Перемістити проміжок"),
+ ("Move Row", "Перемістити рядок"),
+ ("New Book", "Нова книга"),
+ ("New Cell Style…", "Новий стиль клітинки…"),
+ ("New Color Swatch…", "Новий зразок кольору…"),
+ ("New Condition…", "Нова умова…"),
+ ("New Guide", "Нова напрямна"),
+ ("New Hyperlink Destination…", "Нова ціль гіперпосилання…"),
+ ("New Hyperlink…", "Нове гіперпосилання…"),
+ ("New Library", "Нова бібліотека"),
+ ("New Page Reference…", "Нове посилання на сторінку…"),
+ ("New Paragraph Style…", "Новий стиль абзацу…"),
+ ("New Stroke Style…", "Новий стиль обведення…"),
+ ("New Table Style…", "Новий стиль таблиці…"),
+ ("Next Cell", "Наступна клітинка"),
+ ("Next Document", "Наступний документ"),
+ ("Next Object Above", "Наступний об’єкт вище"),
+ ("Next Object Below", "Наступний об’єкт нижче"),
+ ("Next Object in Group", "Наступний об’єкт у групі"),
+ ("Notes", "Примітки"),
+ ("Object Export Options", "Параметри експорту об’єкта"),
+ ("Object Export Options…", "Параметри експорту об’єкта…"),
+ ("Object Layer Options…", "Параметри шарів об’єкта…"),
+ ("Object Layers", "Шари об’єкта"),
+ ("Object Liquid Settings", "Параметри гнучкого макета об’єкта"),
+ ("Open Book", "Відкрити книгу"),
+ ("Open Bytes", "Відкрити байти"),
+ ("Open IDML", "Відкрити IDML"),
+ ("Open Library", "Відкрити бібліотеку"),
+ ("Outer Glow", "Зовнішнє світіння"),
+ ("Overprint Preview", "Перегляд накладання"),
+ ("Override All Parent Page Items", "Перевизначити всі елементи сторінки-шаблону"),
+ ("Page Size", "Розмір сторінки"),
+ ("Page Transition", "Перехід сторінки"),
+ ("Page Transitions", "Переходи сторінок"),
+ ("Paragraph Formatting", "Форматування абзацу"),
+ ("Paragraph Style Options…", "Параметри стилю абзацу…"),
+ ("Paste Into", "Вставити всередину"),
+ ("Paste without Formatting", "Вставити без форматування"),
+ ("Place", "Розмістити"),
+ ("Place Caret", "Установити текстовий курсор"),
+ ("Place Item", "Розмістити елемент"),
+ ("Place Snippet", "Розмістити фрагмент"),
+ ("Place and Link", "Розмістити та пов’язати"),
+ ("Place with Import Options…", "Розмістити з параметрами імпорту…"),
+ ("Polygon Settings…", "Параметри багатокутника…"),
+ ("Preflight Document", "Перевірити документ перед друком"),
+ ("Previous Cell", "Попередня клітинка"),
+ ("Previous Document", "Попередній документ"),
+ ("Previous Object in Group", "Попередній об’єкт у групі"),
+ ("Primary Text Frame", "Основний текстовий фрейм"),
+ ("Print Booklet…", "Друк буклета…"),
+ ("Printers", "Принтери"),
+ ("Proof Colors", "Пробні кольори"),
+ ("Proof Setup", "Налаштування кольоропроби"),
+ ("Quick Apply…", "Швидке застосування…"),
+ ("Recover Documents", "Відновити документи"),
+ ("Recovery Data", "Дані відновлення"),
+ ("Reject All Changes", "Відхилити всі зміни"),
+ ("Release Compound Path", "Звільнити складений контур"),
+ ("Remove All Local Overrides", "Скинути всі локальні перевизначення"),
+ ("Remove Document", "Вилучити документ"),
+ ("Report an Issue…", "Повідомити про проблему…"),
+ ("Reset Shortcuts", "Скинути клавіатурні скорочення"),
+ ("Reset Workspace", "Скинути робоче середовище"),
+ ("Resize", "Змінити розмір"),
+ ("Rotate Spread", "Повернути розворот"),
+ ("Run Script", "Запустити сценарій"),
+ ("Sample Document", "Зразок документа"),
+ ("Satin", "Атлас"),
+ ("Save Recovery Data", "Зберегти дані відновлення"),
+ ("Script Label", "Мітка сценарію"),
+ ("Select All on Layer", "Виділити все на шарі"),
+ ("Select Cells", "Виділити клітинки"),
+ ("Select Column", "Виділити стовпець"),
+ ("Select Frame", "Виділити фрейм"),
+ ("Select Row", "Виділити рядок таблиці"),
+ ("Select Table", "Виділити таблицю"),
+ ("Select Text", "Виділити текст"),
+ ("Select Tool", "Вибрати інструмент"),
+ ("Select Word", "Виділити слово"),
+ ("Selection Attributes", "Атрибути виділення"),
+ ("Separations Preview", "Перегляд кольороподілу"),
+ ("Serialize", "Серіалізувати"),
+ ("Set Active Layer", "Зробити шар активним"),
+ ("Set Shortcut", "Призначити клавіатурне скорочення"),
+ ("Set Visibility / Lock", "Установити видимість / блокування"),
+ ("Show Panel", "Показати панель"),
+ ("Show/Hide Baseline Grid", "Показати/приховати сітку базових ліній"),
+ ("Show/Hide Frame Edges", "Показати/приховати межі фреймів"),
+ ("Show/Hide Guides", "Показати/приховати напрямні"),
+ ("Show/Hide Hidden Characters", "Показати/приховати приховані символи"),
+ ("Show/Hide Panels", "Показати/приховати панелі"),
+ ("Show/Hide Panels Except Tools", "Показати/приховати панелі, крім інструментів"),
+ ("Show/Hide Rulers", "Показати/приховати лінійки"),
+ ("Show/Hide Tagged Frames", "Показати/приховати фрейми з тегами"),
+ ("Show/Hide Text Threads", "Показати/приховати зв’язки текстових фреймів"),
+ ("Smooth", "Згладити"),
+ ("Sort", "Сортувати"),
+ ("Split Cell Horizontally", "Розділити клітинку горизонтально"),
+ ("Split Cell Vertically", "Розділити клітинку вертикально"),
+ ("Split Path", "Розділити контур"),
+ ("Step and Repeat…", "Крок і повторення…"),
+ ("Stroke Styles", "Стилі обведення"),
+ ("Structure", "Структура"),
+ ("Style Source", "Джерело стилів"),
+ ("Synchronize Book", "Синхронізувати книгу"),
+ ("Table Options", "Параметри таблиці"),
+ ("Table Panel", "Панель таблиці"),
+ ("Table Style Options…", "Параметри стилю таблиці…"),
+ ("Table of Contents Entries", "Записи змісту"),
+ ("Table of Contents…", "Зміст…"),
+ ("Text Anchors", "Текстові прив’язки"),
+ ("Text Variables", "Текстові змінні"),
+ ("Toggle Normal/Preview", "Перемкнути звичайний режим / перегляд"),
+ ("Toggle Selection", "Переключити виділення"),
+ ("Tools: Double Column", "Інструменти: дві колонки"),
+ ("Transform Again", "Трансформувати знову"),
+ ("Transform Panel", "Панель «Трансформування»"),
+ ("Transform Values", "Значення перетворення"),
+ ("Transparency Blend Space", "Колірний простір змішування прозорості"),
+ ("Transparency Flattener Presets", "Набори зведення прозорості"),
+ ("Type on a Path", "Текст за контуром"),
+ ("Type on a Path Options…", "Параметри тексту за контуром…"),
+ ("Typical Display", "Типове відображення"),
+ ("UI Scaling", "Масштаб інтерфейсу"),
+ ("Update Link", "Оновити зв’язок"),
+ ("User Dictionary Words", "Слова словника користувача"),
+ ("User Dictionary…", "Словник користувача…"),
+ ("Zoom To", "Масштабувати до"),
+ ("⇧", "⇧"),
+ ("⌃", "⌃"),
+ ("⌘", "⌘"),
+ ("⌥", "⌥"),
+ ("Accept Change", "Прийняти зміну"),
+ ("Add Anchor", "Додати прив’язку"),
+ ("Add Selection to Article", "Додати виділення до статті"),
+ ("Add Unnamed Colors", "Додати кольори без назв"),
+ ("Add to Dictionary", "Додати до словника"),
+ ("Align Center", "Вирівняти по центру"),
+ ("Align Left", "Вирівняти ліворуч"),
+ ("Align Right", "Вирівняти праворуч"),
+ ("Apply Character Style", "Застосувати стиль символу"),
+ ("Apply Object Style", "Застосувати стиль об’єкта"),
+ ("Apply Paragraph Style", "Застосувати стиль абзацу"),
+ ("Apply Table Style", "Застосувати стиль таблиці"),
+ ("Article Options", "Параметри статті"),
+ ("Basic Feather", "Просте розтушовування"),
+ ("Bold", "Напівжирний"),
+ ("Bring Forward", "На один рівень вперед"),
+ ("Bring to Front", "На передній план"),
+ ("Change", "Змінити"),
+ ("Character Style Options…", "Параметри стилю символів…"),
+ ("Close Path", "Замкнути контур"),
+ ("Color Group Options", "Параметри колірної групи"),
+ ("Column Width", "Ширина стовпця"),
+ ("Condition Options…", "Параметри умови…"),
+ ("Convert to Liquid Guide", "Перетворити на напрямну гнучкого макета"),
+ ("Convert to Object", "Перетворити на об’єкт"),
+ ("Create Line", "Створити лінію"),
+ ("Create Path", "Створити контур"),
+ ("Decrease Point Size", "Зменшити розмір шрифту"),
+ ("Delete Article", "Видалити статтю"),
+ ("Delete Bookmark", "Видалити закладку"),
+ ("Delete Endnote", "Видалити кінцеву виноску"),
+ ("Delete Footnote", "Видалити виноску"),
+ ("Delete Guide", "Видалити напрямну"),
+ ("Delete Hyperlink", "Видалити гіперпосилання"),
+ ("Delete List", "Видалити список"),
+ ("Delete Note", "Видалити примітку"),
+ ("Delete Pages", "Видалити сторінки"),
+ ("Delete Paragraph Style", "Видалити стиль абзацу"),
+ ("Delete Stroke Style", "Видалити стиль обведення"),
+ ("Delete Table", "Видалити таблицю"),
+ ("Delete Tag", "Видалити тег"),
+ ("Delete Text", "Видалити текст"),
+ ("Distribute Columns Evenly", "Рівномірно розподілити стовпці"),
+ ("Edit Endnote", "Редагувати кінцеву виноску"),
+ ("Edit Note", "Редагувати примітку"),
+ ("Embed Link", "Вбудувати пов’язаний файл"),
+ ("Fill", "Заливка"),
+ ("Fill with Placeholder Text", "Заповнити текстом-заповнювачем"),
+ ("Flip", "Відобразити"),
+ ("Hyperlink Options…", "Параметри гіперпосилання…"),
+ ("Increase Point Size", "Збільшити розмір шрифту"),
+ ("Insert Column Left", "Вставити стовпець ліворуч"),
+ ("Insert Column Right", "Вставити стовпець праворуч"),
+ ("Insert Row Above", "Вставити рядок вище"),
+ ("Insert Row Below", "Вставити рядок нижче"),
+ ("Italic", "Курсив"),
+ ("Justify Left", "За шириною, останній рядок ліворуч"),
+ ("Layer Options…", "Параметри шару…"),
+ ("Move", "Перемістити"),
+ ("Move Anchors", "Перемістити опорні точки"),
+ ("Move Column", "Перемістити стовпець"),
+ ("Move Guide", "Перемістити напрямну"),
+ ("Move Layer", "Перемістити шар"),
+ ("Move Pages…", "Перемістити сторінки…"),
+ ("Move to Layer", "Перемістити до шару"),
+ ("New Article", "Нова стаття"),
+ ("New Bookmark", "Нова закладка"),
+ ("New Character Style…", "Новий стиль символів…"),
+ ("New Layer…", "Новий шар…"),
+ ("New Note", "Нова примітка"),
+ ("New Object Style…", "Новий стиль об’єкта…"),
+ ("New Parent…", "Новий шаблон…"),
+ ("New Tag", "Новий тег"),
+ ("Opacity", "Непрозорість"),
+ ("Paste in Place", "Вставити на місце"),
+ ("Place Loaded Graphic", "Розмістити завантажене зображення"),
+ ("Reject Change", "Відхилити зміну"),
+ ("Release State to Objects", "Перетворити стан на об’єкти"),
+ ("Remove Hyphenation Exception", "Вилучити виняток переносу"),
+ ("Remove from Article", "Вилучити зі статті"),
+ ("Rename", "Перейменувати"),
+ ("Rename Bookmark", "Перейменувати закладку"),
+ ("Rotate", "Повернути"),
+ ("Row Height", "Висота рядка"),
+ ("Scale", "Масштабувати"),
+ ("Send Backward", "На один рівень назад"),
+ ("Send to Back", "На задній план"),
+ ("Set Cross-Reference Format", "Установити формат перехресного посилання"),
+ ("Set Footnote Text", "Задати текст виноски"),
+ ("Set Story Text", "Задати текстовий матеріал"),
+ ("Shear", "Нахилити"),
+ ("Show All on Spread", "Показати все на розвороті"),
+ ("Show State", "Показати стан"),
+ ("State Options", "Параметри стану"),
+ ("Tag Frame", "Позначити фрейм тегом"),
+ ("Tag Text", "Позначити текст тегом"),
+ ("Track Changes", "Відстежувати зміни"),
+ ("Unlink", "Розірвати зв’язок"),
+ ("Unlock All on Spread", "Розблокувати все на розвороті"),
+ ("Update Index", "Оновити покажчик"),
+ ("Update Table of Contents", "Оновити зміст"),
+ ("Bulleted and Numbered Lists", "Марковані та нумеровані списки"),
+ ("Insert Special Character", "Вставити спеціальний символ"),
+ ("Insert White Space", "Вставити пробільний символ"),
+ ("Insert Break Character", "Вставити символ розриву"),
+ ("Hyperlinks Cross-References", "Гіперпосилання та перехресні посилання"),
+ ("Document Footnote Options...", "Параметри виносок документа..."),
+ (" Close ", " Закрити "),
+ (" New file ", " Новий файл "),
+ (" OK ", " Гаразд "),
+ (" Open ", " Відкрити "),
+ (" Open sample magazine ", " Відкрити зразок журналу "),
+ ("(Top Level)", "(Верхній рівень)"),
+ ("(import)", "(імпорт)"),
+ ("Anchored Object", "Прив’язаний об’єкт"),
+ ("Apply Attributes (Eyedropper", "Застосувати атрибути (піпетка"),
+ ("Baseline Grid", "Сітка базових ліній"),
+ ("Bleed and Slug", "Виліт і службове поле"),
+ ("Buttons act in interactive PDF export.", "Кнопки працюють під час експорту інтерактивного PDF."),
+ ("Captions", "Підписи"),
+ ("Change All (Object", "Замінити все (об’єкти"),
+ ("Character Settings", "Параметри символів"),
+ ("Choose a link.", "Виберіть пов’язаний файл."),
+ ("Clipping Path", "Відсічний контур"),
+ (
+ "Collect objects with the Content Collector (B); place them with the Content Placer.",
+ "Збирайте об’єкти інструментом збирання вмісту (B); розміщуйте їх інструментом розміщення вмісту.",
+ ),
+ ("Content Fitting", "Припасування вмісту"),
+ ("Convert Cell Type", "Перетворити тип клітинки"),
+ ("Copy Link(s", "Копіювати пов’язані файли"),
+ ("Create Alternate Layout", "Створити альтернативний макет"),
+ ("Crop Amount", "Величина обрізання"),
+ ("Cross-Reference Format", "Формат перехресного посилання"),
+ ("Delete Workspace", "Видалити робоче середовище"),
+ ("DesignCraft", "DesignCraft"),
+ ("Display Performance", "Якість відображення"),
+ ("Document Grid", "Сітка документа"),
+ ("Document Recovery Data", "Дані відновлення документа"),
+ ("EPUB and HTML", "EPUB та HTML"),
+ ("Export", "Експорт"),
+ ("Export EPUB (Fixed Layout", "Експортувати EPUB (фіксований макет"),
+ ("Export EPUB (Reflowable", "Експортувати EPUB (адаптивний макет"),
+ ("Facing Pages", "Суміжні сторінки"),
+ ("GREP Styles", "Стилі GREP"),
+ ("Get help, share your layouts and shape what we build next.", "Отримуйте допомогу, діліться макетами та визначайте подальший розвиток."),
+ ("Highlight", "Підсвічування"),
+ ("Hyperlinks & Cross-References", "Гіперпосилання та перехресні посилання"),
+ ("Hyphens and Dashes", "Дефіси та тире"),
+ ("Interactive", "Інтерактивність"),
+ ("Invert", "Інвертувати"),
+ ("Join the ArtCraft community", "Приєднатися до спільноти ArtCraft"),
+ ("Keyboard Increments", "Кроки переміщення клавішами"),
+ ("Liquid guides", "Напрямні гнучкого макета"),
+ ("Markers", "Маркери"),
+ ("More ArtCraft apps", "Інші програми ArtCraft"),
+ ("Nested Line Styles", "Вкладені стилі рядків"),
+ ("Nested Styles", "Вкладені стилі"),
+ ("No articles: exports follow page order.", "Немає статей: експорт дотримується порядку сторінок."),
+ ("No book open.", "Немає відкритої книги."),
+ ("No bookmarks. They become the PDF outline.", "Немає закладок. Вони формують структуру PDF."),
+ ("No changes.", "Немає змін."),
+ (
+ "No conditions. Create one, then select text and click it to apply.",
+ "Немає умов. Створіть умову, виділіть текст і натисніть її, щоб застосувати.",
+ ),
+ ("No document open", "Немає відкритого документа"),
+ ("No document.", "Немає документа."),
+ (
+ "No hyperlinks. Select text or a frame, type a URL and click New.",
+ "Немає гіперпосилань. Виділіть текст або фрейм, введіть URL і натисніть «Створити».",
+ ),
+ ("No library open.", "Немає відкритої бібліотеки."),
+ ("No notes in this document.", "Цей документ не містить приміток."),
+ ("No placed graphics.", "Немає розміщених зображень."),
+ ("No scripts. Add one below.", "Немає сценаріїв. Додайте сценарій нижче."),
+ ("No text anchors in this document.", "Цей документ не містить текстових прив’язок."),
+ ("OK changes all: text and paragraph/character styles.", "«Гаразд» змінює все: текст і стилі абзаців/символів."),
+ ("OVERSET", "ТЕКСТ НЕ ВМІЩУЄТЬСЯ"),
+ ("Open source. No telemetry.", "Відкритий код. Без телеметрії."),
+ ("Options", "Параметри"),
+ ("Options for Black on RGB and Grayscale Devices", "Параметри чорного для пристроїв RGB та відтінків сірого"),
+ ("Other", "Інший"),
+ ("Output", "Виведення"),
+ ("Page Numbering", "Нумерація сторінок"),
+ ("Page layout for print and screen — fast, open and scriptable.", "Верстка для друку й екрана — швидка, відкрита, зі сценаріями."),
+ ("Page · PPI", "Сторінка · PPI"),
+ ("Part of the ArtCraft suite of open creative tools.", "Частина набору відкритих творчих інструментів ArtCraft."),
+ ("Pasteboard Options", "Параметри монтажного столу"),
+ ("Paths", "Контури"),
+ ("Place the insertion point in a table, or create one.", "Установіть курсор у таблиці або створіть її."),
+ ("Plays in EPUB export; print and PDF show the poster.", "Відтворюється в експортованому EPUB; для друку та PDF показується постер."),
+ ("Quotation Marks", "Лапки"),
+ ("Recently Used", "Нещодавно використані"),
+ ("Replace With", "Замінити на"),
+ ("Right-click: stroke, groups", "Права кнопка миші: обведення, групи"),
+ ("Ruler Units", "Одиниці лінійки"),
+ ("Saves which bars show and where the panels are.", "Зберігає видимість панелей і їхнє розташування."),
+ (
+ "Select a placed video or sound (File › Place a .mp4, .mov, .mp3, .wav …).",
+ "Виділіть розміщене відео або звук («Файл» › «Розмістити» файл .mp4, .mov, .mp3, .wav …).",
+ ),
+ ("Select an object to color its fill or stroke.", "Виділіть об’єкт, щоб змінити колір заливки або обведення."),
+ ("Select an object to give its fill a gradient.", "Виділіть об’єкт, щоб застосувати градієнт до заливки."),
+ ("Select an object to make it a button.", "Виділіть об’єкт, щоб перетворити його на кнопку."),
+ (
+ "Select two or more objects (or a group) to make a multi-state object.",
+ "Виділіть два чи більше об’єктів (або групу), щоб створити об’єкт із кількома станами.",
+ ),
+ ("Show", "Показати"),
+ ("Smart Text Reflow", "Розумне перетікання тексту"),
+ ("Spelling", "Орфографія"),
+ ("Start a new document", "Створити новий документ"),
+ ("Status", "Стан"),
+ ("Style Mapping", "Зіставлення стилів"),
+ ("Style Name Conflicts", "Конфлікти назв стилів"),
+ ("Symbols", "Символи"),
+ ("System default", "За замовчуванням у системі"),
+ ("Table Dimensions", "Розміри таблиці"),
+ ("Text Display Options", "Параметри відображення тексту"),
+ ("The fill isn't a gradient.", "Заливка не є градієнтом."),
+ ("The library is empty. Select objects and Add Selection.", "Бібліотека порожня. Виділіть об’єкти й натисніть «Додати виділення»."),
+ ("Transitions play in interactive PDF (full-screen mode).", "Переходи відтворюються в інтерактивному PDF (повноекранний режим)."),
+ ("Type & Tables", "Текст і таблиці"),
+ ("Type Options", "Параметри тексту"),
+ (
+ "Type a word with ~ at each allowed break (ex~am~ple); without ~ the word is never hyphenated.",
+ "Позначайте кожне дозволене місце переносу символом ~ (при~клад); без ~ слово не переноситься.",
+ ),
+ ("User Dictionary (this document)", "Словник користувача (цей документ)"),
+ ("Utilities", "Допоміжні інструменти"),
+ ("Variable Font", "Варіативний шрифт"),
+ ("Welcome to DesignCraft", "Вітаємо в DesignCraft"),
+ ("When Scaling", "Під час масштабування"),
+ ("[Basic] (working) ▾", "[Основний] (робочий) ▾"),
+ ("[Black] Overprint", "Накладання [Чорного]"),
+ ("[No Group]", "[Без групи]"),
+ ("[None]", "[Немає]"),
+ ("[None] — choose a colour on the ramp.", "[Немає] — виберіть колір на шкалі."),
+ ("discord.gg/artcraft — help, feedback and show-and-tell", "discord.gg/artcraft — допомога, відгуки та обмін роботами"),
+ ("⌥-click clears overrides", "⌥-клацання скидає перевизначення"),
+ ("⚠ out of date", "⚠ застаріле"),
+ ("General", "Загальні"),
+ ("Basic Character Formats", "Основне форматування символів"),
+ ("Indents and Spacing", "Відступи та інтервали"),
+ ("Hyphenation", "Переноси"),
+ ("Justification", "Вирівнювання за шириною"),
+ ("Drop Caps and Nested Styles", "Буквиці та вкладені стилі"),
+ ("GREP Style", "Стиль GREP"),
+ ("Character Color", "Колір символів"),
+ ("Numbering and Formatting", "Нумерація та форматування"),
+ ("Spacing Options", "Параметри інтервалів"),
+ ("First Baseline", "Перша базова лінія"),
+ ("Rule Above", "Лінія зверху"),
+ ("Numbering", "Нумерація"),
+ ("Footnote Reference Number in Text", "Номер посилання на виноску в тексті"),
+ ("Footnote Formatting", "Форматування виносок"),
+ ("Span Footnotes Across Columns", "Розміщувати виноски на ширину всіх колонок"),
+ ("Rule On", "Увімкнути розділювальну лінію"),
+ ("Ascent", "Верхній виносний елемент"),
+ ("Cap Height", "Висота великих літер"),
+ ("Leading", "Міжрядковий інтервал"),
+ ("x Height", "Висота малих літер"),
+ ("Fixed", "Фіксований"),
+ ("Never (continuous)", "Ніколи (наскрізна нумерація)"),
+ ("Page", "Сторінка"),
+ ("Spread", "Розворот"),
+ ("Section", "Розділ"),
+ ("Footnote Reference", "Посилання на виноску"),
+ ("Footnote Text", "Текст виноски"),
+ ("Both", "Обидві"),
+ ("Apply Superscript", "Застосувати верхній індекс"),
+ ("Apply Subscript", "Застосувати нижній індекс"),
+ ("Apply Normal", "Застосувати звичайне накреслення"),
+ ("OpenType Superscript", "Верхній індекс OpenType"),
+ ("through", "включно"),
+ ("up to", "не включно"),
+ ("line", "рядок"),
+ ("lines", "рядків"),
+ ("sentences", "речень"),
+ ("words", "слів"),
+ ("characters", "символів"),
+ ("letters", "літер"),
+ ("digits", "цифр"),
+ ("tab", "табуляція"),
+ ("forcedLineBreak", "примусовий розрив рядка"),
+ ("emSpace", "кругла шпація"),
+ ("enSpace", "напівкругла шпація"),
+ ("chars", "символів"),
+ ("[Automatic]", "[Автоматично]"),
+ ("Tracking:", "Інтервал між літерами:"),
+ ("Right Indent:", "Правий відступ:"),
+ ("First Line Indent:", "Відступ першого рядка:"),
+ ("Space Before:", "Інтервал перед:"),
+ ("Space After:", "Інтервал після:"),
+ ("Words with at Least:", "Мінімальна довжина слова:"),
+ ("After First:", "Літер перед переносом:"),
+ ("Before Last:", "Літер після переносу:"),
+ ("Hyphen Limit:", "Максимум переносів поспіль:"),
+ ("Word Spacing:", "Інтервал між словами:"),
+ ("Letter Spacing:", "Інтервал між літерами:"),
+ ("Glyph Scaling:", "Масштаб гліфів:"),
+ ("Insert Kashidas:", "Додавати подовження з’єднань:"),
+ ("In justified Arabic text", "В арабському тексті, вирівняному за шириною"),
+ ("Korean Line Breaks:", "Переноси корейського тексту:"),
+ ("Between syllables (not only at spaces)", "Між складами (а не лише на пробілах)"),
+ ("Binding:", "Напрямок палітурки:"),
+ ("Ruby:", "Фонетичні підписи:"),
+ ("Set over the selected text; empty removes it.", "Розміщується над виділеним текстом; порожнє значення вилучає підпис."),
+ ("[Basic Paragraph]", "[Основний абзац]"),
+ ("[No Paragraph Style]", "[Без стилю абзацу]"),
+ ("Align left", "Вирівняти по лівому краю"),
+ ("Align center", "Вирівняти по центру"),
+ ("Align right", "Вирівняти з правого краю"),
+ ("Justify with last line aligned left", "Вирівняти за шириною, останній рядок — ліворуч"),
+ ("Justify with last line aligned center", "Вирівняти за шириною, останній рядок — по центру"),
+ ("Justify with last line aligned right", "Вирівняти за шириною, останній рядок — праворуч"),
+ ("Justify all lines", "Вирівняти всі рядки за шириною"),
+ ("Align towards spine", "Вирівняти до корінця"),
+ ("Align away from spine", "Вирівняти від корінця"),
+ ("Create New Style from Selection", "Створити новий стиль із виділення"),
+ ("No text selected", "Текст не виділено"),
+ ("Search", "Пошук"),
+ ("Essentials", "Основне"),
+ ("Advanced", "Додатково"),
+ ("Digital Publishing", "Цифрове видавництво"),
+ ("Interactive for PDF", "Інтерактивність для PDF"),
+ ("Printing and Proofing", "Друк і кольоропроба"),
+ ("Typography", "Типографіка"),
+ ("Rulers & Grids", "Лінійки та сітки"),
+ ("No errors", "Немає помилок"),
+ ("error", "помилка"),
+ ("errors", "помилок"),
+ ("Reset", "Скинути"),
+ ("Right to Left", "Справа наліво"),
+ ("Left to Right", "Зліва направо"),
+ ("Portrait", "Книжкова"),
+ ("Landscape", "Альбомна"),
+ ("Display All Blacks as Rich Black", "Показувати всі чорні як насичений чорний"),
+ ("Display All Blacks Accurately", "Точно показувати всі чорні"),
+ ("Output All Blacks as Rich Black", "Виводити всі чорні як насичений чорний"),
+ ("Output All Blacks Accurately", "Точно виводити всі чорні"),
+ ("Top Margin", "Верхнє поле"),
+ ("Top of Page", "Угорі сторінки"),
+ ("Paragraph Styles panel", "Панель стилів абзаців"),
+ ("Character Styles panel", "Панель стилів символів"),
+ ("Create new style", "Створити новий стиль"),
+ ("Clear overrides", "Скинути перевизначення"),
+ ("Apply", "Застосувати"),
+ ("Accept", "Прийняти"),
+ ("Reject", "Відхилити"),
+ ("Add Selection", "Додати виділення"),
+ ("Align Stroke:", "Вирівнювання обведення:"),
+ ("Angle", "Кут"),
+ ("Angle:", "Кут:"),
+ ("Baseline", "Базова лінія"),
+ ("Cap:", "Закінчення:"),
+ ("Cell Text", "Текст клітинки"),
+ ("Cell Type", "Тип клітинки"),
+ ("Composer", "Компонувальник"),
+ ("Convert to Text", "Перетворити на текст"),
+ ("Create New Layer", "Створити новий шар"),
+ ("Delete DTD", "Видалити DTD"),
+ ("Digits", "Цифри"),
+ ("Direction", "Напрямок"),
+ ("Distance", "Відстань"),
+ ("Distribute ↔", "Розподілити ↔"),
+ ("Distribute ↕", "Розподілити ↕"),
+ ("Effects: Drop Shadow", "Ефекти: тінь"),
+ ("Favorites", "Вибране"),
+ ("First Spread", "Перший розворот"),
+ ("Gap Color:", "Колір проміжку:"),
+ ("Graphic…", "Зображення…"),
+ ("Home", "Головна"),
+ ("Join:", "З’єднання:"),
+ ("Language", "Мова"),
+ ("Last Spread", "Останній розворот"),
+ ("Load DTD…", "Завантажити DTD…"),
+ ("Location:", "Розташування:"),
+ ("Lock Guides", "Заблокувати напрямні"),
+ ("Miter Limit:", "Межа гострого з’єднання:"),
+ ("New Article from Selection", "Нова стаття з виділення"),
+ ("New Note at Cursor", "Нова примітка в позиції курсора"),
+ ("New Swatch", "Новий зразок"),
+ ("New condition name", "Назва нової умови"),
+ ("New group…", "Нова група…"),
+ ("New tag", "Новий тег"),
+ ("Next Spread", "Наступний розворот"),
+ ("Note text", "Текст примітки"),
+ ("Offset", "Зміщення"),
+ ("Offsets", "Зміщення"),
+ ("Paragraph alignment", "Вирівнювання абзацу"),
+ ("Previous Spread", "Попередній розворот"),
+ ("Release to Objects", "Перетворити на окремі об’єкти"),
+ ("Remove", "Прибрати"),
+ ("Rotate 90° Counterclockwise", "Повернути на 90° проти годинникової стрілки"),
+ ("Rotation Angle", "Кут повороту"),
+ ("Rows", "Рядки"),
+ ("Run", "Запустити"),
+ ("Scale X Percentage", "Масштаб X у відсотках"),
+ ("Scale Y Percentage", "Масштаб Y у відсотках"),
+ ("Search commands", "Пошук команд"),
+ ("Search fonts", "Пошук шрифтів"),
+ ("Search styles and commands…", "Пошук стилів і команд…"),
+ ("Search: character or U+code", "Пошук: символ або код U+"),
+ ("Share", "Поділитися"),
+ ("Shear X Angle", "Кут нахилу X"),
+ ("Show Baseline Grid", "Показати сітку базових ліній"),
+ ("Show Document Grid", "Показати сітку документа"),
+ ("Show Guides", "Показати напрямні"),
+ ("Stop Color:", "Колір точки градієнта:"),
+ ("Style", "Стиль"),
+ ("Styles panel", "Панель стилів"),
+ ("Text", "Текст"),
+ ("The structure is valid.", "Структура правильна."),
+ ("Toggle double column", "Перемкнути дві колонки"),
+ ("Tracking", "Інтервал між літерами"),
+ ("Untag Selection", "Зняти тег із виділення"),
+ ("Validate", "Перевірити"),
+ ("Weight", "Товщина"),
+ ("Subtract", "Відняти"),
+ ("Intersect", "Перетин"),
+ ("Exclude Overlap", "Виключити перекриття"),
+ ("Minus Back", "Відняти задній об’єкт"),
+ ("Align left edges", "Вирівняти ліві краї"),
+ ("Align horizontal centers", "Вирівняти горизонтальні центри"),
+ ("Align right edges", "Вирівняти праві краї"),
+ ("Align top edges", "Вирівняти верхні краї"),
+ ("Align vertical centers", "Вирівняти вертикальні центри"),
+ ("Align bottom edges", "Вирівняти нижні краї"),
+ ("Align to Selection", "Вирівняти за виділенням"),
+ ("Align to Key Object", "Вирівняти за ключовим об’єктом"),
+ ("Align to Margins", "Вирівняти за полями"),
+ ("Align to Page", "Вирівняти за сторінкою"),
+ ("Align to Spread", "Вирівняти за розворотом"),
+ ("No Text Wrap", "Без обтікання текстом"),
+ ("Wrap Around Bounding Box", "Обтікати габаритну рамку"),
+ ("Wrap Around Object Shape", "Обтікати форму об’єкта"),
+ ("Jump Object", "Пропустити об’єкт"),
+ ("Jump to Next Column", "Перейти до наступної колонки"),
+ ("Bulleted List", "Маркований список"),
+ ("Numbered List", "Нумерований список"),
+ ("UPPERCASE", "ВЕЛИКІ ЛІТЕРИ"),
+ ("Small Caps", "Капітель"),
+ ("Normal", "Звичайний"),
+ ("Butt", "Плоске"),
+ ("Round", "Заокруглений"),
+ ("Projecting", "Виступне"),
+ ("Miter", "Гостре"),
+ ("Bevel", "Скіс"),
+ ("Center", "Центр"),
+ ("Start/End:", "Початок/кінець:"),
+ ("Left", "Ліворуч"),
+ ("Right", "Праворуч"),
+ ("First", "Перший"),
+ ("Last", "Останній"),
+ ("Before", "Перед"),
+ ("After", "Після"),
+ ("Shading", "Заливка"),
+ ("Border", "Межа"),
+ ("Overprint Fill", "Накладання заливки"),
+ ("Overprint Stroke", "Накладення обведення"),
+ ("Overprint Gap", "Накладання проміжку"),
+ ("Nonprinting", "Не друкувати"),
+ ("At Least", "Щонайменше"),
+ ("Exactly", "Точно"),
+ ("Justify", "За шириною"),
+ ("Underline Options", "Параметри підкреслення"),
+ ("Strikethrough Options", "Параметри закреслення"),
+ ("Discretionary Ligatures", "Додаткові лігатури"),
+ ("Fractions", "Дроби"),
+ ("Ordinal", "Порядкові числа"),
+ ("Swash", "Розчерк"),
+ ("Titling Alternates", "Заголовкові альтернативи"),
+ ("Contextual Alternates", "Контекстні альтернативи"),
+ ("Slashed Zero", "Перекреслений нуль"),
+ ("Tabular Lining", "Табличні маюскульні цифри"),
+ ("Proportional Oldstyle", "Пропорційні мінускульні цифри"),
+ ("Proportional Lining", "Пропорційні маюскульні цифри"),
+ ("Tabular Oldstyle", "Табличні мінускульні цифри"),
+ ("Default Figure Style", "Типовий стиль цифр"),
+ ("Not a button", "Не є кнопкою"),
+ ("Go To Next Page", "Перейти до наступної сторінки"),
+ ("Go To Previous Page", "Перейти до попередньої сторінки"),
+ ("Go To First Page", "Перейти до першої сторінки"),
+ ("Go To Last Page", "Перейти до останньої сторінки"),
+ ("Go To Page", "Перейти до сторінки"),
+ ("Go To URL", "Перейти за URL"),
+ ("Not a form field", "Не є полем форми"),
+ ("Text Field", "Текстове поле"),
+ ("Check Box", "Прапорець"),
+ ("Radio Button", "Перемикач"),
+ ("Combo Box", "Розкривний список"),
+ ("List Box", "Список"),
+ ("Signature", "Підпис"),
+ ("Resize width", "Змінювати ширину"),
+ ("Resize height", "Змінювати висоту"),
+ ("Pin top", "Закріпити зверху"),
+ ("Pin bottom", "Закріпити знизу"),
+ ("Pin left", "Закріпити ліворуч"),
+ ("Pin right", "Закріпити праворуч"),
+ ("Inward", "Усередину"),
+ ("Metrics", "Метрики"),
+ ("Optical", "Оптичний"),
+ ("Applies to fill", "Застосовується до заливки"),
+ ("Applies to text", "Застосовується до тексту"),
+ ("Gray", "Сірий"),
+ ("Linear", "Лінійний"),
+ ("Radial", "Радіальний"),
+ ("Arabic", "Арабська"),
+ ("Beveled Rectangle", "Прямокутник зі скошеними кутами"),
+ ("Chinese", "Китайська"),
+ ("Constrain proportions for width and height", "Зберігати співвідношення ширини та висоти"),
+ ("Corner", "Кут"),
+ ("Custom", "Користувацький"),
+ ("Czech", "Чеська"),
+ ("Danish", "Данська"),
+ ("Dashed", "Штриховий"),
+ ("Dotted", "Крапковий"),
+ ("Dutch: 2005 Reform", "Нідерландська: реформа 2005 року"),
+ ("Embedded", "Вбудовано"),
+ ("English: Canadian", "Англійська: Канада"),
+ ("English: UK", "Англійська: Велика Британія"),
+ ("English: USA", "Англійська: США"),
+ ("Farsi", "Перська"),
+ ("Finnish", "Фінська"),
+ ("Fit Frame to Content", "Припасувати фрейм до вмісту"),
+ ("French", "Французька"),
+ ("French: Canadian", "Французька: Канада"),
+ ("German: 2006 Reform", "Німецька: реформа 2006 року"),
+ ("German: Swiss 2006 Reform", "Німецька: Швейцарія, реформа 2006 року"),
+ ("Greek", "Грецька"),
+ ("Hebrew", "Іврит"),
+ ("Hindi", "Гінді"),
+ ("Hungarian", "Угорська"),
+ ("Inverse Rounded Rectangle", "Прямокутник з увігнутими округленими кутами"),
+ ("Italian", "Італійська"),
+ ("Japanese", "Японська"),
+ ("Korean", "Корейська"),
+ ("Make all settings the same", "Зробити всі параметри однаковими"),
+ ("Native", "Відповідно до мови"),
+ ("Norwegian: Bokmål", "Норвезька: букмол"),
+ ("Orthogonal Line", "Ортогональна лінія"),
+ ("Polish", "Польська"),
+ ("Portuguese", "Португальська"),
+ ("Portuguese: Brazilian", "Португальська: Бразилія"),
+ ("Rounded Rectangle", "Округлений прямокутник"),
+ ("Russian", "Російська"),
+ ("Solid", "Суцільний"),
+ ("Spanish", "Іспанська"),
+ ("Story Editor", "Текстовий редактор"),
+ ("Swedish", "Шведська"),
+ ("Thick - Thick", "Товста — товста"),
+ ("Thick - Thin", "Товста — тонка"),
+ ("Thin - Thick", "Тонка — товста"),
+ ("Thin - Thin", "Тонка — тонка"),
+ ("Thin - Thick - Thin", "Тонка — товста — тонка"),
+ ("Thick - Thin - Thick", "Товста — тонка — товста"),
+ ("Wavy", "Хвиляста"),
+ ("Straight Hash", "Пряме штрихування"),
+ ("Stripes", "Смуги"),
+ ("Triangle", "Трикутник"),
+ ("Turkish", "Турецька"),
+ ("Ukrainian", "Українська"),
+ ("Cell Style", "Стиль клітинки"),
+ ("Table Style", "Стиль таблиці"),
+ ("Col ←", "Стовпець ←"),
+ ("Col →", "Стовпець →"),
+ ("Row ↑", "Рядок ↑"),
+ ("Row ↓", "Рядок ↓"),
+ ("Hide Others", "Приховати решту"),
+ ("Lock Others", "Заблокувати решту"),
+ ("Show All Layers", "Показати всі шари"),
+ ("Unlock", "Розблокувати"),
+ ("Unlock All Layers", "Розблокувати усі шари"),
+ ("Added", "Додано"),
+ ("Deleted", "Видалено"),
+ ("Guide-based", "На основі напрямних"),
+ ("Loop", "Зациклити"),
+ ("Object-based", "На основі об’єктів"),
+ ("Off", "Вимкнено"),
+ ("Play on Page Load", "Відтворювати під час відкриття сторінки"),
+ ("Re-center", "Центрувати знову"),
+ ("Show Controls", "Показувати елементи керування"),
+ ("Signature Field", "Поле підпису"),
+ ("Select text to apply a condition", "Виділіть текст, щоб застосувати умову"),
+ ("Blinds", "Жалюзі"),
+ ("Box", "Рамка"),
+ ("Comb", "Гребінка"),
+ ("Cover", "Накриття"),
+ ("Dissolve", "Розчинення"),
+ ("Fade", "Згасання"),
+ ("Push", "Зсув"),
+ ("Split", "Розділення"),
+ ("Uncover", "Відкривання"),
+ ("Wipe", "Витирання"),
+ ("Simple", "Простий"),
+ ("Simple - Wide", "Простий — широкий"),
+ ("Triangle - Wide", "Трикутник — широкий"),
+ ("Barbed", "Із зубцями"),
+ ("Curved", "Вигнутий"),
+ ("Circle", "Коло"),
+ ("Circle - Solid", "Коло — заповнене"),
+ ("Square", "Квадрат"),
+ ("Square - Solid", "Квадрат — заповнений"),
+ ("Bar", "Риска"),
+ ("Fit Text", "Умістити текст"),
+ ("Preview (W)", "Перегляд (W)"),
+ ("Normal (W)", "Звичайний (W)"),
+ ("objects", "об’єктів"),
+ ("pages", "сторінок"),
+ ("stories", "текстових матеріалів"),
+ ("items", "елементів"),
+ ("warnings", "попереджень"),
+ ("Render", "Візуалізація"),
+ ("Set", "Установити"),
+ ("unused", "не використовується"),
+ ("⚠ Missing", "⚠ Немає"),
+ ("▲ Modified", "▲ Змінено"),
+ ("(Text Color)", "(Колір тексту)"),
+ ("{count} glyphs — click to insert at the text cursor", "Гліфів: {count} — клацніть, щоб вставити в позиції текстового курсора"),
+ ("{pages} Pages in {spreads} Spreads", "Сторінок: {pages}; розворотів: {spreads}"),
+ (" (auto)", " (авто)"),
+ ("Absolute Numbering (instead of Section Numbering)", "Наскрізна нумерація (замість нумерації за розділами)"),
+ (
+ "Add and remove pages as the primary text frame's story grows and shrinks",
+ "Додавати й вилучати сторінки зі збільшенням і зменшенням основного текстового матеріалу",
+ ),
+ ("Added Text (highlighted)", "Доданий текст (підсвічений)"),
+ ("Adjust Layout (objects follow the new page size)", "Адаптувати макет (об’єкти враховують новий розмір сторінки)"),
+ ("Auto-Fit", "Автоматичне припасування"),
+ ("Case sensitive", "З урахуванням регістру"),
+ ("Custom Tracking/Kerning", "Власний інтервал між літерами/кернінг"),
+ ("Dimensions Include Stroke Weight", "Розміри враховують товщину обведення"),
+ ("Enable Autocorrect", "Увімкнути автовиправлення"),
+ ("Enable Dynamic Spelling (underline misspelled words)", "Увімкнути динамічну перевірку правопису (підкреслювати помилкові слова)"),
+ ("Grids in Back", "Сітки позаду"),
+ ("H&J Violations", "Порушення переносів і вирівнювання"),
+ ("Include Bleed", "Включати виліт"),
+ ("Include Stroke Weight", "Враховувати товщину обведення"),
+ ("Join our Discord", "Приєднуйтесь до нашого Discord"),
+ ("Keep Violations", "Порушення правил утримання рядків"),
+ ("Overprint [Black] Swatch at 100%", "Накладання зразка [Чорний] на 100%"),
+ ("Printer's Marks", "Друкарські позначки"),
+ ("Remove Styles and Formatting from Text and Tables", "Вилучити стилі й форматування з тексту та таблиць"),
+ ("Ruby", "Фонетичні підписи"),
+ ("Show Note Anchors in Layout View", "Показувати прив’язки приміток у режимі макета"),
+ ("Spreads", "Розвороти"),
+ ("Substituted Fonts", "Замінені шрифти"),
+ ("Transformations are Totals", "Перетворення є сукупними"),
+ ("Use Black Point Compensation", "Використовувати компенсацію чорної точки"),
+ ("Use Typographer's Quotes", "Використовувати друкарські лапки"),
+ ("Whole word", "Ціле слово"),
+ ("Interface", "Інтерфейс"),
+ ("Advanced Type", "Додаткові параметри тексту"),
+ ("Composition", "Компонування"),
+ ("Show Font Names in English", "Показувати назви шрифтів англійською"),
+ ("Missing Glyphs", "Відсутні гліфи"),
+ ("Draw Missing Glyphs from Fallback Fonts", "Відображати відсутні гліфи резервними шрифтами"),
+ ("Units & Increments", "Одиниці та кроки"),
+ ("Grids", "Сітки"),
+ ("Guides & Pasteboard", "Напрямні та монтажний стіл"),
+ ("Dictionary", "Словник"),
+ ("Autocorrect", "Автовиправлення"),
+ ("Story Editor Display", "Відображення текстового редактора"),
+ ("File Handling", "Робота з файлами"),
+ ("Formatting affects container (J)", "Форматування впливає на контейнер (J)"),
+ ("Formatting affects text (J)", "Форматування впливає на текст (J)"),
+ ("Apply None", "Без кольору"),
+ ("Height Only", "Лише висота"),
+ ("Width Only", "Лише ширина"),
+ ("Height and Width", "Висота та ширина"),
+ ("Height only", "Лише висота"),
+ ("Width only", "Лише ширина"),
+ ("Height and width", "Висота та ширина"),
+ ("Version", "Версія"),
+ ("{count} characters don't fit", "Символів поза межами: {count}"),
+ ("document", "документ"),
+ ("story", "текстовий матеріал"),
+ ("selection", "виділення"),
+ ("top", "зверху"),
+ ("center", "по центру"),
+ ("bottom", "знизу"),
+ ("justify", "за шириною"),
+ ("text", "текст"),
+ ("graphic", "графіка"),
+ ("unassigned", "без призначення"),
+ ("Perceptual", "Перцепційний"),
+ ("Relative Colorimetric", "Відносний колориметричний"),
+ ("Saturation", "Насиченість"),
+ ("Absolute Colorimetric", "Абсолютний колориметричний"),
+ ("No conflicts", "Немає конфліктів"),
+ ("Page (1–{count}):", "Сторінка (1–{count}):"),
+ ("Fonts in Document: {count} Missing: {missing}", "Шрифтів у документі: {count} Відсутніх: {missing}"),
+ ("No data source", "Немає джерела даних"),
+ ("Select Data Source…", "Вибрати джерело даних…"),
+ ("Data Merge", "Об’єднання даних"),
+ ("Preview", "Перегляд"),
+ ("Previous", "Назад"),
+ ("Next", "Далі"),
+ ("Record", "Запис"),
+ ("Merge options", "Параметри об’єднання"),
+ ("All records", "Усі записи"),
+ ("One record", "Один запис"),
+ ("Range", "Діапазон"),
+ ("Records per page", "Записів на сторінку"),
+ ("Single record", "Один запис"),
+ ("Multiple records", "Кілька записів"),
+ ("Rows first", "Спочатку рядки"),
+ ("Columns first", "Спочатку стовпці"),
+ ("Column spacing", "Інтервал між стовпцями"),
+ ("Row spacing", "Інтервал між рядками"),
+ ("Fit proportionally", "Пропорційно припасувати"),
+ ("Fill proportionally", "Пропорційно заповнити"),
+ ("Fit content to frame", "Припасувати вміст до фрейму"),
+ ("Center in frame", "Центрувати у фреймі"),
+ ("Link images", "Пов’язувати зображення"),
+ ("Limit", "Обмеження"),
+ ("Bind to frame", "Прив’язати до фрейму"),
+ ("Insert field", "Вставити поле"),
+ ("Pass csv, rows, or bytes from the control channel.", "Передайте csv, rows або bytes через канал керування."),
+ ("Panels", "Панелі"),
+ ("Next Page", "Наступна сторінка"),
+ ("Previous Page", "Попередня сторінка"),
+ ("First Page", "Перша сторінка"),
+ ("Last Page", "Остання сторінка"),
+ ("Object Styles", "Стилі об’єктів"),
+ ("Row", "Рядок"),
+ ("Column", "Стовпець"),
+ ("Rulers", "Лінійки"),
+ ("Hide Guides", "Приховати напрямні"),
+ ("Hide Rulers", "Приховати лінійки"),
+ ("Extras", "Допоміжні елементи"),
+ ("Colour", "Колір"),
+ ("Transparency", "Прозорість"),
+ ("Community", "Спільнота"),
+ ("Join the community", "Приєднатися до спільноти"),
+ ("OK", "Гаразд"),
+ ("Done", "Готово"),
+ ("Orientation", "Орієнтація"),
+ ("Units", "Одиниці"),
+ ("Number of Pages", "Кількість сторінок"),
+ ("Start Page #", "Початкова сторінка №"),
+ ("Slug", "Службове поле"),
+ ("Binding", "Напрямок палітурки"),
+ ("Rotation", "Поворот"),
+ ("Flex Layout", "Гнучкий макет"),
+ ("Auto Size", "Автоматичний розмір"),
+ ("Kerning", "Кернінг"),
+ ("Alignment", "Вирівнювання"),
+ ("Indent", "Відступ"),
+ ("Space Before", "Інтервал перед"),
+ ("Space After", "Інтервал після"),
+ ("New Layer", "Новий шар"),
+ ("Colour Picker", "Палітра кольорів"),
+ ("Find What", "Знайти"),
+ ("Change To", "Замінити на"),
+ ("Selection", "Виділення"),
+ ("All Documents", "Усі документи"),
+ ("On", "Увімкнено"),
+];
+
+/// Contexts whose Ukrainian noun differs from the shared action/text-layout label.
+pub(super) const CONTEXT: &[(&str, &str, &str)] = &[("Columns", "table", "Стовпці"), ("Group", "selection", "Група")];
diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs
index 7e2146036..69415e9b3 100644
--- a/crates/ui-egui/src/icons.rs
+++ b/crates/ui-egui/src/icons.rs
@@ -805,6 +805,8 @@ pub fn button(ui: &mut egui::Ui, name: &str, size: f32, selected: bool, tip: &st
}
let pad = size * 0.16;
paint(ui.painter(), r.shrink(pad), name, if selected { t.text_strong } else { t.icon });
+ let enabled = ui.is_enabled();
+ resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, enabled, selected, tip));
if tip.is_empty() {
resp
} else {
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 7e3870f98..bc84d95a1 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -58,8 +58,8 @@ pub struct Services {
/// Hand bytes to the user as a named file (browser download). When set, Save uses it
/// instead of writing to a path.
pub download: Option,
- /// Files delivered asynchronously, drained every frame: `.designcraft` → `file.openBytes`,
- /// anything else → `file.place`.
+ /// Files delivered asynchronously, drained every frame: documents ([`opens_as_document`]) →
+ /// `file.openBytes`, `.ase` → `swatch.load`, anything else → `file.place`.
pub inbox: Option,
}
@@ -548,10 +548,10 @@ impl DesignApp {
self.ui.dialog = Some(dialogs::Dialog::new("pdfImport", json!({"path": path, "page": "1", "pages": pages})));
Ok(Value::Null)
} else {
- self.run(cmd, json!({"path": path}))
+ self.open_file(cmd, json!({"path": path}))
}
}
- Some(path) => self.run(cmd, json!({"path": path})),
+ Some(path) => self.open_file(cmd, json!({"path": path})),
None => Ok(Value::Null),
};
}
@@ -570,11 +570,11 @@ impl DesignApp {
let lower = name.to_ascii_lowercase();
let r = if lower.ends_with(".ase") {
self.run("swatch.load", json!({"base64": b64}))
- } else if lower.ends_with(".designcraft") || lower.ends_with(".idml") {
+ } else if opens_as_document(&name) {
let title = name.rsplit_once('.').map_or(name.as_str(), |(stem, _)| stem);
- self.run("file.openBytes", json!({"name": title, "base64": b64}))
+ self.open_file("file.openBytes", json!({"name": title, "base64": b64}))
} else {
- self.run("file.place", json!({"name": name, "base64": b64}))
+ self.open_file("file.place", json!({"name": name, "base64": b64}))
};
if let Err(e) = r {
self.status(format!("{name}: {e}"));
@@ -645,18 +645,34 @@ impl DesignApp {
}
#[cfg(not(target_arch = "wasm32"))]
for f in ctx.input(|i| i.raw.dropped_files.clone()) {
- {
- let p = f.path().to_string_lossy().to_string();
- if p.is_empty() {
- continue;
- }
- let lp = p.to_ascii_lowercase();
- let cmd = if lp.ends_with(".designcraft") || lp.ends_with(".idml") { "file.open" } else { "file.place" };
- let _ = self.run(cmd, json!({"path": p}));
+ let p = f.path().to_string_lossy().to_string();
+ if !p.is_empty() {
+ let _ = self.open_dropped(&p);
}
}
}
+ /// A file dropped on the window: documents open, anything else is placed.
+ pub fn open_dropped(&mut self, path: &str) -> Result {
+ let cmd = if opens_as_document(path) { "file.open" } else { "file.place" };
+ self.open_file(cmd, json!({"path": path}))
+ }
+
+ /// Open (`file.open`, `file.openBytes`) or place (`file.place`) a file the user chose. A file
+ /// that can't be opened or placed gets an alert with the reason, as well as the status line.
+ pub fn open_file(&mut self, cmd: &str, params: Value) -> Result {
+ let file = match params.get("path").and_then(Value::as_str) {
+ Some(path) => std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string()),
+ None => params.get("name").and_then(Value::as_str).unwrap_or_default().to_string(),
+ };
+ let r = self.run(cmd, params);
+ if let Err(e) = &r {
+ let title = if cmd == "file.place" { "Can't Place the File" } else { "Can't Open the File" };
+ self.ui.dialog = Some(dialogs::Dialog::new("alert", json!({"title": title, "file": file, "message": e})));
+ }
+ r
+ }
+
/// Inject synthetic events (one pointer event per frame).
pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) {
if self.synthetic.is_empty() {
@@ -865,6 +881,13 @@ impl DesignApp {
}
}
+/// Files that open as documents (when dropped or picked) rather than being placed: DesignCraft
+/// and IDML.
+pub fn opens_as_document(name: &str) -> bool {
+ let n = name.to_ascii_lowercase();
+ [".designcraft", ".idml"].iter().any(|ext| n.ends_with(ext))
+}
+
pub fn now_ms() -> f64 {
#[cfg(not(target_arch = "wasm32"))]
{
@@ -877,10 +900,66 @@ pub fn now_ms() -> f64 {
}
}
+/// Is `text` the name of a key ("AltGraph", "Dead", "AudioVolumeUp") instead of typed text?
+/// Browsers report a key press as a string: the character it types, or a name made of ASCII
+/// letters and digits that starts with a capital. One key press never types such a word.
+pub fn is_key_name(text: &str) -> bool {
+ text.len() > 1 && text.starts_with(|c: char| c.is_ascii_uppercase()) && text.chars().all(|c| c.is_ascii_alphanumeric())
+}
+
+/// Drop text events that are key names. eframe's web backend sends the name of every key it
+/// doesn't know as text, so AltGr typed "AltGraph" into the story. `text_field_focused`: an
+/// egui text field has the keyboard; its text comes from the browser's input events, which can
+/// hold whole words, and is left alone.
+pub fn drop_key_name_text(raw: &mut egui::RawInput, text_field_focused: bool) {
+ if !text_field_focused {
+ raw.events.retain(|e| !matches!(e, egui::Event::Text(t) if is_key_name(t)));
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
+ /// The Control panel's two-row groups were centred as if they were one row tall, so the second
+ /// row hung below the panel and was cut off.
+ #[test]
+ fn control_bar_shows_its_second_row() {
+ use egui_kittest::kittest::Queryable as _;
+ let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default());
+ app.run("file.newSample", json!({})).unwrap();
+ app.ui.control_bar = true;
+ let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
+ |ui, app: &mut DesignApp| {
+ let ctx = ui.ctx().clone();
+ app.logic(&ctx);
+ app.ui(ui);
+ },
+ app,
+ );
+ h.run_steps(4);
+ let bar = egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new("control_bar")).unwrap().outer_rect;
+ assert!(bar.height() > 40.0, "{bar:?}");
+ let rows: Vec = h.query_all_by_label("Y:").map(|n| n.rect()).filter(|r| bar.contains(r.left_top())).collect();
+ assert_eq!(rows.len(), 1, "the Y: caption of the Control panel");
+ assert!(rows[0].bottom() <= bar.bottom(), "Y: caption {:?} hangs below the Control panel {bar:?}", rows[0]);
+ }
+
+ #[test]
+ fn key_names_sent_as_text_are_dropped() {
+ let text = |t: &str| egui::Event::Text(t.to_string());
+ let events = || {
+ vec![text("AltGraph"), text("é"), text("Dead"), text("A"), text("Unidentified"), text("ß"), text("AudioVolumeUp"), text("ab"), text("Æ")]
+ };
+ let mut raw = egui::RawInput { events: events(), ..Default::default() };
+ drop_key_name_text(&mut raw, false);
+ assert_eq!(raw.events, vec![text("é"), text("A"), text("ß"), text("ab"), text("Æ")]);
+ // A focused text field gets its text from input events, which can be whole words.
+ let mut raw = egui::RawInput { events: events(), ..Default::default() };
+ drop_key_name_text(&mut raw, true);
+ assert_eq!(raw.events, events());
+ }
+
#[test]
fn snap_view_uses_the_saved_switches() {
let mut ui = UiState::default();
@@ -892,6 +971,134 @@ mod tests {
assert!(!ui.snap_view().snap_to_guides);
assert_eq!(ui.snap_view().zone_px, 0.0);
}
+
+ /// A temporary file named `name` whose bytes aren't a document or a graphic.
+ fn unreadable_file(name: &str) -> std::path::PathBuf {
+ let path = std::env::temp_dir().join(format!("designcraft-ui-{}-{name}", std::process::id()));
+ std::fs::write(&path, b"neither a document nor a graphic").unwrap();
+ path
+ }
+
+ /// The open alert dialog: (title, message).
+ fn alert(app: &DesignApp) -> (String, String) {
+ let d = app.ui.dialog.as_ref().expect("an alert is open");
+ assert_eq!(d.id, "alert");
+ (d.fields["title"].as_str().unwrap().to_string(), d.fields["message"].as_str().unwrap().to_string())
+ }
+
+ #[test]
+ fn dropped_documents_open_and_the_rest_is_placed() {
+ for name in ["a.designcraft", "B.IDML"] {
+ assert!(opens_as_document(name), "{name}");
+ }
+ for name in ["a.png", "b.pdf", "idml.txt", "c.idml.zip"] {
+ assert!(!opens_as_document(name), "{name}");
+ }
+ }
+
+ #[test]
+ fn dropping_an_unreadable_file_alerts() {
+ // A document opens, so it says it can't be opened.
+ let path = unreadable_file("broken.designcraft");
+ let mut app = DesignApp::new(Session::new(), Services::default());
+ let r = app.open_dropped(&path.to_string_lossy());
+ let _ = std::fs::remove_file(&path);
+ let e = r.unwrap_err();
+ let (title, message) = alert(&app);
+ assert_eq!(title, "Can't Open the File", "a document opens, it isn't placed");
+ assert_eq!(message, e);
+ assert!(app.ui.status.contains(&e), "the status line says it too");
+ assert!(app.session.documents().is_empty());
+ dialogs::confirm(&mut app).unwrap();
+ assert!(app.ui.dialog.is_none(), "OK closes the alert");
+ // Anything else is placed, so it says it can't be placed.
+ app.run("file.new", json!({})).unwrap();
+ let path = unreadable_file("notes.xyz");
+ let r = app.open_dropped(&path.to_string_lossy());
+ let _ = std::fs::remove_file(&path);
+ let e = r.unwrap_err();
+ assert_eq!(alert(&app), ("Can't Place the File".to_string(), e));
+ }
+
+ #[test]
+ fn file_open_failures_show_an_alert() {
+ let path = unreadable_file("open.designcraft");
+ let picked = path.to_string_lossy().to_string();
+ let services = Services { pick_open: Some(Box::new(move |_| Some(picked.clone()))), ..Default::default() };
+ let mut app = DesignApp::new(Session::new(), services);
+ let e = app.run("app.openDialog", json!({})).unwrap_err();
+ assert_eq!(alert(&app), ("Can't Open the File".to_string(), e));
+ // Placing it says so too.
+ app.ui.dialog = None;
+ app.run("file.new", json!({})).unwrap();
+ let e = app.run("app.placeDialog", json!({})).unwrap_err();
+ let _ = std::fs::remove_file(&path);
+ assert_eq!(alert(&app), ("Can't Place the File".to_string(), e));
+ }
+
+ #[test]
+ fn other_command_errors_stay_in_the_status_line() {
+ let mut app = DesignApp::new(Session::new(), Services::default());
+ assert!(app.run("file.open", json!({"path": "/nonexistent/x.designcraft"})).is_err());
+ assert!(app.ui.dialog.is_none(), "a scripted file.open doesn't raise an alert");
+ assert!(!app.ui.status.is_empty());
+ // The control channel's app.open answers with the error; no alert waits for a click.
+ let (req, _rx) = control::ControlRequest::new("app.open", json!({"path": "/nonexistent/x.designcraft"}));
+ let control::Outcome::Done(r) = control::handle(&mut app, &egui::Context::default(), &req) else { panic!("app.open answers") };
+ assert_eq!(r["ok"], json!(false), "{r}");
+ assert!(app.ui.dialog.is_none(), "a control-channel app.open doesn't raise an alert");
+ }
+}
+
+/// The whole window in a headless test harness, at a chosen window size.
+#[cfg(test)]
+pub(crate) mod test_window {
+ use egui_kittest::Harness;
+
+ pub struct Window {
+ pub app: crate::DesignApp,
+ ready: bool,
+ }
+
+ /// The window at `size` with a new document open.
+ pub fn open(mut app: crate::DesignApp, size: egui::Vec2) -> Harness<'static, Window> {
+ if app.session.active().is_none() {
+ app.run("file.new", serde_json::json!({})).unwrap();
+ }
+ let mut h = Harness::builder().with_size(size).with_max_steps(1000).build_ui_state(
+ |ui, w: &mut Window| {
+ // The builder runs a frame before the texture size can be raised: skip it.
+ if !w.ready {
+ return;
+ }
+ let ctx = ui.ctx().clone();
+ w.app.logic(&ctx);
+ w.app.ui(ui);
+ },
+ Window { app, ready: false },
+ );
+ h.input_mut().max_texture_side = Some(8192);
+ h.state_mut().ready = true;
+ h.run_steps(6);
+ h
+ }
+
+ /// The mouse wheel turned over `pos`: positive `delta` moves the content right and down.
+ pub fn wheel(h: &mut Harness<'static, Window>, pos: egui::Pos2, delta: egui::Vec2) {
+ h.hover_at(pos);
+ h.event(egui::Event::MouseWheel {
+ unit: egui::MouseWheelUnit::Point,
+ delta,
+ phase: egui::TouchPhase::Move,
+ modifiers: egui::Modifiers::NONE,
+ });
+ h.run_steps(10);
+ }
+
+ /// The outer rect of a side or top panel, from egui's memory.
+ pub fn panel_rect(h: &Harness<'static, Window>, id: &str) -> egui::Rect {
+ egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new(id)).map(|s| s.outer_rect).unwrap()
+ }
}
/// The whole window in a headless test harness, at a chosen window size.
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
index adabafeeb..53bee42e6 100644
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -38,7 +38,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
"app.language",
"Interface Language",
None,
- "{lang: \"\"|de|fr|es|ja|zh|ar|pt-br} — menus and panel names (the macOS menu bar follows on the next launch)",
+ "{lang: \"\"|de|fr|es|ja|zh|ar|pt-br|uk} — menus and panel names (the macOS menu bar follows on the next launch)",
),
(
"app.flattener",
@@ -241,6 +241,7 @@ pub const MENUS: &[(&str, &[&str])] = &[
"ui:app.language|简体中文|{\"lang\": \"zh\"}",
"ui:app.language|العربية|{\"lang\": \"ar\"}",
"ui:app.language|Português (Brasil)|{\"lang\": \"pt-br\"}",
+ "ui:app.language|Українська|{\"lang\": \"uk\"}",
"<",
">Transparency Flattener Presets",
"ui:app.flattener|None (keep transparency)|{\"preset\": \"\"}",
@@ -583,6 +584,7 @@ pub const MENUS: &[(&str, &[&str])] = &[
"cmd:table.splitHorizontally",
"cmd:table.splitVertically",
"cmd:table.distributeColumns",
+ "cmd:table.distributeRows",
"-",
"ui:app.tablePanel",
],
@@ -711,7 +713,7 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option v,
@@ -1389,10 +1391,20 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option Some("properties"),
+ "Interactive for PDF" | "Digital Publishing" => Some("pages"),
+ _ => None,
+ };
+ if let Some(tab) = dock_tab {
+ app.ui.dock_tab = tab.into();
+ app.ui.dock_expanded = true;
+ }
app.ui.open_panel = match name {
"Typography" => Some("paragraphStyles".into()),
"Printing and Proofing" => Some("swatches".into()),
+ "Interactive for PDF" => Some("buttons".into()),
+ "Digital Publishing" => Some("liquid".into()),
_ => None,
};
app.ui.workspace = name.to_string();
@@ -1422,7 +1434,7 @@ fn download_document(app: &mut DesignApp) -> Result {
fn export_png(app: &mut DesignApp, p: &Value) -> Result {
let st = app.session.active().ok_or("no document")?;
- let page = p.get("page").and_then(Value::as_u64).map(|v| v as usize).or_else(|| crate::canvas::current_page(app)).unwrap_or(0);
+ let page = crate::control::page_index(p, crate::canvas::current_page(app).unwrap_or(0), st.doc.page_count())?;
let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(2.0);
let mut r = designcraft_render::Renderer::new();
let img = r
@@ -1773,6 +1785,44 @@ pub fn activate(app: &mut DesignApp, id: &str, params: &Value) {
let _ = app.run(id, p);
}
+/// A native menu bar item was chosen (macOS): like [`activate`], except that the menu bar takes
+/// ⌘A, ⌘C, ⌘X, ⌘V, ⌘Z and ⇧⌘Z before the window sees them, so while a text field has keyboard
+/// focus, Select All, Copy, Cut, Paste, Undo and Redo act on that field, as in any Mac app.
+pub fn activate_native(app: &mut DesignApp, ctx: &egui::Context, id: &str, params: &Value) {
+ if !(ctx.text_edit_focused() && params.is_null() && forward_to_text_field(app, ctx, id)) {
+ activate(app, id, params);
+ }
+}
+
+/// Whether a native menu bar item can be chosen: as in the in-window menus, but the editing
+/// commands a focused text field takes are always available to it.
+pub fn native_menu_enabled(app: &DesignApp, ctx: &egui::Context, id: &str) -> bool {
+ (ctx.text_edit_focused() && TEXT_FIELD_COMMANDS.contains(&id)) || menu_enabled(app, id)
+}
+
+/// The editing commands a focused text field takes from the native menu bar.
+const TEXT_FIELD_COMMANDS: [&str; 6] = ["edit.selectAll", "edit.copy", "edit.cut", "edit.paste", "edit.undo", "edit.redo"];
+
+/// Hand a standard editing command to the focused text field as next frame's input; false when
+/// `id` isn't one of them.
+fn forward_to_text_field(app: &mut DesignApp, ctx: &egui::Context, id: &str) -> bool {
+ let key = |key: egui::Key, modifiers: egui::Modifiers| {
+ [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers })
+ };
+ match id {
+ "edit.selectAll" => app.synthetic.extend(key(egui::Key::A, egui::Modifiers::COMMAND)),
+ "edit.undo" => app.synthetic.extend(key(egui::Key::Z, egui::Modifiers::COMMAND)),
+ "edit.redo" => app.synthetic.extend(key(egui::Key::Z, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT)),
+ "edit.copy" => app.synthetic.push(egui::Event::Copy),
+ "edit.cut" => app.synthetic.push(egui::Event::Cut),
+ // Only the integration reads the system clipboard: it answers with an `Event::Paste`.
+ "edit.paste" => ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaste),
+ _ => return false,
+ }
+ ctx.request_repaint();
+ true
+}
+
/// The menu bar contents (inside the app bar; macOS uses the native menu instead).
pub fn menu_bar(app: &mut DesignApp, ui: &mut egui::Ui) {
let lang = app.ui.language.clone();
@@ -1785,7 +1835,7 @@ pub fn menu_bar(app: &mut DesignApp, ui: &mut egui::Ui) {
if crate::i18n::is_rtl(&lang) {
ui.set_max_width(320.0);
}
- ui.with_layout(egui::Layout::top_down(if crate::i18n::is_rtl(&lang) { egui::Align::Max } else { egui::Align::Min }), |ui| {
+ ui.with_layout(egui::Layout::top_down_justified(if crate::i18n::is_rtl(&lang) { egui::Align::Max } else { egui::Align::Min }), |ui| {
ui.set_min_width(240.0);
let hidden = menu_items(app, ui, &entries, menu);
if hidden > 0 && !app.ui.show_full_menus {
@@ -1926,6 +1976,9 @@ pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) {
if app.native_shortcuts.contains(&id) && !app.ui.shortcuts.contains_key(&id) {
continue; // The native menu handles it.
}
+ // The key press belongs to the shortcut: what it opens (Quick Apply's list, a dialog's
+ // default button) must not see the same press as Enter or Space this frame.
+ ctx.input_mut(|i| i.consume_key(modifiers, key));
// Like choosing the menu item: "…" commands open their dialog.
activate(app, &id, &Value::Null);
continue;
@@ -1961,6 +2014,13 @@ pub fn palette(app: &mut DesignApp, ctx: &egui::Context) {
);
r.request_focus();
let items = quick_apply_items(&app.session, &q);
+ // Only a fresh Return runs the first entry: ⌘Return pressed again or held down (repeats) is
+ // the shortcut, not a choice.
+ let enter = ui.input(|i| {
+ i.events
+ .iter()
+ .any(|e| matches!(e, egui::Event::Key { key: egui::Key::Enter, pressed: true, repeat: false, modifiers, .. } if !modifiers.command))
+ });
egui::ScrollArea::vertical().max_height(340.0).show(ui, |ui| {
for (i, it) in items.iter().enumerate() {
let mut b = egui::Button::new(it.label.as_str()).frame(false);
@@ -1969,7 +2029,13 @@ pub fn palette(app: &mut DesignApp, ctx: &egui::Context) {
} else if !it.kind.is_empty() {
b = b.shortcut_text(it.kind);
}
- if ui.add_sized([460.0, 22.0], b).clicked() || (i == 0 && ui.input(|i| i.key_pressed(egui::Key::Enter))) {
+ // The button aligns its label by the layout it's in: left, like the rows with a shortcut.
+ let size = egui::vec2(460.0, 22.0);
+ let row =
+ ui.allocate_ui_with_layout(size, egui::Layout::left_to_right(egui::Align::Center).with_main_align(egui::Align::Min), |ui| {
+ ui.add(b.min_size(size))
+ });
+ if row.inner.clicked() || (i == 0 && enter) {
run = Some((it.id.clone(), it.params.clone()));
}
}
@@ -2171,6 +2237,30 @@ mod tests {
}
}
+ #[test]
+ fn ukrainian_language_is_selectable_persisted_and_keeps_document_data() {
+ let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+ app.run("file.new", json!({"pages": 4, "facingPages": true})).unwrap();
+ app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "text", "text": "My words · Мої слова"})).unwrap();
+ let before = serde_json::to_value(&app.session.doc().unwrap().doc).unwrap();
+ run_ui(&mut app, "app.language", &json!({"lang": "uk"})).unwrap().unwrap();
+ assert_eq!(checked(&app, "app.language", &json!({"lang": "uk"})), Some(true));
+ assert_eq!(before, serde_json::to_value(&app.session.doc().unwrap().doc).unwrap());
+ for (title, _) in menu_tree() {
+ assert_ne!(crate::i18n::tr("uk", title), title, "{title}");
+ }
+ let bytes = serde_json::to_vec(&app.ui).unwrap();
+ let restored: crate::UiState = serde_json::from_slice(&bytes).unwrap();
+ assert_eq!(restored.language, "uk");
+ assert_eq!(crate::i18n::tr(&restored.language, "File"), "Файл");
+ assert_eq!(crate::i18n::tr(&restored.language, "A custom name"), "A custom name");
+ assert!(menu_tree().iter().flat_map(|(_, entries)| entries.iter()).any(|entry| {
+ matches!(entry, Item::Sub(label, children) if label == "Interface Language" && children.iter().any(|child| {
+ matches!(child, Item::Cmd { label, id, params, .. } if label == "Українська" && id == "app.language" && params["lang"] == "uk")
+ }))
+ }));
+ }
+
#[test]
fn arabic_dialog_and_language_switch_keep_finite_widget_geometry() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
@@ -2198,7 +2288,7 @@ mod tests {
crate::dialogs::confirm(&mut app).unwrap();
frame(&mut app);
assert_eq!(app.session.documents().len(), 1);
- for lang in ["zh", "", "ar", "pt-br"] {
+ for lang in ["uk", "zh", "", "ar", "pt-br"] {
app.run("app.language", json!({"lang": lang})).unwrap();
frame(&mut app);
}
@@ -2410,6 +2500,78 @@ mod tests {
assert_eq!(app.session.tool_id(), "rectangleFrame");
}
+ #[test]
+ fn the_quick_apply_shortcut_opens_it_without_running_an_entry() {
+ let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+ app.session.execute("file.new", &json!({})).unwrap();
+ let ctx = egui::Context::default();
+ let frame = |app: &mut crate::DesignApp, events: Vec| {
+ let input = egui::RawInput {
+ screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))),
+ events,
+ ..Default::default()
+ };
+ let mut out = ctx.run_ui(input, |ui| {
+ app.logic(&ui.ctx().clone());
+ app.ui(ui);
+ });
+ out.textures_delta.clear();
+ };
+ let cmd_return =
+ |pressed| egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::COMMAND };
+ frame(&mut app, vec![]);
+ frame(&mut app, vec![]);
+ frame(&mut app, vec![cmd_return(true), cmd_return(false)]);
+ frame(&mut app, vec![]);
+ assert_eq!(app.ui.palette.as_deref(), Some(""), "Quick Apply stays open");
+ assert_eq!(app.session.documents().len(), 1, "the first entry (New Document) didn't run");
+ // Pressing the shortcut again, or holding it until it repeats, doesn't run it either.
+ frame(&mut app, vec![cmd_return(true), cmd_return(false)]);
+ let repeat = egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: true, modifiers: egui::Modifiers::COMMAND };
+ frame(&mut app, vec![repeat, cmd_return(false)]);
+ assert_eq!(app.ui.palette.as_deref(), Some(""), "Quick Apply stays open");
+ assert_eq!(app.session.documents().len(), 1, "a second ⌘Return or a repeat doesn't run the first entry");
+ // A plain Return still does.
+ let enter =
+ |pressed| egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::NONE };
+ frame(&mut app, vec![enter(true), enter(false)]);
+ assert_eq!(app.ui.palette, None, "Return runs the first entry and closes Quick Apply");
+ assert_eq!(app.session.documents().len(), 2);
+ }
+
+ #[test]
+ fn quick_apply_rows_are_left_aligned() {
+ let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+ app.session.execute("file.new", &json!({})).unwrap();
+ app.ui.palette = Some(String::new());
+ let ctx = egui::Context::default();
+ let mut shapes = Vec::new();
+ for _ in 0..3 {
+ let input =
+ egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), ..Default::default() };
+ let mut out = ctx.run_ui(input, |ui| {
+ app.logic(&ui.ctx().clone());
+ app.ui(ui);
+ });
+ out.textures_delta.clear();
+ shapes = out.shapes;
+ }
+ let label_x = |label: &str| {
+ // The palette is painted last, over anything else with the same text.
+ shapes.iter().rev().find_map(|s| match &s.shape {
+ egui::Shape::Text(t) if t.galley.text() == label => Some(t.pos.x),
+ _ => None,
+ })
+ };
+ // Rows that show a shortcut or a style kind, and rows that show neither, among the first visible ones.
+ let items = quick_apply_items(&app.session, "");
+ let first = items.iter().take(10);
+ let plain = first.clone().find(|it| it.shortcut.is_none() && it.kind.is_empty()).expect("a row without a shortcut");
+ let with_sc = first.clone().find(|it| it.shortcut.is_some()).expect("a row with a shortcut");
+ let (a, b) = (label_x(&plain.label).expect("plain row drawn"), label_x(&with_sc.label).expect("shortcut row drawn"));
+ assert!((a - b).abs() < 0.5, "{:?} at x {a}, {:?} at x {b}", plain.label, with_sc.label);
+ }
+
#[test]
fn sample_scripts_run() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
@@ -2463,6 +2625,129 @@ mod tests {
assert!(run_ui(&mut app, "window.floatPanel", &json!({"panel": "nope"})).unwrap().is_err());
}
+ /// One frame as the desktop app runs it: queued synthetic input, then the native menu item
+ /// chosen during the frame (`menu`), then the app's logic and ui.
+ fn native_frame(app: &mut crate::DesignApp, ctx: &egui::Context, events: Vec