mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-26 11:16:50 -06:00
Merge remote-tracking branch 'origin/main' into zydvc26s67-web-paragraph-styles-save-delete
# Conflicts: # crates/doc/src/styles.rs # crates/engine/src/cmd/style.rs # crates/ui-egui/src/dialogs.rs # crates/ui-egui/src/panels/styles.rs
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
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: |
|
||||
@@ -72,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
|
||||
@@ -103,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/*
|
||||
@@ -131,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
|
||||
@@ -166,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/*
|
||||
@@ -190,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
|
||||
@@ -207,8 +204,8 @@ jobs:
|
||||
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
|
||||
@@ -232,7 +229,7 @@ jobs:
|
||||
"$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/*
|
||||
@@ -255,8 +252,8 @@ jobs:
|
||||
DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/linux
|
||||
@@ -270,7 +267,7 @@ jobs:
|
||||
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@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-flatpak-${{ matrix.arch }}
|
||||
path: dist/release/*.flatpak
|
||||
@@ -288,17 +285,16 @@ 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
|
||||
- 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
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
@@ -323,7 +319,7 @@ jobs:
|
||||
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@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-freebsd-x86_64
|
||||
path: dist/release/*
|
||||
@@ -339,18 +335,17 @@ jobs:
|
||||
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
|
||||
@@ -366,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/*
|
||||
@@ -385,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Generated
+1
@@ -1114,6 +1114,7 @@ dependencies = [
|
||||
"designcraft-engine",
|
||||
"designcraft-fonts",
|
||||
"designcraft-geom",
|
||||
"designcraft-idml",
|
||||
"designcraft-render",
|
||||
"designcraft-tools",
|
||||
"egui",
|
||||
|
||||
@@ -123,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
|
||||
|
||||
+5
-1
@@ -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.
|
||||
@@ -93,6 +96,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
|
||||
- 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 `<video>`/`<audio>` 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.
|
||||
|
||||
@@ -12,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
|
||||
//! ```
|
||||
@@ -34,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) -> ! {
|
||||
@@ -71,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 (--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"
|
||||
);
|
||||
eprintln!(
|
||||
"\nCommunity: {} · {} · {}",
|
||||
designcraft_engine::links::DISCORD,
|
||||
designcraft_engine::links::APP_PAGE,
|
||||
designcraft_engine::links::GITHUB
|
||||
);
|
||||
eprintln!("{USAGE}");
|
||||
eprintln!("{}", usage_footer());
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
@@ -101,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> {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use super::browser_policy::{has_query_flag, mime_for};
|
||||
use designcraft_engine::Session;
|
||||
use designcraft_ui_egui::{DesignApp, Inbox, Services};
|
||||
use designcraft_ui_egui::{DesignApp, ImportRequest, ImportedFile, Inbox, Services};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
const DOC_EXTS: &[&str] = &["designcraft", "idml"];
|
||||
@@ -88,13 +88,14 @@ impl eframe::App for WebShell {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
|
||||
for f in dropped {
|
||||
let request = self.app.import_request("drop");
|
||||
let inbox = self.inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
|
||||
match f.bytes_async().await {
|
||||
Ok(bytes) => {
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push(ImportedFile { request, name, bytes });
|
||||
ctx.request_repaint();
|
||||
}
|
||||
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
|
||||
@@ -117,12 +118,12 @@ impl eframe::App for WebShell {
|
||||
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
let open_inbox = inbox.clone();
|
||||
Services {
|
||||
open_async: Some(Box::new(move |purpose: &str| {
|
||||
open_async: Some(Box::new(move |request: ImportRequest| {
|
||||
let inbox = open_inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
let dialog = if purpose == "swatches" {
|
||||
let dialog = if request.purpose == "swatches" {
|
||||
rfd::AsyncFileDialog::new().add_filter("Swatch Exchange (ASE)", &["ase"])
|
||||
} else if purpose == "place" {
|
||||
} else if request.purpose == "place" {
|
||||
rfd::AsyncFileDialog::new().add_filter("Graphics", IMAGE_EXTS)
|
||||
} else {
|
||||
rfd::AsyncFileDialog::new().add_filter("DesignCraft", DOC_EXTS)
|
||||
@@ -132,7 +133,7 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
return;
|
||||
};
|
||||
let bytes = file.read().await;
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes));
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push(ImportedFile { request, name: file.file_name(), bytes });
|
||||
ctx.request_repaint();
|
||||
});
|
||||
})),
|
||||
|
||||
@@ -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:
|
||||
//! `<settings dir>/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.<KEEP>.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<LevelFilter> {
|
||||
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` → … → `.<KEEP>.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: `<timestamp> <LEVEL> [<thread>] <target>: <message>\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<File>,
|
||||
pending: Vec<String>,
|
||||
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<Sink>,
|
||||
}
|
||||
|
||||
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<PathBuf, String> {
|
||||
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<AppLogger> = 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -41,8 +42,9 @@ impl eframe::App for App {
|
||||
}
|
||||
}
|
||||
|
||||
fn prefs_path() -> Option<std::path::PathBuf> {
|
||||
let base = if cfg!(target_os = "macos") {
|
||||
/// The per-user settings directory: `ui.json`, `prefs.json` and `logs/` live here.
|
||||
fn config_dir() -> Option<std::path::PathBuf> {
|
||||
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<std::path::PathBuf> {
|
||||
.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<std::path::PathBuf> {
|
||||
config_dir().map(|b| b.join("ui.json"))
|
||||
}
|
||||
|
||||
fn load_prefs(app: &mut DesignApp) {
|
||||
@@ -182,14 +187,28 @@ fn app_icon() -> Option<egui::IconData> {
|
||||
eframe::icon_data::from_png_bytes(png).map_err(|e| log::warn!("app icon: {e}")).ok()
|
||||
}
|
||||
|
||||
/// The control port from `--control <port>` 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<String>) -> Option<u16> {
|
||||
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<u16> = 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")
|
||||
@@ -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");
|
||||
|
||||
@@ -22,5 +22,8 @@ real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
sync_github_name = false
|
||||
|
||||
[people.moppeeer]
|
||||
sync_github_name = true
|
||||
|
||||
[people.thaynej]
|
||||
real_name = "Jeffrey Thayne"
|
||||
|
||||
+303
-40
@@ -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<Item = (&Rule, Rect)> {
|
||||
[(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>, usize)> = Vec::with_capacity(np);
|
||||
let mut var_cache: std::collections::HashMap<Option<usize>, std::sync::Arc<Vec<String>>> = Default::default();
|
||||
// Column balancing above spanning paragraphs: settled and trial bottoms by band, the search
|
||||
// in progress, and a budget for the re-lays.
|
||||
let mut limits: Rc<Vec<(BandKey, f64)>> = Rc::default();
|
||||
let mut trial: Option<Trial> = 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<usize>, 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<f64> = 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>, usize)],
|
||||
notes: &mut Notes,
|
||||
out: &mut ComposedStory,
|
||||
cur: &mut Cursor,
|
||||
list_counter: &mut u32,
|
||||
force_col: &mut [bool],
|
||||
line_cap: &mut [Option<usize>],
|
||||
) {
|
||||
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<Vec<(BandKey, f64)>>,
|
||||
}
|
||||
|
||||
/// 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<Above>,
|
||||
}
|
||||
|
||||
#[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<Trial>, limits: &mut Rc<Vec<(BandKey, f64)>>, budget_left: bool) -> Option<usize> {
|
||||
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<Vec<(BandKey, f64)>>, key: BandKey, bottom: Option<f64>) {
|
||||
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<f64> {
|
||||
limits.iter().find(|(k, _)| *k == key).map(|&(_, b)| b)
|
||||
}
|
||||
|
||||
impl Cursor {
|
||||
fn next_column(&mut self, cols: &[Vec<Rect>]) {
|
||||
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 {
|
||||
@@ -2114,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<usize> = 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<usize> = ft.lines.iter().enumerate().filter(|(_, l)| spanned || l.column == c).map(|(i, _)| i).collect();
|
||||
let (Some(&a), Some(_)) = (idx.first(), idx.last()) else { continue };
|
||||
let bottom = idx.iter().map(|&i| ft.lines[i].baseline + ft.lines[i].descent).fold(f64::NEG_INFINITY, f64::max);
|
||||
let space = f.area.y1 - bottom;
|
||||
if space <= 0.0 {
|
||||
continue;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -573,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());
|
||||
@@ -1889,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<f64> = (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));
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ pub const PRESETS: &[Preset] = &[
|
||||
Preset { name: "A3", intent: Intent::Print, width: 297.0 * MM, height: 420.0 * MM, units: Unit::Millimeters },
|
||||
Preset { name: "A4", intent: Intent::Print, width: 210.0 * MM, height: 297.0 * MM, units: Unit::Millimeters },
|
||||
Preset { name: "A5", intent: Intent::Print, width: 148.0 * MM, height: 210.0 * MM, units: Unit::Millimeters },
|
||||
Preset { name: "A6", intent: Intent::Print, width: 105.0 * MM, height: 148.0 * MM, units: Unit::Millimeters },
|
||||
Preset { name: "B5", intent: Intent::Print, width: 176.0 * MM, height: 250.0 * MM, units: Unit::Millimeters },
|
||||
Preset { name: "Business Card", intent: Intent::Print, width: 252.0, height: 144.0, units: Unit::Picas },
|
||||
Preset { name: "Postcard", intent: Intent::Print, width: 432.0, height: 288.0, units: Unit::Picas },
|
||||
|
||||
@@ -336,9 +336,13 @@ impl Styles {
|
||||
cp
|
||||
}
|
||||
|
||||
/// Character-style-only resolution (no paragraph base, no local overrides).
|
||||
pub fn resolve_char_style(&self, name: &str) -> CharProps {
|
||||
self.resolve_char(&CharProps::default(), &CharFormat { style: name.into(), over: CharAttrs::default() })
|
||||
/// The attributes character style `name` sets, with those of its Based On chain beneath them.
|
||||
pub fn char_style_attrs(&self, name: &str) -> CharAttrs {
|
||||
let mut a = CharAttrs::default();
|
||||
for s in self.char_chain(name) {
|
||||
a.merge(&s.chars);
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
/// Would making `name` based on `parent` create a cycle?
|
||||
@@ -407,6 +411,24 @@ mod tests {
|
||||
assert!(!st.para_based_on_cycles(BASIC_PARAGRAPH, NO_PARA_STYLE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_style_attrs_include_its_based_on_chain_only() {
|
||||
let mut st = Styles::default();
|
||||
let cs = |name: &str, based_on: Option<&str>, chars| CharacterStyle {
|
||||
name: name.into(),
|
||||
based_on: based_on.map(Into::into),
|
||||
chars,
|
||||
shortcut: String::new(),
|
||||
};
|
||||
st.character.push(cs("Base", None, CharAttrs { font_style: Some("Bold".into()), size: Some(9.0), ..Default::default() }));
|
||||
st.character.push(cs("Strong", Some("Base"), CharAttrs { size: Some(14.0), ..Default::default() }));
|
||||
st.character.push(cs("Loop", Some("Loop"), CharAttrs { tracking: Some(5.0), ..Default::default() }));
|
||||
assert_eq!(st.char_style_attrs("Strong"), CharAttrs { font_style: Some("Bold".into()), size: Some(14.0), ..Default::default() });
|
||||
assert_eq!(st.char_style_attrs("Loop"), CharAttrs { tracking: Some(5.0), ..Default::default() });
|
||||
assert!(st.char_style_attrs(NO_CHAR_STYLE).is_empty());
|
||||
assert!(st.char_style_attrs("Missing").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_names() {
|
||||
let names = ["Body", "Body 2"];
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -164,6 +164,7 @@ fn file_open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
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()));
|
||||
@@ -296,10 +297,13 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pdf_page = match p.get("pdfPage").and_then(Value::as_u64) {
|
||||
Some(n) if designcraft_render::is_pdf(&bytes) => {
|
||||
let count = designcraft_render::pdf_page_count(&bytes).unwrap_or(1);
|
||||
if n == 0 || n as usize > count {
|
||||
return Err(bad("file.place", format!("the PDF has {count} page(s)")));
|
||||
let invalid_page = || bad("file.place", format!("the PDF has {count} page(s)"));
|
||||
let one_based = usize::try_from(n).map_err(|_| invalid_page())?;
|
||||
if one_based == 0 || one_based > count {
|
||||
return Err(invalid_page());
|
||||
}
|
||||
n as u32 - 1
|
||||
let zero_based = one_based.checked_sub(1).ok_or_else(invalid_page)?;
|
||||
u32::try_from(zero_based).map_err(|_| invalid_page())?
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
@@ -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::<Vec<_>>()
|
||||
};
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,13 +59,22 @@ pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result<Document> {
|
||||
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<u8>)> {
|
||||
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<std::path::PathBuf> {
|
||||
// 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<u8>)> {
|
||||
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<u8>, Vec<u8>)> {
|
||||
fn linked_idml(link: &str, embed_images: bool) -> Result<(Vec<u8>, Vec<u8>)> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,6 +221,31 @@ fn copy_links(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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()));
|
||||
|
||||
+149
-16
@@ -100,8 +100,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
edit_para
|
||||
),
|
||||
cmd!("style.paragraph.delete", "Delete Paragraph Style", [], None, "{name, replaceWith?}", has_doc, delete_para),
|
||||
cmd!("style.character.create", "New Character Style…", [], None, "{name, basedOn?, chars?: {…}}", has_doc, create_char),
|
||||
cmd!("style.character.edit", "Character Style Options…", [], None, "{name, rename?, basedOn?, chars?}", has_doc, edit_char),
|
||||
cmd!(
|
||||
"style.character.create",
|
||||
"New Character Style…",
|
||||
[],
|
||||
None,
|
||||
"{name, basedOn?: style | \"[None]\", chars?: {…}} — the style sets only `chars` → {name} (made unique)",
|
||||
has_doc,
|
||||
create_char
|
||||
),
|
||||
cmd!(
|
||||
"style.character.edit",
|
||||
"Character Style Options…",
|
||||
[],
|
||||
None,
|
||||
"{name, rename?, basedOn?: style | \"[None]\" | null, chars?: {attr: value | null (no longer set)}} — renaming updates every use",
|
||||
has_doc,
|
||||
edit_char
|
||||
),
|
||||
cmd!("style.character.delete", "Delete Character Style", [], None, "{name, replaceWith?}", has_doc, delete_char),
|
||||
cmd!(query "style.list", "List Styles", [], None, "{} → paragraph and character style names", has_doc, |s, _| {
|
||||
let st = s.doc()?;
|
||||
@@ -508,6 +524,12 @@ fn rename_style(d: &mut designcraft_doc::Document, para: bool, from: &str, to: &
|
||||
s.based_on = Some(to.to_string());
|
||||
}
|
||||
}
|
||||
for s in &mut st.paragraph {
|
||||
rename_char_refs(&mut s.para, from, to);
|
||||
}
|
||||
if d.footnote_options.ref_char_style == from {
|
||||
d.footnote_options.ref_char_style = to.to_string();
|
||||
}
|
||||
}
|
||||
for sid in d.stories.keys().copied().collect::<Vec<_>>() {
|
||||
let Some(story) = d.story_mut(sid) else { continue };
|
||||
@@ -520,12 +542,26 @@ fn rename_style(d: &mut designcraft_doc::Document, para: bool, from: &str, to: &
|
||||
for r in st.chars.iter_mut().filter(|r| r.format.style == from) {
|
||||
r.format.style = to.to_string();
|
||||
}
|
||||
for f in &mut st.paras {
|
||||
rename_char_refs(&mut f.para, from, to);
|
||||
}
|
||||
}
|
||||
st.rev += 1;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Point the nested, nested line and GREP styles of paragraph attributes at a renamed character style.
|
||||
fn rename_char_refs(p: &mut ParaAttrs, from: &str, to: &str) {
|
||||
let names = p.nested_styles.iter_mut().flatten().map(|n| &mut n.style);
|
||||
let names = names.chain(p.nested_line_styles.iter_mut().flatten().map(|n| &mut n.style));
|
||||
for n in names.chain(p.grep_styles.iter_mut().flatten().map(|g| &mut g.style)) {
|
||||
if n == from {
|
||||
*n = to.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn group_styles(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let para = match str_param(p, "kind").unwrap_or("paragraph") {
|
||||
"paragraph" => true,
|
||||
@@ -584,8 +620,14 @@ fn delete_para(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn create_char(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let base = str_param(p, "name").unwrap_or("Character Style 1").to_string();
|
||||
let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
|
||||
let based_on = str_param(p, "basedOn").map(str::to_string);
|
||||
// Based on [None] is based on nothing.
|
||||
let based_on = str_param(p, "basedOn").filter(|b| *b != designcraft_doc::NO_CHAR_STYLE).map(str::to_string);
|
||||
s.edit(|d, _| {
|
||||
if let Some(b) = &based_on
|
||||
&& d.styles.char_style(b).is_none()
|
||||
{
|
||||
return Err(bad("style.character.create", format!("no character style `{b}`")));
|
||||
}
|
||||
let name = Styles::unique_name(|n| d.styles.char_style(n).is_some(), &base);
|
||||
d.styles_mut().character.push(CharacterStyle { name: name.clone(), based_on, chars, shortcut: String::new() });
|
||||
Ok(json!({"name": name}))
|
||||
@@ -594,25 +636,49 @@ fn create_char(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn edit_char(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("style.character.edit", "missing name"))?.to_string();
|
||||
if name == designcraft_doc::NO_CHAR_STYLE {
|
||||
return Err(bad("style.character.edit", "[None] can't be edited"));
|
||||
}
|
||||
let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
|
||||
let rename = str_param(p, "rename").map(str::to_string);
|
||||
let based = p.get("basedOn").cloned();
|
||||
// `null` unsets an attribute (the style no longer sets it).
|
||||
let unset: Vec<String> = p
|
||||
.get("chars")
|
||||
.and_then(Value::as_object)
|
||||
.map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect())
|
||||
.unwrap_or_default();
|
||||
let rename = str_param(p, "rename").filter(|n| *n != name).map(|n| n.trim().to_string());
|
||||
let based: Option<Option<String>> = match p.get("basedOn") {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
Some(Value::String(b)) if b == designcraft_doc::NO_CHAR_STYLE => Some(None),
|
||||
Some(Value::String(b)) => Some(Some(b.clone())),
|
||||
Some(_) => return Err(bad("style.character.edit", "`basedOn` is a style name or null")),
|
||||
};
|
||||
s.edit(|d, _| {
|
||||
if let Some(Value::String(b)) = &based
|
||||
&& d.styles.char_based_on_cycles(&name, b)
|
||||
if let Some(Some(b)) = &based {
|
||||
if d.styles.char_style(b).is_none() {
|
||||
return Err(bad("style.character.edit", format!("no character style `{b}`")));
|
||||
}
|
||||
if d.styles.char_based_on_cycles(&name, b) {
|
||||
return Err(bad("style.character.edit", "based-on would create a cycle"));
|
||||
}
|
||||
}
|
||||
if let Some(n) = &rename
|
||||
&& (n.is_empty() || n.starts_with('[') || d.styles.char_style(n).is_some())
|
||||
{
|
||||
return Err(bad("style.character.edit", "based-on would create a cycle"));
|
||||
return Err(bad("style.character.edit", format!("can't rename to `{n}`")));
|
||||
}
|
||||
let st = d.styles_mut().char_style_mut(&name).ok_or_else(|| bad("style.character.edit", "no such style"))?;
|
||||
let st = d.styles_mut().char_style_mut(&name).ok_or_else(|| bad("style.character.edit", format!("no character style `{name}`")))?;
|
||||
st.chars.merge(&chars);
|
||||
match based {
|
||||
Some(Value::String(b)) => st.based_on = Some(b),
|
||||
Some(Value::Null) => st.based_on = None,
|
||||
_ => {}
|
||||
for k in &unset {
|
||||
st.chars.set_json(k, &Value::Null).map_err(|e| bad("style.character.edit", e))?;
|
||||
}
|
||||
if let Some(n) = rename.clone() {
|
||||
// Every use: other styles, stories, nested/GREP styles, export tags.
|
||||
rename_style(d, false, &name, &n);
|
||||
if let Some(b) = based.clone() {
|
||||
st.based_on = b;
|
||||
}
|
||||
if let Some(n) = &rename {
|
||||
// Every use: other styles, stories, table cells, nested and GREP styles, footnotes.
|
||||
rename_style(d, false, &name, n);
|
||||
}
|
||||
ok()
|
||||
})
|
||||
@@ -1015,6 +1081,73 @@ mod color_tests {
|
||||
assert!(s.execute("style.group", &json!({"names": ["[Basic Paragraph]"], "group": "X"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_style_edit_sets_unsets_rebases_and_renames_every_use() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("style.character.create", &json!({"name": "Base", "chars": {"fontStyle": "Bold"}})).unwrap();
|
||||
s.execute("style.character.create", &json!({"name": "Emphasis", "chars": {"fontStyle": "Italic", "tracking": 20}})).unwrap();
|
||||
s.execute(
|
||||
"style.paragraph.create",
|
||||
&json!({"name": "Lead", "para": {
|
||||
"nestedStyles": [{"style": "Emphasis", "through": true, "count": 1, "until": {"kind": "words"}}],
|
||||
"grepStyles": [{"style": "Emphasis", "pattern": "\\d+"}],
|
||||
"nestedLineStyles": [{"style": "Emphasis", "lines": 1}]}}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute("footnote.options", &json!({"refCharStyle": "Emphasis"})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hi there"})).unwrap();
|
||||
let sid = r["story"].as_u64().unwrap();
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 2})).unwrap();
|
||||
s.execute("style.character.apply", &json!({"name": "Emphasis"})).unwrap();
|
||||
|
||||
s.execute(
|
||||
"style.character.edit",
|
||||
&json!({"name": "Emphasis", "rename": "Strong", "basedOn": "Base", "chars": {"size": 14, "fill": "[Paper]", "fontStyle": null}}),
|
||||
)
|
||||
.unwrap();
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
assert!(d.styles.char_style("Emphasis").is_none());
|
||||
let st = d.styles.char_style("Strong").unwrap();
|
||||
assert_eq!(st.based_on.as_deref(), Some("Base"));
|
||||
// Exactly the attributes set: `null` unset the font style, the rest stayed unset.
|
||||
assert_eq!(st.chars, CharAttrs { size: Some(14.0), fill: Some("[Paper]".into()), tracking: Some(20.0), ..Default::default() });
|
||||
let story = d.story(designcraft_doc::StoryId(sid)).unwrap();
|
||||
assert!(story.chars.iter().any(|r| r.format.style == "Strong"));
|
||||
assert!(story.chars.iter().all(|r| r.format.style != "Emphasis"));
|
||||
let lead = d.styles.para("Lead").unwrap();
|
||||
assert_eq!(lead.para.nested_styles.as_ref().unwrap()[0].style, "Strong");
|
||||
assert_eq!(lead.para.grep_styles.as_ref().unwrap()[0].style, "Strong");
|
||||
assert_eq!(lead.para.nested_line_styles.as_ref().unwrap()[0].style, "Strong");
|
||||
assert_eq!(d.footnote_options.ref_char_style, "Strong");
|
||||
|
||||
// Based On [None] (null or the name) clears it.
|
||||
s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": null})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.styles.char_style("Strong").unwrap().based_on, None);
|
||||
s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": "Base"})).unwrap();
|
||||
s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": "[None]"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.styles.char_style("Strong").unwrap().based_on, None);
|
||||
|
||||
// Refused: a based-on cycle, an unknown parent, a taken or empty name, editing [None].
|
||||
s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": "Base"})).unwrap();
|
||||
assert!(s.execute("style.character.edit", &json!({"name": "Base", "basedOn": "Strong"})).is_err());
|
||||
assert!(s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": "Nope"})).is_err());
|
||||
assert!(s.execute("style.character.edit", &json!({"name": "Strong", "rename": "Base"})).is_err());
|
||||
assert!(s.execute("style.character.edit", &json!({"name": "Strong", "rename": " "})).is_err());
|
||||
assert!(s.execute("style.character.edit", &json!({"name": "[None]", "chars": {"size": 9}})).is_err());
|
||||
assert!(s.doc().unwrap().doc.styles.char_style("[None]").unwrap().chars.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_character_style_checks_its_based_on() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
assert!(s.execute("style.character.create", &json!({"name": "Orphan", "basedOn": "Nope"})).is_err());
|
||||
s.execute("style.character.create", &json!({"name": "Plain", "basedOn": "[None]", "chars": {"size": 9}})).unwrap();
|
||||
let st = s.doc().unwrap().doc.styles.char_style("Plain").cloned().unwrap();
|
||||
assert_eq!((st.based_on, st.chars), (None, CharAttrs { size: Some(9.0), ..Default::default() }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swatches_save_and_load_ase() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -203,6 +203,15 @@ fn table_specs() -> Vec<CommandSpec> {
|
||||
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",
|
||||
@@ -743,6 +752,63 @@ fn set_col_width(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
fn distribute_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
let g = target(s, p, "table.distributeColumns")?;
|
||||
edit_table(s, &g, "table.distributeColumns", |t| {
|
||||
@@ -1326,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::<Vec<_>>(), [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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> {
|
||||
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<String> {
|
||||
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<FontFace> {
|
||||
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<Arc<FontFace>> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -346,7 +346,8 @@ impl<'a> Ex<'a> {
|
||||
let n = format!("<?AID 001b?>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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -7,6 +7,8 @@ use designcraft_geom::{Rect, shapes};
|
||||
|
||||
use super::*;
|
||||
|
||||
mod decorations;
|
||||
|
||||
fn zip_files(files: &[(&str, &str)]) -> Vec<u8> {
|
||||
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#"<Document xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" Self="d">
|
||||
<Layer Self="L1" Name="Background"/>
|
||||
<Layer Self="L2" Name="Content"/>
|
||||
<idPkg:Spread src="Spreads/Spread_s.xml"/>
|
||||
</Document>"#;
|
||||
let spread = r#"<idPkg:Spread xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging">
|
||||
<Spread Self="s">
|
||||
<Page Self="p" GeometricBounds="0 0 100 100" ItemTransform="1 0 0 1 0 0"/>
|
||||
<Rectangle Self="r" ItemLayer="L1">
|
||||
<Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>
|
||||
<PathPointType Anchor="0 0"/><PathPointType Anchor="0 100"/>
|
||||
<PathPointType Anchor="100 100"/><PathPointType Anchor="100 0"/>
|
||||
</PathPointArray></GeometryPathType></PathGeometry></Properties>
|
||||
</Rectangle>
|
||||
</Spread>
|
||||
</idPkg:Spread>"#;
|
||||
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::<Vec<_>>();
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
use super::*;
|
||||
|
||||
fn fixture(local: &str) -> Document {
|
||||
let styles = r#"<idPkg:Styles xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging">
|
||||
<RootParagraphStyleGroup>
|
||||
<ParagraphStyle Self="ParagraphStyle/PBase" Name="PBase" Underline="true" StrikeThru="true" UnderlineColor="Color/Black" StrikeThruColor="Color/Black"/>
|
||||
<ParagraphStyle Self="ParagraphStyle/PReset" Name="PReset" UnderlineColor=" Text Color " StrikeThruColor="Text Color"><Properties><BasedOn type="object">ParagraphStyle/PBase</BasedOn></Properties></ParagraphStyle>
|
||||
<ParagraphStyle Self="ParagraphStyle/PLeaf" Name="PLeaf"><Properties><BasedOn type="object">ParagraphStyle/PReset</BasedOn></Properties></ParagraphStyle>
|
||||
</RootParagraphStyleGroup>
|
||||
<RootCharacterStyleGroup>
|
||||
<CharacterStyle Self="CharacterStyle/CBase" Name="CBase" UnderlineColor="Color/Black" StrikeThruColor="Color/Black"/>
|
||||
<CharacterStyle Self="CharacterStyle/CReset" Name="CReset"><Properties><BasedOn type="object">CharacterStyle/CBase</BasedOn><UnderlineColor type="string">Text Color</UnderlineColor><StrikeThruColor type="string">Text Color</StrikeThruColor></Properties></CharacterStyle>
|
||||
<CharacterStyle Self="CharacterStyle/CLeaf" Name="CLeaf"><Properties><BasedOn type="object">CharacterStyle/CReset</BasedOn></Properties></CharacterStyle>
|
||||
<CharacterStyle Self="CharacterStyle/CInherit" Name="CInherit"><Properties><BasedOn type="object">CharacterStyle/CBase</BasedOn></Properties></CharacterStyle>
|
||||
</RootCharacterStyleGroup>
|
||||
</idPkg:Styles>"#;
|
||||
let story = format!(
|
||||
r#"<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"><Story Self="s"><ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/PBase"><CharacterStyleRange AppliedCharacterStyle="CharacterStyle/CBase" {local}><Content>Ink</Content></CharacterStyleRange></ParagraphStyleRange></Story></idPkg:Story>"#
|
||||
);
|
||||
let map = r#"<Document xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"><idPkg:Styles src="Resources/Styles.xml"/><idPkg:Story src="Stories/s.xml"/><Layer Self="L" Name="Art"/><idPkg:Spread src="Spreads/s.xml"/></Document>"#;
|
||||
let spread = r#"<idPkg:Spread xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"><Spread Self="sp"><Page Self="p" GeometricBounds="0 0 240 240"/><TextFrame Self="frame" ParentStory="s" ItemLayer="L" GeometricBounds="0 0 200 200"><Properties><PathGeometry><GeometryPathType><PathPointArray><PathPointType Anchor="0 0"/><PathPointType Anchor="200 0"/><PathPointType Anchor="200 200"/><PathPointType Anchor="0 200"/></PathPointArray></GeometryPathType></PathGeometry></Properties></TextFrame></Spread></idPkg:Spread>"#;
|
||||
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());
|
||||
}
|
||||
@@ -941,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<krilla::paint::Paint> {
|
||||
fn fill_paint(&mut self, fill: &designcraft_doc::Fill, bounds: Rect) -> Option<krilla::paint::Paint> {
|
||||
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<Stop> = 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()),
|
||||
@@ -1281,6 +1302,10 @@ fn relabel_pdf_header(data: &[u8], from: &[u8], to: &[u8]) -> Option<Vec<u8>> {
|
||||
}
|
||||
|
||||
fn lossless(data: &Arc<Vec<u8>>, fmt: Option<image::ImageFormat>, interpolate: bool) -> Option<Image> {
|
||||
// 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(), interpolate).ok(),
|
||||
Some(image::ImageFormat::Gif) => Image::from_gif(data.clone().into(), interpolate).ok(),
|
||||
@@ -1289,7 +1314,6 @@ fn lossless(data: &Arc<Vec<u8>>, fmt: Option<image::ImageFormat>, interpolate: b
|
||||
};
|
||||
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))
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -240,6 +240,31 @@ fn place(d: &mut Document, data: Vec<u8>, 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<u8>)> {
|
||||
use hayro_syntax::object::Name;
|
||||
|
||||
+34
-14
@@ -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<Font> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<f32> {
|
||||
if let Some(functions) = function.get::<Array<'_>>(b"Functions") {
|
||||
let functions: Vec<_> = functions.iter::<Dict<'_>>().collect();
|
||||
return endpoint(if first { functions.first().unwrap() } else { functions.last().unwrap() }, first);
|
||||
}
|
||||
function.get::<Array<'_>>(if first { b"C0" } else { b"C1" }).unwrap().iter::<f32>().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::<Dict<'_>>(name.as_ref()).unwrap();
|
||||
let shading = pattern.get::<Dict<'_>>(b"Shading").unwrap();
|
||||
assert_eq!(shading.get::<Name<'_>>(b"ColorSpace").unwrap().as_ref(), space.as_bytes());
|
||||
let function = shading.get::<Dict<'_>>(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());
|
||||
}
|
||||
+26
-13
@@ -25,8 +25,9 @@ pub(crate) struct LineGlyphs {
|
||||
pub bounds: Option<Rect>,
|
||||
/// 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<Rect>, b: Rect) -> Option<Rect> {
|
||||
|
||||
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);
|
||||
|
||||
@@ -27,6 +27,7 @@ serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
designcraft-idml = { workspace = true }
|
||||
# Headless (offscreen wgpu) rendering of the whole window for UI screenshots: `examples/ui_shot.rs`.
|
||||
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
|
||||
designcraft-fonts = { workspace = true, features = ["testing"] }
|
||||
|
||||
@@ -164,7 +164,7 @@ pub fn control_bar(app: &mut DesignApp, ui: &mut egui::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(|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 {
|
||||
@@ -589,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()),
|
||||
);
|
||||
@@ -671,7 +678,7 @@ pub fn start_screen(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
));
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for p in designcraft_doc::build::PRESETS.iter().take(10) {
|
||||
for p in designcraft_doc::build::PRESETS.iter().filter(|p| p.intent == designcraft_doc::Intent::Print) {
|
||||
let (cr, resp) = ui.allocate_exact_size(vec2(120.0, 150.0), Sense::click());
|
||||
let hov = resp.hovered();
|
||||
ui.painter().rect_filled(cr, 6.0, if hov { t.hover } else { t.panel });
|
||||
|
||||
@@ -302,6 +302,7 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
}
|
||||
"ui.dialog.confirm" => wrap(crate::dialogs::confirm(app)),
|
||||
"ui.dialog.cancel" => {
|
||||
app.cancel_pdf_import();
|
||||
app.ui.dialog = None;
|
||||
ok(Value::Null)
|
||||
}
|
||||
|
||||
+724
-198
File diff suppressed because it is too large
Load Diff
+127
-2
@@ -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].
|
||||
@@ -2259,6 +2262,38 @@ const TABLE: &[(&str, [&str; 5])] = &[
|
||||
("Justify", ["Justify", "Justify", "Justify", "Justify", "两端对齐"]),
|
||||
("Underline Options", ["Underline Options", "Underline Options", "Underline Options", "Underline Options", "下划线选项"]),
|
||||
("Strikethrough Options", ["Strikethrough Options", "Strikethrough Options", "Strikethrough Options", "Strikethrough Options", "删除线选项"]),
|
||||
(
|
||||
"Character Style Options",
|
||||
["Character Style Options", "Character Style Options", "Character Style Options", "Character Style Options", "字符样式选项"],
|
||||
),
|
||||
(
|
||||
"Advanced Character Formats",
|
||||
["Advanced Character Formats", "Advanced Character Formats", "Advanced Character Formats", "Advanced Character Formats", "高级字符格式"],
|
||||
),
|
||||
("OpenType Features", ["OpenType Features", "OpenType Features", "OpenType Features", "OpenType Features", "OpenType 功能"]),
|
||||
("Style Settings:", ["Style Settings:", "Style Settings:", "Style Settings:", "Style Settings:", "样式设置:"]),
|
||||
("Kerning:", ["Kerning:", "Kerning:", "Kerning:", "Kerning:", "字偶间距:"]),
|
||||
("Case:", ["Case:", "Case:", "Case:", "Case:", "大小写:"]),
|
||||
(
|
||||
"OpenType All Small Caps",
|
||||
["OpenType All Small Caps", "OpenType All Small Caps", "OpenType All Small Caps", "OpenType All Small Caps", "OpenType 全部小型大写字母"],
|
||||
),
|
||||
("Subscript", ["Subscript", "Subscript", "Subscript", "Subscript", "下标"]),
|
||||
("OpenType Subscript", ["OpenType Subscript", "OpenType Subscript", "OpenType Subscript", "OpenType Subscript", "OpenType 下标"]),
|
||||
("OpenType Numerator", ["OpenType Numerator", "OpenType Numerator", "OpenType Numerator", "OpenType Numerator", "OpenType 分子"]),
|
||||
("OpenType Denominator", ["OpenType Denominator", "OpenType Denominator", "OpenType Denominator", "OpenType Denominator", "OpenType 分母"]),
|
||||
("Ligatures", ["Ligatures", "Ligatures", "Ligatures", "Ligatures", "连字"]),
|
||||
("No Break", ["No Break", "No Break", "No Break", "No Break", "不换行"]),
|
||||
("Horizontal Scale:", ["Horizontal Scale:", "Horizontal Scale:", "Horizontal Scale:", "Horizontal Scale:", "水平缩放:"]),
|
||||
("Vertical Scale:", ["Vertical Scale:", "Vertical Scale:", "Vertical Scale:", "Vertical Scale:", "垂直缩放:"]),
|
||||
("Baseline Shift:", ["Baseline Shift:", "Baseline Shift:", "Baseline Shift:", "Baseline Shift:", "基线偏移:"]),
|
||||
("Skew:", ["Skew:", "Skew:", "Skew:", "Skew:", "倾斜:"]),
|
||||
("Language:", ["Language:", "Language:", "Language:", "Language:", "语言:"]),
|
||||
("Tint:", ["Tint:", "Tint:", "Tint:", "Tint:", "色调:"]),
|
||||
("Figure Style:", ["Figure Style:", "Figure Style:", "Figure Style:", "Figure Style:", "数字样式:"]),
|
||||
("Underline On", ["Underline On", "Underline On", "Underline On", "Underline On", "启用下划线"]),
|
||||
("Strikethrough On", ["Strikethrough On", "Strikethrough On", "Strikethrough On", "Strikethrough On", "启用删除线"]),
|
||||
("Edit \"{name}\"…", ["„{name}“ bearbeiten…", "Modifier « {name} »…", "Editar \"{name}\"…", "\"{name}\" を編集…", "编辑\"{name}\"…"]),
|
||||
(
|
||||
"Discretionary Ligatures",
|
||||
["Discretionary Ligatures", "Discretionary Ligatures", "Discretionary Ligatures", "Discretionary Ligatures", "自由连字"],
|
||||
@@ -2750,19 +2785,50 @@ fn column(lang: &str) -> Option<usize> {
|
||||
|
||||
/// `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<HashMap<&'static str, &'static str>> = OnceLock::new();
|
||||
static TRANSLATIONS: OnceLock<HashMap<&'static str, [&'static str; 5]>> = OnceLock::new();
|
||||
static ARABIC: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
|
||||
static UKRAINIAN: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
|
||||
static PORTUGUESE_BR: OnceLock<HashMap<&'static str, &'static str>> = 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 {
|
||||
@@ -2787,7 +2853,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);
|
||||
}
|
||||
@@ -2797,8 +2866,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()));
|
||||
}
|
||||
}
|
||||
@@ -2830,6 +2902,59 @@ 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));
|
||||
// A new English string falls back to English until it is translated, so only stale
|
||||
// entries (keys no catalog knows any more) fail.
|
||||
let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect();
|
||||
assert!(stale.is_empty(), "Ukrainian entries for unknown keys: {stale:?}");
|
||||
let placeholders =
|
||||
|s: &str| s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::<Vec<_>>();
|
||||
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 [
|
||||
|
||||
@@ -570,6 +570,29 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Justify", "ضبط"),
|
||||
("Underline Options", "خيارات التسطير"),
|
||||
("Strikethrough Options", "خيارات الخط المتوسط"),
|
||||
("Character Style Options", "خيارات نمط الأحرف"),
|
||||
("Advanced Character Formats", "تنسيقات الأحرف المتقدمة"),
|
||||
("OpenType Features", "ميزات OpenType"),
|
||||
("Style Settings:", "إعدادات النمط:"),
|
||||
("Kerning:", "تقنين الأزواج:"),
|
||||
("Case:", "حالة الأحرف:"),
|
||||
("OpenType All Small Caps", "كل الأحرف الكبيرة المصغّرة OpenType"),
|
||||
("Subscript", "النص المنخفض"),
|
||||
("OpenType Subscript", "منخفض OpenType"),
|
||||
("OpenType Numerator", "بسط OpenType"),
|
||||
("OpenType Denominator", "مقام OpenType"),
|
||||
("Ligatures", "الحروف المركبة"),
|
||||
("No Break", "بدون فصل"),
|
||||
("Horizontal Scale:", "المقياس الأفقي:"),
|
||||
("Vertical Scale:", "المقياس الرأسي:"),
|
||||
("Baseline Shift:", "إزاحة خط الأساس:"),
|
||||
("Skew:", "الإمالة:"),
|
||||
("Language:", "اللغة:"),
|
||||
("Tint:", "الصبغة:"),
|
||||
("Figure Style:", "نمط الأرقام:"),
|
||||
("Underline On", "تشغيل التسطير"),
|
||||
("Strikethrough On", "تشغيل الخط المتوسط"),
|
||||
("Edit \"{name}\"…", "تحرير \"{name}\"…"),
|
||||
("Discretionary Ligatures", "روابط اختيارية"),
|
||||
("Fractions", "كسور"),
|
||||
("Ordinal", "ترتيبي"),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -569,6 +569,29 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Justify", "Justificar"),
|
||||
("Underline Options", "Opções de sublinhado"),
|
||||
("Strikethrough Options", "Opções de tachado"),
|
||||
("Character Style Options", "Opções de estilo de caractere"),
|
||||
("Advanced Character Formats", "Formatos avançados de caracteres"),
|
||||
("OpenType Features", "Recursos OpenType"),
|
||||
("Style Settings:", "Configurações do estilo:"),
|
||||
("Kerning:", "Kerning:"),
|
||||
("Case:", "Caixa:"),
|
||||
("OpenType All Small Caps", "Todas em versalete OpenType"),
|
||||
("Subscript", "Subscrito"),
|
||||
("OpenType Subscript", "Subscrito OpenType"),
|
||||
("OpenType Numerator", "Numerador OpenType"),
|
||||
("OpenType Denominator", "Denominador OpenType"),
|
||||
("Ligatures", "Ligaturas"),
|
||||
("No Break", "Sem quebra"),
|
||||
("Horizontal Scale:", "Escala horizontal:"),
|
||||
("Vertical Scale:", "Escala vertical:"),
|
||||
("Baseline Shift:", "Deslocamento da linha de base:"),
|
||||
("Skew:", "Inclinação:"),
|
||||
("Language:", "Idioma:"),
|
||||
("Tint:", "Matiz:"),
|
||||
("Figure Style:", "Estilo de algarismos:"),
|
||||
("Underline On", "Sublinhado ativado"),
|
||||
("Strikethrough On", "Tachado ativado"),
|
||||
("Edit \"{name}\"…", "Editar \"{name}\"…"),
|
||||
("Discretionary Ligatures", "Ligaduras discricionárias"),
|
||||
("Fractions", "Frações"),
|
||||
("Ordinal", "Ordinal"),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+392
-20
@@ -38,10 +38,21 @@ pub use control::{ControlRequest, ControlResponse};
|
||||
pub type ReadFn = Box<dyn Fn(&str) -> Result<Vec<u8>, String>>;
|
||||
pub type WriteFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;
|
||||
pub type PickFn = Box<dyn FnMut(&str) -> Option<String>>;
|
||||
pub type OpenAsyncFn = Box<dyn FnMut(&str)>;
|
||||
/// Context captured before any host file-picker or read await.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ImportRequest {
|
||||
pub purpose: String,
|
||||
pub target: Option<u64>,
|
||||
}
|
||||
pub struct ImportedFile {
|
||||
pub request: ImportRequest,
|
||||
pub name: String,
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
pub type OpenAsyncFn = Box<dyn FnMut(ImportRequest)>;
|
||||
pub type DownloadFn = Box<dyn FnMut(&str, &[u8])>;
|
||||
/// Files `(name, bytes)` delivered asynchronously by the host (web file picker, dropped files).
|
||||
pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
/// Files delivered asynchronously with their original operation and document identity.
|
||||
pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<ImportedFile>>>;
|
||||
|
||||
/// Platform services injected by the host (desktop or web).
|
||||
#[derive(Default)]
|
||||
@@ -58,8 +69,7 @@ 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<DownloadFn>,
|
||||
/// Files delivered asynchronously, drained every frame: documents ([`opens_as_document`]) →
|
||||
/// `file.openBytes`, `.ase` → `swatch.load`, anything else → `file.place`.
|
||||
/// Files delivered asynchronously, drained every frame with their captured request context.
|
||||
pub inbox: Option<Inbox>,
|
||||
}
|
||||
|
||||
@@ -81,6 +91,7 @@ pub struct SavedWorkspace {
|
||||
pub name: String,
|
||||
pub control_bar: bool,
|
||||
pub task_bar: bool,
|
||||
pub task_bar_pin: Option<[f32; 2]>,
|
||||
pub tools_double_column: bool,
|
||||
pub dock_tab: String,
|
||||
pub dock_expanded: bool,
|
||||
@@ -189,6 +200,12 @@ pub struct UiState {
|
||||
pub snap_zone: f64,
|
||||
/// Window > Contextual Task Bar.
|
||||
pub task_bar: bool,
|
||||
/// Where the Contextual Task Bar is pinned: its top-left, in points from the canvas's top-left
|
||||
/// (`None`: it follows the selection).
|
||||
pub task_bar_pin: Option<[f32; 2]>,
|
||||
/// Where the Contextual Task Bar was last shown, like [`Self::task_bar_pin`].
|
||||
#[serde(skip)]
|
||||
pub task_bar_at: Option<[f32; 2]>,
|
||||
/// Help › About DesignCraft is open.
|
||||
pub about: bool,
|
||||
/// The About window's tab: 0 About, 1 Contributors, 2 Models (`about::ABOUT_TABS`).
|
||||
@@ -264,6 +281,8 @@ impl Default for UiState {
|
||||
smart_spacing: true,
|
||||
snap_zone: 4.0,
|
||||
task_bar: true,
|
||||
task_bar_pin: None,
|
||||
task_bar_at: None,
|
||||
about: false,
|
||||
about_tab: 0,
|
||||
pending_urls: Vec::new(),
|
||||
@@ -377,6 +396,7 @@ pub struct Perf {
|
||||
}
|
||||
|
||||
pub struct DesignApp {
|
||||
pending_pdf: Option<ImportedFile>,
|
||||
pub session: Session,
|
||||
pub ui: UiState,
|
||||
pub services: Services,
|
||||
@@ -425,6 +445,7 @@ impl DesignApp {
|
||||
// The font menus and the first file opened need the installed fonts: catalog them now.
|
||||
designcraft_fonts::FontDb::global().scan_in_background();
|
||||
DesignApp {
|
||||
pending_pdf: None,
|
||||
session,
|
||||
ui: UiState::default(),
|
||||
services,
|
||||
@@ -528,6 +549,7 @@ impl DesignApp {
|
||||
/// Ask the host for a file to open (`open`) or place (`place`). Synchronous pickers run the
|
||||
/// command right away; asynchronous ones (web) deliver the file through the inbox.
|
||||
pub fn pick_and_open(&mut self, purpose: &str) -> Result<Value, String> {
|
||||
let target = self.session.active().map(|d| d.uid);
|
||||
let cmd = if purpose == "place" { "file.place" } else { "file.open" };
|
||||
if let Some(pick) = self.services.pick_open.as_mut() {
|
||||
return match pick(purpose) {
|
||||
@@ -536,7 +558,8 @@ impl DesignApp {
|
||||
let pages =
|
||||
self.services.read.as_mut().and_then(|r| r(&path).ok()).and_then(|b| designcraft_render::pdf_page_count(&b)).unwrap_or(1);
|
||||
if pages > 1 {
|
||||
self.ui.dialog = Some(dialogs::Dialog::new("pdfImport", json!({"path": path, "page": "1", "pages": pages})));
|
||||
self.ui.dialog =
|
||||
Some(dialogs::Dialog::new("pdfImport", json!({"path": path, "page": "1", "pages": pages, "target": target})));
|
||||
Ok(Value::Null)
|
||||
} else {
|
||||
self.open_file(cmd, json!({"path": path}))
|
||||
@@ -546,33 +569,99 @@ impl DesignApp {
|
||||
None => Ok(Value::Null),
|
||||
};
|
||||
}
|
||||
let request = self.import_request(purpose);
|
||||
if let Some(open) = self.services.open_async.as_mut() {
|
||||
open(purpose);
|
||||
open(request);
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Open or place files delivered through the inbox.
|
||||
pub fn import_request(&self, purpose: &str) -> ImportRequest {
|
||||
ImportRequest { purpose: purpose.into(), target: self.session.active().map(|d| d.uid) }
|
||||
}
|
||||
|
||||
pub(crate) fn activate_import_target(&mut self, request: &ImportRequest) -> Result<(), String> {
|
||||
let Some(index) = request.target.and_then(|uid| self.session.documents().iter().position(|d| d.uid == uid)) else {
|
||||
let message = "Import cancelled: the originating document has closed";
|
||||
self.status(message);
|
||||
return Err(message.into());
|
||||
};
|
||||
self.run("file.activate", json!({"index": index}))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn cancel_pdf_import(&mut self) {
|
||||
self.pending_pdf = None;
|
||||
}
|
||||
|
||||
pub(crate) fn confirm_pdf_import(&mut self, page: u64, crop: String) -> Result<Value, String> {
|
||||
let file = self.pending_pdf.take().ok_or("PDF import cancelled: no pending file")?;
|
||||
self.activate_import_target(&file.request)?;
|
||||
self.open_file(
|
||||
"file.place",
|
||||
json!({"name": file.name, "base64": designcraft_engine::cmd::base64_encode(&file.bytes), "pdfPage": page, "pdfCrop": crop}),
|
||||
)
|
||||
}
|
||||
|
||||
/// A single unresolved PDF owns its bytes. Later deliveries cancel explicitly rather than
|
||||
/// replacing its options. Cancellation, replacement and target closure release its payload.
|
||||
fn drain_inbox(&mut self) {
|
||||
if let Some(file) = &self.pending_pdf {
|
||||
let target_exists = self.session.documents().iter().any(|d| Some(d.uid) == file.request.target);
|
||||
let owns_dialog =
|
||||
self.ui.dialog.as_ref().is_some_and(|d| d.id == "pdfImport" && d.fields.get("async").and_then(Value::as_bool) == Some(true));
|
||||
if !target_exists || !owns_dialog {
|
||||
self.pending_pdf = None;
|
||||
if !target_exists {
|
||||
if owns_dialog {
|
||||
self.ui.dialog = None;
|
||||
}
|
||||
self.status("PDF import cancelled: the originating document has closed");
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(inbox) = self.services.inbox.clone() else { return };
|
||||
let files = std::mem::take(&mut *inbox.lock().unwrap_or_else(|e| e.into_inner()));
|
||||
for (name, bytes) in files {
|
||||
let b64 = designcraft_engine::cmd::base64_encode(&bytes);
|
||||
let lower = name.to_ascii_lowercase();
|
||||
let r = if lower.ends_with(".ase") {
|
||||
self.run("swatch.load", json!({"base64": b64}))
|
||||
} else if opens_as_document(&name) {
|
||||
let title = name.rsplit_once('.').map_or(name.as_str(), |(stem, _)| stem);
|
||||
self.open_file("file.openBytes", json!({"name": title, "base64": b64}))
|
||||
} else {
|
||||
self.open_file("file.place", json!({"name": name, "base64": b64}))
|
||||
};
|
||||
if let Err(e) = r {
|
||||
for file in files {
|
||||
if self.ui.dialog.is_some() {
|
||||
self.status(format!("{}: import cancelled while a dialog is open; choose the file again", file.name));
|
||||
continue;
|
||||
}
|
||||
let name = file.name.clone();
|
||||
if let Err(e) = self.import_file(file) {
|
||||
self.status(format!("{name}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn import_file(&mut self, file: ImportedFile) -> Result<Value, String> {
|
||||
let lower = file.name.to_ascii_lowercase();
|
||||
let layout = opens_as_document(&file.name);
|
||||
let place =
|
||||
file.request.purpose == "place" || (matches!(file.request.purpose.as_str(), "drop" | "open") && !layout && !lower.ends_with(".ase"));
|
||||
if place || lower.ends_with(".ase") || file.request.purpose == "swatches" {
|
||||
self.activate_import_target(&file.request)?;
|
||||
}
|
||||
if place && lower.ends_with(".pdf") {
|
||||
let pages = designcraft_render::pdf_page_count(&file.bytes).unwrap_or(1);
|
||||
if pages > 1 {
|
||||
self.ui.dialog =
|
||||
Some(dialogs::Dialog::new("pdfImport", json!({"async": true, "name": file.name.clone(), "page": "1", "pages": pages})));
|
||||
self.pending_pdf = Some(file);
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
}
|
||||
let b64 = designcraft_engine::cmd::base64_encode(&file.bytes);
|
||||
if lower.ends_with(".ase") || file.request.purpose == "swatches" {
|
||||
self.run("swatch.load", json!({"base64": b64}))
|
||||
} else if place {
|
||||
self.open_file("file.place", json!({"name": file.name, "base64": b64}))
|
||||
} else {
|
||||
let title = file.name.rsplit_once('.').map_or(file.name.as_str(), |(stem, _)| stem);
|
||||
self.open_file("file.openBytes", json!({"name": title, "base64": b64}))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_tool(&mut self, id: &str) {
|
||||
let _ = self.run("tool.select", json!({"tool": id}));
|
||||
self.ui.flyout = None;
|
||||
@@ -912,6 +1001,30 @@ pub fn drop_key_name_text(raw: &mut egui::RawInput, text_field_focused: bool) {
|
||||
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<egui::Rect> = 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());
|
||||
@@ -1066,4 +1179,263 @@ pub(crate) mod test_window {
|
||||
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()
|
||||
}
|
||||
|
||||
/// A primary click at `pos`, then a few frames.
|
||||
pub fn click_at(h: &mut Harness<'static, Window>, pos: egui::Pos2) {
|
||||
h.hover_at(pos);
|
||||
h.drag_at(pos);
|
||||
h.drop_at(pos);
|
||||
h.run_steps(4);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod browser_import_tests {
|
||||
use super::*;
|
||||
|
||||
fn app() -> DesignApp {
|
||||
let mut app = DesignApp::new(Session::new(), Services { inbox: Some(Inbox::default()), ..Default::default() });
|
||||
app.run("file.new", json!({"title": "A"})).unwrap();
|
||||
app
|
||||
}
|
||||
fn deliver(app: &mut DesignApp, request: ImportRequest, name: &str, bytes: Vec<u8>) {
|
||||
app.services.inbox.as_ref().unwrap().lock().unwrap().push(ImportedFile { request, name: name.into(), bytes });
|
||||
app.drain_inbox();
|
||||
}
|
||||
fn image() -> Vec<u8> {
|
||||
br##"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10" fill="#ff0000"/></svg>"##.to_vec()
|
||||
}
|
||||
fn pdf_pages(count: usize) -> Vec<u8> {
|
||||
let kids = (0..count).map(|i| format!("{} 0 R", 3 + i * 2)).collect::<Vec<_>>().join(" ");
|
||||
let mut objects = vec!["<< /Type /Catalog /Pages 2 0 R >>".to_string(), format!("<< /Type /Pages /Kids [{kids}] /Count {count} >>")];
|
||||
for (i, color) in ["1 0 0", "0 1 0", "0 0 1"].iter().take(count).enumerate() {
|
||||
let content = format!("{color} rg 0 0 200 200 re f\n");
|
||||
objects.push(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents {} 0 R >>", 4 + i * 2));
|
||||
objects.push(format!("<< /Length {} >>\nstream\n{content}endstream", content.len()));
|
||||
}
|
||||
let mut result = "%PDF-1.4\n".to_string();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, object) in objects.iter().enumerate() {
|
||||
offsets.push(result.len());
|
||||
result.push_str(&format!("{} 0 obj\n{object}\nendobj\n", i + 1));
|
||||
}
|
||||
let xref = result.len();
|
||||
result.push_str(&format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len() + 1));
|
||||
for offset in offsets {
|
||||
result.push_str(&format!("{offset:010} 00000 n \n"));
|
||||
}
|
||||
result.push_str(&format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1));
|
||||
result.into_bytes()
|
||||
}
|
||||
fn pdf() -> Vec<u8> {
|
||||
pdf_pages(3)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_receives_context_before_returning_and_cancel_does_not_edit() {
|
||||
let captured = std::sync::Arc::new(std::sync::Mutex::new(None));
|
||||
let copy = captured.clone();
|
||||
let mut app = app();
|
||||
app.services.open_async = Some(Box::new(move |request| *copy.lock().unwrap() = Some(request)));
|
||||
let uid = app.session.doc().unwrap().uid;
|
||||
app.pick_and_open("place").unwrap();
|
||||
app.run("file.new", json!({"title": "B"})).unwrap();
|
||||
let request = captured.lock().unwrap().take().unwrap();
|
||||
assert_eq!(request.purpose, "place");
|
||||
assert_eq!(request.target, Some(uid));
|
||||
assert!(app.session.documents().iter().all(|d| d.doc.assets.is_empty()));
|
||||
deliver(&mut app, request, "broken.pdf", b"not a PDF".to_vec());
|
||||
assert!(app.session.documents().iter().all(|d| d.doc.assets.is_empty()));
|
||||
assert!(app.pending_pdf.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delayed_place_activates_original_document_and_undo_belongs_to_it() {
|
||||
let mut app = app();
|
||||
let request = app.import_request("place");
|
||||
app.run("file.new", json!({"title": "B"})).unwrap();
|
||||
deliver(&mut app, request, "red.svg", image());
|
||||
assert_eq!(app.session.active_index(), Some(0));
|
||||
assert_eq!(app.session.documents()[0].doc.assets.len(), 1);
|
||||
assert!(app.session.documents()[1].doc.assets.is_empty());
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert!(app.session.doc().unwrap().doc.assets.is_empty());
|
||||
app.run("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(app.session.doc().unwrap().doc.assets.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_target_is_not_replaced_by_a_reused_tab_index() {
|
||||
let mut app = app();
|
||||
let request = app.import_request("place");
|
||||
app.run("file.close", json!({})).unwrap();
|
||||
app.run("file.new", json!({"title": "replacement"})).unwrap();
|
||||
deliver(&mut app, request, "red.svg", image());
|
||||
assert!(app.session.doc().unwrap().doc.assets.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_earlier_tab_does_not_shift_import_target() {
|
||||
let mut app = app();
|
||||
app.run("file.new", json!({"title": "target"})).unwrap();
|
||||
let request = app.import_request("place");
|
||||
app.run("file.new", json!({"title": "other"})).unwrap();
|
||||
app.run("file.close", json!({"index": 0})).unwrap();
|
||||
deliver(&mut app, request, "red.svg", image());
|
||||
assert_eq!(app.session.doc().unwrap().doc.title, "target");
|
||||
assert_eq!(app.session.documents()[0].doc.assets.len(), 1);
|
||||
assert!(app.session.documents()[1].doc.assets.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_place_and_open_preserve_explicit_intent() {
|
||||
let mut source = Session::new();
|
||||
source.execute("file.new", &json!({"title": "source"})).unwrap();
|
||||
source.execute("frame.create", &json!({"rect": [10, 10, 50, 50], "content": "text", "text": "placed"})).unwrap();
|
||||
let native = designcraft_engine::cmd::to_bytes(&source.doc().unwrap().doc);
|
||||
let idml = designcraft_idml::export_idml(&source.doc().unwrap().doc);
|
||||
for (name, bytes) in [("source.designcraft", native), ("source.idml", idml)] {
|
||||
let mut app = app();
|
||||
let request = app.import_request("place");
|
||||
deliver(&mut app, request, name, bytes.clone());
|
||||
assert_eq!(app.session.documents().len(), 1);
|
||||
assert_eq!(app.session.doc().unwrap().doc.title, "A");
|
||||
assert!(app.session.doc().unwrap().doc.stories.values().any(|s| s.text == "placed"));
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert!(app.session.doc().unwrap().doc.stories.is_empty());
|
||||
app.run("edit.redo", json!({})).unwrap();
|
||||
assert!(!app.session.doc().unwrap().doc.stories.is_empty());
|
||||
let request = app.import_request("open");
|
||||
deliver(&mut app, request, name, bytes);
|
||||
assert_eq!(app.session.documents().len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_options_keep_bytes_target_and_selected_page() {
|
||||
let mut app = app();
|
||||
let bytes = pdf();
|
||||
let request = app.import_request("place");
|
||||
deliver(&mut app, request, "three.pdf", bytes.clone());
|
||||
assert!(app.session.doc().unwrap().doc.assets.is_empty());
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().id, "pdfImport");
|
||||
app.run("file.new", json!({"title": "B"})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("page".into(), json!("2"));
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(app.session.active_index(), Some(0));
|
||||
let asset = app.session.doc().unwrap().doc.assets.values().next().unwrap();
|
||||
assert_eq!(asset.page, 1);
|
||||
assert_eq!(*asset.data, bytes);
|
||||
assert!(app.pending_pdf.is_none());
|
||||
assert!(app.session.documents()[1].doc.assets.is_empty());
|
||||
let exported = app.run("file.exportPdf", json!({})).unwrap();
|
||||
let exported = designcraft_engine::cmd::base64_decode(exported["base64"].as_str().unwrap());
|
||||
let pixels = designcraft_render::render_pdf_page(&exported, 0, 400).unwrap();
|
||||
assert!(pixels.data().iter().filter(|p| p.g > 240 && p.r < 10 && p.b < 10).count() > 1000);
|
||||
assert_eq!(pixels.data().iter().filter(|p| p.r > 240 && p.g < 10 && p.b < 10).count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_options_reject_oversized_page_without_editing() {
|
||||
let mut app = app();
|
||||
for page in [0_u64, 4, 4_294_967_296, 4_294_967_297, u64::MAX] {
|
||||
let request = app.import_request("place");
|
||||
deliver(&mut app, request, "three.pdf", pdf());
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("page".into(), json!(page.to_string()));
|
||||
// The native dialog clamps zero to one; positive invalid pages must fail.
|
||||
if page == 0 {
|
||||
assert!(app.confirm_pdf_import(page, "crop".into()).is_err());
|
||||
} else {
|
||||
assert!(dialogs::confirm(&mut app).is_err());
|
||||
}
|
||||
app.ui.dialog = None;
|
||||
assert!(app.session.doc().unwrap().doc.assets.is_empty());
|
||||
assert!(app.pending_pdf.is_none());
|
||||
}
|
||||
let request = app.import_request("place");
|
||||
deliver(&mut app, request, "three.pdf", pdf());
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("page".into(), json!("2"));
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(app.session.doc().unwrap().doc.assets.values().next().unwrap().page, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_routing_and_nonlayout_open_remain_compatible() {
|
||||
let mut app = app();
|
||||
let native = designcraft_engine::cmd::to_bytes(&app.session.doc().unwrap().doc);
|
||||
let request = app.import_request("drop");
|
||||
deliver(&mut app, request, "document.designcraft", native);
|
||||
assert_eq!(app.session.documents().len(), 2);
|
||||
let request = app.import_request("drop");
|
||||
app.run("file.activate", json!({"index": 0})).unwrap();
|
||||
deliver(&mut app, request, "red.svg", image());
|
||||
assert_eq!(app.session.active_index(), Some(1));
|
||||
assert_eq!(app.session.doc().unwrap().doc.assets.len(), 1);
|
||||
let request = app.import_request("open");
|
||||
deliver(&mut app, request, "single.pdf", pdf_pages(1));
|
||||
assert_eq!(app.session.documents().len(), 2);
|
||||
assert!(app.pending_pdf.is_none());
|
||||
assert!(app.ui.dialog.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ase_swatches_keep_purpose_and_target() {
|
||||
let mut source = Session::new();
|
||||
source.execute("file.new", &json!({})).unwrap();
|
||||
source.execute("swatch.create", &json!({"name": "Import Teal", "color": "#108080"})).unwrap();
|
||||
let data = source.execute("swatch.save", &json!({"names": ["Import Teal"]})).unwrap();
|
||||
let bytes = designcraft_engine::cmd::base64_decode(data["base64"].as_str().unwrap());
|
||||
for (purpose, name) in [("swatches", "picker-file"), ("drop", "palette.ase")] {
|
||||
let mut app = app();
|
||||
let request = app.import_request(purpose);
|
||||
app.run("file.new", json!({"title": "B"})).unwrap();
|
||||
deliver(&mut app, request, name, bytes.clone());
|
||||
assert_eq!(app.session.active_index(), Some(0));
|
||||
assert!(app.session.documents()[0].doc.swatches.iter().any(|s| s.name == "Import Teal"));
|
||||
assert!(!app.session.documents()[1].doc.swatches.iter().any(|s| s.name == "Import Teal"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_pdf_dialog_keeps_path_and_uid_at_confirmation() {
|
||||
let mut app = app();
|
||||
let path = std::env::temp_dir().join(format!("designcraft-native-import-{}-{}.pdf", std::process::id(), app.session.doc().unwrap().uid));
|
||||
std::fs::write(&path, pdf()).unwrap();
|
||||
let pick_path = path.to_string_lossy().to_string();
|
||||
app.services.pick_open = Some(Box::new(move |_| Some(pick_path.clone())));
|
||||
app.services.read = Some(Box::new(|path| std::fs::read(path).map_err(|e| e.to_string())));
|
||||
app.pick_and_open("place").unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().id, "pdfImport");
|
||||
app.run("file.new", json!({"title": "B"})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("page".into(), json!("2"));
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(app.session.active_index(), Some(0));
|
||||
assert_eq!(app.session.doc().unwrap().doc.assets.values().next().unwrap().page, 1);
|
||||
assert!(app.session.documents()[1].doc.assets.is_empty());
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_pdf_cancel_close_supersession_and_second_delivery_release_bytes() {
|
||||
let mut app = app();
|
||||
let request = app.import_request("place");
|
||||
deliver(&mut app, request.clone(), "first.pdf", pdf());
|
||||
deliver(&mut app, request.clone(), "second.svg", image());
|
||||
assert_eq!(app.pending_pdf.as_ref().unwrap().name, "first.pdf");
|
||||
assert!(app.session.doc().unwrap().doc.assets.is_empty());
|
||||
app.ui.dialog = None;
|
||||
app.drain_inbox();
|
||||
assert!(app.pending_pdf.is_none());
|
||||
deliver(&mut app, request.clone(), "first.pdf", pdf());
|
||||
app.ui.dialog = Some(dialogs::Dialog::new("newDocument", json!({})));
|
||||
app.drain_inbox();
|
||||
assert!(app.pending_pdf.is_none());
|
||||
app.ui.dialog = None;
|
||||
deliver(&mut app, request, "first.pdf", pdf());
|
||||
app.run("file.close", json!({})).unwrap();
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
assert!(dialogs::confirm(&mut app).is_err());
|
||||
assert!(app.pending_pdf.is_none());
|
||||
assert!(app.session.doc().unwrap().doc.assets.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -129,6 +129,13 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
|
||||
("window.split", "Split Window", None, "{on?: bool} — two views of the document side by side, each with its own zoom and scroll"),
|
||||
("window.newWindow", "New Window", None, "{on?: bool} — another view of the active document in its own window"),
|
||||
("window.taskBar", "Contextual Task Bar", None, "{}"),
|
||||
(
|
||||
"window.taskBarPin",
|
||||
"Pin Bar Position",
|
||||
None,
|
||||
"{on?: bool, at?: [x, y] (points from the canvas's top-left)} — the Contextual Task Bar stays where it is (or at `at`) instead of following the selection; with neither, toggles",
|
||||
),
|
||||
("window.taskBarReset", "Reset Bar Position", None, "{} — the Contextual Task Bar follows the selection again, under it"),
|
||||
("help.discord", "Join the ArtCraft Discord…", None, "{} — opens https://discord.gg/artcraft in the browser"),
|
||||
("help.appPage", "DesignCraft Website…", None, "{} — opens https://getartcraft.com/apps/designcraft"),
|
||||
("help.github", "DesignCraft on GitHub…", None, "{} — opens https://github.com/storytold/designcraft"),
|
||||
@@ -234,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\": \"\"}",
|
||||
@@ -576,6 +584,7 @@ pub const MENUS: &[(&str, &[&str])] = &[
|
||||
"cmd:table.splitHorizontally",
|
||||
"cmd:table.splitVertically",
|
||||
"cmd:table.distributeColumns",
|
||||
"cmd:table.distributeRows",
|
||||
"-",
|
||||
"ui:app.tablePanel",
|
||||
],
|
||||
@@ -849,8 +858,9 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
|
||||
None => Ok(Value::Null),
|
||||
});
|
||||
}
|
||||
let request = app.import_request("swatches");
|
||||
if let Some(open) = app.services.open_async.as_mut() {
|
||||
open("swatches");
|
||||
open(request);
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
@@ -1284,6 +1294,26 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
|
||||
Ok(json!(app.second_window))
|
||||
}
|
||||
"window.taskBar" => flag(&mut app.ui.task_bar),
|
||||
"window.taskBarPin" => {
|
||||
let at = match p.get("at") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => match v.as_array().map(|a| a.iter().map(Value::as_f64).collect::<Vec<_>>()).as_deref() {
|
||||
Some([Some(x), Some(y)]) if x.is_finite() && y.is_finite() => Some([x.clamp(-1e6, 1e6) as f32, y.clamp(-1e6, 1e6) as f32]),
|
||||
_ => return Some(Err("window.taskBarPin: `at` is [x, y] in points".into())),
|
||||
},
|
||||
};
|
||||
let on = p.get("on").and_then(Value::as_bool).unwrap_or(at.is_some() || app.ui.task_bar_pin.is_none());
|
||||
app.ui.task_bar_pin = match (on, at.or(app.ui.task_bar_pin).or(app.ui.task_bar_at)) {
|
||||
(false, _) => None,
|
||||
(true, Some(at)) => Some(at),
|
||||
(true, None) => return Some(Err("window.taskBarPin: the Contextual Task Bar isn't showing; give `at`".into())),
|
||||
};
|
||||
Ok(json!(app.ui.task_bar_pin))
|
||||
}
|
||||
"window.taskBarReset" => {
|
||||
app.ui.task_bar_pin = None;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"help.about" => {
|
||||
if let Some(tab) = p.get("tab").and_then(Value::as_str) {
|
||||
let Some(i) = crate::about::ABOUT_TABS.iter().position(|t| t.eq_ignore_ascii_case(tab)) else {
|
||||
@@ -1318,6 +1348,7 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
|
||||
name: name.to_string(),
|
||||
control_bar: u.control_bar,
|
||||
task_bar: u.task_bar,
|
||||
task_bar_pin: u.task_bar_pin,
|
||||
tools_double_column: u.tools_double_column,
|
||||
dock_tab: u.dock_tab.clone(),
|
||||
dock_expanded: u.dock_expanded,
|
||||
@@ -1350,6 +1381,7 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
|
||||
if let Some(w) = app.ui.custom_workspaces.iter().find(|w| w.name == name).cloned() {
|
||||
app.ui.control_bar = w.control_bar;
|
||||
app.ui.task_bar = w.task_bar;
|
||||
app.ui.task_bar_pin = w.task_bar_pin;
|
||||
app.ui.tools_double_column = w.tools_double_column;
|
||||
app.ui.dock_tab = w.dock_tab;
|
||||
app.ui.dock_expanded = w.dock_expanded;
|
||||
@@ -1701,6 +1733,7 @@ pub fn checked(app: &DesignApp, id: &str, params: &Value) -> Option<bool> {
|
||||
"app.language" => app.ui.language == params.get("lang").and_then(Value::as_str).unwrap_or(""),
|
||||
"app.flattener" => app.ui.flattener == params.get("preset").and_then(Value::as_str).unwrap_or(""),
|
||||
"window.taskBar" => app.ui.task_bar,
|
||||
"window.taskBarPin" => app.ui.task_bar_pin.is_some(),
|
||||
"window.toolsDoubleColumn" => app.ui.tools_double_column,
|
||||
"view.togglePreview" => app.ui.screen_mode == crate::ScreenMode::Preview,
|
||||
"window.brightness" => params.get("brightness").and_then(Value::as_str) == Some(app.ui.brightness.id()),
|
||||
@@ -1740,6 +1773,20 @@ pub fn activate(app: &mut DesignApp, id: &str, params: &Value) {
|
||||
app.ui.dialog = Some(crate::dialogs::Dialog::new("textFrameOptions", json!({})));
|
||||
return;
|
||||
}
|
||||
// New Paragraph/Character Style…: the style options for the new style.
|
||||
if params.is_null() && matches!(id, "style.paragraph.create" | "style.character.create") {
|
||||
crate::dialogs::open_new_style(app, id == "style.paragraph.create");
|
||||
return;
|
||||
}
|
||||
// Paragraph/Character Style Options…: those of the selected text's style.
|
||||
if params.is_null() && matches!(id, "style.paragraph.edit" | "style.character.edit") {
|
||||
let para = id == "style.paragraph.edit";
|
||||
let cur = crate::panels::text_attrs(app).and_then(|a| a[if para { "paragraphStyle" } else { "characterStyle" }].as_str().map(str::to_string));
|
||||
if let Some(name) = cur {
|
||||
crate::dialogs::open_style_options(app, para, &name);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if params.is_null()
|
||||
&& ui_label(id).is_none()
|
||||
&& let Some(c) = designcraft_engine::find_command(id)
|
||||
@@ -1803,7 +1850,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 {
|
||||
@@ -2205,6 +2252,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());
|
||||
@@ -2232,7 +2303,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);
|
||||
}
|
||||
@@ -2705,6 +2776,25 @@ mod tests {
|
||||
assert!(quick_apply_items(&s, "preferences").iter().any(|i| i.id == "app.preferences"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distribute_rows_menu_dispatches_an_undoable_edit() {
|
||||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
let frame = app.run("frame.create", json!({"rect": [72, 72, 500, 700], "content": "text", "text": ""})).unwrap();
|
||||
app.run("text.select", json!({"story": frame["story"], "anchor": 0, "focus": 0})).unwrap();
|
||||
app.run("table.insert", json!({"rows": 2, "cols": 2})).unwrap();
|
||||
app.run("table.setRowHeight", json!({"row": 0, "height": 40, "mode": "exactly"})).unwrap();
|
||||
app.run("table.setRowHeight", json!({"row": 1, "height": 80, "mode": "exactly"})).unwrap();
|
||||
app.run("table.selectTable", json!({})).unwrap();
|
||||
activate(&mut app, "table.distributeRows", &Value::Null);
|
||||
assert!(app.ui.dialog.is_none());
|
||||
let info = app.run("table.get", json!({})).unwrap();
|
||||
assert_eq!(info["rows"][0]["height"], 60.0);
|
||||
assert_eq!(info["rows"][1]["height"], 60.0);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(app.run("table.get", json!({})).unwrap()["rows"][0]["height"], 40.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_menu_entry_is_a_command() {
|
||||
let mut all = Vec::new();
|
||||
@@ -2712,6 +2802,7 @@ mod tests {
|
||||
walk(&items, &mut all);
|
||||
}
|
||||
assert!(all.len() > 80, "{}", all.len());
|
||||
assert!(all.iter().any(|(label, id, _)| label == "Distribute Rows Evenly" && id == "table.distributeRows"));
|
||||
for (label, id, params) in &all {
|
||||
assert!(ui_label(id).is_some() || designcraft_engine::find_command(id).is_some(), "menu entry {label}: unknown command {id}");
|
||||
assert!(!label.is_empty() && label != id, "menu entry {id} has no label");
|
||||
|
||||
@@ -33,13 +33,10 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
let (db, scope) = (super::fonts(app), super::font_scope(app));
|
||||
ui.horizontal(|ui| {
|
||||
let menu = super::font_menu(app);
|
||||
egui::ComboBox::from_id_salt("glyph_family").selected_text(super::font_label(app, &menu, &st.family)).width(140.0).show_ui(ui, |ui| {
|
||||
if let Some(f) = super::font_menu_rows(app, ui, &menu, &st.family) {
|
||||
st.style = db.styles(&f).into_iter().next().unwrap_or_else(|| "Regular".into());
|
||||
st.family = f;
|
||||
}
|
||||
});
|
||||
if let Some(f) = super::font_combo(app, ui, "glyph_family", &st.family, 140.0) {
|
||||
st.style = db.styles(&f).into_iter().next().unwrap_or_else(|| "Regular".into());
|
||||
st.family = f;
|
||||
}
|
||||
egui::ComboBox::from_id_salt("glyph_style").selected_text(&st.style).width(80.0).show_ui(ui, |ui| {
|
||||
for s in db.styles(&st.family) {
|
||||
if ui.selectable_label(s == st.style, &s).clicked() {
|
||||
|
||||
@@ -135,7 +135,7 @@ pub fn articles(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
crate::rtl::label(ui, egui::RichText::new(&name).strong());
|
||||
crate::rtl::label(
|
||||
ui,
|
||||
egui::RichText::new(format!("{} {}", a["items"].as_array().map_or(0, Vec::len), crate::i18n::tr(&app.ui.language, "objects")))
|
||||
egui::RichText::new(crate::i18n::count_label(&app.ui.language, "objects", a["items"].as_array().map_or(0, Vec::len)))
|
||||
.size(10.5)
|
||||
.color(t.text_dim),
|
||||
);
|
||||
|
||||
+305
-120
@@ -112,8 +112,10 @@ pub fn fonts(app: &DesignApp) -> designcraft_fonts::ScopedFonts<'static> {
|
||||
/// The font menus' families in menu order: Western (and other scripts) first, then Japanese,
|
||||
/// Simplified Chinese, Traditional Chinese and Korean, each alphabetical by the name shown (CJK
|
||||
/// families by their native names unless Show Font Names in English).
|
||||
/// macOS's hidden system families (named with a leading `.`) are left out.
|
||||
pub fn font_menu(app: &DesignApp) -> Vec<designcraft_fonts::FamilyInfo> {
|
||||
let mut v = fonts(app).family_infos();
|
||||
v.retain(|f| !f.family.starts_with('.'));
|
||||
designcraft_fonts::sort_for_menu(&mut v, app.session.prefs.show_font_names_in_english);
|
||||
v
|
||||
}
|
||||
@@ -124,29 +126,6 @@ pub fn font_label(app: &DesignApp, menu: &[designcraft_fonts::FamilyInfo], famil
|
||||
menu.iter().find(|f| f.family == family).map_or(family, |f| f.label(english)).to_string()
|
||||
}
|
||||
|
||||
/// `menu` as rows of a popup or combo box: the name shown (the English name on hover when they
|
||||
/// differ), a separator between groups. Returns the family clicked.
|
||||
pub fn font_menu_rows(app: &DesignApp, ui: &mut egui::Ui, menu: &[designcraft_fonts::FamilyInfo], current: &str) -> Option<String> {
|
||||
let english = app.session.prefs.show_font_names_in_english;
|
||||
let mut pick = None;
|
||||
let mut group = None;
|
||||
for f in menu {
|
||||
if group.is_some_and(|g| g != f.group) {
|
||||
ui.separator();
|
||||
}
|
||||
group = Some(f.group);
|
||||
let label = f.label(english);
|
||||
let mut resp = ui.selectable_label(f.family == current, label);
|
||||
if label != f.family {
|
||||
resp = resp.on_hover_text(&f.family);
|
||||
}
|
||||
if resp.clicked() {
|
||||
pick = Some(f.family.clone());
|
||||
}
|
||||
}
|
||||
pick
|
||||
}
|
||||
|
||||
/// The active document's font scope (0 without a document or fonts of its own).
|
||||
pub fn font_scope(app: &DesignApp) -> u32 {
|
||||
app.session.active().map_or(0, |d| d.doc.font_scope)
|
||||
@@ -174,6 +153,34 @@ pub fn preflight_errors(app: &DesignApp) -> usize {
|
||||
n
|
||||
}
|
||||
|
||||
/// Height of one swatch-menu row.
|
||||
const SWATCH_MENU_ROW_H: f32 = 22.0;
|
||||
|
||||
/// One swatch-menu row: a chip and the swatch name; the whole row is clickable (the chip included).
|
||||
/// `name == current` highlights it. Returns true when the row was clicked.
|
||||
pub fn swatch_menu_row(ui: &mut egui::Ui, doc: &designcraft_doc::Document, name: &str, current: &str) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), SWATCH_MENU_ROW_H), egui::Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, name == current, name));
|
||||
if name == current {
|
||||
ui.painter().rect_filled(row, 0.0, t.row_selected);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(row, 0.0, t.hover);
|
||||
}
|
||||
let (c, g) = crate::widgets::swatch_colors(doc, name, 1.0);
|
||||
let chip = egui::Rect::from_min_size(row.min + vec2(4.0, 3.0), vec2(16.0, 16.0));
|
||||
crate::widgets::paint_chip(ui.painter(), chip, c, g);
|
||||
crate::rtl::paint(
|
||||
ui.painter(),
|
||||
row.min + vec2(28.0, SWATCH_MENU_ROW_H / 2.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
name,
|
||||
egui::FontId::proportional(12.5),
|
||||
t.text,
|
||||
);
|
||||
resp.clicked()
|
||||
}
|
||||
|
||||
/// A swatch dropdown showing a chip and name; `on_pick` gets the chosen swatch name.
|
||||
pub fn swatch_picker(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, current: Option<String>, on_pick: impl FnOnce(&mut DesignApp, String)) {
|
||||
let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return };
|
||||
@@ -196,15 +203,10 @@ pub fn swatch_picker(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, current:
|
||||
);
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
ui.set_min_width(200.0);
|
||||
ui.set_max_width(280.0);
|
||||
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
|
||||
for sw in &doc.swatches {
|
||||
let (c, g) = crate::widgets::swatch_colors(&doc, &sw.name, 1.0);
|
||||
let row = ui.horizontal(|ui| {
|
||||
let (cr, _) = ui.allocate_exact_size(vec2(14.0, 14.0), egui::Sense::hover());
|
||||
crate::widgets::paint_chip(ui.painter(), cr, c, g);
|
||||
ui.add(egui::Button::new(&sw.name).frame(false).selected(sw.name == cur))
|
||||
});
|
||||
if row.inner.clicked() {
|
||||
if swatch_menu_row(ui, &doc, &sw.name, &cur) {
|
||||
picked = Some(sw.name.clone());
|
||||
ui.close();
|
||||
}
|
||||
@@ -236,108 +238,189 @@ fn paint_star(p: &egui::Painter, c: egui::Pos2, r: f32, filled: bool, color: egu
|
||||
}
|
||||
}
|
||||
|
||||
/// The Font menu: search, favourites (★, Show Favorites Only), and each family's name shown in
|
||||
/// that family.
|
||||
pub fn font_family_picker(app: &mut DesignApp, ui: &mut egui::Ui, current: &str, width: f32) {
|
||||
/// The font menu's state while it is open: the search, Show Favorites Only, the match ↑/↓ highlight
|
||||
/// and the frame it was last shown (a menu not shown the frame before has just opened).
|
||||
#[derive(Clone, Default)]
|
||||
struct FontMenuState {
|
||||
query: String,
|
||||
only_favs: bool,
|
||||
highlight: Option<usize>,
|
||||
frame: Option<u64>,
|
||||
}
|
||||
|
||||
/// The Font menu, inside any font field's popup or combo box: a search field (it has the keyboard
|
||||
/// while the menu is open, and starts empty each time it opens), favourites (★, Show Favorites
|
||||
/// Only), and each family's name with a sample in that family, the script groups apart. ↑/↓ move
|
||||
/// through the matches and Return picks one. Returns the family picked and closes the menu; the
|
||||
/// search, Favorites and the stars keep it open (the popup closes on a click outside only).
|
||||
pub fn font_menu_body(app: &mut DesignApp, ui: &mut egui::Ui, menu: &[designcraft_fonts::FamilyInfo], current: &str, width: f32) -> Option<String> {
|
||||
let (fonts, scope) = (fonts(app), font_scope(app));
|
||||
let menu = font_menu(app);
|
||||
let english = app.session.prefs.show_font_names_in_english;
|
||||
let shown_current = font_label(app, &menu, current);
|
||||
let mut favs = app.session.prefs.favorite_fonts.clone();
|
||||
let mut pick = None;
|
||||
let mut favs_changed = false;
|
||||
let state_id = egui::Id::new("font_menu_state");
|
||||
let (mut query, mut only_favs): (String, bool) = ui.data(|d| d.get_temp(state_id)).unwrap_or_default();
|
||||
let mut st: FontMenuState = ui.data(|d| d.get_temp(state_id)).unwrap_or_default();
|
||||
let frame = ui.ctx().cumulative_frame_nr();
|
||||
if st.frame.is_none_or(|f| f.saturating_add(1) < frame) {
|
||||
st.query.clear();
|
||||
st.highlight = None;
|
||||
}
|
||||
st.frame = Some(frame);
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
egui::ComboBox::from_id_salt("font_family")
|
||||
.selected_text(if current.is_empty() { "—" } else { &shown_current })
|
||||
.width(width)
|
||||
.height(420.0)
|
||||
// The search field, Favorites and the stars keep the menu open; picking a font closes it.
|
||||
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
|
||||
.show_ui(ui, |ui| {
|
||||
ui.set_min_width(300.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut query)
|
||||
.hint_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Search fonts")))
|
||||
.desired_width(200.0),
|
||||
);
|
||||
ui.toggle_value(&mut only_favs, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Favorites"))).on_hover_ui(|ui| {
|
||||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Show Favorites Only"));
|
||||
});
|
||||
ui.set_width(width.max(300.0));
|
||||
let search = ui
|
||||
.horizontal(|ui| {
|
||||
let search = ui.add(
|
||||
egui::TextEdit::singleline(&mut st.query)
|
||||
.hint_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Search fonts")))
|
||||
.desired_width(200.0)
|
||||
// Return picks the highlighted match instead of leaving the field.
|
||||
.return_key(None),
|
||||
);
|
||||
ui.toggle_value(&mut st.only_favs, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Favorites"))).on_hover_ui(|ui| {
|
||||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Show Favorites Only"));
|
||||
});
|
||||
let q = query.to_lowercase();
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let shown: Vec<&designcraft_fonts::FamilyInfo> =
|
||||
menu.iter().filter(|f| f.matches(&q) && (!only_favs || favs.contains(&f.family))).collect();
|
||||
let mut group = None;
|
||||
for info in shown {
|
||||
// A separator between the Western fonts and each CJK language's.
|
||||
if group.is_some_and(|g| g != info.group) {
|
||||
ui.separator();
|
||||
}
|
||||
group = Some(info.group);
|
||||
let f = &info.family;
|
||||
let label = info.label(english);
|
||||
let (row, mut resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 24.0), egui::Sense::click());
|
||||
if !ui.is_rect_visible(row) {
|
||||
continue;
|
||||
}
|
||||
if label != f {
|
||||
resp = resp.on_hover_text(f);
|
||||
}
|
||||
if f == current {
|
||||
ui.painter().rect_filled(row, 0.0, t.row_selected);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(row, 0.0, t.hover);
|
||||
}
|
||||
// Favourite star.
|
||||
let star = egui::Rect::from_min_size(row.min + egui::vec2(2.0, 4.0), egui::vec2(16.0, 16.0));
|
||||
let fav = favs.contains(f);
|
||||
let sr = ui.interact(star, ui.id().with(("fav", f)), egui::Sense::click());
|
||||
paint_star(ui.painter(), star.center(), 6.5, fav, if fav { t.accent } else { t.text_dim });
|
||||
if sr.clicked() {
|
||||
if fav {
|
||||
favs.retain(|x| x != f);
|
||||
} else {
|
||||
favs.push(f.clone());
|
||||
}
|
||||
favs_changed = true;
|
||||
}
|
||||
ui.painter().text(row.min + egui::vec2(22.0, 12.0), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(12.0), t.text);
|
||||
// The name in its own face (rendered once per family and scale).
|
||||
let key = egui::Id::new(("font_preview", f, scope, (ppp * 100.0) as u32, t.text.to_array()));
|
||||
let tex: Option<egui::TextureHandle> = ui.data(|d| d.get_temp(key));
|
||||
let tex = tex.unwrap_or_else(|| {
|
||||
let img = designcraft_render::glyphs::text_line(&fonts, f, "Regular", "Sample", (18.0 * ppp) as u32, t.text.to_array());
|
||||
let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
|
||||
let h = ui.ctx().load_texture(format!("font_preview_{f}"), ci, egui::TextureOptions::LINEAR);
|
||||
ui.data_mut(|d| d.insert_temp(key, h.clone()));
|
||||
h
|
||||
});
|
||||
let size = tex.size_vec2() / ppp;
|
||||
let at = egui::pos2(row.max.x - size.x - 4.0, row.center().y - size.y / 2.0);
|
||||
ui.painter().image(
|
||||
tex.id(),
|
||||
egui::Rect::from_min_size(at, size),
|
||||
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
|
||||
egui::Color32::WHITE,
|
||||
);
|
||||
if resp.clicked() {
|
||||
pick = Some(f.clone());
|
||||
ui.close();
|
||||
}
|
||||
search
|
||||
})
|
||||
.inner;
|
||||
// Typing always goes to the search, never to the document under the menu.
|
||||
if !search.has_focus() {
|
||||
search.request_focus();
|
||||
}
|
||||
let q = st.query.to_lowercase();
|
||||
let shown: Vec<&designcraft_fonts::FamilyInfo> = menu.iter().filter(|f| f.matches(&q) && (!st.only_favs || favs.contains(&f.family))).collect();
|
||||
if search.changed() {
|
||||
st.highlight = if q.is_empty() { None } else { Some(0) };
|
||||
}
|
||||
let (down, up, enter) = ui.input(|i| (i.key_pressed(egui::Key::ArrowDown), i.key_pressed(egui::Key::ArrowUp), i.key_pressed(egui::Key::Enter)));
|
||||
let last = shown.len().checked_sub(1);
|
||||
st.highlight = match (st.highlight, last) {
|
||||
(_, None) => None,
|
||||
(None, Some(_)) if down => Some(0),
|
||||
(Some(h), Some(l)) if down => Some((h + 1).min(l)),
|
||||
(Some(h), Some(l)) if up => Some(h.saturating_sub(1).min(l)),
|
||||
(h, Some(l)) => h.map(|h| h.min(l)),
|
||||
};
|
||||
if enter && let Some(f) = st.highlight.and_then(|h| shown.get(h)) {
|
||||
pick = Some(f.family.clone());
|
||||
}
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
egui::ScrollArea::vertical().max_height(380.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
let mut group = None;
|
||||
for (k, info) in shown.iter().enumerate() {
|
||||
// A separator between the Western fonts and each CJK language's.
|
||||
if group.is_some_and(|g| g != info.group) {
|
||||
ui.separator();
|
||||
}
|
||||
});
|
||||
ui.data_mut(|d| d.insert_temp(state_id, (query, only_favs)));
|
||||
group = Some(info.group);
|
||||
let f = &info.family;
|
||||
let label = info.label(english);
|
||||
let (row, mut resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 24.0), egui::Sense::click());
|
||||
let highlighted = st.highlight == Some(k);
|
||||
if highlighted && (down || up) {
|
||||
ui.scroll_to_rect(row, None);
|
||||
}
|
||||
if !ui.is_rect_visible(row) {
|
||||
continue;
|
||||
}
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, f == current, label));
|
||||
if label != f {
|
||||
resp = resp.on_hover_text(f);
|
||||
}
|
||||
if f == current {
|
||||
ui.painter().rect_filled(row, 0.0, t.row_selected);
|
||||
} else if highlighted || resp.hovered() {
|
||||
ui.painter().rect_filled(row, 0.0, t.hover);
|
||||
}
|
||||
// Favourite star.
|
||||
let star = egui::Rect::from_min_size(row.min + egui::vec2(2.0, 4.0), egui::vec2(16.0, 16.0));
|
||||
let fav = favs.contains(f);
|
||||
let sr = ui.interact(star, ui.id().with(("fav", f)), egui::Sense::click());
|
||||
paint_star(ui.painter(), star.center(), 6.5, fav, if fav { t.accent } else { t.text_dim });
|
||||
if sr.clicked() {
|
||||
if fav {
|
||||
favs.retain(|x| x != f);
|
||||
} else {
|
||||
favs.push(f.clone());
|
||||
}
|
||||
favs_changed = true;
|
||||
}
|
||||
ui.painter().text(row.min + egui::vec2(22.0, 12.0), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(12.0), t.text);
|
||||
// The name in its own face (rendered once per family and scale).
|
||||
let key = egui::Id::new(("font_preview", f, scope, (ppp * 100.0) as u32, t.text.to_array()));
|
||||
let tex: Option<egui::TextureHandle> = ui.data(|d| d.get_temp(key));
|
||||
let tex = tex.unwrap_or_else(|| {
|
||||
let img = designcraft_render::glyphs::text_line(&fonts, f, "Regular", "Sample", (18.0 * ppp) as u32, t.text.to_array());
|
||||
let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
|
||||
let h = ui.ctx().load_texture(format!("font_preview_{f}"), ci, egui::TextureOptions::LINEAR);
|
||||
ui.data_mut(|d| d.insert_temp(key, h.clone()));
|
||||
h
|
||||
});
|
||||
let size = tex.size_vec2() / ppp;
|
||||
let at = egui::pos2(row.max.x - size.x - 4.0, row.center().y - size.y / 2.0);
|
||||
ui.painter().image(
|
||||
tex.id(),
|
||||
egui::Rect::from_min_size(at, size),
|
||||
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
|
||||
egui::Color32::WHITE,
|
||||
);
|
||||
if resp.clicked() && !sr.clicked() {
|
||||
pick = Some(f.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.data_mut(|d| d.insert_temp(state_id, st));
|
||||
if favs_changed {
|
||||
let _ = app.run("prefs.set", json!({ "favoriteFonts": favs }));
|
||||
}
|
||||
if let Some(f) = pick {
|
||||
let styles = fonts.styles(&f);
|
||||
let style = if styles.iter().any(|s| s == "Regular") { "Regular".to_string() } else { styles.first().cloned().unwrap_or_default() };
|
||||
let _ = app.run("type.char", json!({"attrs": {"fontFamily": f, "fontStyle": style}}));
|
||||
if pick.is_some() {
|
||||
ui.close();
|
||||
}
|
||||
pick
|
||||
}
|
||||
|
||||
/// A font family combo box showing `current` by its menu name, with [`font_menu_body`] as its menu.
|
||||
/// Returns the family picked.
|
||||
pub fn font_combo(
|
||||
app: &mut DesignApp,
|
||||
ui: &mut egui::Ui,
|
||||
id_salt: impl std::hash::Hash + std::fmt::Debug,
|
||||
current: &str,
|
||||
width: f32,
|
||||
) -> Option<String> {
|
||||
let menu = font_menu(app);
|
||||
let shown = if current.is_empty() { "—".to_string() } else { font_label(app, &menu, current) };
|
||||
egui::ComboBox::from_id_salt(id_salt)
|
||||
.selected_text(shown)
|
||||
.width(width)
|
||||
.height(440.0)
|
||||
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
|
||||
.show_ui(ui, |ui| font_menu_body(app, ui, &menu, current, width))
|
||||
.inner
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// [`font_menu_body`] in a popup under `field` (a font field the caller draws). Returns the family
|
||||
/// picked.
|
||||
pub fn font_popup(app: &mut DesignApp, field: &egui::Response, menu: &[designcraft_fonts::FamilyInfo], current: &str) -> Option<String> {
|
||||
egui::Popup::menu(field)
|
||||
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
|
||||
.show(|ui| font_menu_body(app, ui, menu, current, field.rect.width()))
|
||||
.and_then(|r| r.inner)
|
||||
}
|
||||
|
||||
/// Set the text's font to `family`, in its Regular style (else its first).
|
||||
pub fn apply_font_family(app: &mut DesignApp, family: &str) {
|
||||
let styles = fonts(app).styles(family);
|
||||
let style = if styles.iter().any(|s| s == "Regular") { "Regular".to_string() } else { styles.first().cloned().unwrap_or_default() };
|
||||
let _ = app.run("type.char", json!({"attrs": {"fontFamily": family, "fontStyle": style}}));
|
||||
}
|
||||
|
||||
/// The text's font family field: [`font_combo`], applied to the text.
|
||||
pub fn font_family_picker(app: &mut DesignApp, ui: &mut egui::Ui, current: &str, width: f32) {
|
||||
if let Some(f) = font_combo(app, ui, "font_family", current, width) {
|
||||
apply_font_family(app, &f);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,4 +480,106 @@ mod font_menu_tests {
|
||||
app.run("prefs.set", json!({"showFontNamesInEnglish": true})).unwrap();
|
||||
assert_eq!(font_label(&app, &font_menu(&app), FAMILY), FAMILY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_system_fonts_stay_out_of_the_font_menus() {
|
||||
const HIDDEN: &str = ".DC UI Test Hidden";
|
||||
designcraft_fonts::FontDb::global().add_font(font_with(HIDDEN, &['a']).unwrap());
|
||||
let app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
assert!(fonts(&app).family_infos().iter().any(|f| f.family == HIDDEN), "installed");
|
||||
let menu = font_menu(&app);
|
||||
assert!(menu.iter().all(|f| !f.family.starts_with('.')), "no hidden family is listed");
|
||||
// Text that already uses one still shows its name.
|
||||
assert_eq!(font_label(&app, &menu, HIDDEN), HIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod font_menu_ui_tests {
|
||||
use egui::vec2;
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::test_window::{self, Window, click_at};
|
||||
|
||||
/// The Type tool's selection over a new text frame's text, with the Control bar on. Returns the
|
||||
/// story.
|
||||
fn typing() -> (Harness<'static, Window>, u64) {
|
||||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.ui.control_bar = true;
|
||||
let mut h = test_window::open(app, vec2(1440.0, 900.0));
|
||||
let app = &mut h.state_mut().app;
|
||||
let r = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hello", "caret": true})).unwrap();
|
||||
app.run("tool.select", json!({"tool": "type"})).unwrap();
|
||||
app.run("text.select", json!({"story": r["story"], "anchor": 0, "focus": 5})).unwrap();
|
||||
h.run_steps(4);
|
||||
(h, r["story"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn story_text(h: &mut Harness<'static, Window>, story: u64) -> String {
|
||||
h.state_mut().app.run("story.get", json!({"story": story})).unwrap()["text"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// The Control bar's font field, showing `family`.
|
||||
fn field(h: &Harness<'static, Window>, family: &str) -> egui::Rect {
|
||||
h.get_by(|n| n.role() == egui::accesskit::Role::ComboBox && n.value().as_deref() == Some(family)).rect()
|
||||
}
|
||||
|
||||
/// The text in the focused search field.
|
||||
fn search(h: &Harness<'static, Window>) -> String {
|
||||
h.get_by(|n| n.role() == egui::accesskit::Role::TextInput && n.is_focused()).value().unwrap_or_default()
|
||||
}
|
||||
|
||||
fn family(h: &mut Harness<'static, Window>) -> String {
|
||||
h.state_mut().app.run("type.selectionAttrs", json!({})).unwrap()["chars"]["fontFamily"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_in_the_font_menu_searches_and_never_reaches_the_story() {
|
||||
let (mut h, story) = typing();
|
||||
let before = story_text(&mut h, story);
|
||||
let at = field(&h, designcraft_fonts::DEFAULT_FAMILY);
|
||||
click_at(&mut h, at.center());
|
||||
assert!(h.ctx.text_edit_focused(), "the search field takes the keyboard when the menu opens");
|
||||
h.event(egui::Event::Text("source sans".into()));
|
||||
h.run_steps(4);
|
||||
assert_eq!(search(&h), "source sans");
|
||||
assert_eq!(story_text(&mut h, story), before, "the typing went to the search, not the story");
|
||||
assert!(h.query_by_label("Source Sans 3").is_some(), "a match is listed");
|
||||
assert!(h.query_by_label(designcraft_fonts::DEFAULT_FAMILY).is_none(), "families that don't match aren't");
|
||||
// ↓ and Return pick the first match and close the menu.
|
||||
h.key_press(egui::Key::ArrowDown);
|
||||
h.run_steps(2);
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(4);
|
||||
assert_eq!(family(&mut h), "Source Sans 3");
|
||||
assert_eq!(story_text(&mut h, story), before);
|
||||
assert!(!h.ctx.text_edit_focused(), "the menu closed");
|
||||
// Reopened, the search starts empty.
|
||||
let at = field(&h, "Source Sans 3");
|
||||
click_at(&mut h, at.center());
|
||||
assert_eq!(search(&h), "", "the search starts empty");
|
||||
// A click on a family picks it.
|
||||
h.event(egui::Event::Text("serif 4".into()));
|
||||
h.run_steps(4);
|
||||
let row = h.get_by_label(designcraft_fonts::DEFAULT_FAMILY).rect();
|
||||
click_at(&mut h, row.center());
|
||||
assert_eq!(family(&mut h), designcraft_fonts::DEFAULT_FAMILY);
|
||||
// Escape closes the menu and leaves the text as it was.
|
||||
let at = field(&h, designcraft_fonts::DEFAULT_FAMILY);
|
||||
click_at(&mut h, at.center());
|
||||
// A star marks a favourite and keeps the menu open.
|
||||
h.event(egui::Event::Text("serif 4".into()));
|
||||
h.run_steps(4);
|
||||
let row = h.get_by_label(designcraft_fonts::DEFAULT_FAMILY).rect();
|
||||
click_at(&mut h, row.min + vec2(10.0, 12.0));
|
||||
assert!(h.state().app.session.prefs.favorite_fonts.iter().any(|f| f == designcraft_fonts::DEFAULT_FAMILY));
|
||||
assert_eq!(search(&h), "serif 4", "the menu stays open");
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(4);
|
||||
assert!(!h.ctx.text_edit_focused(), "the menu closed");
|
||||
assert_eq!(story_text(&mut h, story), before);
|
||||
assert!(h.state().app.session.active().unwrap().selection.text.is_some(), "the text is still selected");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ fn selection_title(app: &DesignApp, info: &Option<SelInfo>) -> String {
|
||||
Some(i) if i.count > 1 => crate::i18n::tr(&app.ui.language, "Multiple Objects").into(),
|
||||
Some(i) => match i.kind {
|
||||
"<text frame>" => crate::i18n::tr(&app.ui.language, "Text Frame").into(),
|
||||
"<group>" => crate::i18n::tr(&app.ui.language, "Group").into(),
|
||||
"<group>" => crate::i18n::tr_context(&app.ui.language, "Group", "selection").into(),
|
||||
"<image>" => crate::i18n::tr(&app.ui.language, "Image").into(),
|
||||
"<rectangle>" => crate::i18n::tr(&app.ui.language, "Rectangle").into(),
|
||||
"<ellipse>" => crate::i18n::tr(&app.ui.language, "Ellipse").into(),
|
||||
@@ -164,6 +164,7 @@ const PAGE_PRESETS: &[(&str, f64, f64)] = &[
|
||||
("A3", 841.89, 1190.55),
|
||||
("A4", 595.276, 841.89),
|
||||
("A5", 419.528, 595.276),
|
||||
("A6", 297.638, 419.528),
|
||||
("B5", 498.898, 708.661),
|
||||
];
|
||||
|
||||
@@ -572,6 +573,7 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) {
|
||||
widgets::paint_chip(ui.painter(), chip, fc, fg);
|
||||
ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
|
||||
let resp = ui.interact(chip, ui.id().with("fillchip"), Sense::click()).on_hover_text(format!("Fill: {}", i.fill));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Fill swatch"));
|
||||
swatch_menu(app, &resp, &doc, &i.fill, |app, n| {
|
||||
let _ = app.run("object.fill", json!({"swatch": n}));
|
||||
});
|
||||
@@ -591,6 +593,7 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) {
|
||||
widgets::paint_stroke_chip(ui.painter(), chip, sc, egui::Color32::from_gray(20));
|
||||
ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
|
||||
let resp = ui.interact(chip, ui.id().with("strokechip"), Sense::click()).on_hover_text(format!("Stroke: {}", i.stroke));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Stroke swatch"));
|
||||
swatch_menu(app, &resp, &doc, &i.stroke, |app, n| {
|
||||
let _ = app.run("object.stroke", json!({"swatch": n}));
|
||||
});
|
||||
@@ -667,15 +670,10 @@ fn swatch_menu(app: &mut DesignApp, resp: &egui::Response, doc: &designcraft_doc
|
||||
let mut picked = None;
|
||||
egui::Popup::menu(resp).show(|ui| {
|
||||
ui.set_min_width(200.0);
|
||||
ui.set_max_width(280.0);
|
||||
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
|
||||
for sw in &doc.swatches {
|
||||
let (c, g) = widgets::swatch_colors(doc, &sw.name, 1.0);
|
||||
let row = ui.horizontal(|ui| {
|
||||
let (cr, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover());
|
||||
widgets::paint_chip(ui.painter(), cr, c, g);
|
||||
ui.add(egui::Button::new(&sw.name).frame(false).selected(sw.name == cur))
|
||||
});
|
||||
if row.inner.clicked() {
|
||||
if super::swatch_menu_row(ui, doc, &sw.name, cur) {
|
||||
picked = Some(sw.name.clone());
|
||||
ui.close();
|
||||
}
|
||||
@@ -999,20 +997,8 @@ fn character_section(app: &mut DesignApp, ui: &mut Ui) {
|
||||
egui::FontId::proportional(11.5),
|
||||
t.text,
|
||||
);
|
||||
let mut pick_fam = None;
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
ui.set_min_width(fw);
|
||||
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
|
||||
if let Some(f) = super::font_menu_rows(app, ui, &menu, &fam) {
|
||||
pick_fam = Some(f);
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
if let Some(f) = pick_fam {
|
||||
let styles = fonts.styles(&f);
|
||||
let style = if styles.iter().any(|s| s == "Regular") { "Regular".to_string() } else { styles.first().cloned().unwrap_or_default() };
|
||||
let _ = app.run("type.char", json!({"attrs": {"fontFamily": f, "fontStyle": style}}));
|
||||
if let Some(f) = super::font_popup(app, &resp, &menu, &fam) {
|
||||
super::apply_font_family(app, &f);
|
||||
}
|
||||
ui.add_space(1.0);
|
||||
let styles = fonts.styles(&fam);
|
||||
@@ -1190,7 +1176,7 @@ fn text_frame_section(app: &mut DesignApp, ui: &mut Ui) {
|
||||
}
|
||||
|
||||
/// Languages offered for text (InDesign-style names).
|
||||
const LANGUAGES: &[&str] = &[
|
||||
pub(crate) const LANGUAGES: &[&str] = &[
|
||||
"[No Language]",
|
||||
"English: USA",
|
||||
"English: UK",
|
||||
@@ -1935,13 +1921,10 @@ pub fn info_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
crate::rtl::label(
|
||||
ui,
|
||||
format!(
|
||||
"{} {} · {} {} · {} {}",
|
||||
st.doc.page_count(),
|
||||
crate::i18n::tr(&app.ui.language, "pages"),
|
||||
st.doc.stories.len(),
|
||||
crate::i18n::tr(&app.ui.language, "stories"),
|
||||
st.doc.all_items().len(),
|
||||
crate::i18n::tr(&app.ui.language, "items")
|
||||
"{} · {} · {}",
|
||||
crate::i18n::count_label(&app.ui.language, "pages", st.doc.page_count()),
|
||||
crate::i18n::count_label(&app.ui.language, "stories", st.doc.stories.len()),
|
||||
crate::i18n::count_label(&app.ui.language, "items", st.doc.all_items().len())
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -2055,10 +2038,9 @@ pub fn preflight_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
crate::i18n::tr(&app.ui.language, "No errors").to_string()
|
||||
} else {
|
||||
format!(
|
||||
"{errors} {}, {} {}",
|
||||
crate::i18n::tr(&app.ui.language, "errors"),
|
||||
issues.len() - errors,
|
||||
crate::i18n::tr(&app.ui.language, "warnings")
|
||||
"{}, {}",
|
||||
crate::i18n::count_label(&app.ui.language, "errors", errors),
|
||||
crate::i18n::count_label(&app.ui.language, "warnings", issues.len() - errors)
|
||||
)
|
||||
},
|
||||
);
|
||||
@@ -2175,4 +2157,43 @@ mod tests {
|
||||
h.run_steps(2);
|
||||
assert_eq!(language(h.state_mut()), "French");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_a_swatch_chip_in_the_appearance_fill_menu_applies_it() {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
// A graphic frame: the Appearance section shows a single Fill chip.
|
||||
let id = app.run("frame.create", json!({"rect": [72, 72, 300, 200]})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("selection.set", json!({"ids": [id]})).unwrap();
|
||||
let before = app.session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().fill.swatch.clone();
|
||||
assert_ne!(before, "[Paper]");
|
||||
let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state(
|
||||
|ui, app: &mut DesignApp| {
|
||||
let id = egui::Id::new("test_fonts");
|
||||
if ui.data(|d| d.get_temp::<bool>(id)).is_none() {
|
||||
crate::theme::install_fonts(ui.ctx(), "");
|
||||
ui.data_mut(|d| d.insert_temp(id, true));
|
||||
return;
|
||||
}
|
||||
show(app, ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
// Open the Fill menu from the Appearance chip.
|
||||
h.get_by_label("Fill swatch").click();
|
||||
h.run_steps(3);
|
||||
// Click the *chip* (the row's left edge), not the name.
|
||||
let name = "[Paper]".to_string();
|
||||
let row = h.get_by_label(&name).rect();
|
||||
// The row spans the menu (chip + name), not just the name text.
|
||||
assert!(row.width() > 150.0, "the row is the full menu width, chip included: {row:?}");
|
||||
let p = row.min + vec2(8.0, row.height() / 2.0);
|
||||
h.hover_at(p);
|
||||
h.drag_at(p);
|
||||
h.drop_at(p);
|
||||
h.run_steps(4);
|
||||
let after = h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().fill.swatch.clone();
|
||||
assert_eq!(after, name, "clicking the swatch chip applies it");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,8 +78,8 @@ pub fn character(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
list(app, ui, false);
|
||||
}
|
||||
|
||||
/// One style in the list: click applies (⌥ clears overrides), double-click edits (paragraph),
|
||||
/// right-click: apply, break link, move to group.
|
||||
/// One style in the list: click applies (⌥ clears overrides), double-click edits,
|
||||
/// right-click: edit, apply, break link, move to group.
|
||||
fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, current: Option<&str>, groups: &[String], t: &Tokens) {
|
||||
let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
|
||||
if current == Some(n) {
|
||||
@@ -96,10 +96,9 @@ fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, curren
|
||||
egui::FontId::proportional(12.5),
|
||||
t.text,
|
||||
);
|
||||
if resp.double_clicked() && para {
|
||||
app.ui.dialog = Some(crate::dialogs::Dialog::new("paragraphStyleOptions", json!({"name": n})));
|
||||
} else if resp.double_clicked() && !n.starts_with('[') {
|
||||
app.ui.dialog = Some(crate::dialogs::Dialog::new("characterStyleOptions", json!({"name": n})));
|
||||
let editable = n != if para { designcraft_doc::NO_PARA_STYLE } else { designcraft_doc::NO_CHAR_STYLE };
|
||||
if resp.double_clicked() && editable {
|
||||
crate::dialogs::open_style_options(app, para, n);
|
||||
} else if resp.clicked() {
|
||||
let cmd = if para { "style.paragraph.apply" } else { "style.character.apply" };
|
||||
let clear = ui.input(|i| i.modifiers.alt);
|
||||
@@ -107,17 +106,12 @@ fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, curren
|
||||
}
|
||||
let kind = if para { "paragraph" } else { "character" };
|
||||
resp.context_menu(|ui| {
|
||||
if ui
|
||||
.button(crate::rtl::widget(
|
||||
ui,
|
||||
format!(
|
||||
"{} \"{}\"",
|
||||
crate::i18n::tr(&app.ui.language, "Apply"),
|
||||
if n.contains('/') { shown } else { crate::i18n::style_name(&app.ui.language, shown) }
|
||||
),
|
||||
))
|
||||
.clicked()
|
||||
{
|
||||
let label = if n.contains('/') { shown } else { crate::i18n::style_name(&app.ui.language, shown) };
|
||||
if editable && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Edit \"{name}\"…").replace("{name}", label))).clicked() {
|
||||
crate::dialogs::open_style_options(app, para, n);
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(crate::rtl::widget(ui, format!("{} \"{}\"", crate::i18n::tr(&app.ui.language, "Apply"), label))).clicked() {
|
||||
let cmd = if para { "style.paragraph.apply" } else { "style.character.apply" };
|
||||
let _ = app.run(cmd, json!({"name": n}));
|
||||
ui.close();
|
||||
@@ -156,10 +150,6 @@ fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, curren
|
||||
}
|
||||
if para && !n.starts_with('[') {
|
||||
ui.separator();
|
||||
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Edit Paragraph Style…"))).clicked() {
|
||||
app.ui.dialog = Some(crate::dialogs::Dialog::new("paragraphStyleOptions", json!({"name": n})));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete Paragraph Style…"))).clicked() {
|
||||
app.ui.dialog = Some(crate::dialogs::Dialog::new("deleteParagraphStyle", json!({"name": n})));
|
||||
ui.close();
|
||||
@@ -167,10 +157,6 @@ fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, curren
|
||||
}
|
||||
if !para && !n.starts_with('[') {
|
||||
ui.separator();
|
||||
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Edit Character Style…"))).clicked() {
|
||||
app.ui.dialog = Some(crate::dialogs::Dialog::new("characterStyleOptions", json!({"name": n})));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete Character Style…"))).clicked() {
|
||||
app.ui.dialog = Some(crate::dialogs::Dialog::new("deleteCharacterStyle", json!({"name": n})));
|
||||
ui.close();
|
||||
|
||||
@@ -100,7 +100,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
run(app, "table.deleteRow", json!({"table": tid, "rows": [n, rows.len() - 1]}));
|
||||
}
|
||||
}
|
||||
caption(ui, crate::i18n::tr(&app.ui.language, "Columns"));
|
||||
caption(ui, crate::i18n::tr_context(&app.ui.language, "Columns", "table"));
|
||||
if let Some(v) = number(ui, "tp_cols", Some(cols.len() as f64), "", 52.0, 0) {
|
||||
let n = v.round().max(1.0) as usize;
|
||||
if n > cols.len() {
|
||||
@@ -139,6 +139,14 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
ui.end_row();
|
||||
});
|
||||
if ui
|
||||
.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Distribute Rows Evenly")))
|
||||
.on_hover_text("Equal fixed heights for the selected rows; cell text may become overset.")
|
||||
.clicked()
|
||||
&& let Err(e) = app.run("table.distributeRows", json!({}))
|
||||
{
|
||||
app.status(format!("Table: {e}"));
|
||||
}
|
||||
divider(ui);
|
||||
caption(ui, crate::i18n::tr(&app.ui.language, "Cell Text"));
|
||||
ui.horizontal(|ui| {
|
||||
|
||||
+171
-25
@@ -10,10 +10,16 @@ use crate::{DesignApp, icons, panels};
|
||||
|
||||
pub const HEIGHT: f32 = 36.0;
|
||||
|
||||
/// Show the bar under `sel` (screen rect of the selection), kept inside `canvas`.
|
||||
/// The grip that moves the bar.
|
||||
const GRIP: &str = "task_bar_grip";
|
||||
|
||||
/// Show the bar under `sel` (screen rect of the selection), or where it is pinned, kept inside
|
||||
/// `canvas`.
|
||||
pub fn show(app: &mut DesignApp, ctx: &egui::Context, sel: Rect, canvas: Rect) {
|
||||
let grip_id = egui::Id::new(GRIP);
|
||||
// (Two separate context calls: `is_pointer_over_egui` inside an `input` closure deadlocks.)
|
||||
if !app.ui.task_bar || (ctx.input(|i| i.pointer.any_down()) && !ctx.is_pointer_over_egui()) {
|
||||
let dragging = ctx.dragged_id() == Some(grip_id);
|
||||
if !app.ui.task_bar || (ctx.input(|i| i.pointer.any_down()) && !ctx.is_pointer_over_egui() && !dragging) {
|
||||
return;
|
||||
}
|
||||
let Some(st) = app.session.active() else { return };
|
||||
@@ -25,16 +31,23 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context, sel: Rect, canvas: Rect) {
|
||||
let id = egui::Id::new("task_bar");
|
||||
// Width from the previous frame (content-sized).
|
||||
let w = ctx.memory(|m| m.area_rect(id).map(|r| r.width())).unwrap_or(420.0);
|
||||
let mut x = sel.center().x - w / 2.0;
|
||||
x = x.clamp(canvas.min.x + 8.0, (canvas.max.x - w - 8.0).max(canvas.min.x + 8.0));
|
||||
let mut y = sel.max.y + 14.0;
|
||||
if y + HEIGHT > canvas.max.y - 8.0 {
|
||||
y = (sel.min.y - HEIGHT - 14.0).max(canvas.min.y + 8.0);
|
||||
}
|
||||
if y + HEIGHT > canvas.max.y - 4.0 {
|
||||
y = canvas.max.y - HEIGHT - 8.0;
|
||||
}
|
||||
egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos2(x, y)).show(ctx, |ui| {
|
||||
let at = match app.ui.task_bar_pin {
|
||||
Some([x, y]) => inside(canvas.min + vec2(x, y), w, canvas),
|
||||
None => {
|
||||
let mut x = sel.center().x - w / 2.0;
|
||||
x = x.clamp(canvas.min.x + 8.0, (canvas.max.x - w - 8.0).max(canvas.min.x + 8.0));
|
||||
let mut y = sel.max.y + 14.0;
|
||||
if y + HEIGHT > canvas.max.y - 8.0 {
|
||||
y = (sel.min.y - HEIGHT - 14.0).max(canvas.min.y + 8.0);
|
||||
}
|
||||
if y + HEIGHT > canvas.max.y - 4.0 {
|
||||
y = canvas.max.y - HEIGHT - 8.0;
|
||||
}
|
||||
pos2(x, y)
|
||||
}
|
||||
};
|
||||
app.ui.task_bar_at = Some([at.x - canvas.min.x, at.y - canvas.min.y]);
|
||||
egui::Area::new(id).order(egui::Order::Middle).fixed_pos(at).show(ctx, |ui| {
|
||||
egui::Frame::NONE
|
||||
.fill(t.panel)
|
||||
.stroke(Stroke::new(1.0, t.border))
|
||||
@@ -45,8 +58,17 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context, sel: Rect, canvas: Rect) {
|
||||
ui.set_height(HEIGHT);
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 6.0;
|
||||
// Dragging the grip moves the bar, and pins it where it is dropped.
|
||||
let (g, _) = ui.allocate_exact_size(vec2(12.0, 20.0), Sense::hover());
|
||||
icons::paint(ui.painter(), g, "grip", t.icon);
|
||||
let grip = ui.interact(g, grip_id, Sense::drag());
|
||||
icons::paint(ui.painter(), g, "grip", if grip.hovered() || grip.dragged() { t.text_strong } else { t.icon });
|
||||
if grip.dragged() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
let to = inside(at + grip.drag_delta(), w, canvas) - canvas.min;
|
||||
let _ = app.run("window.taskBarPin", json!({"at": [to.x, to.y]}));
|
||||
} else if grip.hovered() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
|
||||
}
|
||||
let text_frame = panels::sel_info(app).is_some_and(|i| i.is_text && i.count == 1);
|
||||
if has_text || text_frame {
|
||||
text_controls(app, ui, has_text);
|
||||
@@ -59,6 +81,13 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context, sel: Rect, canvas: Rect) {
|
||||
});
|
||||
}
|
||||
|
||||
/// `at` moved as little as needed for a bar `w` wide to sit inside `canvas` (8 pt in).
|
||||
fn inside(at: egui::Pos2, w: f32, canvas: Rect) -> egui::Pos2 {
|
||||
let x = at.x.min(canvas.max.x - w - 8.0).max(canvas.min.x + 8.0);
|
||||
let y = at.y.min(canvas.max.y - HEIGHT - 8.0).max(canvas.min.y + 8.0);
|
||||
pos2(x, y)
|
||||
}
|
||||
|
||||
fn split_button(ui: &mut Ui, icon: &str, label: &str) -> (egui::Response, egui::Response) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let g = crate::rtl::plain(ui.ctx(), label, egui::FontId::proportional(11.0), t.text);
|
||||
@@ -109,15 +138,10 @@ fn text_controls(app: &mut DesignApp, ui: &mut Ui, in_text: bool) {
|
||||
let sty = c["fontStyle"].as_str().unwrap_or("").to_string();
|
||||
let fonts = panels::fonts(app);
|
||||
let menu = panels::font_menu(app);
|
||||
let english = app.session.prefs.show_font_names_in_english;
|
||||
let labels: Vec<String> = menu.iter().map(|f| f.label(english).to_string()).collect();
|
||||
let cur = menu.iter().position(|f| f.family == fam);
|
||||
let shown = if fam.is_empty() { "—".to_string() } else { panels::font_label(app, &menu, &fam) };
|
||||
if let Some(f) = widgets::dropdown_list(ui, &shown, 150.0, &labels, cur).and_then(|k| menu.get(k)) {
|
||||
let f = f.family.clone();
|
||||
let styles = fonts.styles(&f);
|
||||
let style = if styles.iter().any(|s| s == "Regular") { "Regular".to_string() } else { styles.first().cloned().unwrap_or_default() };
|
||||
let _ = app.run("type.char", json!({"attrs": {"fontFamily": f, "fontStyle": style}}));
|
||||
let field = widgets::dropdown(ui, &shown, 150.0);
|
||||
if let Some(f) = panels::font_popup(app, &field, &menu, &fam) {
|
||||
panels::apply_font_family(app, &f);
|
||||
}
|
||||
let styles = fonts.styles(&fam);
|
||||
let cur = styles.iter().position(|s| *s == sty);
|
||||
@@ -136,11 +160,14 @@ fn text_controls(app: &mut DesignApp, ui: &mut Ui, in_text: bool) {
|
||||
let sw = c["fill"].as_str().unwrap_or("[Black]").to_string();
|
||||
let (col, g) = widgets::swatch_colors(&doc, &sw, 1.0);
|
||||
let resp = widgets::swatch_chip(ui, 17.0, col, g).on_hover_text(format!("Fill: {sw}"));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Task Bar fill"));
|
||||
let mut picked = None;
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
ui.set_min_width(180.0);
|
||||
ui.set_max_width(280.0);
|
||||
egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {
|
||||
for s in &doc.swatches {
|
||||
if ui.selectable_label(s.name == sw, &s.name).clicked() {
|
||||
if panels::swatch_menu_row(ui, &doc, &s.name, &sw) {
|
||||
picked = Some(s.name.clone());
|
||||
ui.close();
|
||||
}
|
||||
@@ -204,11 +231,16 @@ fn object_controls(app: &mut DesignApp, ui: &mut Ui) {
|
||||
widgets::paint_stroke_chip(ui.painter(), r, col, egui::Color32::from_gray(20));
|
||||
}
|
||||
let resp = resp.on_hover_text(format!("{}: {sw}", if k == 0 { "Fill" } else { "Stroke" }));
|
||||
resp.widget_info(|| {
|
||||
egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("Task Bar {}", if k == 0 { "fill" } else { "stroke" }))
|
||||
});
|
||||
let mut picked = None;
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
ui.set_min_width(180.0);
|
||||
ui.set_max_width(280.0);
|
||||
egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {
|
||||
for s in &doc.swatches {
|
||||
if ui.selectable_label(s.name == sw, &s.name).clicked() {
|
||||
if panels::swatch_menu_row(ui, &doc, &s.name, &sw) {
|
||||
picked = Some(s.name.clone());
|
||||
ui.close();
|
||||
}
|
||||
@@ -251,8 +283,14 @@ fn more(app: &mut DesignApp, ui: &mut Ui) {
|
||||
app.ui.task_bar = false;
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Properties Panel"))).clicked() {
|
||||
let _ = app.run("window.panel", json!({"panel": "properties"}));
|
||||
let pinned = app.ui.task_bar_pin.is_some();
|
||||
let pin = crate::i18n::tr(&app.ui.language, "Pin Bar Position");
|
||||
if ui.button(crate::rtl::widget(ui, if pinned { format!("✓ {pin}") } else { format!(" {pin}") })).clicked() {
|
||||
let _ = app.run("window.taskBarPin", json!({"on": !pinned}));
|
||||
ui.close();
|
||||
}
|
||||
if ui.add_enabled(pinned, egui::Button::new(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Reset Bar Position")))).clicked() {
|
||||
let _ = app.run("window.taskBarReset", json!({}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
@@ -263,3 +301,111 @@ fn sep(ui: &mut Ui) {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(1.0, 24.0), Sense::hover());
|
||||
ui.painter().line_segment([r.center_top(), r.center_bottom()], Stroke::new(1.0, t.border));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use egui::{pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::test_window::{self, Window, click_at};
|
||||
|
||||
/// A selected frame, with the task bar under it.
|
||||
fn window() -> (Harness<'static, Window>, u64) {
|
||||
let app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
let mut h = test_window::open(app, vec2(1440.0, 900.0));
|
||||
let app = &mut h.state_mut().app;
|
||||
let id = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hello"})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("selection.set", json!({"ids": [id]})).unwrap();
|
||||
h.run_steps(4);
|
||||
(h, id)
|
||||
}
|
||||
|
||||
fn bar(h: &Harness<'static, Window>) -> egui::Rect {
|
||||
h.ctx.memory(|m| m.area_rect(egui::Id::new("task_bar"))).unwrap()
|
||||
}
|
||||
|
||||
fn grip(h: &Harness<'static, Window>) -> egui::Pos2 {
|
||||
let b = bar(h);
|
||||
pos2(b.min.x + 10.0, b.center().y)
|
||||
}
|
||||
|
||||
fn drag(h: &mut Harness<'static, Window>, from: egui::Pos2, by: egui::Vec2) {
|
||||
h.hover_at(from);
|
||||
h.drag_at(from);
|
||||
h.run_steps(2);
|
||||
for k in 1..=8 {
|
||||
h.hover_at(from + by * (k as f32 / 8.0));
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.drop_at(from + by);
|
||||
h.run_steps(4);
|
||||
}
|
||||
|
||||
fn more(h: &mut Harness<'static, Window>) {
|
||||
let b = bar(h);
|
||||
click_at(h, pos2(b.max.x - 19.0, b.center().y));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinning_is_a_command() {
|
||||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
let run = |app: &mut crate::DesignApp, id: &str, p| crate::menus::run_ui(app, id, &p).unwrap();
|
||||
assert!(run(&mut app, "window.taskBarPin", json!({})).is_err(), "nowhere to pin a bar that isn't showing");
|
||||
assert!(run(&mut app, "window.taskBarPin", json!({"at": [1, "x"]})).is_err());
|
||||
run(&mut app, "window.taskBarPin", json!({"at": [40, 60]})).unwrap();
|
||||
assert_eq!(app.ui.task_bar_pin, Some([40.0, 60.0]));
|
||||
assert_eq!(crate::menus::checked(&app, "window.taskBarPin", &json!({})), Some(true));
|
||||
run(&mut app, "window.taskBarPin", json!({})).unwrap();
|
||||
assert_eq!(app.ui.task_bar_pin, None, "toggled off");
|
||||
app.ui.task_bar_at = Some([10.0, 20.0]);
|
||||
run(&mut app, "window.taskBarPin", json!({"on": true})).unwrap();
|
||||
assert_eq!(app.ui.task_bar_pin, Some([10.0, 20.0]), "pinned where it shows");
|
||||
run(&mut app, "window.taskBarReset", json!({})).unwrap();
|
||||
assert_eq!(app.ui.task_bar_pin, None);
|
||||
// A workspace keeps the bar's place.
|
||||
app.ui.task_bar_pin = Some([5.0, 6.0]);
|
||||
run(&mut app, "window.newWorkspace", json!({"name": "Mine"})).unwrap();
|
||||
app.ui.task_bar_pin = None;
|
||||
run(&mut app, "window.workspace", json!({"name": "Mine"})).unwrap();
|
||||
assert_eq!(app.ui.task_bar_pin, Some([5.0, 6.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_grip_moves_the_bar_and_it_stays_there() {
|
||||
let (mut h, id) = window();
|
||||
let before = bar(&h);
|
||||
let g = grip(&h);
|
||||
drag(&mut h, g, vec2(200.0, 150.0));
|
||||
let after = bar(&h);
|
||||
assert!((after.min - before.min - vec2(200.0, 150.0)).length() < 2.0, "dragged from {before:?} to {after:?}");
|
||||
assert!(h.state().app.ui.task_bar_pin.is_some(), "pinned where it was dropped");
|
||||
// A moved selection leaves it where it is.
|
||||
h.state_mut().app.run("transform.set", json!({"ids": [id], "y": 300})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert_eq!(bar(&h).min, after.min);
|
||||
// Dragged past the canvas, it stays inside.
|
||||
let canvas = h.state().app.canvas_rect.unwrap();
|
||||
let g = grip(&h);
|
||||
drag(&mut h, g, vec2(5000.0, 5000.0));
|
||||
assert!(canvas.contains_rect(bar(&h)), "{:?} outside the canvas {canvas:?}", bar(&h));
|
||||
// Reset Bar Position: back under the selection.
|
||||
more(&mut h);
|
||||
let at = h.get_by_label("Reset Bar Position").rect().center();
|
||||
click_at(&mut h, at);
|
||||
assert_eq!(h.state().app.ui.task_bar_pin, None);
|
||||
// Pin Bar Position keeps it where it is.
|
||||
more(&mut h);
|
||||
let at = h.get_by_label_contains("Pin Bar Position").rect().center();
|
||||
click_at(&mut h, at);
|
||||
let at = bar(&h).min - canvas.min;
|
||||
let [x, y] = h.state().app.ui.task_bar_pin.unwrap();
|
||||
assert!((vec2(x, y) - at).length() < 1.0, "pinned at {x}, {y}; showing at {at:?}");
|
||||
// Hide Bar.
|
||||
more(&mut h);
|
||||
let at = h.get_by_label("Hide Bar").rect().center();
|
||||
click_at(&mut h, at);
|
||||
assert!(!h.state().app.ui.task_bar);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,3 +424,31 @@ mod japanese_font_tests {
|
||||
super::install_fonts(&egui::Context::default(), "ja");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ukrainian_font_tests {
|
||||
#[test]
|
||||
fn every_catalog_cyrillic_glyph_exists_in_the_bundled_ui_fonts() {
|
||||
// Check the actual installed face data, without craft-fonts or system fonts.
|
||||
// egui's has_glyph can report false for the face providing its replacement glyph.
|
||||
let definitions = super::font_definitions(&[], "uk");
|
||||
let db = designcraft_fonts::FontDb::with_font_dirs(Vec::new());
|
||||
db.set_system_fallback(false);
|
||||
let characters: std::collections::HashSet<_> = crate::i18n::ukrainian_catalog()
|
||||
.iter()
|
||||
.flat_map(|text| text.chars())
|
||||
.chain("ҐґЄєІіЇї".chars())
|
||||
.filter(|c| matches!(*c, '\u{0400}'..='\u{052f}'))
|
||||
.collect();
|
||||
for (name, family, style) in
|
||||
[("ui", "Source Sans 3", "Regular"), ("ui-semibold", "Source Sans 3", "Semibold"), ("mono", "JetBrains Mono", "Regular")]
|
||||
{
|
||||
let data = &definitions.font_data[name].font;
|
||||
let face = db.face(family, style);
|
||||
assert_eq!(face.data(), data.as_ref(), "configured UI face {name}");
|
||||
for character in &characters {
|
||||
assert_ne!(face.glyph_for(*character), 0, "missing {character} in {name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,12 +134,19 @@ impl<'a> NumField<'a> {
|
||||
/// Draw the field; returns the new value when the user commits an edit (Enter, focus loss,
|
||||
/// a spinner click, ↑/↓ while focused (Shift = ×10) or a preset).
|
||||
pub fn show(self, ui: &mut Ui) -> Option<f64> {
|
||||
self.show_or_clear(ui).flatten()
|
||||
}
|
||||
|
||||
/// Like [`NumField::show`]; a field emptied while it had a value commits `Some(None)` (for
|
||||
/// values that may be unset, like a character style's attributes).
|
||||
pub fn show_or_clear(self, ui: &mut Ui) -> Option<Option<f64>> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let key = ui.id().with(self.id);
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(self.width, FIELD_H), Sense::hover());
|
||||
let step = self.step.unwrap_or_else(|| self.kind.step());
|
||||
let clamp = |v: f64| v.clamp(self.range.0, self.range.1);
|
||||
let mut out: Option<f64> = None;
|
||||
let mut cleared = false;
|
||||
let mut text_rect = rect;
|
||||
let focused_id = key.with("te");
|
||||
let focused = ui.memory(|m| m.has_focus(focused_id));
|
||||
@@ -218,6 +225,8 @@ impl<'a> NumField<'a> {
|
||||
&& self.value.is_none_or(|old| (old - v).abs() > 1e-9)
|
||||
{
|
||||
out = Some(v);
|
||||
} else if buf.trim().is_empty() && self.value.is_some() {
|
||||
cleared = true;
|
||||
}
|
||||
ui.data_mut(|d| d.remove::<String>(key));
|
||||
} else if !r.has_focus() {
|
||||
@@ -246,7 +255,7 @@ impl<'a> NumField<'a> {
|
||||
}
|
||||
});
|
||||
}
|
||||
out
|
||||
if cleared { Some(None) } else { out.map(Some) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -662,6 +671,36 @@ mod tests {
|
||||
assert_eq!(got, vec![12.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emptying_a_clearable_field_clears_it() {
|
||||
let ctx = egui::Context::default();
|
||||
let field = |ui: &mut Ui| NumField::number("c", Some(12.0), " pt", 2).width(80.0).show_or_clear(ui);
|
||||
let mut rect = Rect::NOTHING;
|
||||
run(&ctx, vec![], egui::Modifiers::NONE, &mut |ui| {
|
||||
rect = ui.available_rect_before_wrap();
|
||||
field(ui);
|
||||
});
|
||||
let key = |k, m| egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m };
|
||||
let mut frames: Vec<Vec<egui::Event>> = click(rect.min + vec2(40.0, 10.0), egui::Modifiers::NONE);
|
||||
frames.push(vec![key(egui::Key::A, egui::Modifiers::COMMAND)]);
|
||||
frames.push(vec![key(egui::Key::Backspace, egui::Modifiers::NONE)]);
|
||||
frames.push(vec![key(egui::Key::Enter, egui::Modifiers::NONE)]);
|
||||
let mut got = vec![];
|
||||
for ev in frames {
|
||||
let m = if ev.iter().any(|e| matches!(e, egui::Event::Key { modifiers, .. } if modifiers.command)) {
|
||||
egui::Modifiers::COMMAND
|
||||
} else {
|
||||
egui::Modifiers::NONE
|
||||
};
|
||||
run(&ctx, ev, m, &mut |ui| {
|
||||
if let Some(v) = field(ui) {
|
||||
got.push(v);
|
||||
}
|
||||
});
|
||||
}
|
||||
assert_eq!(got, vec![None]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_kind_formats_and_parses() {
|
||||
let k = NumKind::Number { suffix: "%", decimals: 0 };
|
||||
|
||||
@@ -30,7 +30,7 @@ so only enable it while you use it. Transport: `apps/designcraft/src/control_ser
|
||||
| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Synthetic keyboard input (typing into a text frame) |
|
||||
| `ui.move` / `ui.click` / `ui.drag` | screen points, `button?: left\|right\|middle` | Real egui pointer input — reaches every widget, menu and panel |
|
||||
| `ui.set` | `{brightness?, panel?, rulers?, guides?, frameEdges?, baselineGrid?, textThreads?, screenMode?, zoom?, page?, fit?}` | UI state |
|
||||
| `ui.dialog.open` | `{id, fields?}` | Open a dialog by id (e.g. `paragraphStyleOptions` with `{name, section}`) |
|
||||
| `ui.dialog.open` | `{id, fields?}` | Open a dialog by id (e.g. `paragraphStyleOptions` or `characterStyleOptions` with `{name, section}`, or `{new: true}` for a new style) |
|
||||
| `ui.dialog.set` / `ui.dialog.confirm` / `ui.dialog.cancel` | `{field, value}` | Fill and confirm the open dialog |
|
||||
| `ui.resize`, `ui.focus` | | Window control |
|
||||
| `ui.screenshot` | `{path?}` | PNG of the whole window |
|
||||
|
||||
+17
@@ -24,6 +24,23 @@ Other MCP clients use the same command line, e.g. in a JSON config:
|
||||
{"mcpServers": {"designcraft": {"command": "/path/to/designcraft-cli", "args": ["mcp"]}}}
|
||||
```
|
||||
|
||||
### From an installed release
|
||||
|
||||
The release packages ship `designcraft-cli` alongside the desktop app, so no build is needed:
|
||||
|
||||
| Install | CLI |
|
||||
|---|---|
|
||||
| Windows (MSI) | `C:\Program Files\DesignCraft\designcraft-cli.exe` by default (wherever you installed it otherwise), not on `PATH` |
|
||||
| Linux (deb, rpm) | `/usr/bin/designcraft-cli` |
|
||||
| macOS | the separate `designcraft-cli-<version>-macos-<arch>.zip` release asset (the `.app` holds only the desktop app) |
|
||||
|
||||
```sh
|
||||
# Windows, default install folder
|
||||
claude mcp add designcraft -- "C:\Program Files\DesignCraft\designcraft-cli.exe" mcp
|
||||
# Linux, or macOS with the CLI unzipped onto PATH
|
||||
claude mcp add designcraft -- designcraft-cli mcp
|
||||
```
|
||||
|
||||
## Modes
|
||||
|
||||
| | Headless (`mcp`) | Connected (`mcp --connect PORT` or `HOST:PORT`) |
|
||||
|
||||
Reference in New Issue
Block a user