Compare commits
111
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
079f9a8b9a | ||
|
|
8e40794153 | ||
|
|
ee7a7ebfac | ||
|
|
0d4d125f9b | ||
|
|
deaec7fc71 | ||
|
|
a26aa5b203 | ||
|
|
7be80776f2 | ||
|
|
a9c31fcbe7 | ||
|
|
89ff09e9fd | ||
|
|
71efade26b | ||
|
|
6a62dc58ba | ||
|
|
c462057460 | ||
|
|
539dcb84b7 | ||
|
|
d5e21cc735 | ||
|
|
bc688bd1b5 | ||
|
|
11799fa0cc | ||
|
|
b6649a3fad | ||
|
|
49dc3b90be | ||
|
|
c256818665 | ||
|
|
a20e680f62 | ||
|
|
68a14472c6 | ||
|
|
e2227849a1 | ||
|
|
3c096da84d | ||
|
|
47104f5f4c | ||
|
|
ea5a04504a | ||
|
|
d6eb526186 | ||
|
|
ce3683a508 | ||
|
|
333a487dab | ||
|
|
b8f77aefb6 | ||
|
|
40bdfd3aca | ||
|
|
dd595cf2e8 | ||
|
|
614606cc48 | ||
|
|
13f65ce6d4 | ||
|
|
308c096889 | ||
|
|
ba2ee7a174 | ||
|
|
b743d08f11 | ||
|
|
ab2daad763 | ||
|
|
80323a7a4e | ||
|
|
5f59332ec8 | ||
|
|
926cdc80a5 | ||
|
|
b55e3ad70a | ||
|
|
48c96b504d | ||
|
|
2e134e56be | ||
|
|
a01e045905 | ||
|
|
eac383d5af | ||
|
|
94bc59b068 | ||
|
|
c6ac2bf3c5 | ||
|
|
198725ed38 | ||
|
|
7ce27caf88 | ||
|
|
c91ca3634f | ||
|
|
24f7d5fba5 | ||
|
|
cc4992172a | ||
|
|
9dc05ac980 | ||
|
|
339eb78a9b | ||
|
|
e01077a674 | ||
|
|
6313c8cfae | ||
|
|
f9a89a3211 | ||
|
|
fbcadf317c | ||
|
|
64891c1a2e | ||
|
|
c60cb4a9c2 | ||
|
|
83faf568b0 | ||
|
|
ee1e95d1c7 | ||
|
|
e960baa131 | ||
|
|
0e5e093dd9 | ||
|
|
6572636d62 | ||
|
|
cf7a43d786 | ||
|
|
f4181890bd | ||
|
|
116ff3eee7 | ||
|
|
902c3ae8c1 | ||
|
|
2507ed9dd9 | ||
|
|
e80b7e383b | ||
|
|
597b651bb6 | ||
|
|
945f8db359 | ||
|
|
e50f02e638 | ||
|
|
aeaece9728 | ||
|
|
c6c2c7d0fe | ||
|
|
61cd4ed6e6 | ||
|
|
0e1a5b3e45 | ||
|
|
b05e575eb8 | ||
|
|
1012361773 | ||
|
|
f2a2671933 | ||
|
|
10fafed6c4 | ||
|
|
aafd565b99 | ||
|
|
571881a830 | ||
|
|
347369187f | ||
|
|
d899a63286 | ||
|
|
f95b032c82 | ||
|
|
ce9f3aa7c6 | ||
|
|
ef3fa1689f | ||
|
|
68352dab02 | ||
|
|
51a9b899c1 | ||
|
|
81e7b915af | ||
|
|
fcc9414480 | ||
|
|
377204f90e | ||
|
|
0306d51592 | ||
|
|
ebc6d1af7b | ||
|
|
cdb734aede | ||
|
|
2f743f155f | ||
|
|
4bc0837867 | ||
|
|
2ba0e1f6e5 | ||
|
|
80eb5844c7 | ||
|
|
77159bb08e | ||
|
|
047a3bfc6f | ||
|
|
ab6a668aa9 | ||
|
|
8dc2fd077f | ||
|
|
66fa0a998a | ||
|
|
b045390311 | ||
|
|
caba979b01 | ||
|
|
32f73e9cce | ||
|
|
0aba6ef6e4 | ||
|
|
980372f6d0 |
No files matched your search
@@ -1,5 +1,6 @@
|
||||
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
|
||||
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
|
||||
# The `package` job checks packaging/freebsd/package.sh (the release job runs it for real).
|
||||
name: FreeBSD
|
||||
|
||||
on:
|
||||
@@ -11,6 +12,8 @@ on:
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- .github/workflows/freebsd.yml
|
||||
- packaging/freebsd/**
|
||||
- packaging/env.sh
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -55,3 +58,47 @@ jobs:
|
||||
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
|
||||
|
||||
package:
|
||||
name: FreeBSD 14 (package)
|
||||
# The release build takes a while: only for PRs that touch FreeBSD packaging (or dependencies)
|
||||
# and on demand, not on every push to main.
|
||||
if: github.event_name != 'push'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 150
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR in the VM).
|
||||
# Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
path: craft-fonts
|
||||
- name: Package in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
mem: 12288
|
||||
prepare: |
|
||||
pkg install -y bash curl git pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# Build outside the checkout so only dist/ is copied back out of the VM.
|
||||
# The craft-fonts checkout is synced into the VM with the workspace.
|
||||
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
|
||||
export CARGO_TARGET_DIR=/var/tmp/vectorcraft-target CARGO_BUILD_JOBS=4
|
||||
bash packaging/freebsd/package.sh
|
||||
# Every part the package must have, the craft-fonts licences included.
|
||||
tar -tzf dist/release/vectorcraft-*-freebsd-x86_64.tar.gz >/var/tmp/contents.txt
|
||||
for want in '/bin/vectorcraft$' '/bin/vectorcraft-cli$' '/share/applications/.*\.desktop$' '/share/icons/hicolor/' '/share/doc/vectorcraft/NOTICE$' '/share/doc/vectorcraft/OFL-.*\.txt$'; do
|
||||
grep -Eq "$want" /var/tmp/contents.txt || { echo "missing from the package: $want"; exit 1; }
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: freebsd-package
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
@@ -232,6 +232,56 @@ jobs:
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
freebsd:
|
||||
name: FreeBSD x86_64
|
||||
needs: version
|
||||
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM (same image as freebsd.yml).
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 150
|
||||
environment: release
|
||||
env:
|
||||
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR in the VM).
|
||||
# Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
if: inputs.version != ''
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$VECTORCRAFT_VERSION"
|
||||
- name: Build and package in a FreeBSD VM
|
||||
# v1.5.9, pinned by commit.
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
mem: 12288
|
||||
# Passed through by name; never templated into the script.
|
||||
envs: "VECTORCRAFT_VERSION VECTORCRAFT_BUILD_DATE VECTORCRAFT_BUILD_SHA"
|
||||
prepare: |
|
||||
pkg install -y bash curl git pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# Build outside the checkout so only dist/ is copied back out of the VM.
|
||||
# The craft-fonts checkout is synced into the VM with the workspace.
|
||||
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
|
||||
export CARGO_TARGET_DIR=/var/tmp/vectorcraft-target CARGO_BUILD_JOBS=4
|
||||
bash packaging/freebsd/package.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-freebsd
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
web:
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
@@ -277,7 +327,7 @@ jobs:
|
||||
|
||||
release:
|
||||
name: Draft GitHub Release
|
||||
needs: [version, macos, windows, linux, web]
|
||||
needs: [version, macos, windows, linux, freebsd, web]
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions:
|
||||
|
||||
Generated
+28
-25
@@ -4211,7 +4211,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"clipboard-win",
|
||||
@@ -4220,6 +4220,8 @@ dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
"muda",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"vectorcraft-engine",
|
||||
@@ -4231,7 +4233,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-brush"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
@@ -4243,7 +4245,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cad"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4259,7 +4261,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cli"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"vectorcraft-color",
|
||||
@@ -4275,7 +4277,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-color"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"moxcms",
|
||||
@@ -4287,7 +4289,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-doc"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4301,7 +4303,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-effects"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde_json",
|
||||
@@ -4315,7 +4317,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-engine"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4347,7 +4349,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4364,7 +4366,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-format"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
@@ -4378,7 +4380,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
@@ -4390,7 +4392,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-mcp"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4409,7 +4411,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-metafile"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4423,7 +4425,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"linesweeper",
|
||||
@@ -4435,7 +4437,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pdf"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"flate2",
|
||||
@@ -4462,7 +4464,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-plugins"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4476,7 +4478,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-render"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"gif",
|
||||
@@ -4501,7 +4503,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-svg"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4525,7 +4527,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-testkit"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4542,7 +4544,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-text"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4551,11 +4553,12 @@ dependencies = [
|
||||
"subsetter",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-geom",
|
||||
"write-fonts",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-tools"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4570,7 +4573,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-trace"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4583,7 +4586,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-ui-egui"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui_extras",
|
||||
@@ -4611,7 +4614,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-web"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5685,7 +5688,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -16,13 +16,13 @@
|
||||
|
||||
<p align="center">
|
||||
A fast, open-source, clean-room take on the Adobe Illustrator workflow. It runs natively on
|
||||
macOS, Windows and Linux, and in the browser via WebAssembly. Built by the ArtCraft team.
|
||||
macOS, Windows, Linux and FreeBSD, and in the browser via WebAssembly. Built by the ArtCraft team.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Status: in active development" src="https://img.shields.io/badge/status-in%20active%20development-e8573f">
|
||||
<img alt="Written in pure Rust" src="https://img.shields.io/badge/pure-Rust-b83a24?logo=rust&logoColor=white">
|
||||
<img alt="Runs on macOS, Windows, Linux and the web" src="https://img.shields.io/badge/runs%20on-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20Web-555555">
|
||||
<img alt="Runs on macOS, Windows, Linux, FreeBSD and the web" src="https://img.shields.io/badge/runs%20on-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20Web-555555">
|
||||
<img alt="MCP server for agents" src="https://img.shields.io/badge/agents-MCP%20server-555555">
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-555555">
|
||||
</p>
|
||||
@@ -197,7 +197,8 @@ the UI can be swapped without touching the engine.
|
||||
|
||||
Agent and contributor rules (clean-room, the asset policy, no panics in shipped code, quality gates) are in
|
||||
[`AGENTS.md`](AGENTS.md); [`docs/development.md`](docs/development.md#robustness-vectorcraft-never-crashes)
|
||||
explains how VectorCraft avoids crashing. Every bundled asset is listed with its licence in [`ASSETS.md`](ASSETS.md).
|
||||
explains how VectorCraft avoids crashing, and [`docs/releasing.md`](docs/releasing.md) how releases are built,
|
||||
signed and published. Every bundled asset is listed with its licence in [`ASSETS.md`](ASSETS.md).
|
||||
|
||||
## The Crafting Apps
|
||||
|
||||
|
||||
+31
-22
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
|
||||
|
||||
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
|
||||
|
||||
_Last updated: 2026-10-06 (after M4.14–M4.98, M8.1–M8.20 and M14.4–M14.7; see [Honest assessment](#honest-assessment-2026-10-05))._
|
||||
_Last updated: 2026-10-07 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, and the 2026-10-07 issue fixes; see [Honest assessment](#honest-assessment-2026-10-05))._
|
||||
|
||||
## Where we are
|
||||
|
||||
@@ -35,7 +35,7 @@ since 2026-10-01.
|
||||
|---|---|---|
|
||||
| **Breadth:** menus, tools and panels exist | ~90% | 16 menu items still stubbed; every tool implemented except Touch Type; 51 panel modules |
|
||||
| **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~45%), brushes and symbols (in progress), raster effects (~20%) |
|
||||
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~1 of ~56 filters), SVG Filters, CJK composition (vertical type has initial support), Variables (data merge), scripting |
|
||||
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~1 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku and tate-chu-yoko; ruby, mojikumi and vertical metrics open), Variables (data merge), scripting |
|
||||
| **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first |
|
||||
| **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured |
|
||||
| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export, Place and Links, Package, Print, clipboard flavours. Native `.ai` private data is out of scope by design; DWG has no open spec |
|
||||
@@ -58,8 +58,8 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent
|
||||
3. **3D and Materials:** Extrude & Bevel, Revolve, Inflate and Rotate with lighting and materials, using a software
|
||||
renderer in its own crate (layering allows it below L6), with output in SVG/PDF as rasters or projected vectors.
|
||||
The largest single gap. 50–80 h.
|
||||
4. **Advanced type:** CJK composition for vertical type (kinsoku, ruby, tate-chu-yoko, vertical font metrics,
|
||||
mixed-script orientation), Optical Margin Alignment, the composer/hyphenation options, tab leaders, a
|
||||
4. **Advanced type:** CJK composition for vertical type (ruby, mojikumi, vertical font metrics, manual
|
||||
tate-chu-yoko), Optical Margin Alignment, the composer/hyphenation options, tab leaders, a
|
||||
spell-check dictionary (open licence), Touch Type and Snap to Glyph. Vertical point, area and path type have
|
||||
initial support. 35–48 h across type core and advanced.
|
||||
5. **Brushes, symbols and libraries:** brush options depth; original brush, symbol and graphic-style libraries
|
||||
@@ -88,31 +88,39 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **Automation:**
|
||||
- Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command.
|
||||
- JSON-lines control channel with real egui pointer and keyboard injection.
|
||||
- MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless.
|
||||
- MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings.
|
||||
- MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads).
|
||||
- Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`).
|
||||
- Actions panel that records and plays back commands.
|
||||
- **UI:** Illustrator 2026 layout restyled to measured values:
|
||||
- Medium Dark theme, categorized and Advanced toolbars, 35 pt document tabs, 33 pt panel tabs.
|
||||
- Hint bar, contextual task bar, 19 dock panels with ≡ menus.
|
||||
- Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan) ships complete, and Czech and Japanese cover every menu label, with system-locale detection on macOS, Windows and Linux, a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins).
|
||||
- Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool.
|
||||
- A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas.
|
||||
- Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close).
|
||||
- Four brightness themes, persistent preferences.
|
||||
- On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content.
|
||||
- On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one.
|
||||
- **Tools:**
|
||||
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso.
|
||||
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join.
|
||||
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools.
|
||||
- **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes).
|
||||
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres), Reshape, Free Transform (distort/perspective).
|
||||
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout.
|
||||
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag).
|
||||
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout. Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them.
|
||||
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type.
|
||||
- **Other:** Eyedropper, Gradient annotator, Artboard, Measure, Type, Hand, Zoom, Rotate View.
|
||||
- **Drawing aids:** Smart Guides and snapping, and Draw Normal / Behind / Inside modes.
|
||||
- **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked.
|
||||
- **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool.
|
||||
- **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, and a symbol dragged onto the canvas places an instance where it is dropped.
|
||||
- **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, Gradient, Eyedropper, Blend and the Liquify tools their panels or options.
|
||||
- **Drawing aids:** Smart Guides and snapping, guides dragged out of the rulers, and Draw Normal / Behind / Inside modes.
|
||||
- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard.
|
||||
- **Geometry and effects:**
|
||||
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below.
|
||||
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
|
||||
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline.
|
||||
- **Japanese interface and vertical type:** VectorCraft ▸ Language switches the menus between English and Japanese (untranslated labels stay English) and persists; the bundled OFL Shippori Mincho gives Japanese glyphs without system fonts. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs with `vert`/`vrt2` alternates); Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
|
||||
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
|
||||
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline. Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done.
|
||||
- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional Chinese covers the whole interface, Czech and Japanese every menu label (tested); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech ones from the bundled UI fonts. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku (the strict set) applies to horizontal and vertical Japanese; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
|
||||
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
|
||||
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type, Gradient Mesh, Shape Builder, Live Paint (every path an edge, open lines included), Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
|
||||
- **Paint and appearance (M3):**
|
||||
@@ -126,10 +134,11 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`.
|
||||
- **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names.
|
||||
- **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize.
|
||||
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing.
|
||||
- **Files (M4):**
|
||||
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash, background save and export.
|
||||
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units; PDF and PDF-compatible `.ai` in and out (layers, masks, editable text, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3).
|
||||
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export.
|
||||
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units; PDF and PDF-compatible `.ai` in and out (layers, masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3).
|
||||
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs Illustrator's AGM/CoolType prologs (resource categories, `resourceforall`, `clipsave`/`cliprestore`, subarrays and substrings that share storage) and falls back to its palette TIFF preview. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole).
|
||||
- Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours.
|
||||
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
|
||||
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`).
|
||||
@@ -145,14 +154,14 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 |
|
||||
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
|
||||
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3–6 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, CJK names in the UI, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options (with Width/Height), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
|
||||
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
|
||||
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
|
||||
| M11 | Artboards & views (artboard panel/tool, Trim View, middle-button pan and print tiling done; multiple windows, presentation polish) | 🟡 | 15–25 |
|
||||
| M11 | Artboards & views (artboard panel/tool, Trim View, middle-button pan, print tiling and the status bar's artboard navigator done; multiple windows, presentation polish) | 🟡 | 15–25 |
|
||||
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (12 presets, 18 ms/1k² image), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, regenerate in place) done; graph Design/Column/Marker designs pending | 6–10 |
|
||||
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 |
|
||||
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor, workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs and menus that scroll when longer than the window done; accessibility, Windows/Linux packaging pending | 18–28 |
|
||||
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed), workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs and menus that scroll when longer than the window done; accessibility, Windows/Linux packaging pending | 18–28 |
|
||||
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 60–90 |
|
||||
| — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 | 50–80 |
|
||||
| | **Total to full parity** (one agent; re-derived from the [Parity estimate](#parity-estimate) table) | | **~320–500** |
|
||||
@@ -180,18 +189,18 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
|
||||
| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters | 6–10 |
|
||||
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 20% | ~55 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery; Document Raster Effects Settings and raster effects in PDF are done | 28–42 |
|
||||
| 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 |
|
||||
| Type core | 9 | 78% | composer/hyphenation options, Optical Margin Alignment, hidden characters | 15–20 |
|
||||
| Type advanced | 4 | 45% | CJK composition for vertical type (kinsoku, ruby, tate-chu-yoko, vertical metrics, mixed-script orientation), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support | 20–28 |
|
||||
| Type core | 9 | 78% | composer/hyphenation options, Optical Margin Alignment, hidden characters, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 15–20 |
|
||||
| Type advanced | 4 | 48% | CJK composition for vertical type (ruby, mojikumi, vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support | 20–28 |
|
||||
| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 |
|
||||
| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (Vector Halftone is done) | 6–10 |
|
||||
| Layers, artboards, document setup | 5 | 80% | Layers panel options depth, artboard presets/rearrange polish (Document Setup and New Document are done) | 5–8 |
|
||||
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 |
|
||||
| Guides, grids, smart guides, snapping, rulers | 3 | 75% | global/video rulers, smart-guide preference depth | 4–8 |
|
||||
| File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers and presets are done | 6–10 |
|
||||
| Guides, grids, smart guides, snapping, rulers | 3 | 75% | global/video rulers, smart-guide preference depth, ruler-drag modifiers (Shift snaps to ticks, Alt swaps orientation) and dragging existing guides | 4–8 |
|
||||
| File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, Illustrator EPS checked against real files (only reduced repros so far), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers and presets are done | 6–10 |
|
||||
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 |
|
||||
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 |
|
||||
| Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 |
|
||||
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | Variables, SVG Interactivity, Properties per context, Consolidate All Windows (Links, Asset Export, CSS Properties, Attributes are done) | 15–25 |
|
||||
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | Variables, SVG Interactivity, Properties per context, Consolidate All Windows, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done) | 15–25 |
|
||||
| Libraries, Links, Package | 2 | 50% | a local Libraries panel (no cloud by design); Links and Package are done | 5–8 |
|
||||
| **Feature parity** | **103** | **~75%** | | **~210–330** |
|
||||
| Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! ```text
|
||||
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless]
|
||||
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}']]... [--export out.svg|.png|.pdf|.jpg|.webp|.vectorcraft]... [--scale 2]
|
||||
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}']]... [--export out.svg]... [--scale 2]
|
||||
//! vectorcraft-cli commands
|
||||
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text]
|
||||
//! vectorcraft-cli info FILE
|
||||
@@ -14,11 +14,41 @@
|
||||
use std::io::Write;
|
||||
use std::process::ExitCode;
|
||||
|
||||
/// `println!` / `print!` that end the program quietly when stdout is closed
|
||||
/// (`vectorcraft-cli commands | head`) instead of panicking with "failed printing to stdout:
|
||||
/// Broken pipe (os error 32)".
|
||||
macro_rules! outln {
|
||||
($($arg:tt)*) => {{
|
||||
use std::io::Write as _;
|
||||
if let Err(e) = writeln!(std::io::stdout(), $($arg)*) {
|
||||
$crate::stdout_failed(e);
|
||||
}
|
||||
}};
|
||||
}
|
||||
macro_rules! out {
|
||||
($($arg:tt)*) => {{
|
||||
use std::io::Write as _;
|
||||
if let Err(e) = write!(std::io::stdout(), $($arg)*) {
|
||||
$crate::stdout_failed(e);
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
mod perf;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_mcp::{Backend, DEFAULT_ADDR, Headless, Remote, Server};
|
||||
|
||||
/// 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) -> ! {
|
||||
if e.kind() == std::io::ErrorKind::BrokenPipe {
|
||||
std::process::exit(0);
|
||||
}
|
||||
eprintln!("vectorcraft-cli: can't write to stdout: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
const USAGE: &str = "\
|
||||
vectorcraft-cli — VectorCraft automation
|
||||
|
||||
@@ -29,16 +59,17 @@ USAGE:
|
||||
|
||||
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON]]... [--export FILE]... [--scale N]
|
||||
Headless batch: open FILE (any readable format) or start a new document, run commands in
|
||||
order, export (.svg, .png, .pdf, .jpg, .webp, .vectorcraft by extension). Prints one JSON result per step.
|
||||
order, export each FILE in the format its extension picks (see Writable formats). Prints one
|
||||
JSON result per step.
|
||||
|
||||
vectorcraft-cli commands
|
||||
Print the command catalogue as JSON.
|
||||
|
||||
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text]
|
||||
Open IN (any readable format) and export OUT by extension (.svg, .pdf, .png, .jpg, .webp,
|
||||
.vectorcraft). --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
|
||||
unless one of them is given, the other formats the first. Live effects are kept;
|
||||
--outline-text writes SVG text as paths.
|
||||
Open IN (any readable format) and export OUT in the format its extension picks (see Writable
|
||||
formats). --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
|
||||
unless one of them is given, EPS the bounds of the art, the other formats the first artboard.
|
||||
Live effects are kept; --outline-text writes SVG text as paths.
|
||||
|
||||
vectorcraft-cli info FILE
|
||||
Print a JSON summary: title, colour mode, units, artboards, object counts by kind, fonts.
|
||||
@@ -52,9 +83,10 @@ USAGE:
|
||||
synthetic N-path document (default 50000). Exits non-zero if a budget is exceeded.
|
||||
";
|
||||
|
||||
/// The usage text plus the formats `document.open` reads.
|
||||
/// The usage text plus the formats `document.open` reads and `document.export` writes.
|
||||
fn usage() -> String {
|
||||
format!("{USAGE}\nReadable formats: .{}\n", vectorcraft_engine::cmd::fileio::OPEN_EXTS.join(", ."))
|
||||
use vectorcraft_engine::cmd::fileio::{OPEN_EXTS, export_extensions};
|
||||
format!("{USAGE}\nReadable formats: .{}\nWritable formats: .{}\n", OPEN_EXTS.join(", ."), export_extensions().join(", ."))
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
@@ -68,11 +100,11 @@ fn main() -> ExitCode {
|
||||
Some("bench") => bench(&args[1..]),
|
||||
Some("perf") => perf::run(&args[1..]),
|
||||
Some("-h" | "--help" | "help") | None => {
|
||||
print!("{}", usage());
|
||||
out!("{}", usage());
|
||||
Ok(())
|
||||
}
|
||||
Some("-V" | "--version") => {
|
||||
println!("vectorcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
outln!("vectorcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
Ok(())
|
||||
}
|
||||
Some(other) => Err(format!("unknown subcommand `{other}`\n\n{}", usage())),
|
||||
@@ -112,6 +144,9 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
};
|
||||
eprintln!("vectorcraft-cli: MCP server on stdio ({})", backend.describe());
|
||||
// The binary owns the logger, not the library: installing one here keeps an embedder that
|
||||
// uses `vectorcraft_mcp` free to bring its own. Silent until a client sends logging/setLevel.
|
||||
vectorcraft_mcp::logging::install();
|
||||
let stdin = std::io::stdin();
|
||||
let stdout = std::io::stdout();
|
||||
Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())
|
||||
@@ -120,7 +155,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
fn commands() -> Result<(), String> {
|
||||
let mut h = Headless::new();
|
||||
let v = h.call("engine.commands", json!({}))?;
|
||||
println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
|
||||
outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -146,7 +181,7 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
json!({"command": "document.export", "params": {"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text}}),
|
||||
)
|
||||
.map_err(|e| format!("export {output}: {e}"))?;
|
||||
println!("{r}");
|
||||
outln!("{r}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -162,7 +197,7 @@ fn info(args: &[String]) -> Result<(), String> {
|
||||
let artboards: Vec<Value> =
|
||||
doc.artboards.iter().map(|a| json!({"name": a.name, "rect": [a.rect.x0, a.rect.y0, a.rect.width(), a.rect.height()]})).collect();
|
||||
let v = json!({"file": file, "info": base, "artboards": artboards, "kinds": kinds, "fonts": fonts});
|
||||
println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
|
||||
outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -252,7 +287,7 @@ fn bench(args: &[String]) -> Result<(), String> {
|
||||
* vectorcraft_geom::Affine::scale(z)
|
||||
* vectorcraft_geom::Affine::translate(-b.center().to_vec2());
|
||||
let opts = vectorcraft_render::RenderOptions::default();
|
||||
println!("{file}: {} nodes, {w}x{h}", doc.layers.iter().map(|l| l.count()).sum::<usize>());
|
||||
outln!("{file}: {} nodes, {w}x{h}", doc.layers.iter().map(|l| l.count()).sum::<usize>());
|
||||
for threads in [vectorcraft_render::default_threads(), 0] {
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
r.threads = threads;
|
||||
@@ -261,7 +296,7 @@ fn bench(args: &[String]) -> Result<(), String> {
|
||||
for _ in 0..iters {
|
||||
r.render(&doc, w, h, view, &opts);
|
||||
}
|
||||
println!(
|
||||
outln!(
|
||||
" threads {threads}: {:.1} ms/frame (drawn {}, culled {})",
|
||||
t.elapsed().as_secs_f64() * 1000.0 / iters as f64,
|
||||
r.stats.drawn,
|
||||
|
||||
@@ -81,14 +81,14 @@ pub fn run(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
let cores = std::thread::available_parallelism().map_or(1, |c| c.get());
|
||||
let load = load_average();
|
||||
println!("VectorCraft performance budgets — {n} paths, {cores} cores, load average {}", load.map_or("?".into(), |l| format!("{l:.1}")));
|
||||
outln!("VectorCraft performance budgets — {n} paths, {cores} cores, load average {}", load.map_or("?".into(), |l| format!("{l:.1}")));
|
||||
let noisy = load.is_some_and(|l| l > cores as f64 * 0.75);
|
||||
if noisy {
|
||||
println!("WARNING: the machine is busy; wall-clock timings below are not trustworthy.");
|
||||
outln!("WARNING: the machine is busy; wall-clock timings below are not trustworthy.");
|
||||
}
|
||||
let t = Instant::now();
|
||||
let doc = synthetic(n)?;
|
||||
println!(" (built in {:.0} ms)", t.elapsed().as_secs_f64() * 1000.0);
|
||||
outln!(" (built in {:.0} ms)", t.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
let mut rows: Vec<(&str, f64, f64)> = vec![];
|
||||
let opts = RenderOptions::default();
|
||||
@@ -150,16 +150,16 @@ pub fn run(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
|
||||
let mut over = 0;
|
||||
println!(" {:<40} {:>10} {:>10}", "", "measured", "budget");
|
||||
outln!(" {:<40} {:>10} {:>10}", "", "measured", "budget");
|
||||
for (name, ms, budget) in &rows {
|
||||
let ok = ms <= budget;
|
||||
over += usize::from(!ok);
|
||||
println!(" {name:<40} {ms:>8.2} ms {budget:>7.0} ms {}", if ok { "ok" } else { "OVER" });
|
||||
outln!(" {name:<40} {ms:>8.2} ms {budget:>7.0} ms {}", if ok { "ok" } else { "OVER" });
|
||||
}
|
||||
match (over, noisy) {
|
||||
(0, _) => Ok(()),
|
||||
(_, true) => {
|
||||
println!("{over} over budget, but the machine is busy: re-run when idle.");
|
||||
outln!("{over} over budget, but the machine is busy: re-run when idle.");
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(format!("{over} budget(s) exceeded")),
|
||||
|
||||
@@ -82,3 +82,17 @@ fn mcp_headless_over_stdio() {
|
||||
assert_eq!(replies[2]["result"]["isError"], false);
|
||||
assert_eq!(replies[3]["result"]["content"][0]["type"], "image");
|
||||
}
|
||||
|
||||
/// `vectorcraft-cli commands | head -1` panicked with "failed printing to
|
||||
/// stdout: Broken pipe (os error 32)" and exit status 101.
|
||||
#[test]
|
||||
fn closed_stdout_ends_quietly() {
|
||||
use std::io::Read;
|
||||
let mut child = Command::new(BIN).arg("commands").stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
let mut first = [0u8; 16];
|
||||
child.stdout.as_mut().unwrap().read_exact(&mut first).unwrap();
|
||||
drop(child.stdout.take());
|
||||
let out = child.wait_with_output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success() && !err.contains("panicked"), "{:?}: {err}", out.status);
|
||||
}
|
||||
@@ -30,6 +30,8 @@ clipboard-win = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
muda = { version = "0.21", default-features = false }
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSTextInputContext"] }
|
||||
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for vectorcraft.exe; build.rs only uses it when targeting Windows.
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
//! Loopback JSON-lines control server: one request per line, one reply per line.
|
||||
//! This is the transport the MCP server (`vectorcraft mcp --connect`) wraps.
|
||||
//!
|
||||
//! The server only ever reads requests: a line that is not a JSON object with a string `method`
|
||||
//! (an HTTP request line from a browser's cross-origin `fetch`, a stray `nc`, binary junk) gets
|
||||
//! one error reply and the connection is closed, so nothing after it is executed. Lines are
|
||||
//! bounded ([`MAX_LINE`]) and so is the number of open connections ([`MAX_CONNECTIONS`]); junk
|
||||
//! never reaches the UI thread. See `docs/control-protocol.md`.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_ui_egui::ControlRequest;
|
||||
|
||||
/// Longest accepted request line (bytes, without the newline). Requests are small JSON objects;
|
||||
/// anything longer is refused and the connection closed.
|
||||
pub const MAX_LINE: usize = 4 * 1024 * 1024;
|
||||
/// Connections served at once; further ones get an error line and are closed.
|
||||
pub const MAX_CONNECTIONS: usize = 16;
|
||||
|
||||
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
@@ -19,44 +33,249 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
}
|
||||
};
|
||||
eprintln!("vectorcraft: control server listening on 127.0.0.1:{port}");
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
std::thread::spawn(move || serve(stream, tx, ctx));
|
||||
}
|
||||
});
|
||||
std::thread::spawn(move || accept_loop(listener, tx, ctx));
|
||||
rx
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
for line in BufReader::new(read).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if line.trim().is_empty() {
|
||||
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let open = Arc::new(AtomicUsize::new(0));
|
||||
for mut stream in listener.incoming().flatten() {
|
||||
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
|
||||
open.fetch_sub(1, Ordering::SeqCst);
|
||||
// Best effort: the connection is dropped either way, and a peer that can't read the reason loses nothing.
|
||||
let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"}));
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
Ok(msg) => {
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
let open = Arc::clone(&open);
|
||||
std::thread::spawn(move || {
|
||||
serve(stream, &tx, &ctx);
|
||||
open.fetch_sub(1, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
serve_lines(read, stream, |method, params| {
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
tx.send(req).ok()?;
|
||||
ctx.request_repaint();
|
||||
Some(rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})))
|
||||
});
|
||||
}
|
||||
|
||||
/// One parsed line.
|
||||
enum Line {
|
||||
Request {
|
||||
id: Value,
|
||||
method: String,
|
||||
params: Value,
|
||||
},
|
||||
Blank,
|
||||
/// Not a request: reply with this error and close the connection.
|
||||
Reject(String),
|
||||
}
|
||||
|
||||
fn parse_line(bytes: &[u8]) -> Line {
|
||||
let Ok(text) = std::str::from_utf8(bytes) else {
|
||||
return Line::Reject("not a control request (invalid UTF-8); closing the connection".into());
|
||||
};
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Line::Blank;
|
||||
}
|
||||
let msg = match serde_json::from_str::<Value>(text) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Line::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection")),
|
||||
};
|
||||
let Some(obj) = msg.as_object() else {
|
||||
return Line::Reject("not a control request (expected a JSON object); closing the connection".into());
|
||||
};
|
||||
let Some(method) = obj.get("method").and_then(Value::as_str) else {
|
||||
return Line::Reject("not a control request (missing string `method`); closing the connection".into());
|
||||
};
|
||||
Line::Request {
|
||||
id: obj.get("id").cloned().unwrap_or(Value::Null),
|
||||
method: method.to_string(),
|
||||
params: obj.get("params").cloned().unwrap_or(json!({})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
|
||||
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
|
||||
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
|
||||
buf.clear();
|
||||
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
|
||||
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
|
||||
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?;
|
||||
if n == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
if buf.last() == Some(&b'\n') {
|
||||
buf.pop();
|
||||
if buf.last() == Some(&b'\r') {
|
||||
buf.pop();
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
if buf.len() > max {
|
||||
return Err(format!("request line longer than {max} bytes; closing the connection"));
|
||||
}
|
||||
// Last line without a newline, then end of stream.
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Serve requests from `read`, answering on `out`, until the peer closes, sends something that is
|
||||
/// not a request, or `handle` returns `None` (app gone). `handle` gets `(method, params)` and
|
||||
/// returns the reply object (the request's `id` is added here).
|
||||
fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(String, Value) -> Option<Value>) {
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
|
||||
Ok(false) => return,
|
||||
Err(e) => e,
|
||||
Ok(true) => match parse_line(&buf) {
|
||||
Line::Blank => continue,
|
||||
Line::Reject(e) => e,
|
||||
Line::Request { id, method, params } => {
|
||||
let Some(mut r) = handle(method, params) else { return };
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
if writeln!(out, "{r}").and_then(|()| out.flush()).is_err() {
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
r
|
||||
}
|
||||
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
|
||||
},
|
||||
};
|
||||
if writeln!(out, "{reply}").is_err() {
|
||||
break;
|
||||
// Best effort: the connection closes either way, and a peer that can't read the reason loses nothing.
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": reject})).and_then(|()| out.flush());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run(input: &[u8]) -> (Vec<Value>, Vec<String>) {
|
||||
let mut out = Vec::new();
|
||||
let mut calls = Vec::new();
|
||||
serve_lines(input, &mut out, |m, _p| {
|
||||
calls.push(m);
|
||||
Some(json!({"ok": true, "result": null}))
|
||||
});
|
||||
let replies = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str::<Value>(l).unwrap()).collect();
|
||||
(replies, calls)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requests_are_answered_in_order_with_ids() {
|
||||
let (r, calls) = run(b"{\"id\":1,\"method\":\"a\"}\n\n{\"id\":2,\"method\":\"b\",\"params\":{}}\r\n{\"method\":\"c\"}");
|
||||
assert_eq!(calls, ["a", "b", "c"]);
|
||||
assert_eq!(r.len(), 3);
|
||||
assert_eq!(r[0]["id"], 1);
|
||||
assert_eq!(r[1]["id"], 2);
|
||||
assert_eq!(r[2]["id"], Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_request_closes_before_the_body_runs() {
|
||||
// A browser's cross-origin `fetch` POST: the request line is rejected and the
|
||||
// connection closed, so the JSON body is never executed.
|
||||
let http = b"POST / HTTP/1.1\r\nHost: 127.0.0.1:7979\r\nOrigin: https://attacker.example\r\nContent-Type: text/plain\r\n\r\n\
|
||||
{\"id\":7,\"method\":\"engine.execute\",\"params\":{\"command\":\"file.new\",\"params\":{}}}\n";
|
||||
let (r, calls) = run(http);
|
||||
assert!(calls.is_empty(), "nothing may run: {calls:?}");
|
||||
assert_eq!(r.len(), 1);
|
||||
assert_eq!(r[0]["ok"], false);
|
||||
assert!(r[0]["error"].as_str().unwrap().contains("closing the connection"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_request_json_closes_the_connection() {
|
||||
for junk in [
|
||||
&b"[1,2]\n{\"method\":\"a\"}\n"[..],
|
||||
b"{\"id\":1}\n{\"method\":\"a\"}\n",
|
||||
b"{\"method\":5}\n{\"method\":\"a\"}\n",
|
||||
b"\xff\xfe\n{\"method\":\"a\"}\n",
|
||||
] {
|
||||
let (r, calls) = run(junk);
|
||||
assert!(calls.is_empty(), "{calls:?}");
|
||||
assert_eq!(r.len(), 1);
|
||||
assert_eq!(r[0]["ok"], false);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_before_junk_still_runs() {
|
||||
let (r, calls) = run(b"{\"method\":\"a\"}\nGET / HTTP/1.1\n{\"method\":\"b\"}\n");
|
||||
assert_eq!(calls, ["a"]);
|
||||
assert_eq!(r.len(), 2);
|
||||
assert_eq!(r[1]["ok"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlong_line_is_refused() {
|
||||
// One byte over the limit, then a valid request: neither runs.
|
||||
let mut input = vec![b'x'; MAX_LINE + 1];
|
||||
input.extend_from_slice(b"\n{\"method\":\"a\"}\n");
|
||||
let (r, calls) = run(&input);
|
||||
assert!(calls.is_empty(), "{calls:?}");
|
||||
assert_eq!(r.len(), 1);
|
||||
assert!(r[0]["error"].as_str().unwrap().contains("longer than"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_at_the_limit_is_accepted() {
|
||||
let mut buf = Vec::new();
|
||||
let line = vec![b'a'; 10];
|
||||
let mut input = line.clone();
|
||||
input.push(b'\n');
|
||||
assert_eq!(read_bounded_line(&mut &input[..], &mut buf, 10), Ok(true));
|
||||
assert_eq!(buf, line);
|
||||
assert!(read_bounded_line(&mut &[b'a'; 11][..], &mut buf, 10).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tcp_connection_is_closed_after_junk() {
|
||||
// End to end over a real loopback socket (the transport the app uses).
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (s, _) = listener.accept().unwrap();
|
||||
let read = s.try_clone().unwrap();
|
||||
serve_lines(read, s, |_, _| Some(json!({"ok": true})));
|
||||
});
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n{\"method\":\"y\"}\n").unwrap();
|
||||
let mut text = String::new();
|
||||
c.read_to_string(&mut text).unwrap(); // returns at EOF: the server closed the connection
|
||||
server.join().unwrap();
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines.len(), 2, "{text}");
|
||||
assert!(lines[0].contains("\"id\":7"));
|
||||
assert!(lines[1].contains("closing the connection"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_connections_are_refused() {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, _rx) = channel::<ControlRequest>();
|
||||
std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default()));
|
||||
// Idle connections are accepted in order and held open.
|
||||
let held: Vec<TcpStream> = (0..16).map(|_| TcpStream::connect(addr).unwrap()).collect();
|
||||
let mut extra = TcpStream::connect(addr).unwrap();
|
||||
extra.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(&mut extra).read_line(&mut line).unwrap();
|
||||
assert!(line.contains("too many control connections"), "{line}");
|
||||
drop(held);
|
||||
}
|
||||
}
|
||||
@@ -42,12 +42,27 @@ impl eframe::App for App {
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.0.ui(ui);
|
||||
#[cfg(target_os = "macos")]
|
||||
if self.0.take_ime_discard() {
|
||||
discard_marked_text();
|
||||
}
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
save_prefs(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tell the macOS input method to drop its composition (the Type tool kept the marked text as
|
||||
/// typed). winit's IME toggle only clears its own copy, so the IME would type it again.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn discard_marked_text() {
|
||||
if let Some(mtm) = objc2::MainThreadMarker::new()
|
||||
&& let Some(ic) = objc2_app_kit::NSTextInputContext::currentInputContext(mtm)
|
||||
{
|
||||
ic.discardMarkedText();
|
||||
}
|
||||
}
|
||||
|
||||
/// Where UI preferences live: ~/Library/Application Support/VectorCraft (macOS),
|
||||
/// %APPDATA%\VectorCraft (Windows), $XDG_CONFIG_HOME or ~/.config/vectorcraft (Linux).
|
||||
fn prefs_path() -> Option<std::path::PathBuf> {
|
||||
@@ -231,6 +246,7 @@ fn app_icon() -> egui::IconData {
|
||||
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
vectorcraft_ui_egui::i18n::detect_system_lang_in_background();
|
||||
let mut control_port: Option<u16> = std::env::var("VECTORCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let mut files = Vec::new();
|
||||
let mut args = std::env::args().skip(1);
|
||||
|
||||
@@ -10,6 +10,7 @@ use muda::accelerator::Accelerator;
|
||||
use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
|
||||
use serde_json::Value;
|
||||
use vectorcraft_ui_egui::VectorcraftApp;
|
||||
use vectorcraft_ui_egui::i18n::{self, Lang, t};
|
||||
use vectorcraft_ui_egui::menus::{self, Item};
|
||||
|
||||
enum Handle {
|
||||
@@ -18,7 +19,6 @@ enum Handle {
|
||||
}
|
||||
|
||||
pub struct NativeMenu {
|
||||
language: vectorcraft_ui_egui::i18n::Language,
|
||||
_menu: Menu,
|
||||
items: HashMap<String, (String, Value, Handle, String)>,
|
||||
last_refresh: f64,
|
||||
@@ -28,6 +28,8 @@ pub struct NativeMenu {
|
||||
slots: usize,
|
||||
/// Plug-in registry revision the Plug-ins submenus were built for.
|
||||
plugins: u64,
|
||||
/// The language the labels were built in (a change rebuilds the menu).
|
||||
language: Lang,
|
||||
}
|
||||
|
||||
/// Accelerator for a shortcut like "Cmd+Shift+]" (modifier-less shortcuts stay in the app so they
|
||||
@@ -48,7 +50,7 @@ impl NativeMenu {
|
||||
let mut items = HashMap::new();
|
||||
let mut counter = 0usize;
|
||||
for (title, entries) in menus::menu_tree() {
|
||||
let sub = Submenu::new(app.ui.language.tr(title), true);
|
||||
let sub = Submenu::new(t(title), true);
|
||||
build(app, &sub, &entries, &mut items, &mut counter);
|
||||
let _ = menu.append(&sub);
|
||||
}
|
||||
@@ -66,7 +68,7 @@ impl NativeMenu {
|
||||
generation,
|
||||
slots: menus::listed_slots(app),
|
||||
plugins: menus::plugin_revision(),
|
||||
language: app.ui.language,
|
||||
language: i18n::current(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,12 +85,12 @@ impl NativeMenu {
|
||||
return;
|
||||
}
|
||||
self.last_refresh = now;
|
||||
// Shortcuts edited, workspaces added, saved views, recent files or plug-ins changed: rebuild so
|
||||
// accelerators and lists are current.
|
||||
// Shortcuts edited, workspaces added, saved views, recent files, plug-ins or the UI language
|
||||
// changed: rebuild so accelerators, lists and labels are current.
|
||||
if vectorcraft_ui_egui::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed) != self.generation
|
||||
|| menus::listed_slots(app) != self.slots
|
||||
|| menus::plugin_revision() != self.plugins
|
||||
|| app.ui.language != self.language
|
||||
|| i18n::current() != self.language
|
||||
{
|
||||
*self = NativeMenu::install(app);
|
||||
return;
|
||||
@@ -121,7 +123,7 @@ impl NativeMenu {
|
||||
"view.cornerWidget" | "view.textThreads" | "view.gradientAnnotator" | "effect.last" | "type.hiddenCharacters"
|
||||
)
|
||||
{
|
||||
i.set_text(app.ui.language.tr(&menus::dynamic_label(app, cmd, "")));
|
||||
i.set_text(menus::display_label(app, cmd, ""));
|
||||
}
|
||||
}
|
||||
Handle::Check(c) => {
|
||||
@@ -146,13 +148,13 @@ fn build(
|
||||
let _ = parent.append(&PredefinedMenuItem::separator());
|
||||
}
|
||||
Item::Header(h) => {
|
||||
let _ = parent.append(&MenuItem::new(app.ui.language.tr(h), false, None));
|
||||
let _ = parent.append(&MenuItem::new(t(h), false, None));
|
||||
}
|
||||
Item::Todo(label, sc) => {
|
||||
let _ = parent.append(&MenuItem::new(app.ui.language.tr(label), false, accel(sc)));
|
||||
let _ = parent.append(&MenuItem::new(t(label), false, accel(sc)));
|
||||
}
|
||||
Item::Sub(label, children) => {
|
||||
let sub = Submenu::new(app.ui.language.tr(label), true);
|
||||
let sub = Submenu::new(t(label), true);
|
||||
build(app, &sub, children, items, counter);
|
||||
let _ = parent.append(&sub);
|
||||
}
|
||||
@@ -163,11 +165,11 @@ fn build(
|
||||
let id = format!("dc{counter}");
|
||||
let sc = if params.is_null() { menus::shortcut_of(cmd).and_then(accel) } else { None };
|
||||
let handle = if menus::checked(app, cmd, params).is_some() {
|
||||
let c = CheckMenuItem::with_id(id.clone(), app.ui.language.tr(label), true, false, sc);
|
||||
let c = CheckMenuItem::with_id(id.clone(), menus::display_label(app, cmd, label), true, false, sc);
|
||||
let _ = parent.append(&c);
|
||||
Handle::Check(c)
|
||||
} else {
|
||||
let i = MenuItem::with_id(id.clone(), app.ui.language.tr(label), true, sc);
|
||||
let i = MenuItem::with_id(id.clone(), menus::display_label(app, cmd, label), true, sc);
|
||||
let _ = parent.append(&i);
|
||||
Handle::Plain(i)
|
||||
};
|
||||
|
||||
+152
-10
@@ -75,12 +75,16 @@ impl LayerColor {
|
||||
#[serde(tag = "shape", rename_all = "lowercase")]
|
||||
pub enum LiveShape {
|
||||
Rectangle {
|
||||
/// Untransformed width/height.
|
||||
/// Untransformed width/height. Transforms fold their scale into these (see
|
||||
/// [`LiveShape::transform`]), so they are document lengths unless `xf` shears.
|
||||
w: f64,
|
||||
h: f64,
|
||||
/// Corner radii: top-left, top-right, bottom-right, bottom-left.
|
||||
/// Corner radii: top-left, top-right, bottom-right, bottom-left. In the same units as
|
||||
/// `w`/`h`: the corners are circular arcs in the document whenever `xf` doesn't scale.
|
||||
radii: [f64; 4],
|
||||
/// Maps the untransformed shape (origin at its top-left) into the document.
|
||||
/// Maps the untransformed shape (origin at its top-left) into the document. A rotation
|
||||
/// or reflection plus a move, unless a shear (or a file saved before transforms folded
|
||||
/// their scale into `w`/`h`) put more in it.
|
||||
xf: Affine,
|
||||
},
|
||||
Ellipse {
|
||||
@@ -129,12 +133,44 @@ impl LiveShape {
|
||||
LiveShape::Line { a, b } => shapes::line(*a, *b),
|
||||
}
|
||||
}
|
||||
pub fn transform(&mut self, a: Affine) {
|
||||
/// Apply `a` to the shape. True when the shape is no longer `a` applied to the old path, so
|
||||
/// the caller must regenerate the path with [`LiveShape::to_path`].
|
||||
///
|
||||
/// A rectangle keeps round corners whatever its proportions: when `a` scales it along its own
|
||||
/// sides (any rotation or reflection, no shear), the scale goes into `w`/`h` and `xf` keeps
|
||||
/// only the rotation, reflection and position. The radii scale by the mean scale (the square
|
||||
/// root of the determinant), which [`LiveShape::keep_corners`] undoes for Scale Corners off.
|
||||
/// A shear can't keep circular corners: `xf` takes it, as do moves (so files saved with a
|
||||
/// scale in `xf` keep their exact geometry until they're next transformed).
|
||||
pub fn transform(&mut self, a: Affine) -> bool {
|
||||
match self {
|
||||
LiveShape::Rectangle { xf, .. } | LiveShape::Ellipse { xf, .. } | LiveShape::Polygon { xf, .. } => *xf = a * *xf,
|
||||
LiveShape::Rectangle { w, h, radii, xf } => {
|
||||
let m = a * *xf;
|
||||
let [a0, a1, a2, a3, _, _] = a.as_coeffs();
|
||||
let moves_only = (a0 - 1.0).abs() < 1e-12 && a1.abs() < 1e-12 && a2.abs() < 1e-12 && (a3 - 1.0).abs() < 1e-12;
|
||||
let [m0, m1, m2, m3, m4, m5] = m.as_coeffs();
|
||||
let (sx, sy) = (m0.hypot(m1), m2.hypot(m3));
|
||||
let orthogonal = sx > 1e-12 && sy > 1e-12 && (m0 * m2 + m1 * m3).abs() <= 1e-9 * sx * sy;
|
||||
let finite = m.as_coeffs().iter().all(|c| c.is_finite()) && w.is_finite() && h.is_finite();
|
||||
if moves_only || !orthogonal || !finite {
|
||||
*xf = m;
|
||||
return false;
|
||||
}
|
||||
let k = (sx * sy).sqrt();
|
||||
radii.iter_mut().for_each(|r| *r *= k);
|
||||
*w *= sx;
|
||||
*h *= sy;
|
||||
*xf = Affine::new([m0 / sx, m1 / sx, m2 / sy, m3 / sy, m4, m5]);
|
||||
true
|
||||
}
|
||||
LiveShape::Ellipse { xf, .. } | LiveShape::Polygon { xf, .. } => {
|
||||
*xf = a * *xf;
|
||||
false
|
||||
}
|
||||
LiveShape::Line { a: p, b } => {
|
||||
*p = a * *p;
|
||||
*b = a * *b;
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -613,11 +649,10 @@ impl Node {
|
||||
NodeKind::Path { path, live, .. } => {
|
||||
path.transform(a);
|
||||
if let Some(l) = live {
|
||||
l.transform(a);
|
||||
if sc.keep_corners
|
||||
&& let Some(k) = k
|
||||
&& l.keep_corners(k)
|
||||
{
|
||||
// A rectangle scaled unevenly keeps circular corners: its path is regenerated.
|
||||
let reshaped = l.transform(a);
|
||||
let kept = sc.keep_corners && k.is_some_and(|k| l.keep_corners(k));
|
||||
if reshaped || kept {
|
||||
*path = l.to_path();
|
||||
}
|
||||
}
|
||||
@@ -1135,6 +1170,113 @@ mod tests {
|
||||
assert_eq!(l.label(), "Rectangle");
|
||||
}
|
||||
|
||||
/// A node holding a live rectangle (`w` × `h`, all radii `r`, placed by `xf`) and its path.
|
||||
fn live_rect(w: f64, h: f64, r: f64, xf: Affine) -> Node {
|
||||
let live = LiveShape::Rectangle { w, h, radii: [r; 4], xf };
|
||||
let mut n = Node::path(NodeId(1), live.to_path(), Appearance::default_art());
|
||||
if let NodeKind::Path { live: slot, .. } = &mut n.kind {
|
||||
*slot = Some(live);
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
fn live_of(n: &Node) -> (f64, f64, [f64; 4], Affine) {
|
||||
let NodeKind::Path { live: Some(LiveShape::Rectangle { w, h, radii, xf }), .. } = &n.kind else { panic!("not a live rectangle") };
|
||||
(*w, *h, *radii, *xf)
|
||||
}
|
||||
|
||||
fn near(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-9
|
||||
}
|
||||
|
||||
fn near_xf(a: Affine, b: Affine) -> bool {
|
||||
a.as_coeffs().iter().zip(b.as_coeffs()).all(|(x, y)| near(*x, y))
|
||||
}
|
||||
|
||||
/// Size and radius of a live rectangle, within rounding.
|
||||
fn assert_live(n: &Node, w: f64, h: f64, r: f64) {
|
||||
let (lw, lh, radii, _) = live_of(n);
|
||||
assert!(near(lw, w) && near(lh, h) && radii.iter().all(|x| near(*x, r)), "{lw} × {lh} r {radii:?}, want {w} × {h} r {r}");
|
||||
}
|
||||
|
||||
/// Each corner arc of a rounded rectangle's path, mapped by `back` (to undo a rotation),
|
||||
/// spans `r` along x and y: the corner is a circular arc of radius `r`.
|
||||
fn assert_round_corners(n: &Node, back: Affine, r: f64) {
|
||||
let NodeKind::Path { path, .. } = &n.kind else { panic!("not a path") };
|
||||
let a: Vec<Point> = path.subpaths[0].anchors.iter().map(|a| back * a.p).collect();
|
||||
assert_eq!(a.len(), 8, "a rounded rectangle");
|
||||
// Anchors 1→2, 3→4, 5→6 and 7→0 are the arcs.
|
||||
for (i, j) in [(1, 2), (3, 4), (5, 6), (7, 0)] {
|
||||
let (x, y) = ((a[j].x - a[i].x).abs(), (a[j].y - a[i].y).abs());
|
||||
assert!(near(x, r) && near(y, r), "corner spans {x} × {y}, want {r} × {r}");
|
||||
}
|
||||
}
|
||||
|
||||
const KEEP_CORNERS: Scaling = Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: true };
|
||||
|
||||
#[test]
|
||||
fn uneven_scales_keep_live_corners_circular() {
|
||||
// A 100 pt rounded square at (10, 10) stretched to 300 × 100 (#291).
|
||||
let mut n = live_rect(100.0, 100.0, 20.0, Affine::translate((10.0, 10.0)));
|
||||
let stretch = Affine::translate((10.0, 10.0)) * Affine::scale_non_uniform(3.0, 1.0) * Affine::translate((-10.0, -10.0));
|
||||
n.transform(stretch, KEEP_CORNERS);
|
||||
assert_live(&n, 300.0, 100.0, 20.0);
|
||||
assert!(near_xf(live_of(&n).3, Affine::translate((10.0, 10.0))), "the scale went into the size");
|
||||
let b = n.geometric_bounds().unwrap();
|
||||
assert!(near(b.x0, 10.0) && near(b.y0, 10.0) && near(b.x1, 310.0) && near(b.y1, 110.0), "{b:?}");
|
||||
assert_round_corners(&n, Affine::IDENTITY, 20.0);
|
||||
// Scale Corners on: the radius scales by the mean scale, still circular.
|
||||
let mut n = live_rect(100.0, 100.0, 20.0, Affine::IDENTITY);
|
||||
n.transform(Affine::scale_non_uniform(4.0, 1.0), Scaling::default());
|
||||
assert_live(&n, 400.0, 100.0, 40.0);
|
||||
assert_round_corners(&n, Affine::IDENTITY, 40.0);
|
||||
// Squashed below the radius, the short sides are fully round; stretched back, it returns.
|
||||
n.transform(Affine::scale_non_uniform(1.0, 0.5), KEEP_CORNERS);
|
||||
assert_round_corners(&n, Affine::IDENTITY, 25.0);
|
||||
n.transform(Affine::scale_non_uniform(1.0, 2.0), KEEP_CORNERS);
|
||||
assert_round_corners(&n, Affine::IDENTITY, 40.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_and_reflected_live_rectangles_scale_along_their_own_sides() {
|
||||
let place = Affine::translate((200.0, 100.0)) * Affine::rotate(30f64.to_radians());
|
||||
let mut n = live_rect(100.0, 50.0, 10.0, place);
|
||||
// Stretched along its own width, as a bounding-box drag of a rotated shape does.
|
||||
n.transform(place * Affine::scale_non_uniform(2.0, 1.0) * place.inverse(), KEEP_CORNERS);
|
||||
assert_live(&n, 200.0, 50.0, 10.0);
|
||||
assert!(near_xf(live_of(&n).3, place));
|
||||
assert_round_corners(&n, place.inverse(), 10.0);
|
||||
// A reflection stays in `xf`; the corners stay as they were.
|
||||
n.transform(Affine::scale_non_uniform(-1.0, 1.0), KEEP_CORNERS);
|
||||
assert_live(&n, 200.0, 50.0, 10.0);
|
||||
let xf = live_of(&n).3;
|
||||
assert!(near(xf.determinant(), -1.0));
|
||||
assert_round_corners(&n, xf.inverse(), 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shears_and_moves_keep_the_old_transform() {
|
||||
// Scaling a rotated rectangle along the page axes shears it: `xf` takes it all, as before.
|
||||
let rot = Affine::rotate(30f64.to_radians());
|
||||
let mut n = live_rect(100.0, 50.0, 10.0, rot);
|
||||
let before = n.clone();
|
||||
let a = Affine::scale_non_uniform(2.0, 1.0);
|
||||
n.transform(a, Scaling::default());
|
||||
assert_eq!(live_of(&n), (100.0, 50.0, [10.0; 4], a * rot));
|
||||
let (NodeKind::Path { path, .. }, NodeKind::Path { path: old, .. }) = (&n.kind, &before.kind) else { panic!() };
|
||||
assert_eq!(*path, old.transformed(a));
|
||||
// A file saved with an uneven scale in `xf` keeps its exact geometry when moved...
|
||||
let legacy = Affine::translate((5.0, 5.0)) * Affine::scale_non_uniform(2.0, 1.0);
|
||||
let mut n = live_rect(100.0, 100.0, 20.0, legacy);
|
||||
n.transform(Affine::translate((10.0, 0.0)), KEEP_CORNERS);
|
||||
assert_eq!(live_of(&n), (100.0, 100.0, [20.0; 4], Affine::translate((10.0, 0.0)) * legacy));
|
||||
// ...and once scaled, has document units and circular corners of its mean radius.
|
||||
n.transform(Affine::scale(2.0), Scaling::default());
|
||||
assert_live(&n, 400.0, 200.0, 20.0 * 2f64.sqrt() * 2.0);
|
||||
assert!(near(live_of(&n).3.determinant(), 1.0));
|
||||
assert_round_corners(&n, Affine::IDENTITY, 20.0 * 2f64.sqrt() * 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_transparency() {
|
||||
let p = || Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
|
||||
|
||||
+89
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Rect};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Rect, Vec2};
|
||||
|
||||
use crate::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, LineJoin, StrokeLayer};
|
||||
|
||||
@@ -476,6 +476,49 @@ impl TextObject {
|
||||
pub fn transform(&mut self, a: Affine) {
|
||||
self.xf = a * self.xf;
|
||||
}
|
||||
/// Area type's frame (the type area) in document space; None for other type.
|
||||
pub fn area_frame(&self) -> Option<PathData> {
|
||||
match &self.kind {
|
||||
TextKind::Area { frame } => Some(frame.transformed(self.xf)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Reshape area type's frame by `a`, a document-space transform, keeping the type at its size
|
||||
/// (a bounding-box resize: the text reflows, it isn't scaled). False, changing nothing, for
|
||||
/// other type, a degenerate `xf` or a frame that would not be finite.
|
||||
pub fn transform_area(&mut self, a: Affine) -> bool {
|
||||
let xf = self.xf;
|
||||
self.reshape_area(|frame, to_text| {
|
||||
frame.transform(to_text * a * xf);
|
||||
true
|
||||
})
|
||||
}
|
||||
/// Move anchors `refs` (subpath, anchor) of area type's frame by `d` in document space, with
|
||||
/// their handles (a Direct Selection drag of a frame corner or edge). False, changing
|
||||
/// nothing, when an anchor doesn't exist, or as [`Self::transform_area`].
|
||||
pub fn move_area_anchors(&mut self, refs: &[(usize, usize)], d: Vec2) -> bool {
|
||||
self.reshape_area(|frame, to_text| {
|
||||
// A distance: only the linear part of the document → text map applies.
|
||||
let local = to_text * d.to_point() - to_text * Point::ZERO;
|
||||
refs.iter().all(|&(si, ai)| frame.anchor_mut(si, ai).map(|a| a.translate(local)).is_some())
|
||||
})
|
||||
}
|
||||
/// Edit a copy of the area frame with `f` (given the document → text space map) and keep it
|
||||
/// when `f` succeeds and the result is finite.
|
||||
fn reshape_area(&mut self, f: impl FnOnce(&mut PathData, Affine) -> bool) -> bool {
|
||||
let det = self.xf.determinant();
|
||||
if !det.is_finite() || det.abs() < 1e-12 {
|
||||
return false;
|
||||
}
|
||||
let to_text = self.xf.inverse();
|
||||
let TextKind::Area { frame } = &mut self.kind else { return false };
|
||||
let mut next = frame.clone();
|
||||
if !f(&mut next, to_text) || !next.anchors().all(|(_, _, a)| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite()) {
|
||||
return false;
|
||||
}
|
||||
*frame = next;
|
||||
true
|
||||
}
|
||||
/// Scale the character strokes' weights and dashes by `s` (they are drawn in text space, so
|
||||
/// `1 / scale` keeps their weight through a transform that scales the type).
|
||||
pub fn scale_char_strokes(&mut self, s: f64) {
|
||||
@@ -505,4 +548,49 @@ mod tests {
|
||||
assert!(b.x0 >= 10.0 - 1e-9 && b.y1 > 20.0);
|
||||
assert_eq!(CharStyle::default().effective_leading(), 14.399999999999999);
|
||||
}
|
||||
|
||||
/// 120 × 40 area type at (40, 40), its text drawn at twice its size.
|
||||
fn area_type() -> TextObject {
|
||||
let mut t = TextObject::point(Point::ZERO, "Some text", CharStyle::default());
|
||||
t.kind = TextKind::Area { frame: vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 60.0, 20.0)) };
|
||||
t.xf = Affine::translate((40.0, 40.0)) * Affine::scale(2.0);
|
||||
t
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_resizes_its_frame_and_keeps_its_type() {
|
||||
let mut t = area_type();
|
||||
assert_eq!(t.bounds(), Some(Rect::new(40.0, 40.0, 160.0, 80.0)));
|
||||
// Bottom-right handle to (200, 140), about the top-left corner.
|
||||
let o = Affine::translate((40.0, 40.0));
|
||||
assert!(t.transform_area(o * Affine::scale_non_uniform(160.0 / 120.0, 100.0 / 40.0) * o.inverse()));
|
||||
assert_eq!(t.xf, Affine::translate((40.0, 40.0)) * Affine::scale(2.0), "the type keeps its size");
|
||||
let b = t.bounds().unwrap();
|
||||
assert!((b.x1 - 200.0).abs() < 1e-9 && (b.y1 - 140.0).abs() < 1e-9 && b.x0 == 40.0, "{b:?}");
|
||||
assert_eq!(t.area_frame().unwrap().bounds(), Some(b));
|
||||
// Point type has no area; a degenerate transform or a non-finite frame changes nothing.
|
||||
let mut p = TextObject::point(Point::ZERO, "x", CharStyle::default());
|
||||
assert!(!p.transform_area(Affine::scale(2.0)) && p.area_frame().is_none());
|
||||
let before = t.clone();
|
||||
assert!(!t.transform_area(Affine::scale(f64::INFINITY)));
|
||||
t.xf = Affine::scale(0.0);
|
||||
assert!(!t.transform_area(Affine::scale(2.0)));
|
||||
t.xf = before.xf;
|
||||
assert_eq!(t, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_anchors_move_in_document_space() {
|
||||
let mut t = area_type();
|
||||
// The bottom-right corner (anchor 2) 20 pt right: 10 pt in text space at 2×.
|
||||
assert!(t.move_area_anchors(&[(0, 2)], Vec2::new(20.0, 0.0)));
|
||||
let TextKind::Area { frame } = &t.kind else { panic!() };
|
||||
assert_eq!(frame.subpaths[0].anchors[2].p, Point::new(70.0, 20.0));
|
||||
assert_eq!(frame.subpaths[0].anchors[1].p, Point::new(60.0, 0.0), "the others stay");
|
||||
// A missing anchor changes nothing.
|
||||
let before = t.clone();
|
||||
assert!(!t.move_area_anchors(&[(0, 1), (0, 9)], Vec2::new(5.0, 5.0)));
|
||||
assert!(!t.move_area_anchors(&[(3, 0)], Vec2::new(5.0, 5.0)));
|
||||
assert_eq!(t, before);
|
||||
}
|
||||
}
|
||||
@@ -116,10 +116,21 @@ fn set_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// A typing session in progress (the Type tool previews the whole session as one interaction).
|
||||
fn typing_in_progress(s: &Session) -> bool {
|
||||
s.active().and_then(|d| d.interaction.as_ref()).is_some_and(|it| it.label == "Typing" && it.preview.is_some())
|
||||
}
|
||||
|
||||
fn undo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
// What is still in progress (typing, a drag) is the step to undo: keep it first, so Undo takes
|
||||
// back only that and Redo brings it back, instead of dropping it and undoing the step before.
|
||||
// A typing session also ends in the Type tool, whose caret and marked text (IME) must follow
|
||||
// the document.
|
||||
let typing = typing_in_progress(s);
|
||||
s.commit_interaction()?;
|
||||
if typing {
|
||||
s.set_tool_option("endTyping", &Value::Bool(true));
|
||||
}
|
||||
let st = s.doc_mut()?;
|
||||
let e = st.history.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?;
|
||||
let label = e.label.clone();
|
||||
|
||||
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Apply Effect",
|
||||
[],
|
||||
None,
|
||||
"{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.<id>\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow) → {ids, index, item}",
|
||||
"{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.<id>\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow; a Pathfinder effect, which combines a group's contents, groups several loose selected objects first, in the same undo step) → {ids, index, item, grouped?}",
|
||||
has_doc,
|
||||
apply
|
||||
),
|
||||
@@ -130,6 +130,15 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let effect = effects::new_effect(id, ¶ms).ok_or_else(|| bad(C, format!("unknown effect `{id}`")))?;
|
||||
let label = effects::effect_info(id).map(|e| e.label.trim_end_matches('…').to_string()).unwrap_or_default();
|
||||
if let Some(loose) = loose_for_pathfinder(s, p, id)? {
|
||||
let gid = s.edit(&label, |d, sel| {
|
||||
let gid = super::object::group_nodes(d, &loose)?;
|
||||
d.node_mut(gid).ok_or(EngineError::NoNode(gid))?.appearance.effects.push(effect.clone());
|
||||
sel.set([gid]);
|
||||
Ok(gid)
|
||||
})?;
|
||||
return Ok(json!({ "ids": [gid.0], "index": 0, "item": null, "grouped": true }));
|
||||
}
|
||||
let item = item_target(s, p, C)?;
|
||||
let mut index = 0;
|
||||
let ids = edit_effects(s, p, item, C, &label, "Apply Effect: select objects", |fx| {
|
||||
@@ -143,6 +152,19 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "ids": ids_json(&ids), "index": index, "item": landed }))
|
||||
}
|
||||
|
||||
/// The selected objects a Pathfinder effect should group before it applies: it combines the
|
||||
/// contents of a group or layer, so on several loose objects (none a group) it would do nothing.
|
||||
/// `None` when the effect isn't one, objects are named (`ids`) or another target is asked for.
|
||||
fn loose_for_pathfinder(s: &Session, p: &Value, id: &str) -> Result<Option<Vec<NodeId>>> {
|
||||
if !effects::is_pathfinder(id) || ["ids", "id", "item", "target"].iter().any(|k| p.get(k).is_some()) {
|
||||
return Ok(None);
|
||||
}
|
||||
let roots = super::appearance::subject_roots(s)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let grouped = roots.iter().any(|r| d.node(*r).is_some_and(|n| matches!(n.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { .. })));
|
||||
Ok((roots.len() >= 2 && !grouped).then_some(roots))
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let catalog: Vec<Value> = effects::effect_catalog()
|
||||
.into_iter()
|
||||
@@ -507,6 +529,34 @@ mod tests {
|
||||
assert!((b.x1 - 320.0).abs() < 1e-6, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pathfinder_effect_on_loose_objects_groups_them_first() {
|
||||
let (mut s, a) = session_with_rect();
|
||||
let b = NodeId(s.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
let r = s.execute("effect.apply", &json!({"effect": "pathfinder.add"})).unwrap();
|
||||
assert_eq!(r["grouped"], true);
|
||||
let g = NodeId(r["ids"][0].as_u64().unwrap());
|
||||
let gn = node(&s, g);
|
||||
assert_eq!(gn.children().unwrap().iter().map(|c| c.id).collect::<Vec<_>>(), [a, b], "stacking order kept");
|
||||
assert!(vectorcraft_render::effects::has_pathfinder(&gn));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [g]);
|
||||
// Live: one united shape, so expanding leaves a single path.
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, g).children().unwrap().len(), 1);
|
||||
// Grouping and applying are one undo step.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert!(d.node(g).is_none());
|
||||
assert!(d.node(a).is_some_and(|n| n.appearance.effects.is_empty()) && d.parent_of(a) == d.parent_of(b));
|
||||
// A selection holding a group applies to each object as before; so does a single object.
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
let r = s.execute("effect.apply", &json!({"effect": "pathfinder.add"})).unwrap();
|
||||
assert!(r.get("grouped").is_none());
|
||||
assert_eq!(node(&s, a).appearance.effects.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pathfinder_effect_on_a_group_renders_live_and_expands() {
|
||||
let (mut s, a) = session_with_rect();
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::DocState;
|
||||
|
||||
use super::super::*;
|
||||
use super::encode::single_artboard;
|
||||
use super::{ARTBOARD_PARAMS, Format, default_name, encode, encode_all, merge, writable, write_encoded, write_or_return};
|
||||
use super::{ARTBOARD_PARAMS, Format, default_name, encode, encode_all, encode_with_warnings, merge, writable, write_encoded, write_or_return};
|
||||
|
||||
pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let f = writable("document.serialize", Some(str_param(p, "format").unwrap_or("vectorcraft")), None)?;
|
||||
@@ -151,8 +151,10 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let expanded = expand_presets(s, C, p)?;
|
||||
let p = &*expanded;
|
||||
let (d, bounds) = selection(s, C)?;
|
||||
let bytes = encode(&d, f.id, &without_artboards(p))?;
|
||||
write_or_return(path, &bytes, json!({ "bounds": [bounds.x0, bounds.y0, bounds.width(), bounds.height()] }))
|
||||
// The encoder's warnings (options accepted but not applied, features approximated or left
|
||||
// out), as document.export reports them.
|
||||
let (bytes, warnings) = encode_with_warnings(&d, f.id, &without_artboards(p))?;
|
||||
write_or_return(path, &bytes, json!({ "format": f.id, "warnings": warnings, "bounds": [bounds.x0, bounds.y0, bounds.width(), bounds.height()] }))
|
||||
}
|
||||
|
||||
/// File → Save for Office Documents: one artboard as a PNG at `ppi`, on white unless
|
||||
|
||||
@@ -105,7 +105,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export Selection…",
|
||||
["File"],
|
||||
None,
|
||||
"{path?, format?: png|jpg|webp|svg|svgz|pdf (default: from the extension, else png), scale?: 1, …the format's options} the selected objects cropped to their bounds (template layers left out) → {path, bytes, bounds} (no path → {dataBase64, bounds})",
|
||||
"{path?, format?: png|jpg|webp|svg|svgz|pdf (default: from the extension, else png), scale?: 1, …the format's options} the selected objects cropped to their bounds (template layers left out) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export",
|
||||
has_selection,
|
||||
export::export_selection
|
||||
),
|
||||
@@ -581,6 +581,18 @@ pub const OPEN_EXTS: &[&str] = &[
|
||||
"eps",
|
||||
];
|
||||
|
||||
/// The extension that picks each writable format when exporting (the format's first; PNG-8 shares
|
||||
/// `.png` with PNG, so `.png` comes once), in [`FORMATS`] order.
|
||||
pub fn export_extensions() -> Vec<&'static str> {
|
||||
let mut v: Vec<&'static str> = Vec::new();
|
||||
for e in FORMATS.iter().filter(|f| f.write).filter_map(|f| f.extensions.first()) {
|
||||
if !v.contains(e) {
|
||||
v.push(e);
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Text files: File → Place sets them as area type (Text Import Options).
|
||||
pub const TEXT_EXTS: &[&str] = &["txt"];
|
||||
|
||||
|
||||
@@ -150,6 +150,39 @@ fn open_exts_cover_every_readable_format() {
|
||||
assert_eq!(filters.last(), Some(&("Plug-ins", crate::cmd::plugin::EXTS)), "File › Open installs plug-ins");
|
||||
}
|
||||
|
||||
/// The extensions usage texts list for export: one per writable format, each picking that format
|
||||
/// (`.png` picks PNG; PNG-8 is asked for by `format`).
|
||||
#[test]
|
||||
fn export_extensions_name_every_writable_format() {
|
||||
let exts = export_extensions();
|
||||
assert_eq!(
|
||||
exts,
|
||||
[
|
||||
"vectorcraft",
|
||||
"svg",
|
||||
"svgz",
|
||||
"pdf",
|
||||
"png",
|
||||
"jpg",
|
||||
"gif",
|
||||
"webp",
|
||||
"tif",
|
||||
"bmp",
|
||||
"vctemplate",
|
||||
"txt",
|
||||
"dxf",
|
||||
"eps",
|
||||
"emf",
|
||||
"wmf",
|
||||
"tga",
|
||||
"psd"
|
||||
]
|
||||
);
|
||||
for e in exts {
|
||||
assert!(format_for_name(&format!("x.{e}")).is_some_and(|f| f.write), "{e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_query_lists_readers_writers_and_options() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fonts in Document",
|
||||
[],
|
||||
None,
|
||||
"{selectionOnly?} → [{family, style, runs, objects, missing}] sorted by name",
|
||||
"{selectionOnly?} → [{family, style, runs, objects, missing, status: exact|substitute|missing, resolved: {family, style}, missingGlyphs}] sorted by name. A font is found by any of its names (ヒラギノ角ゴシック = Hiragino Sans) among the loaded and installed fonts; substitute: the family is there but not the style (resolved names the stand-in); missing: the family is unknown (the fallback family stands in); missingGlyphs: characters of its text the resolved font lacks (drawn by a fallback font)",
|
||||
has_doc,
|
||||
fonts
|
||||
),
|
||||
@@ -26,7 +26,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Replace Font",
|
||||
[],
|
||||
None,
|
||||
"{from: {family, style?}, to: {family, style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style) → {runs}",
|
||||
"{from: {family, style?}, to: {family (any of its names; stored as the font's own family name), style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style) → {runs}",
|
||||
has_doc,
|
||||
replace
|
||||
),
|
||||
@@ -44,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Font List",
|
||||
[],
|
||||
None,
|
||||
"{family?} → {families: [names]} sorted: the bundled fonts, fonts added and the fonts installed on the system (none on the web); with family: {family, styles: [names]} (upright styles by weight, then italics; an error when the family isn't available)",
|
||||
"{family?} → {families: [names]} sorted: the bundled fonts, fonts added and the fonts installed on the system (none on the web), as the font menus list them (without the system's hidden families, whose names start with a dot; they still resolve by name); with family: {family, styles: [names]} (upright styles by weight, then italics; an error when the family isn't available)",
|
||||
always,
|
||||
font_list
|
||||
),
|
||||
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Refresh Font List",
|
||||
[],
|
||||
None,
|
||||
"{} scan the system font folders again, for fonts installed or removed since the app started; text in a font that became available redraws in it (no fonts are installed on the web) → {families, faces} (families available, installed faces found)",
|
||||
"{} scan the system font folders again, for fonts installed or removed since the app started; text in a font that became available redraws in it (no fonts are installed on the web) → {families, faces} (families listed, installed faces found)",
|
||||
always,
|
||||
rescan
|
||||
),
|
||||
@@ -96,12 +96,12 @@ fn fonts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = scope(s, bool_or(p, "selectionOnly", false))?;
|
||||
let d = &s.doc()?.doc;
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let families = db.families();
|
||||
let mut found: BTreeMap<(String, String), (usize, Vec<NodeId>)> = BTreeMap::new();
|
||||
let mut found: BTreeMap<(String, String), (usize, Vec<NodeId>, String)> = BTreeMap::new();
|
||||
for id in ids {
|
||||
for r in text(d, id).map(|t| t.runs.as_slice()).unwrap_or_default() {
|
||||
let e = found.entry((r.style.font_family.clone(), r.style.font_style.clone())).or_default();
|
||||
e.0 += 1;
|
||||
e.2.push_str(&r.text);
|
||||
if !e.1.contains(&id) {
|
||||
e.1.push(id);
|
||||
}
|
||||
@@ -109,10 +109,20 @@ fn fonts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let list: Vec<Value> = found
|
||||
.into_iter()
|
||||
.map(|((family, style), (runs, objects))| {
|
||||
let have_family = families.iter().any(|f| f.eq_ignore_ascii_case(&family));
|
||||
let missing = !have_family || !db.styles(&family).iter().any(|st| st.eq_ignore_ascii_case(&style));
|
||||
json!({ "family": family, "style": style, "runs": runs, "objects": objects.len(), "missing": missing })
|
||||
.map(|((family, style), (runs, objects, chars))| {
|
||||
let resolved = db.resolve(&family, &style);
|
||||
let status = resolved.as_ref().map(|(_, m)| *m).unwrap_or(vectorcraft_text::FontMatch::Missing);
|
||||
let mut lacking: Vec<char> = chars.chars().filter(|c| !c.is_whitespace() && !c.is_control()).collect();
|
||||
lacking.sort_unstable();
|
||||
lacking.dedup();
|
||||
lacking.retain(|c| !resolved.as_ref().is_some_and(|(f, _)| f.covers(*c)));
|
||||
json!({
|
||||
"family": family, "style": style, "runs": runs, "objects": objects.len(),
|
||||
"missing": status == vectorcraft_text::FontMatch::Missing,
|
||||
"status": status.as_str(),
|
||||
"resolved": resolved.as_ref().map(|(f, _)| json!({ "family": f.family, "style": f.style })),
|
||||
"missingGlyphs": lacking.len(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!(list))
|
||||
@@ -130,9 +140,11 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (ff, fs) = font_param(p, "from", C)?;
|
||||
let (tf, ts) = font_param(p, "to", C)?;
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
if !db.families().iter().any(|f| f.eq_ignore_ascii_case(&tf)) {
|
||||
return Err(bad(C, format!("font family `{tf}` is not available")));
|
||||
}
|
||||
// Any name of the family (ヒラギノ角ゴシック) is stored as its own name (Hiragino Sans).
|
||||
let tf = match db.resolve(&tf, ts.as_deref().unwrap_or("Regular")) {
|
||||
Some((f, m)) if m != vectorcraft_text::FontMatch::Missing => f.family.clone(),
|
||||
_ => return Err(bad(C, format!("font family `{tf}` is not available"))),
|
||||
};
|
||||
let ids = scope(s, bool_or(p, "selectionOnly", false))?;
|
||||
let matches = |st: &vectorcraft_doc::CharStyle| {
|
||||
st.font_family.eq_ignore_ascii_case(&ff) && fs.as_ref().is_none_or(|x| st.font_style.eq_ignore_ascii_case(x))
|
||||
@@ -196,7 +208,7 @@ fn font_list(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = db.family_list().iter().find(|f| f.eq_ignore_ascii_case(family)).cloned().unwrap_or_else(|| family.to_string());
|
||||
Ok(json!({ "family": name, "styles": db.styles(family) }))
|
||||
}
|
||||
None => Ok(json!({ "families": *db.family_list() })),
|
||||
None => Ok(json!({ "families": *db.menu_family_list() })),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,7 +241,7 @@ fn rescan(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
d.revision += 1;
|
||||
}
|
||||
Ok(json!({ "families": db.family_list().len(), "faces": faces }))
|
||||
Ok(json!({ "families": db.menu_family_list().len(), "faces": faces }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -261,6 +273,41 @@ mod tests {
|
||||
mod open_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn list_says_how_each_font_resolved() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
// "Black Wide" is a style no Source Sans 3 has, bundled or installed, so it is always a
|
||||
// substitute (the installed family can hold a real Black).
|
||||
for (text, font, style) in
|
||||
[("Exact", "Source Sans 3", "Semibold"), ("Closest", "Source Sans 3", "Black Wide"), ("Gone", "No Such Font Family", "Regular")]
|
||||
{
|
||||
s.execute("text.create", &json!({"x": 10, "y": 20, "text": text, "font": font, "style": style})).unwrap();
|
||||
}
|
||||
let list = s.execute("text.fonts", &json!({})).unwrap();
|
||||
let rows: Vec<(String, String, String, String)> = list
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|f| {
|
||||
(
|
||||
f["style"].as_str().unwrap().into(),
|
||||
f["status"].as_str().unwrap().into(),
|
||||
f["resolved"]["family"].as_str().unwrap().into(),
|
||||
f["resolved"]["style"].as_str().unwrap().into(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let row = |style: &str| rows.iter().find(|r| r.0 == style).cloned().unwrap();
|
||||
assert_eq!(row("Semibold").1, "exact");
|
||||
assert_eq!(row("Black Wide").1, "substitute", "{rows:?}");
|
||||
assert_eq!(row("Black Wide").2, "Source Sans 3");
|
||||
assert_eq!((row("Regular").1.as_str(), row("Regular").2.as_str()), ("missing", vectorcraft_text::FALLBACK_FAMILY));
|
||||
s.execute("text.create", &json!({"x": 10, "y": 90, "text": "Ab 雅楽", "font": "Inter"})).unwrap();
|
||||
let inter = s.execute("text.fonts", &json!({})).unwrap().as_array().unwrap().iter().find(|f| f["family"] == "Inter").cloned().unwrap();
|
||||
assert_eq!(inter["missingGlyphs"], 2, "雅 and 楽 come from a fallback font: {inter}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opened_documents_have_exact_text_bounds() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -20,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Transform",
|
||||
[],
|
||||
None,
|
||||
"{matrix: [a,b,c,d,e,f], copy?: bool, ids?, strokes?: bool, corners?: bool} apply an affine to the selection (or ids); strokes/corners: Scale Strokes & Effects / Scale Corners (default: the preferences)",
|
||||
"{matrix: [a,b,c,d,e,f], copy?: bool, ids?, strokes?: bool, corners?: bool, typeAreas?: bool} apply an affine to the selection (or ids); strokes/corners: Scale Strokes & Effects / Scale Corners (default: the preferences); typeAreas: area type among them (not type inside a group) reshapes its frame by the matrix and its text reflows at its size, as a bounding-box handle drag does (default: the type transforms too)",
|
||||
has_doc,
|
||||
transform
|
||||
),
|
||||
@@ -202,15 +202,18 @@ pub(crate) fn scaling(s: &mut Session, p: &Value) -> Scaling {
|
||||
Scaling { strokes, effects: strokes.then_some(vectorcraft_render::effects::scale_effect), keep_type_strokes: !strokes, keep_corners: !corners }
|
||||
}
|
||||
|
||||
/// Apply `xf` to `ids` (`copy` param: duplicate first; `strokes`/`corners`: see [`scaling`]).
|
||||
/// Records Transform Again.
|
||||
/// Apply `xf` to `ids` (`copy` param: duplicate first; `strokes`/`corners`: see [`scaling`];
|
||||
/// `typeAreas`: see [`resize_type_area`]). Records Transform Again.
|
||||
pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec<NodeId>, xf: Affine, p: &Value) -> Result<Value> {
|
||||
let copy = bool_or(p, "copy", false);
|
||||
let areas = bool_or(p, "typeAreas", false);
|
||||
let sc = if Scaling::factor(xf).is_some() { scaling(s, p) } else { Scaling::default() };
|
||||
let ids = s.edit(label, |d, sel| {
|
||||
let targets = if copy { duplicate_in(d, sel, &ids, Affine::IDENTITY)? } else { ids.clone() };
|
||||
for id in &targets {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
if let Some(n) = d.node_mut(*id)
|
||||
&& !(areas && resize_type_area(n, xf))
|
||||
{
|
||||
n.transform(xf, sc);
|
||||
}
|
||||
}
|
||||
@@ -224,6 +227,17 @@ pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec<NodeId>, xf
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// Area type resized by its bounding box: `xf` reshapes its frame (the type area) and the text
|
||||
/// reflows at its size. False for anything else (and type in perspective), which transforms as
|
||||
/// usual.
|
||||
fn resize_type_area(n: &mut Node, xf: Affine) -> bool {
|
||||
if n.perspective.is_some() {
|
||||
return false;
|
||||
}
|
||||
let NodeKind::Text(t) = &mut n.kind else { return false };
|
||||
super::typecmd::reshape_area_with(t, |t| t.transform_area(xf))
|
||||
}
|
||||
|
||||
fn transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let m = matrix_param(p, "matrix").ok_or_else(|| bad("object.transform", "missing matrix [a,b,c,d,e,f]"))?;
|
||||
let ids = match ids_param(p, "ids") {
|
||||
@@ -375,20 +389,26 @@ fn send_to_current_layer(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
fn group(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
let Some(top) = ids.last().copied() else { return Err(EngineError::Other("nothing selected".into())) };
|
||||
let gid = s.edit("Group", |d, sel| {
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
let gid = d.alloc_id();
|
||||
d.insert(par, idx + 1, Node::group(gid, vec![]))?;
|
||||
for id in &ids {
|
||||
d.move_node(*id, Some(gid), usize::MAX)?;
|
||||
}
|
||||
let gid = group_nodes(d, &ids)?;
|
||||
sel.set([gid]);
|
||||
Ok(gid)
|
||||
})?;
|
||||
Ok(json!({ "id": gid.0 }))
|
||||
}
|
||||
|
||||
/// Put `ids` (top-level objects, in paint order) in a new group where the front-most one was.
|
||||
pub(crate) fn group_nodes(d: &mut Document, ids: &[NodeId]) -> Result<NodeId> {
|
||||
let Some(top) = ids.last().copied() else { return Err(EngineError::Other("nothing selected".into())) };
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
let gid = d.alloc_id();
|
||||
d.insert(par, idx + 1, Node::group(gid, vec![]))?;
|
||||
for id in ids {
|
||||
d.move_node(*id, Some(gid), usize::MAX)?;
|
||||
}
|
||||
Ok(gid)
|
||||
}
|
||||
|
||||
fn ungroup(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
s.edit("Ungroup", |d, sel| {
|
||||
|
||||
@@ -260,6 +260,8 @@ pub const PREF_SPECS: &[PrefSpec] = &[
|
||||
p!("largeTabs", "User Interface", "", "Large Tabs", bool),
|
||||
p!("uiScaling", "User Interface", "UI Scaling", "Scale", num(0.75, 2.0, "×")),
|
||||
p!("scaleCursorWithUi", "User Interface", "UI Scaling", "Scale Cursor Proportional to UI", bool),
|
||||
// `auto` or a language code the shell registers (`zh-hant`); the shell shows it as a dropdown.
|
||||
p!("interfaceLanguage", "User Interface", "Language", "Language", text),
|
||||
// Performance
|
||||
p!("gpuPerformance", "Performance", "GPU Performance", "GPU Performance", bool),
|
||||
p!("animatedZoom", "Performance", "GPU Performance", "Animated Zoom", bool),
|
||||
@@ -390,6 +392,11 @@ pub fn validate(key: &str, v: &Value) -> std::result::Result<Value, String> {
|
||||
Err(format!("`{key}` must be a #rrggbb colour"))
|
||||
}
|
||||
}
|
||||
PrefKind::Text if key == "interfaceLanguage" => {
|
||||
let s = v.as_str().map(str::trim).unwrap_or("");
|
||||
let ok = !s.is_empty() && s.len() <= 16 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-');
|
||||
if ok { Ok(json!(s.to_ascii_lowercase())) } else { Err(format!("`{key}` must be `auto` or a language code such as `en` or `zh-hant`")) }
|
||||
}
|
||||
PrefKind::Text => match v {
|
||||
Value::String(s) => Ok(json!(s)),
|
||||
_ => Err(format!("`{key}` must be a string")),
|
||||
|
||||
@@ -188,7 +188,8 @@ fn key(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
fn parse_refs(v: Option<&Value>) -> Vec<(usize, usize)> {
|
||||
/// `[[subpath, anchor]…]` anchor references (malformed entries are left out).
|
||||
pub(crate) fn parse_refs(v: Option<&Value>) -> Vec<(usize, usize)> {
|
||||
v.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(|x| Some((x.get(0)?.as_u64()? as usize, x.get(1)?.as_u64()? as usize))).collect())
|
||||
.unwrap_or_default()
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Justify, Node, NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_geom::PathData;
|
||||
use vectorcraft_geom::{Affine, PathData, Vec2};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::pathops::{len_param, num_param, shape_node};
|
||||
@@ -48,10 +48,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Area Type Options…",
|
||||
["Type"],
|
||||
None,
|
||||
"{ids?, rows?, columns?, gutter?: pt, inset?: pt, firstBaseline?: ascent|capHeight|xHeight|leading|fixed, firstBaselineMin?: pt} set the selected area type's options (none given: query) → the first object's options",
|
||||
"{ids?, width?: pt, height?: pt, rows?, columns?, gutter?: pt, inset?: pt, firstBaseline?: ascent|capHeight|xHeight|leading|fixed, firstBaselineMin?: pt} set the selected area type's options; width and height size the type area from its top-left corner, along the type's own axes, and the text reflows at its size (none given: query) → the first object's options",
|
||||
has_selection,
|
||||
area_options
|
||||
),
|
||||
cmd!(
|
||||
"text.reshapeArea",
|
||||
"Reshape Type Area",
|
||||
[],
|
||||
None,
|
||||
"{id, anchors: [[subpath, anchor]…], dx, dy} move anchors of area type's frame (the type area) by dx, dy points, with their handles; the text reflows at its size, through its thread too (what Direct Selection does dragging a frame corner or edge)",
|
||||
has_doc,
|
||||
reshape_area
|
||||
),
|
||||
cmd!(
|
||||
"text.fitHeadline",
|
||||
"Fit Headline",
|
||||
@@ -265,25 +274,82 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// Reshape area type's frame with `f` ([`TextObject::transform_area`],
|
||||
/// [`TextObject::move_area_anchors`]) and lay its text out again. False, changing nothing, when
|
||||
/// `f` fails or the frame would reach past [`crate::MAX_COORD`] (the text flows over its height).
|
||||
pub(crate) fn reshape_area_with(t: &mut TextObject, f: impl FnOnce(&mut TextObject) -> bool) -> bool {
|
||||
let before = t.kind.clone();
|
||||
let within = |t: &TextObject| match &t.kind {
|
||||
vectorcraft_doc::TextKind::Area { frame } => {
|
||||
frame.bounds().is_some_and(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.abs() <= crate::MAX_COORD))
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if !(f(t) && within(t)) {
|
||||
t.kind = before;
|
||||
return false;
|
||||
}
|
||||
refresh_bounds(t);
|
||||
true
|
||||
}
|
||||
|
||||
/// Area type's text, if `n` is area type.
|
||||
fn area_text(n: Option<&Node>) -> Option<&TextObject> {
|
||||
match n.map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(t)) if matches!(t.kind, vectorcraft_doc::TextKind::Area { .. }) => Some(t),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The width and height of area type's frame in points, along the type's own axes, and the frame's
|
||||
/// bounds in text space.
|
||||
fn area_size(t: &TextObject) -> Option<(f64, f64, vectorcraft_geom::Rect)> {
|
||||
let vectorcraft_doc::TextKind::Area { frame } = &t.kind else { return None };
|
||||
let b = frame.bounds()?;
|
||||
let [a, bb, c, d, _, _] = t.xf.as_coeffs();
|
||||
Some((b.width() * a.hypot(bb), b.height() * c.hypot(d), b))
|
||||
}
|
||||
|
||||
/// Size area type's frame to `w` × `h` points (None: keep that side) from its top-left corner,
|
||||
/// and lay its text out again. False when the frame doesn't change.
|
||||
fn size_area(t: &mut TextObject, w: Option<f64>, h: Option<f64>) -> bool {
|
||||
let Some((cw, ch, b)) = area_size(t) else { return false };
|
||||
let factor = |to: Option<f64>, cur: f64| match to {
|
||||
Some(to) if cur > 1e-9 && (to - cur).abs() > 1e-9 => to / cur,
|
||||
_ => 1.0,
|
||||
};
|
||||
let (sx, sy) = (factor(w, cw), factor(h, ch));
|
||||
if sx == 1.0 && sy == 1.0 {
|
||||
return false;
|
||||
}
|
||||
let o = Affine::translate(b.origin().to_vec2());
|
||||
let a = t.xf * o * Affine::scale_non_uniform(sx, sy) * o.inverse() * t.xf.inverse();
|
||||
reshape_area_with(t, |t| t.transform_area(a))
|
||||
}
|
||||
|
||||
fn area_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.areaOptions";
|
||||
let ids: Vec<NodeId> = {
|
||||
let d = &s.doc()?.doc;
|
||||
text_targets(s, p, C)?
|
||||
.into_iter()
|
||||
.filter(|i| matches!(d.node(*i).map(|n| &n.kind), Some(NodeKind::Text(t)) if matches!(t.kind, vectorcraft_doc::TextKind::Area { .. })))
|
||||
.collect()
|
||||
text_targets(s, p, C)?.into_iter().filter(|i| area_text(d.node(*i)).is_some()).collect()
|
||||
};
|
||||
let first = ids.first().ok_or_else(|| bad(C, "select area type (text in a frame)"))?;
|
||||
let current = match &s.doc()?.doc.node(*first).map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(t)) => t.area.clone(),
|
||||
_ => return Err(bad(C, "select area type (text in a frame)")),
|
||||
let first = *ids.first().ok_or_else(|| bad(C, "select area type (text in a frame)"))?;
|
||||
let options = |s: &Session| -> Result<Value> {
|
||||
let t = area_text(s.doc()?.doc.node(first)).ok_or_else(|| bad(C, "select area type (text in a frame)"))?;
|
||||
let mut v = serde_json::to_value(&t.area).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
if let (Some(o), Some((w, h, _))) = (v.as_object_mut(), area_size(t)) {
|
||||
o.insert("width".into(), json!(w));
|
||||
o.insert("height".into(), json!(h));
|
||||
}
|
||||
Ok(v)
|
||||
};
|
||||
let mut v = serde_json::to_value(¤t).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let mut changed = false;
|
||||
let mut v = options(s)?;
|
||||
let size = |k: &str| p.get(k).and_then(Value::as_f64).map(|x| x.clamp(1.0, 100_000.0));
|
||||
let (w, h) = (size("width"), size("height"));
|
||||
let mut changed = w.is_some() || h.is_some();
|
||||
if let (Some(o), Some(src)) = (v.as_object_mut(), p.as_object()) {
|
||||
for (k, val) in src {
|
||||
if o.contains_key(k) && k != "ids" {
|
||||
if o.contains_key(k) && !matches!(k.as_str(), "ids" | "width" | "height") {
|
||||
o.insert(k.clone(), val.clone());
|
||||
changed = true;
|
||||
}
|
||||
@@ -298,23 +364,66 @@ fn area_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
opts.gutter = opts.gutter.clamp(0.0, 10_000.0);
|
||||
opts.inset = opts.inset.clamp(0.0, 10_000.0);
|
||||
opts.first_baseline_min = opts.first_baseline_min.clamp(0.0, 10_000.0);
|
||||
let out = serde_json::to_value(&opts).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
s.edit("Area Type Options", |d, _| {
|
||||
for id in &ids {
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) {
|
||||
t.area = opts.clone();
|
||||
refresh_bounds(t);
|
||||
// A resized frame has laid its text out again; otherwise the new options do here.
|
||||
if !size_area(t, w, h) {
|
||||
refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(out)
|
||||
options(s)
|
||||
}
|
||||
|
||||
fn reshape_area(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.reshapeArea";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let mut refs = super::select::parse_refs(p.get("anchors"));
|
||||
refs.sort_unstable();
|
||||
refs.dedup();
|
||||
if refs.is_empty() {
|
||||
return Err(bad(C, "missing anchors [[subpath, anchor]…]"));
|
||||
}
|
||||
let d = Vec2::new(f64_req(p, "dx", C)?, f64_req(p, "dy", C)?);
|
||||
let node = s.doc()?.doc.node(id);
|
||||
if area_text(node).is_none() || node.is_some_and(|n| n.perspective.is_some()) {
|
||||
return Err(bad(C, "not area type (text in a frame) outside perspective"));
|
||||
}
|
||||
s.edit("Reshape Type Area", |doc, _| {
|
||||
let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
|
||||
if !reshape_area_with(t, |t| t.move_area_anchors(&refs, d)) {
|
||||
return Err(bad(C, "no such frame anchor, or the frame would reach past the canvas"));
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod area_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn vertical_point_type_keeps_its_anchor_on_the_column_centre_line() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 100, "y": 50, "text": "§§§§ abc"})).unwrap()["id"].as_u64().unwrap();
|
||||
let bounds = |s: &Session| s.doc().unwrap().doc.node(NodeId(id)).unwrap().geometric_bounds().unwrap();
|
||||
let wide = bounds(&s);
|
||||
assert!(wide.width() > wide.height());
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
s.execute("type.orientation.vertical", &json!({})).unwrap();
|
||||
let tall = bounds(&s);
|
||||
assert!(tall.height() > tall.width(), "{tall:?}");
|
||||
assert!((tall.center().x - 100.0).abs() < 6.0, "the anchor stays on the column's centre line: {tall:?}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(bounds(&s), wide);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_options_columns_change_layout() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -29,7 +29,21 @@ pub fn node_summary(n: &Node) -> Value {
|
||||
if n.bbox_angle != 0.0 {
|
||||
v["rotation"] = json!(n.bbox_angle);
|
||||
}
|
||||
if !n.is_container() {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
// Type shows its characters' paint: the first run's, as paint.proxies and the Stroke
|
||||
// options read it. Fills and strokes of the type object itself, above or below its
|
||||
// characters, come separately so the two stay apart.
|
||||
let default = vectorcraft_doc::CharStyle::default();
|
||||
let style = t.runs.first().map_or(&default, |r| &r.style);
|
||||
v["fill"] = json!(style.fill.label());
|
||||
v["stroke"] = json!(style.stroke.label());
|
||||
v["strokeWidth"] = json!(if style.stroke.is_none() { 0.0 } else { style.stroke_width });
|
||||
if n.appearance.fill().is_some() || n.appearance.stroke().is_some() {
|
||||
v["objectFill"] = json!(n.appearance.fill_paint().label());
|
||||
v["objectStroke"] = json!(n.appearance.stroke_paint().label());
|
||||
v["objectStrokeWidth"] = json!(n.appearance.stroke_width());
|
||||
}
|
||||
} else if !n.is_container() {
|
||||
v["fill"] = json!(n.appearance.fill_paint().label());
|
||||
v["stroke"] = json!(n.appearance.stroke_paint().label());
|
||||
v["strokeWidth"] = json!(n.appearance.stroke_width());
|
||||
|
||||
@@ -391,6 +391,9 @@ pub struct Prefs {
|
||||
pub large_tabs: bool,
|
||||
pub ui_scaling: f64,
|
||||
pub scale_cursor_with_ui: bool,
|
||||
/// UI language: `auto` (follow the system locale) or a language code such as `en`, `zh-hant`.
|
||||
/// The list of languages belongs to the shell (`ui-egui` i18n); an unknown code reads as `auto`.
|
||||
pub interface_language: String,
|
||||
// Performance
|
||||
pub gpu_performance: bool,
|
||||
pub animated_zoom: bool,
|
||||
@@ -592,6 +595,7 @@ impl Default for Prefs {
|
||||
large_tabs: false,
|
||||
ui_scaling: 1.0,
|
||||
scale_cursor_with_ui: false,
|
||||
interface_language: s("auto"),
|
||||
gpu_performance: true,
|
||||
animated_zoom: true,
|
||||
history_states: 500,
|
||||
@@ -1384,6 +1388,8 @@ mod tests_toolsettings;
|
||||
#[cfg(test)]
|
||||
mod tests_transparencygrid;
|
||||
#[cfg(test)]
|
||||
mod tests_typearea;
|
||||
#[cfg(test)]
|
||||
mod tests_units;
|
||||
#[cfg(test)]
|
||||
mod tests_webexport;
|
||||
|
||||
@@ -285,6 +285,39 @@ fn inspect_lists_everything() {
|
||||
assert!(s.commands().len() > 100);
|
||||
}
|
||||
|
||||
/// Type reports the paint its characters show, and its own object-level paint apart from it.
|
||||
#[test]
|
||||
fn inspect_reports_the_paint_of_types_characters() {
|
||||
let mut s = session();
|
||||
let id = s.execute("text.create", &json!({"x": 50, "y": 60, "text": "HI", "size": 24})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [id]})).unwrap();
|
||||
let node = |s: &mut Session| {
|
||||
let v = s.execute("document.inspect", &json!({})).unwrap();
|
||||
v["layers"][0]["children"].as_array().unwrap().iter().find(|n| n["id"] == id).cloned().unwrap()
|
||||
};
|
||||
let t = node(&mut s);
|
||||
assert_eq!((t["fill"].as_str(), t["stroke"].as_str(), t["strokeWidth"].as_f64()), (Some("#ff0000"), Some("None"), Some(0.0)), "{t}");
|
||||
assert!(t.get("objectFill").is_none(), "no object-level paint: {t}");
|
||||
s.execute("paint.setFill", &json!({"color": "#0000ff", "ids": [id]})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"color": "#00ff00", "ids": [id]})).unwrap();
|
||||
s.execute("stroke.set", &json!({"weight": 3, "ids": [id]})).unwrap();
|
||||
let t = node(&mut s);
|
||||
assert_eq!((t["fill"].as_str(), t["stroke"].as_str(), t["strokeWidth"].as_f64()), (Some("#0000ff"), Some("#00ff00"), Some(3.0)), "{t}");
|
||||
// An object-level fill on the type stays distinguishable from its characters' paint.
|
||||
s.execute("appearance.addFill", &json!({"ids": [id]})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#ffff00", "item": 0, "ids": [id]})).unwrap();
|
||||
let t = node(&mut s);
|
||||
assert_eq!(t["objectFill"], "#ffff00", "{t}");
|
||||
assert_eq!(t["fill"], "#0000ff", "{t}");
|
||||
// A path keeps reporting its own appearance.
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [r]})).unwrap();
|
||||
let v = s.execute("document.inspect", &json!({})).unwrap();
|
||||
let rect = v["layers"][0]["children"].as_array().unwrap().iter().find(|n| n["id"] == r).cloned().unwrap();
|
||||
assert_eq!(rect["fill"], "#ff0000");
|
||||
assert!(rect.get("objectFill").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_command_has_unique_id_and_doc() {
|
||||
let mut ids: Vec<&str> = command_specs().iter().map(|c| c.id).collect();
|
||||
|
||||
@@ -168,6 +168,33 @@ fn export_selection_crops_to_the_selection() {
|
||||
assert_eq!(text.matches("<path").count(), 1, "{text}");
|
||||
}
|
||||
|
||||
/// A selection export reports the encoder's warnings like a whole-document export, and an empty
|
||||
/// list when nothing was approximated.
|
||||
#[test]
|
||||
fn export_selection_reports_the_encoders_warnings() {
|
||||
let mut s = session();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
|
||||
s.execute("transparency.set", &json!({"blend": "Multiply"})).unwrap();
|
||||
let warnings = |v: &serde_json::Value| v["warnings"].as_array().cloned().unwrap_or_else(|| panic!("no warnings key: {v}"));
|
||||
// Control: nothing approximated.
|
||||
let plain = s.execute("document.exportSelection", &json!({"format": "svg"})).unwrap();
|
||||
assert_eq!(plain["format"], "svg");
|
||||
assert!(warnings(&plain).is_empty(), "{plain}");
|
||||
for (format, options, says) in [
|
||||
("webp", json!({"lossless": false}), "lossless"),
|
||||
("svg", json!({"profile": "tiny12"}), "blend"),
|
||||
("pdf", json!({"createLayers": true, "compatibility": "1.4"}), "layers"),
|
||||
] {
|
||||
let mut p = options.clone();
|
||||
p["format"] = json!(format);
|
||||
let sel = s.execute("document.exportSelection", &p).unwrap();
|
||||
let doc = s.execute("document.export", &p).unwrap();
|
||||
let w = warnings(&sel);
|
||||
assert!(w.iter().any(|w| w.as_str().unwrap_or("").to_lowercase().contains(says)), "{format}: {sel}");
|
||||
assert_eq!(w, warnings(&doc), "{format}: the same warnings as a whole-document export");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_opens_as_untitled() {
|
||||
let mut s = session();
|
||||
@@ -206,3 +233,30 @@ fn raster_export_too_large_is_an_error_not_a_crash() {
|
||||
assert!(std::fs::read(&path).unwrap().starts_with(b"\x89PNG"));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// Japanese file and folder names save, open, export and title documents as typed: the native
|
||||
/// format, SVG, PNG and PDF, in a folder with a Japanese name too.
|
||||
#[test]
|
||||
fn japanese_file_names_save_open_and_export() {
|
||||
let mut s = session();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 80, "height": 40})).unwrap();
|
||||
s.execute("text.create", &json!({"x": 10, "y": 80, "text": "日本語のテキスト"})).unwrap();
|
||||
let dir = std::env::temp_dir().join(format!("vc-日本語フォルダ-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("運動会カード(最終版)・2024.vectorcraft");
|
||||
s.execute("document.save", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert!(path.exists());
|
||||
assert_eq!(s.doc().unwrap().title(), "運動会カード(最終版)・2024.vectorcraft", "the tab names the file as typed");
|
||||
s.execute("document.open", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
assert_eq!(d.path.as_deref(), Some(path.to_string_lossy().as_ref()));
|
||||
let mut found = false;
|
||||
d.doc.walk(|n| found |= matches!(&n.kind, vectorcraft_doc::NodeKind::Text(t) if t.plain_text() == "日本語のテキスト"));
|
||||
assert!(found, "the Japanese text comes back");
|
||||
for (format, name) in [("svg", "書き出し.svg"), ("png", "書き出し.png"), ("pdf", "書き出し.pdf")] {
|
||||
let out = dir.join(name);
|
||||
s.execute("document.export", &json!({"format": format, "path": out.to_string_lossy()})).unwrap();
|
||||
assert!(std::fs::metadata(&out).is_ok_and(|m| m.len() > 0), "{name}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
@@ -14,14 +14,14 @@ fn names(v: &Value) -> Vec<String> {
|
||||
fn the_font_list_names_every_available_family_with_its_styles() {
|
||||
let mut s = Session::new();
|
||||
let fams = names(&s.execute("text.fontList", &json!({})).unwrap()["families"]);
|
||||
assert_eq!(fams, FontDb::global().families());
|
||||
assert_eq!(fams, *FontDb::global().menu_family_list());
|
||||
for f in ["Source Sans 3", "Source Serif 4", "Inter", "JetBrains Mono"] {
|
||||
assert!(fams.iter().any(|x| x == f), "{f}");
|
||||
}
|
||||
// Every installed family, though nothing asked for system fonts.
|
||||
let installed = FontDb::with_font_dirs(system_font_dirs());
|
||||
installed.load_system_fonts();
|
||||
let missing: Vec<String> = installed.families().into_iter().filter(|f| !fams.contains(f)).collect();
|
||||
let missing: Vec<String> = installed.menu_family_list().iter().filter(|f| !fams.contains(f)).cloned().collect();
|
||||
assert!(missing.is_empty(), "{missing:?}");
|
||||
let r = s.execute("text.fontList", &json!({"family": "source sans 3"})).unwrap();
|
||||
assert_eq!(r["family"], "Source Sans 3");
|
||||
@@ -38,7 +38,7 @@ fn refreshing_the_font_list_redraws_type_without_editing_the_document() {
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
let revision = s.doc().unwrap().revision;
|
||||
let r = s.execute("text.rescanFonts", &json!({})).unwrap();
|
||||
assert_eq!(r["families"], json!(FontDb::global().families().len()));
|
||||
assert_eq!(r["families"], json!(FontDb::global().menu_family_list().len()));
|
||||
assert!(r["faces"].is_u64());
|
||||
let d = s.doc().unwrap();
|
||||
assert!(d.revision > revision, "the canvas redraws");
|
||||
|
||||
@@ -153,6 +153,61 @@ fn scale_corners_keeps_or_scales_live_corner_radii() {
|
||||
assert!((n.geometric_bounds().unwrap().width() - 400.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// The extent along x and y of each corner arc of an upright live rounded rectangle's path.
|
||||
fn corner_spans(n: &Node) -> Vec<(f64, f64)> {
|
||||
let NodeKind::Path { path, .. } = &n.kind else { panic!("not a path") };
|
||||
let a = &path.subpaths[0].anchors;
|
||||
assert_eq!(a.len(), 8, "a rounded rectangle");
|
||||
[(1, 2), (3, 4), (5, 6), (7, 0)].iter().map(|(i, j)| ((a[*j].p.x - a[*i].p.x).abs(), (a[*j].p.y - a[*i].p.y).abs())).collect()
|
||||
}
|
||||
|
||||
/// Every corner is a circular arc of radius `r`.
|
||||
fn round_corners(n: &Node, r: f64) -> bool {
|
||||
corner_spans(n).iter().all(|(x, y)| (x - r).abs() < 1e-9 && (y - r).abs() < 1e-9)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_corners_stay_circular_when_a_rectangle_is_scaled_unevenly() {
|
||||
// #291: a rounded square widened into a long rectangle keeps round corners.
|
||||
let mut s = session();
|
||||
let id = id_of(&run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100, "radius": 10})));
|
||||
run(&mut s, "object.scale", json!({"sx": 300, "sy": 100, "corners": false}));
|
||||
let n = node(&s, id);
|
||||
assert!((n.geometric_bounds().unwrap().width() - 300.0).abs() < 1e-9);
|
||||
assert!(round_corners(&n, 10.0), "{:?}", corner_spans(&n));
|
||||
let NodeKind::Path { live: Some(LiveShape::Rectangle { w, h, radii, .. }), .. } = &n.kind else { panic!("not live") };
|
||||
assert!((w - 300.0).abs() < 1e-9 && (h - 100.0).abs() < 1e-9 && (radii[0] - 10.0).abs() < 1e-9, "the panels read document units");
|
||||
// Scale Corners on: the radius scales by the mean scale (√(2 × 0.5) = 1 here, 2 below).
|
||||
run(&mut s, "object.scale", json!({"sx": 200, "sy": 50, "corners": true}));
|
||||
assert!(round_corners(&node(&s, id), 10.0), "{:?}", corner_spans(&node(&s, id)));
|
||||
run(&mut s, "object.scale", json!({"sx": 100, "sy": 400, "corners": true}));
|
||||
assert!(round_corners(&node(&s, id), 20.0), "{:?}", corner_spans(&node(&s, id)));
|
||||
// Undo brings each step back.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(round_corners(&node(&s, id), 10.0));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(round_corners(&node(&s, id), 10.0));
|
||||
assert!((node(&s, id).geometric_bounds().unwrap().width() - 100.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bounding_box_drag_keeps_live_corners_circular() {
|
||||
// The Selection tool's handle drag previews `object.transform` from the shape as it began.
|
||||
let mut s = session();
|
||||
let id = id_of(&run(&mut s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 100, "radius": 25})));
|
||||
s.begin_interaction("Scale").unwrap();
|
||||
for sx in [1.5, 2.5, 4.0] {
|
||||
// Dragging the right-middle handle: scaled about the left edge.
|
||||
s.preview("object.transform", &json!({"matrix": [sx, 0, 0, 1, 50.0 - 50.0 * sx, 0]})).unwrap();
|
||||
}
|
||||
s.commit_interaction().unwrap();
|
||||
let n = node(&s, id);
|
||||
let b = n.geometric_bounds().unwrap();
|
||||
assert!((b.x0 - 50.0).abs() < 1e-9 && (b.width() - 400.0).abs() < 1e-9 && (b.height() - 100.0).abs() < 1e-9, "{b:?}");
|
||||
assert!(round_corners(&n, 25.0), "{:?}", corner_spans(&n));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_journal_records_the_scale_options_used() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -157,6 +157,98 @@ fn undo_during_a_drag_takes_back_only_the_drag() {
|
||||
assert_eq!(b.x0, 100.0, "back where it was: {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ime_composition_shows_in_place_and_commits_as_one_step() {
|
||||
// Romaji → kana → conversion → commit, as the macOS Japanese IME sends it.
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
click(&mut s, 200.0, 200.0);
|
||||
let id = s.doc().unwrap().selection.objects[0];
|
||||
let created = undo_labels(&s).len();
|
||||
s.tool_text("曲:", v).unwrap();
|
||||
for (t, r) in [("g", 1..1), ("が", 1..1), ("がg", 2..2), ("ががく", 3..3)] {
|
||||
s.tool_preedit(t, Some(r), v).unwrap();
|
||||
assert_eq!(obj(&s, id).plain_text(), format!("曲:{t}"), "marked text lays out in place");
|
||||
assert!(s.tool_composing());
|
||||
}
|
||||
// Conversion: the active clause is the whole word.
|
||||
s.tool_preedit("雅楽", Some(0..2), v).unwrap();
|
||||
assert_eq!(obj(&s, id).plain_text(), "曲:雅楽");
|
||||
assert_eq!(s.tool_options()["composing"], json!(true));
|
||||
// macOS clears the marked text, then commits.
|
||||
s.tool_preedit("", None, v).unwrap();
|
||||
s.tool_text("雅楽", v).unwrap();
|
||||
assert!(!s.tool_composing());
|
||||
assert_eq!(obj(&s, id).plain_text(), "曲:雅楽");
|
||||
assert_eq!(s.tool_options()["caret"], json!("曲:雅楽".len()));
|
||||
// Typing goes on after the commit, in the same session.
|
||||
s.tool_text("。", v).unwrap();
|
||||
key(&mut s, ToolKey::Escape, Mods::default());
|
||||
assert_eq!(obj(&s, id).plain_text(), "曲:雅楽。");
|
||||
assert_eq!(undo_labels(&s).len(), created + 1, "one undo step for the session");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(obj(&s, id).plain_text(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ime_cancel_leaves_no_undo_step_and_keeps_the_text() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
let id = text(&mut s, "雅楽");
|
||||
s.select_tool("type", v).unwrap();
|
||||
let t = obj(&s, id);
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
let (a, b) = vectorcraft_text::caret_position(&lay, "雅楽".len());
|
||||
let p = t.xf * a.midpoint(b);
|
||||
click(&mut s, p.x - 0.1, p.y);
|
||||
assert!(s.tool_wants_text());
|
||||
s.set_tool_option("select", &json!({"start": 6, "end": 6}));
|
||||
let before = undo_labels(&s).len();
|
||||
s.tool_preedit("えんそう", Some(4..4), v).unwrap();
|
||||
assert_eq!(obj(&s, id).plain_text(), "雅楽えんそう");
|
||||
// Escape in the IME: the marked text goes, nothing else changes.
|
||||
s.tool_preedit("", None, v).unwrap();
|
||||
assert!(!s.tool_composing());
|
||||
assert!(!s.in_interaction(), "a cancelled composition leaves no typing session");
|
||||
assert_eq!(obj(&s, id).plain_text(), "雅楽");
|
||||
assert_eq!(undo_labels(&s).len(), before);
|
||||
// A stray clear (no composition) never deletes the selection.
|
||||
s.set_tool_option("select", &json!({"start": 0, "end": 3}));
|
||||
s.tool_preedit("", None, v).unwrap();
|
||||
assert_eq!(obj(&s, id).plain_text(), "雅楽");
|
||||
// A composition over a selection replaces it.
|
||||
s.tool_preedit("が", Some(1..1), v).unwrap();
|
||||
s.tool_preedit("", None, v).unwrap();
|
||||
s.tool_text("我", v).unwrap();
|
||||
assert_eq!(obj(&s, id).plain_text(), "我楽");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_away_keeps_marked_text_as_typed_and_undo_mid_composition_is_safe() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
click(&mut s, 200.0, 200.0);
|
||||
let id = s.doc().unwrap().selection.objects[0];
|
||||
s.tool_preedit("しょうこ", Some(4..4), v).unwrap();
|
||||
// Keys belong to the IME while it composes.
|
||||
key(&mut s, ToolKey::Backspace, Mods::default());
|
||||
assert_eq!(obj(&s, id).plain_text(), "しょうこ");
|
||||
click(&mut s, 600.0, 500.0);
|
||||
assert!(!s.tool_composing());
|
||||
assert_eq!(obj(&s, id).plain_text(), "しょうこ", "the marked text stays, committed");
|
||||
// The UI holds Undo back while composing; if it comes anyway, the session is committed first.
|
||||
click(&mut s, 50.0, 50.0);
|
||||
let id2 = s.doc().unwrap().selection.objects[0];
|
||||
s.tool_preedit("ひちりき", Some(4..4), v).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!s.tool_composing());
|
||||
assert_eq!(obj(&s, id2).plain_text(), "");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(obj(&s, id2).plain_text(), "ひちりき");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_tool_selection_replace_and_delete() {
|
||||
let mut s = session();
|
||||
@@ -236,6 +328,23 @@ fn select_all_and_cut_like_ui() {
|
||||
assert_eq!(obj(&s, id).plain_text(), "abc defabc def");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_dragged_with_the_type_tools_is_the_dragged_rectangle() {
|
||||
for tool in ["type", "verticalType"] {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool(tool, v).unwrap();
|
||||
for (k, x, y) in [(PointerKind::Down, 100.0, 100.0), (PointerKind::Drag, 300.0, 180.0), (PointerKind::Up, 300.0, 180.0)] {
|
||||
s.pointer(&PointerEvent::new(k, x, y), v).unwrap();
|
||||
}
|
||||
let t = obj(&s, s.doc().unwrap().selection.objects[0]);
|
||||
let TextKind::Area { frame } = &t.kind else { panic!("{tool}: not area type") };
|
||||
let b = frame.bounds().unwrap();
|
||||
let (a, z) = (t.xf * Point::new(b.x0, b.y0), t.xf * Point::new(b.x1, b.y1));
|
||||
assert!((a - Point::new(100.0, 100.0)).hypot() < 1e-9 && (z - Point::new(300.0, 180.0)).hypot() < 1e-9, "{tool}: {a:?} {z:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_type_from_a_circle_keeps_glyphs_inside() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
//! Resizing area type (#252): a bounding-box handle drag and Area Type Options size the type area
|
||||
//! and the text reflows at its size; Direct Selection reshapes the frame by a corner or an edge.
|
||||
//! Point type still scales.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeKind, TextKind, TextObject};
|
||||
use vectorcraft_geom::{Affine, Point, Rect};
|
||||
use vectorcraft_text::TextLayout;
|
||||
use vectorcraft_tools::bbox::Handle;
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
|
||||
|
||||
use super::*;
|
||||
|
||||
const STORY: &str = "The quick brown fox jumps over the lazy dog. A second sentence follows the first one here. Then a third one ends it.";
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn id_of(v: &Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
/// 12 pt area type in a `w` × `h` frame at (`x`, `y`).
|
||||
fn area(s: &mut Session, x: f64, y: f64, w: f64, h: f64, text: &str) -> NodeId {
|
||||
id_of(&s.execute("text.create", &json!({"x": x, "y": y, "size": 12, "area": {"width": w, "height": h}, "text": text})).unwrap())
|
||||
}
|
||||
|
||||
fn sel(s: &mut Session, ids: &[NodeId]) {
|
||||
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
|
||||
}
|
||||
|
||||
fn text(s: &Session, id: NodeId) -> TextObject {
|
||||
match &s.doc().unwrap().doc.node(id).unwrap().kind {
|
||||
NodeKind::Text(t) => (**t).clone(),
|
||||
k => panic!("not text: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame's anchors in text space.
|
||||
fn corners(s: &Session, id: NodeId) -> Vec<Point> {
|
||||
match text(s, id).kind {
|
||||
TextKind::Area { frame } => frame.anchors().map(|(_, _, a)| a.p).collect(),
|
||||
k => panic!("not area type: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn frame(s: &Session, id: NodeId) -> Rect {
|
||||
match text(s, id).kind {
|
||||
TextKind::Area { frame } => frame.bounds().unwrap(),
|
||||
k => panic!("not area type: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn layout(s: &Session, id: NodeId) -> TextLayout {
|
||||
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s, id))
|
||||
}
|
||||
|
||||
fn undo_len(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
fn last_step(s: &Session) -> String {
|
||||
s.doc().unwrap().history.undo.last().unwrap().label.clone()
|
||||
}
|
||||
|
||||
/// Press, drag and release `tool` from `from` to `to`.
|
||||
fn drag(s: &mut Session, tool: &str, from: Point, to: Point) {
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool(tool, v).unwrap();
|
||||
for (kind, p) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] {
|
||||
s.pointer(&PointerEvent::new(kind, p.x, p.y).with_mods(Mods::default()), v).unwrap();
|
||||
}
|
||||
assert!(!s.in_interaction());
|
||||
}
|
||||
|
||||
fn near(a: Point, b: Point) -> bool {
|
||||
a.distance(b) < 1e-6
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_handle_drag_resizes_the_type_area_and_the_text_reflows() {
|
||||
let mut s = session();
|
||||
let id = area(&mut s, 40.0, 40.0, 120.0, 40.0, STORY);
|
||||
let before = layout(&s, id);
|
||||
assert!(before.overflow, "the story overflows 120 × 40");
|
||||
sel(&mut s, &[id]);
|
||||
let n = undo_len(&s);
|
||||
// The bounding box's bottom-right handle, from (160, 80) to (200, 140): 160 × 100.
|
||||
drag(&mut s, "selection", Point::new(160.0, 80.0), Point::new(200.0, 140.0));
|
||||
assert_eq!(undo_len(&s), n + 1, "one step");
|
||||
assert_eq!(last_step(&s), "Resize Type Area");
|
||||
let r = frame(&s, id);
|
||||
assert!(near(r.origin(), Point::ZERO) && (r.width() - 160.0).abs() < 1e-6 && (r.height() - 100.0).abs() < 1e-6, "{r:?}");
|
||||
let t = text(&s, id);
|
||||
assert_eq!(t.xf, Affine::translate((40.0, 40.0)), "the type isn't scaled");
|
||||
assert!(t.runs.iter().all(|r| r.style.size == 12.0));
|
||||
let after = layout(&s, id);
|
||||
assert!(after.lines.len() > before.lines.len(), "more lines fit: {} → {}", before.lines.len(), after.lines.len());
|
||||
assert!(after.glyphs.len() > before.glyphs.len());
|
||||
assert!(!after.overflow, "the whole story fits 160 × 100");
|
||||
// The bounding box hugs the new frame.
|
||||
assert_eq!(s.doc().unwrap().doc.node(id).unwrap().geometric_bounds(), Some(Rect::new(40.0, 40.0, 200.0, 140.0)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(frame(&s, id), Rect::new(0.0, 0.0, 120.0, 40.0));
|
||||
assert!(layout(&s, id).overflow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_type_still_scales_with_its_handles() {
|
||||
let mut s = session();
|
||||
let id = id_of(&s.execute("text.create", &json!({"x": 40, "y": 40, "size": 12, "text": "Hello"})).unwrap());
|
||||
sel(&mut s, &[id]);
|
||||
let b = s.transform_box(&s.doc().unwrap().selection.objects).unwrap();
|
||||
let (tl, br) = (b.to_doc() * Handle::TopLeft.pos(b.rect), b.to_doc() * Handle::BottomRight.pos(b.rect));
|
||||
drag(&mut s, "selection", br, tl + (br - tl) * 2.0);
|
||||
assert_eq!(last_step(&s), "Scale");
|
||||
let c = text(&s, id).xf.as_coeffs();
|
||||
assert!((c[0] - 2.0).abs() < 1e-6 && (c[3] - 2.0).abs() < 1e-6, "the type doubles: {c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_other_objects_the_type_area_resizes_and_the_others_scale() {
|
||||
let mut s = session();
|
||||
let id = area(&mut s, 40.0, 40.0, 120.0, 40.0, STORY);
|
||||
let r = id_of(&s.execute("shape.rectangle", &json!({"x": 40, "y": 100, "width": 120, "height": 20})).unwrap());
|
||||
sel(&mut s, &[id, r]);
|
||||
// The box spans (40, 40)–(160, 120); its right handle doubles the width.
|
||||
drag(&mut s, "selection", Point::new(160.0, 80.0), Point::new(280.0, 80.0));
|
||||
assert_eq!(last_step(&s), "Scale");
|
||||
assert_eq!(s.doc().unwrap().doc.node(r).unwrap().geometric_bounds(), Some(Rect::new(40.0, 100.0, 280.0, 120.0)));
|
||||
assert_eq!(frame(&s, id), Rect::new(0.0, 0.0, 240.0, 40.0));
|
||||
assert_eq!(text(&s, id).xf, Affine::translate((40.0, 40.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rotated_type_area_resizes_along_its_own_axes() {
|
||||
let mut s = session();
|
||||
let id = area(&mut s, 40.0, 40.0, 120.0, 40.0, STORY);
|
||||
sel(&mut s, &[id]);
|
||||
s.execute("object.rotate", &json!({"angle": 30, "absolute": true})).unwrap();
|
||||
let xf = text(&s, id).xf;
|
||||
let b = s.transform_box(&[id]).unwrap();
|
||||
let right = b.to_doc() * Handle::Right.pos(b.rect);
|
||||
let along = (b.to_doc() * Handle::Right.pos(b.rect)) - (b.to_doc() * Handle::Left.pos(b.rect));
|
||||
drag(&mut s, "selection", right, right + along * 0.5);
|
||||
assert_eq!(text(&s, id).xf, xf, "the type keeps its size and angle");
|
||||
let r = frame(&s, id);
|
||||
assert!((r.width() - 180.0).abs() < 1e-6 && (r.height() - 40.0).abs() < 1e-6, "still a rectangle, 180 × 40: {r:?}");
|
||||
assert_eq!(corners(&s, id).len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_selection_drags_a_frame_corner_or_edge() {
|
||||
let mut s = session();
|
||||
let id = area(&mut s, 40.0, 40.0, 120.0, 40.0, STORY);
|
||||
let glyphs = layout(&s, id).glyphs.len();
|
||||
let n = undo_len(&s);
|
||||
// The bottom-right corner, not selected yet: the corner moves, the others stay.
|
||||
drag(&mut s, "directSelection", Point::new(160.0, 80.0), Point::new(200.0, 140.0));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![id]);
|
||||
assert_eq!(undo_len(&s), n + 1, "one step");
|
||||
assert_eq!(last_step(&s), "Reshape Type Area");
|
||||
let c = corners(&s, id);
|
||||
assert!(near(c[0], Point::ZERO) && near(c[1], Point::new(120.0, 0.0)) && near(c[3], Point::new(0.0, 40.0)), "{c:?}");
|
||||
assert!(near(c[2], Point::new(160.0, 100.0)), "{c:?}");
|
||||
assert!(layout(&s, id).glyphs.len() > glyphs, "more text fits the larger area");
|
||||
assert_eq!(text(&s, id).xf, Affine::translate((40.0, 40.0)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(frame(&s, id), Rect::new(0.0, 0.0, 120.0, 40.0));
|
||||
// The right edge: both its ends move, and the area stays a rectangle.
|
||||
drag(&mut s, "directSelection", Point::new(160.0, 60.0), Point::new(180.0, 60.0));
|
||||
assert_eq!(frame(&s, id), Rect::new(0.0, 0.0, 140.0, 40.0));
|
||||
assert_eq!(text(&s, id).runs[0].style.size, 12.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_resized_frame_reflows_its_thread() {
|
||||
let mut s = session();
|
||||
let a = area(&mut s, 40.0, 40.0, 120.0, 40.0, STORY);
|
||||
let b = area(&mut s, 300.0, 40.0, 120.0, 200.0, "");
|
||||
sel(&mut s, &[a, b]);
|
||||
s.execute("text.thread.create", &json!({})).unwrap();
|
||||
let rest = text(&s, b).plain_text().len();
|
||||
assert!(rest > 0, "the overflow flows on");
|
||||
// Bounding-box resize of the first frame to 160 × 100, as the Selection tool sends it.
|
||||
let m = Affine::translate((40.0, 40.0)) * Affine::scale_non_uniform(160.0 / 120.0, 100.0 / 40.0) * Affine::translate((-40.0, -40.0));
|
||||
let c = m.as_coeffs();
|
||||
s.execute("object.transform", &json!({"ids": [a.0], "matrix": c, "typeAreas": true})).unwrap();
|
||||
assert!(text(&s, b).plain_text().len() < rest, "the second frame holds less");
|
||||
assert_eq!(text(&s, a).plain_text().len() + text(&s, b).plain_text().len(), STORY.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_type_options_size_the_type_area() {
|
||||
let mut s = session();
|
||||
let id = area(&mut s, 40.0, 40.0, 120.0, 40.0, STORY);
|
||||
sel(&mut s, &[id]);
|
||||
let q = s.execute("text.areaOptions", &json!({})).unwrap();
|
||||
assert_eq!((q["width"].as_f64(), q["height"].as_f64()), (Some(120.0), Some(40.0)));
|
||||
let n = undo_len(&s);
|
||||
let r = s.execute("text.areaOptions", &json!({"width": 160, "height": 100})).unwrap();
|
||||
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(160.0), Some(100.0)));
|
||||
assert_eq!(frame(&s, id), Rect::new(0.0, 0.0, 160.0, 100.0));
|
||||
assert_eq!(text(&s, id).xf, Affine::translate((40.0, 40.0)));
|
||||
assert!(!layout(&s, id).overflow);
|
||||
// The dialog's OK sends the size back unchanged with the other options: only those change.
|
||||
s.execute("text.areaOptions", &json!({"width": 160, "height": 100, "inset": 4})).unwrap();
|
||||
assert_eq!(frame(&s, id), Rect::new(0.0, 0.0, 160.0, 100.0));
|
||||
assert_eq!(undo_len(&s), n + 2);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(frame(&s, id), Rect::new(0.0, 0.0, 120.0, 40.0));
|
||||
// Type scaled 200%: sizes are in points on the page, along the type's axes.
|
||||
s.execute("object.scale", &json!({"sx": 200, "origin": [40, 40]})).unwrap();
|
||||
assert_eq!(s.execute("text.areaOptions", &json!({})).unwrap()["width"].as_f64(), Some(240.0));
|
||||
s.execute("text.areaOptions", &json!({"width": 300})).unwrap();
|
||||
assert_eq!(frame(&s, id), Rect::new(0.0, 0.0, 150.0, 40.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_area_takes_untrusted_params() {
|
||||
let mut s = session();
|
||||
let id = area(&mut s, 40.0, 40.0, 120.0, 40.0, STORY);
|
||||
let point = id_of(&s.execute("text.create", &json!({"x": 40, "y": 300, "text": "Point"})).unwrap());
|
||||
let rect = id_of(&s.execute("shape.rectangle", &json!({"x": 300, "y": 300, "width": 50, "height": 50})).unwrap());
|
||||
let n = undo_len(&s);
|
||||
for bad in [
|
||||
json!({}),
|
||||
json!({"anchors": [[0, 2]], "dx": 1, "dy": 1}),
|
||||
json!({"id": point.0, "anchors": [[0, 0]], "dx": 1, "dy": 1}),
|
||||
json!({"id": rect.0, "anchors": [[0, 0]], "dx": 1, "dy": 1}),
|
||||
json!({"id": 99_999, "anchors": [[0, 0]], "dx": 1, "dy": 1}),
|
||||
json!({"id": id.0, "dx": 1, "dy": 1}),
|
||||
json!({"id": id.0, "anchors": [], "dx": 1, "dy": 1}),
|
||||
json!({"id": id.0, "anchors": [["a", 1], [0], "x"], "dx": 1, "dy": 1}),
|
||||
json!({"id": id.0, "anchors": [[0, 9]], "dx": 1, "dy": 1}),
|
||||
json!({"id": id.0, "anchors": [[0, 1], [7, 0]], "dx": 1, "dy": 1}),
|
||||
json!({"id": id.0, "anchors": [[0, 2]], "dy": 1}),
|
||||
json!({"id": id.0, "anchors": [[0, 2]], "dx": "far", "dy": 1}),
|
||||
json!({"id": id.0, "anchors": [[0, 2]], "dx": 0, "dy": 1e300}),
|
||||
json!({"id": id.0, "anchors": [[0, 2]], "dx": -1e7, "dy": 0}),
|
||||
] {
|
||||
assert!(s.execute("text.reshapeArea", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(undo_len(&s), n, "nothing changed");
|
||||
assert_eq!(frame(&s, id), Rect::new(0.0, 0.0, 120.0, 40.0));
|
||||
// Listed twice, an anchor still moves once.
|
||||
s.execute("text.reshapeArea", &json!({"id": id.0, "anchors": [[0, 2], [0, 2]], "dx": 10, "dy": 5})).unwrap();
|
||||
assert_eq!(corners(&s, id)[2], Point::new(130.0, 45.0));
|
||||
assert_eq!(last_step(&s), "Reshape Type Area");
|
||||
// A frame collapsed to nothing lays out (everything overflows) without trouble.
|
||||
for m in [[0.0, 0.0, 0.0, 1.0, 40.0, 0.0], [1.0, 0.0, 0.0, 0.0, 0.0, 40.0], [0.0; 6]] {
|
||||
s.execute("object.transform", &json!({"ids": [id.0], "matrix": m, "typeAreas": true})).unwrap();
|
||||
assert!(layout(&s, id).overflow);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_area_is_a_registered_command() {
|
||||
let spec = cmd::find_command("text.reshapeArea").unwrap();
|
||||
assert_eq!(spec.label, "Reshape Type Area");
|
||||
assert!(spec.params.contains("anchors") && spec.params.contains("dx"));
|
||||
assert!(cmd::find_command("object.transform").unwrap().params.contains("typeAreas"));
|
||||
assert!(session().commands().iter().any(|c| c.id == "text.reshapeArea" && c.enabled));
|
||||
}
|
||||
@@ -148,6 +148,27 @@ fn free_transform_distort_mode_via_pointer() {
|
||||
assert_eq!(s.journal.last().map(|j| j.0.as_str()), Some("object.distort"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_transform_cmd_held_once_a_corner_drag_started_distorts_in_one_step() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("freeTransform", v).unwrap();
|
||||
let before = undo_len(&s);
|
||||
let cmd = Mods { cmd: true, ..Default::default() };
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 200.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 210.0, 210.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 250.0, 260.0).with_mods(cmd), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 250.0, 260.0).with_mods(cmd), v).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let pts: Vec<Point> = d.node(a).unwrap().path_data().unwrap().anchors().map(|(_, _, an)| an.p).collect();
|
||||
for p in [Point::new(250.0, 260.0), Point::new(100.0, 100.0), Point::new(200.0, 100.0), Point::new(100.0, 200.0)] {
|
||||
assert!(pts.iter().any(|q| q.distance(p) < 1e-6), "{p:?} not in {pts:?}");
|
||||
}
|
||||
assert_eq!(undo_len(&s), before + 1);
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Distort"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_transform_side_handle_scales() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -214,6 +214,24 @@ impl Session {
|
||||
self.tool.wants_text()
|
||||
}
|
||||
|
||||
/// IME marked text for the active tool (see `Tool::ime_preedit`; `active_chars` counts
|
||||
/// characters).
|
||||
pub fn tool_preedit(&mut self, text: &str, active_chars: Option<std::ops::Range<usize>>, view: ViewInfo) -> Result<Vec<UiRequest>> {
|
||||
let acts = self.with_tool_cx(view, |t, cx| t.ime_preedit(cx, text, active_chars));
|
||||
self.take_tool_panic()?;
|
||||
self.apply_actions(acts)
|
||||
}
|
||||
|
||||
/// Is the active tool showing uncommitted IME text?
|
||||
pub fn tool_composing(&self) -> bool {
|
||||
self.tool.composing()
|
||||
}
|
||||
|
||||
/// The caret line (document space) the IME candidate window follows.
|
||||
pub fn tool_ime_caret(&mut self, view: ViewInfo) -> Option<(Point, Point)> {
|
||||
self.with_tool_cx(view, |t, cx| t.ime_caret(cx))
|
||||
}
|
||||
|
||||
pub fn tool_busy(&self) -> bool {
|
||||
self.tool.busy()
|
||||
}
|
||||
|
||||
@@ -910,6 +910,9 @@ fn arb_ps_token() -> impl Strategy<Value = String> {
|
||||
"stopped", "begin", "end", "dict", "array", "string", "get", "put", "getinterval", "putinterval", "aload", "astore", "forall",
|
||||
"[", "]", "<<", ">>", "{", "}", "matrix", "currentmatrix", "setmatrix", "transform", "itransform", "invertmatrix", "pathbbox",
|
||||
"clippath", "initclip", "currentpoint", "eexec", "cleartomark", "mark", "counttomark", "showpage", "gstate", "setgstate",
|
||||
"clipsave", "cliprestore", "languagelevel", "cvx", "findresource", "defineresource", "undefineresource", "resourcestatus",
|
||||
"resourceforall", "/Category", "/Generic", "/ProcSet", "(*)", "(G?n*\\\\*?)", "{pop}", "8 string",
|
||||
"(*) {pop} 8 string /Category resourceforall", "/Generic /Category findresource dup length dict copy /Category defineresource",
|
||||
"/ASCII85Decode", "/ASCIIHexDecode", "/FlateDecode", "/RunLengthDecode", "/LZWDecode", "/DCTDecode", "/SubFileDecode",
|
||||
"/DeviceRGB", "/DeviceCMYK", "/DeviceGray", "/Pattern", "/x", "x", "/Helvetica", "(abc)", "<ff00>", "<~87cURD]i,\"Ebo80~>",
|
||||
"true", "false", "null", "[/Separation (S) /DeviceCMYK {dup dup dup}]", "[/Indexed /DeviceRGB 1 <ff000000ff00>]",
|
||||
|
||||
@@ -12,7 +12,7 @@ use vectorcraft_geom::Affine;
|
||||
use super::graphics::{Space, process};
|
||||
use super::interp::{Interp, matrix_of};
|
||||
use super::lex::{find, hex_decode};
|
||||
use super::obj::{DictRef, Key, Obj, Op, PsError, Res, ps_err};
|
||||
use super::obj::{DictRef, Key, Obj, Op, PsError, Res, Shared, ps_err};
|
||||
use crate::ps;
|
||||
|
||||
/// Most bytes one filter or image reads.
|
||||
@@ -223,20 +223,15 @@ impl Interp<'_> {
|
||||
ReadHexString | ReadString => {
|
||||
let s = self.pop_str()?;
|
||||
let Obj::File(f) = self.pop()? else { return ps_err("typecheck", "") };
|
||||
let n = s.borrow().len();
|
||||
let n = s.len();
|
||||
let got = if op == ReadString { self.read(&f, n)? } else { self.read_hex(&f, n)? };
|
||||
let full = got.len() == n;
|
||||
self.alloc(got.len())?;
|
||||
if let Some(d) = s.borrow_mut().get_mut(..got.len()) {
|
||||
d.copy_from_slice(&got);
|
||||
}
|
||||
self.push(Obj::string(got))?;
|
||||
self.push(Obj::Bool(full))?;
|
||||
self.fill(s, &got)?;
|
||||
self.push(Obj::Bool(got.len() == n))?;
|
||||
}
|
||||
ReadLine => {
|
||||
let s = self.pop_str()?;
|
||||
let Obj::File(f) = self.pop()? else { return ps_err("typecheck", "") };
|
||||
let n = s.borrow().len();
|
||||
let n = s.len();
|
||||
let mut line = vec![];
|
||||
let mut ended = false;
|
||||
while line.len() < n {
|
||||
@@ -252,8 +247,7 @@ impl Interp<'_> {
|
||||
Some(b) => line.push(*b),
|
||||
}
|
||||
}
|
||||
self.alloc(line.len())?;
|
||||
self.push(Obj::string(line))?;
|
||||
self.fill(s, &line)?;
|
||||
self.push(Obj::Bool(ended))?;
|
||||
}
|
||||
FlushFile | CloseFile => {
|
||||
@@ -307,12 +301,20 @@ impl Interp<'_> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Read bytes `got` into string `s` and push the part of `s` they fill.
|
||||
fn fill(&mut self, s: Shared<u8>, got: &[u8]) -> Res {
|
||||
if !s.write(0, got) {
|
||||
return ps_err("rangecheck", "");
|
||||
}
|
||||
self.push_interval(Obj::Str(s), 0, got.len())
|
||||
}
|
||||
|
||||
/// `filter`: a file that decodes its source. Over the program's own data it decodes when
|
||||
/// first read (the data follows the operator that reads it); over other data, at once.
|
||||
fn filter(&mut self) -> Res {
|
||||
let name = self.pop()?.text().ok_or(PsError::Ps("typecheck", "filter".into()))?;
|
||||
let sub = if &*name == "SubFileDecode" {
|
||||
let eod = self.pop_str()?.borrow().clone();
|
||||
let eod = self.pop_str()?.to_vec();
|
||||
let count = self.pop_count()?;
|
||||
(count, eod)
|
||||
} else {
|
||||
@@ -687,8 +689,8 @@ struct Pixels {
|
||||
fn source(o: &Obj) -> Res<Source> {
|
||||
Ok(match o {
|
||||
Obj::File(f) => Source::File(f.clone()),
|
||||
Obj::Str(s) => Source::Str(s.borrow().clone()),
|
||||
p @ Obj::Array { .. } => Source::Proc(Obj::Array { items: p.items().cloned().unwrap_or_default(), exec: true }),
|
||||
Obj::Str(s) => Source::Str(s.to_vec()),
|
||||
Obj::Array { items, .. } => Source::Proc(Obj::Array { items: items.clone(), exec: true }),
|
||||
_ => return ps_err("typecheck", "DataSource"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -106,6 +106,8 @@ pub(crate) struct GState {
|
||||
pub cur: Option<Point>,
|
||||
pub start: Option<Point>,
|
||||
pub clips: Vec<Arc<Clip>>,
|
||||
/// The clips `clipsave` saved, for `cliprestore`.
|
||||
pub saved_clips: Vec<Rc<[Arc<Clip>]>>,
|
||||
pub overprint: bool,
|
||||
pub font: Option<DictRef>,
|
||||
}
|
||||
@@ -128,6 +130,7 @@ impl Default for GState {
|
||||
cur: None,
|
||||
start: None,
|
||||
clips: vec![],
|
||||
saved_clips: vec![],
|
||||
overprint: false,
|
||||
font: None,
|
||||
}
|
||||
@@ -489,7 +492,7 @@ impl Interp<'_> {
|
||||
let (family, items) = match o {
|
||||
Obj::Name(n) | Obj::Exec(n) => (n.clone(), vec![]),
|
||||
Obj::Array { items, .. } => {
|
||||
let v = items.borrow().clone();
|
||||
let v = items.to_vec();
|
||||
let n = v.first().and_then(Obj::text).ok_or(PsError::Ps("typecheck", "setcolorspace".into()))?;
|
||||
(n, v)
|
||||
}
|
||||
@@ -702,7 +705,7 @@ impl Interp<'_> {
|
||||
let d = match f {
|
||||
Obj::Dict(d) => d.clone(),
|
||||
Obj::Array { items, .. } => {
|
||||
let fs = items.borrow().clone();
|
||||
let fs = items.to_vec();
|
||||
let mut out = vec![];
|
||||
for g in &fs {
|
||||
out.extend(self.eval(g, t, depth + 1)?.first().copied());
|
||||
@@ -730,7 +733,7 @@ impl Interp<'_> {
|
||||
Ok(c0.iter().zip(&c1).map(|(a, b)| a + x * (b - a)).collect())
|
||||
}
|
||||
Some(3.0) => {
|
||||
let fs = get("Functions").and_then(|o| o.items().map(|i| i.borrow().clone())).unwrap_or_default();
|
||||
let fs = get("Functions").and_then(|o| o.items().map(|i| i.to_vec())).unwrap_or_default();
|
||||
let bounds = nums(get("Bounds"));
|
||||
let encode = nums(get("Encode"));
|
||||
let (lo, hi) = (domain.first().copied().unwrap_or(0.0), domain.get(1).copied().unwrap_or(1.0));
|
||||
@@ -1095,6 +1098,19 @@ impl Interp<'_> {
|
||||
self.g.start = self.g.cur;
|
||||
}
|
||||
InitClip => self.g.clips.clear(),
|
||||
ClipSave => {
|
||||
if self.g.saved_clips.len() >= MAX_GSAVE {
|
||||
return Err(PsError::Limit("too many saved clips"));
|
||||
}
|
||||
self.alloc(std::mem::size_of_val(self.g.clips.as_slice()))?;
|
||||
let clips = self.g.clips.as_slice().into();
|
||||
self.g.saved_clips.push(clips);
|
||||
}
|
||||
ClipRestore => {
|
||||
if let Some(clips) = self.g.saved_clips.pop() {
|
||||
self.g.clips = clips.to_vec();
|
||||
}
|
||||
}
|
||||
Clip | EoClip => {
|
||||
let bp = self.g.path.clone();
|
||||
self.clip_with(bp, if op == EoClip { FillRule::EvenOdd } else { FillRule::NonZero });
|
||||
@@ -1118,7 +1134,7 @@ impl Interp<'_> {
|
||||
}
|
||||
RectStroke => {
|
||||
let m = match self.stack.last() {
|
||||
Some(Obj::Array { items, .. }) if items.borrow().len() == 6 => Some(self.pop_matrix()?),
|
||||
Some(Obj::Array { items, .. }) if items.len() == 6 => Some(self.pop_matrix()?),
|
||||
_ => None,
|
||||
};
|
||||
let mut bp = self.rects()?;
|
||||
@@ -1157,7 +1173,7 @@ fn linear_points(f: &Obj) -> Option<Vec<f64>> {
|
||||
match num("FunctionType")? {
|
||||
2.0 if num("N").unwrap_or(1.0) == 1.0 => Some(vec![]),
|
||||
3.0 => {
|
||||
let fs = d.get(&Key::name("Functions"))?.items()?.borrow().clone();
|
||||
let fs = d.get(&Key::name("Functions"))?.items()?.to_vec();
|
||||
if !fs.iter().all(|g| linear_points(g).is_some_and(|p| p.is_empty())) {
|
||||
return None;
|
||||
}
|
||||
|
||||
+139
-96
@@ -11,7 +11,7 @@ use vectorcraft_geom::Affine;
|
||||
|
||||
use super::graphics::{GState, Out};
|
||||
use super::lex::Lexer;
|
||||
use super::obj::{Dict, DictRef, Key, Obj, Op, PsError, Res, ps_err};
|
||||
use super::obj::{Dict, DictRef, Key, Obj, Op, PsError, Res, Shared, ps_err};
|
||||
|
||||
/// Most operations one file may run.
|
||||
const MAX_OPS: u64 = 50_000_000;
|
||||
@@ -61,6 +61,12 @@ fn encoding() -> Obj {
|
||||
Obj::array(vec![Obj::name(".notdef"); 256])
|
||||
}
|
||||
|
||||
/// The resource categories there are to begin with (instances of `Category`).
|
||||
const CATEGORIES: &str = "Category Generic Font CIDFont CMap FontSet Encoding Form Pattern ProcSet ColorSpace Halftone \r
|
||||
ColorRendering IdiomSet InkParams TrapParams OutputDevice ControlLanguage Localization PDL HWOptions \r
|
||||
Filter ColorSpaceFamily Emulator IODevice ColorRenderingType FMapType FontType FormType HalftoneType \r
|
||||
ImageType PatternType FunctionType ShadingType";
|
||||
|
||||
impl<'a> Interp<'a> {
|
||||
pub fn new(src: &'a [u8], g: GState, out: Out) -> Self {
|
||||
let system = new_dict();
|
||||
@@ -72,7 +78,15 @@ impl<'a> Interp<'a> {
|
||||
for name in ["StandardEncoding", "ISOLatin1Encoding"] {
|
||||
d.insert(Key::name(name), encoding());
|
||||
}
|
||||
// An integer, as interpreters have it (`systemdict /languagelevel get 2 ge`): run, the
|
||||
// name pushes it all the same.
|
||||
d.insert(Key::name("languagelevel"), Obj::Int(3));
|
||||
}
|
||||
// Each category's implementation dictionary (`/Generic /Category findresource`).
|
||||
let categories: Dict =
|
||||
CATEGORIES.split_whitespace().map(|c| (Key::name(c), Obj::dict(Dict::from([(Key::name("Category"), Obj::name(c))])))).collect();
|
||||
let resources = new_dict();
|
||||
resources.borrow_mut().insert(Key::name("Category"), Obj::dict(categories));
|
||||
let fonts = new_dict();
|
||||
system.borrow_mut().insert(Key::name("FontDirectory"), Obj::Dict(fonts.clone()));
|
||||
system.borrow_mut().insert(Key::name("GlobalFontDirectory"), Obj::Dict(fonts.clone()));
|
||||
@@ -87,7 +101,7 @@ impl<'a> Interp<'a> {
|
||||
status: new_dict(),
|
||||
error: new_dict(),
|
||||
fonts,
|
||||
resources: new_dict(),
|
||||
resources,
|
||||
depth: 0,
|
||||
ops: 0,
|
||||
allocated: 0,
|
||||
@@ -175,7 +189,7 @@ impl<'a> Interp<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pop_str(&mut self) -> Res<Rc<RefCell<Vec<u8>>>> {
|
||||
pub fn pop_str(&mut self) -> Res<Shared<u8>> {
|
||||
match self.pop()? {
|
||||
Obj::Str(s) => Ok(s),
|
||||
_ => ps_err("typecheck", "a string"),
|
||||
@@ -183,7 +197,7 @@ impl<'a> Interp<'a> {
|
||||
}
|
||||
|
||||
/// An array or procedure.
|
||||
pub fn pop_array(&mut self) -> Res<Rc<RefCell<Vec<Obj>>>> {
|
||||
pub fn pop_array(&mut self) -> Res<Shared<Obj>> {
|
||||
match self.pop()? {
|
||||
Obj::Array { items, .. } => Ok(items),
|
||||
_ => ps_err("typecheck", "an array"),
|
||||
@@ -197,15 +211,9 @@ impl<'a> Interp<'a> {
|
||||
}
|
||||
|
||||
/// Store `m` into the matrix operand `items` and push it back.
|
||||
pub fn put_matrix(&mut self, items: Rc<RefCell<Vec<Obj>>>, m: Affine) -> Res {
|
||||
{
|
||||
let mut v = items.borrow_mut();
|
||||
if v.len() != 6 {
|
||||
return ps_err("rangecheck", "a matrix");
|
||||
}
|
||||
for (slot, c) in v.iter_mut().zip(m.as_coeffs()) {
|
||||
*slot = Obj::Real(c);
|
||||
}
|
||||
pub fn put_matrix(&mut self, items: Shared<Obj>, m: Affine) -> Res {
|
||||
if items.len() != 6 || !items.write(0, &m.as_coeffs().map(Obj::Real)) {
|
||||
return ps_err("rangecheck", "a matrix");
|
||||
}
|
||||
self.push(Obj::Array { items, exec: false })
|
||||
}
|
||||
@@ -276,24 +284,24 @@ impl<'a> Interp<'a> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_proc(&mut self, items: &Rc<RefCell<Vec<Obj>>>) -> Res {
|
||||
fn run_proc(&mut self, items: &Shared<Obj>) -> Res {
|
||||
self.enter()?;
|
||||
let r = self.run_items(items);
|
||||
self.depth -= 1;
|
||||
r
|
||||
}
|
||||
|
||||
fn run_items(&mut self, items: &Rc<RefCell<Vec<Obj>>>) -> Res {
|
||||
fn run_items(&mut self, items: &Shared<Obj>) -> Res {
|
||||
let mut i = 0;
|
||||
loop {
|
||||
let Some(o) = items.borrow().get(i).cloned() else { return Ok(()) };
|
||||
let Some(o) = items.get(i) else { return Ok(()) };
|
||||
i += 1;
|
||||
self.exec_token(o)?;
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `proc` as a loop body: `Ok(false)` when it ran `exit`.
|
||||
fn body(&mut self, proc: &Obj) -> Res<bool> {
|
||||
pub fn body(&mut self, proc: &Obj) -> Res<bool> {
|
||||
self.tick()?;
|
||||
match self.call(proc.clone()) {
|
||||
Ok(()) => Ok(true),
|
||||
@@ -303,7 +311,7 @@ impl<'a> Interp<'a> {
|
||||
}
|
||||
|
||||
/// Run a procedure operand, executable or not (`exec`, `if`, loops).
|
||||
fn pop_proc(&mut self) -> Res<Obj> {
|
||||
pub fn pop_proc(&mut self) -> Res<Obj> {
|
||||
match self.pop()? {
|
||||
Obj::Array { items, .. } => Ok(Obj::Array { items, exec: true }),
|
||||
o @ (Obj::Op(_) | Obj::Exec(_)) => Ok(o),
|
||||
@@ -480,7 +488,7 @@ impl<'a> Interp<'a> {
|
||||
Gt | Ge | Lt | Le => {
|
||||
let (b, a) = (self.pop()?, self.pop()?);
|
||||
let ord = match (&a, &b) {
|
||||
(Obj::Str(x), Obj::Str(y)) => x.borrow().as_slice().partial_cmp(y.borrow().as_slice()),
|
||||
(Obj::Str(x), Obj::Str(y)) => x.borrow().partial_cmp(&*y.borrow()),
|
||||
_ => a.as_num().zip(b.as_num()).and_then(|(x, y)| x.partial_cmp(&y)),
|
||||
}
|
||||
.ok_or(PsError::Ps("typecheck", String::new()))?;
|
||||
@@ -646,14 +654,10 @@ impl<'a> Interp<'a> {
|
||||
} else {
|
||||
show_text(&self.pop()?)
|
||||
};
|
||||
let n = text.len();
|
||||
if n > s.borrow().len() {
|
||||
if !s.write(0, text.as_bytes()) {
|
||||
return ps_err("rangecheck", "");
|
||||
}
|
||||
if let Some(d) = s.borrow_mut().get_mut(..n) {
|
||||
d.copy_from_slice(text.as_bytes());
|
||||
}
|
||||
self.push_interval(Obj::Str(s), 0, n)?;
|
||||
self.push_interval(Obj::Str(s), 0, text.len())?;
|
||||
}
|
||||
ReadOnly | ExecuteOnly | NoAccess | SetPacking | SetGlobal | SetObjectFormat => {
|
||||
if matches!(op, SetPacking | SetGlobal | SetObjectFormat) {
|
||||
@@ -683,8 +687,8 @@ impl<'a> Interp<'a> {
|
||||
}
|
||||
Length => {
|
||||
let n = match self.pop()? {
|
||||
Obj::Array { items, .. } => items.borrow().len(),
|
||||
Obj::Str(s) => s.borrow().len(),
|
||||
Obj::Array { items, .. } => items.len(),
|
||||
Obj::Str(s) => s.len(),
|
||||
Obj::Dict(d) => d.borrow().len(),
|
||||
Obj::Name(n) | Obj::Exec(n) => n.len(),
|
||||
_ => return ps_err("typecheck", ""),
|
||||
@@ -699,14 +703,8 @@ impl<'a> Interp<'a> {
|
||||
Get => {
|
||||
let k = self.pop()?;
|
||||
let o = match self.pop()? {
|
||||
Obj::Array { items, .. } => {
|
||||
let i = index(&k)?;
|
||||
items.borrow().get(i).cloned().ok_or(PsError::Ps("rangecheck", String::new()))?
|
||||
}
|
||||
Obj::Str(s) => {
|
||||
let i = index(&k)?;
|
||||
Obj::Int(i64::from(*s.borrow().get(i).ok_or(PsError::Ps("rangecheck", String::new()))?))
|
||||
}
|
||||
Obj::Array { items, .. } => items.get(index(&k)?).ok_or(PsError::Ps("rangecheck", String::new()))?,
|
||||
Obj::Str(s) => Obj::Int(i64::from(s.get(index(&k)?).ok_or(PsError::Ps("rangecheck", String::new()))?)),
|
||||
Obj::Dict(d) => {
|
||||
let key = k.key().ok_or(PsError::Ps("typecheck", String::new()))?;
|
||||
let v = d.borrow().get(&key).cloned();
|
||||
@@ -718,21 +716,21 @@ impl<'a> Interp<'a> {
|
||||
}
|
||||
Put => {
|
||||
let (v, k, c) = (self.pop()?, self.pop()?, self.pop()?);
|
||||
match c {
|
||||
Obj::Array { items, .. } => {
|
||||
let i = index(&k)?;
|
||||
*items.borrow_mut().get_mut(i).ok_or(PsError::Ps("rangecheck", String::new()))? = v;
|
||||
}
|
||||
let written = match c {
|
||||
Obj::Array { items, .. } => items.write(index(&k)?, std::slice::from_ref(&v)),
|
||||
Obj::Str(s) => {
|
||||
let i = index(&k)?;
|
||||
let b = v.as_num().ok_or(PsError::Ps("typecheck", String::new()))?;
|
||||
*s.borrow_mut().get_mut(i).ok_or(PsError::Ps("rangecheck", String::new()))? = b as i64 as u8;
|
||||
s.write(index(&k)?, &[b as i64 as u8])
|
||||
}
|
||||
Obj::Dict(d) => {
|
||||
let key = k.key().ok_or(PsError::Ps("typecheck", String::new()))?;
|
||||
insert(&d, key, v)?;
|
||||
true
|
||||
}
|
||||
_ => return ps_err("typecheck", ""),
|
||||
};
|
||||
if !written {
|
||||
return ps_err("rangecheck", "");
|
||||
}
|
||||
}
|
||||
GetInterval => {
|
||||
@@ -744,41 +742,35 @@ impl<'a> Interp<'a> {
|
||||
PutInterval => {
|
||||
let src = self.pop()?;
|
||||
let at = self.pop_count()?;
|
||||
match (self.pop()?, src) {
|
||||
(Obj::Array { items, .. }, Obj::Array { items: from, .. }) => {
|
||||
let from = from.borrow().clone();
|
||||
let mut v = items.borrow_mut();
|
||||
let d = v.get_mut(at..at + from.len()).ok_or(PsError::Ps("rangecheck", String::new()))?;
|
||||
d.clone_from_slice(&from);
|
||||
}
|
||||
(Obj::Str(s), Obj::Str(from)) => {
|
||||
let from = from.borrow().clone();
|
||||
let mut v = s.borrow_mut();
|
||||
let d = v.get_mut(at..at + from.len()).ok_or(PsError::Ps("rangecheck", String::new()))?;
|
||||
d.copy_from_slice(&from);
|
||||
}
|
||||
let written = match (self.pop()?, src) {
|
||||
(Obj::Array { items, .. }, Obj::Array { items: from, .. }) => items.write(at, &from.to_vec()),
|
||||
(Obj::Str(s), Obj::Str(from)) => s.write(at, &from.to_vec()),
|
||||
_ => return ps_err("typecheck", ""),
|
||||
};
|
||||
if !written {
|
||||
return ps_err("rangecheck", "");
|
||||
}
|
||||
}
|
||||
Aload => {
|
||||
let items = self.pop_array()?;
|
||||
let all = items.borrow().clone();
|
||||
for o in all {
|
||||
for o in items.to_vec() {
|
||||
self.push(o)?;
|
||||
}
|
||||
self.push(Obj::Array { items, exec: false })?;
|
||||
}
|
||||
Astore => {
|
||||
let items = self.pop_array()?;
|
||||
let n = items.borrow().len();
|
||||
let at = self.stack.len().checked_sub(n).ok_or(PsError::Ps("stackunderflow", String::new()))?;
|
||||
*items.borrow_mut() = self.stack.split_off(at);
|
||||
let at = self.stack.len().checked_sub(items.len()).ok_or(PsError::Ps("stackunderflow", String::new()))?;
|
||||
if !items.write(0, self.stack.get(at..).unwrap_or_default()) {
|
||||
return ps_err("rangecheck", "");
|
||||
}
|
||||
self.stack.truncate(at);
|
||||
self.push(Obj::Array { items, exec: false })?;
|
||||
}
|
||||
Search | AnchorSearch => {
|
||||
let seek = self.pop_str()?.borrow().clone();
|
||||
let seek = self.pop_str()?.to_vec();
|
||||
let s = self.pop_str()?;
|
||||
let text = s.borrow().clone();
|
||||
let text = s.to_vec();
|
||||
let at = if op == Search { super::lex::find(&text, &seek) } else { text.starts_with(&seek).then_some(0) };
|
||||
match at {
|
||||
Some(i) => {
|
||||
@@ -888,7 +880,6 @@ impl<'a> Interp<'a> {
|
||||
self.push(Obj::Int(v))?;
|
||||
}
|
||||
}
|
||||
LanguageLevel => self.push(Obj::Int(3))?,
|
||||
Version => self.push(Obj::string(b"3010".to_vec()))?,
|
||||
Product => self.push(Obj::string(b"VectorCraft".to_vec()))?,
|
||||
RealTime | UserTime => self.push(Obj::Int(0))?,
|
||||
@@ -922,19 +913,18 @@ impl<'a> Interp<'a> {
|
||||
}
|
||||
}
|
||||
Obj::Array { items, exec } => {
|
||||
let from = self.pop_array()?.borrow().clone();
|
||||
let n = from.len();
|
||||
{
|
||||
let mut v = items.borrow_mut();
|
||||
v.get_mut(..n).ok_or(PsError::Ps("rangecheck", String::new()))?.clone_from_slice(&from);
|
||||
let from = self.pop_array()?.to_vec();
|
||||
if !items.write(0, &from) {
|
||||
return ps_err("rangecheck", "copy");
|
||||
}
|
||||
self.push_interval(Obj::Array { items, exec }, 0, n)?;
|
||||
self.push_interval(Obj::Array { items, exec }, 0, from.len())?;
|
||||
}
|
||||
Obj::Str(s) => {
|
||||
let from = self.pop_str()?.borrow().clone();
|
||||
let n = from.len();
|
||||
s.borrow_mut().get_mut(..n).ok_or(PsError::Ps("rangecheck", String::new()))?.copy_from_slice(&from);
|
||||
self.push_interval(Obj::Str(s), 0, n)?;
|
||||
let from = self.pop_str()?.to_vec();
|
||||
if !s.write(0, &from) {
|
||||
return ps_err("rangecheck", "copy");
|
||||
}
|
||||
self.push_interval(Obj::Str(s), 0, from.len())?;
|
||||
}
|
||||
Obj::Dict(d) => {
|
||||
let from = self.pop_dict()?.borrow().clone();
|
||||
@@ -946,16 +936,12 @@ impl<'a> Interp<'a> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `n` items of an array or string from `at` (a copy: shared storage isn't modelled).
|
||||
fn push_interval(&mut self, o: Obj, at: usize, n: usize) -> Res {
|
||||
let end = at.checked_add(n).ok_or(PsError::Ps("rangecheck", String::new()))?;
|
||||
self.alloc(n * if matches!(o, Obj::Str(_)) { 1 } else { std::mem::size_of::<Obj>() })?;
|
||||
/// `n` items of an array or string from `at`, sharing its storage.
|
||||
pub fn push_interval(&mut self, o: Obj, at: usize, n: usize) -> Res {
|
||||
let range = || PsError::Ps("rangecheck", String::new());
|
||||
let r = match o {
|
||||
Obj::Array { items, exec } => {
|
||||
let v = items.borrow().get(at..end).ok_or(PsError::Ps("rangecheck", String::new()))?.to_vec();
|
||||
Obj::Array { items: Rc::new(RefCell::new(v)), exec }
|
||||
}
|
||||
Obj::Str(s) => Obj::string(s.borrow().get(at..end).ok_or(PsError::Ps("rangecheck", String::new()))?.to_vec()),
|
||||
Obj::Array { items, exec } => Obj::Array { items: items.sub(at, n).ok_or_else(range)?, exec },
|
||||
Obj::Str(s) => Obj::Str(s.sub(at, n).ok_or_else(range)?),
|
||||
_ => return ps_err("typecheck", "getinterval"),
|
||||
};
|
||||
self.push(r)
|
||||
@@ -966,7 +952,7 @@ impl<'a> Interp<'a> {
|
||||
match self.pop()? {
|
||||
Obj::Array { items, .. } => {
|
||||
let mut i = 0;
|
||||
while let Some(o) = items.borrow().get(i).cloned() {
|
||||
while let Some(o) = items.get(i) {
|
||||
i += 1;
|
||||
self.push(o)?;
|
||||
if !self.body(&p)? {
|
||||
@@ -975,8 +961,7 @@ impl<'a> Interp<'a> {
|
||||
}
|
||||
}
|
||||
Obj::Str(s) => {
|
||||
let bytes = s.borrow().clone();
|
||||
for b in bytes {
|
||||
for b in s.to_vec() {
|
||||
self.push(Obj::Int(i64::from(b)))?;
|
||||
if !self.body(&p)? {
|
||||
break;
|
||||
@@ -1000,22 +985,19 @@ impl<'a> Interp<'a> {
|
||||
|
||||
/// Replace the names in a procedure that stand for operators by the operators, down through
|
||||
/// nested procedures.
|
||||
fn bind(&mut self, items: &Rc<RefCell<Vec<Obj>>>, depth: u32) {
|
||||
fn bind(&mut self, items: &Shared<Obj>, depth: u32) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
let n = items.borrow().len();
|
||||
for i in 0..n {
|
||||
let o = items.borrow().get(i).cloned();
|
||||
match o {
|
||||
for i in 0..items.len() {
|
||||
match items.get(i) {
|
||||
Some(Obj::Exec(name)) => {
|
||||
if let Some(Obj::Op(op)) = self.lookup(&name)
|
||||
&& let Some(slot) = items.borrow_mut().get_mut(i)
|
||||
{
|
||||
*slot = Obj::Op(op);
|
||||
if let Some(o @ Obj::Op(_)) = self.lookup(&name) {
|
||||
// `i` is in range and nothing is reading the procedure: the write can't fail.
|
||||
items.write(i, &[o]);
|
||||
}
|
||||
}
|
||||
Some(Obj::Array { items: inner, exec: true }) if !Rc::ptr_eq(&inner, items) => self.bind(&inner, depth + 1),
|
||||
Some(Obj::Array { items: inner, exec: true }) if !inner.shares(items) => self.bind(&inner, depth + 1),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -1032,6 +1014,67 @@ impl<'a> Interp<'a> {
|
||||
_ => ps_err("typecheck", ""),
|
||||
})
|
||||
}
|
||||
|
||||
/// The instances of `category`: `FontDirectory` for fonts.
|
||||
pub fn instances(&mut self, category: &Obj) -> Res<DictRef> {
|
||||
if category.text().as_deref() == Some("Font") { Ok(self.fonts.clone()) } else { self.category(category) }
|
||||
}
|
||||
|
||||
/// `template proc scratch category resourceforall`: `proc` run on each instance name that
|
||||
/// matches `template`, copied into `scratch` (in name order).
|
||||
pub fn resource_for_all(&mut self) -> Res {
|
||||
let category = self.pop()?;
|
||||
let scratch = self.pop_str()?;
|
||||
let p = self.pop_proc()?;
|
||||
let template = self.pop_str()?.to_vec();
|
||||
let mut names: Vec<Vec<u8>> = self.instances(&category)?.borrow().keys().map(|k| show_text(&k.obj()).into_bytes()).collect();
|
||||
names.sort();
|
||||
for name in names {
|
||||
if !self.matches(&template, &name)? {
|
||||
continue;
|
||||
}
|
||||
if !scratch.write(0, &name) {
|
||||
return ps_err("rangecheck", "resourceforall");
|
||||
}
|
||||
self.push_interval(Obj::Str(scratch.clone()), 0, name.len())?;
|
||||
if !self.body(&p)? {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Does `name` match a `resourceforall` template: `*` any run of bytes, `?` any one, `\` the
|
||||
/// next one as it is? Each step counts as an operation.
|
||||
fn matches(&mut self, template: &[u8], name: &[u8]) -> Res<bool> {
|
||||
let (mut t, mut n) = (0, 0);
|
||||
// Where the last `*` was followed (template, name): a mismatch after it retries from there.
|
||||
let mut star: Option<(usize, usize)> = None;
|
||||
loop {
|
||||
self.tick()?;
|
||||
if n == name.len() {
|
||||
return Ok(template.get(t..).unwrap_or_default().iter().all(|b| *b == b'*'));
|
||||
}
|
||||
let (want, width) = match template.get(t) {
|
||||
Some(b'*') => {
|
||||
t += 1;
|
||||
star = Some((t, n));
|
||||
continue;
|
||||
}
|
||||
Some(b'?') => (name.get(n).copied(), 1),
|
||||
Some(b'\\') => (template.get(t + 1).copied(), 2),
|
||||
c => (c.copied(), 1),
|
||||
};
|
||||
if want.is_some() && want == name.get(n).copied() {
|
||||
(t, n) = (t + width, n + 1);
|
||||
continue;
|
||||
}
|
||||
// The last `*` takes one more byte, else there is no match.
|
||||
let Some((st, sn)) = star else { return Ok(false) };
|
||||
star = Some((st, sn + 1));
|
||||
(t, n) = (st, sn + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Put `v` under `k` in `d`, within [`MAX_ALLOC`] entries.
|
||||
@@ -1057,7 +1100,7 @@ fn equal(a: &Obj, b: &Obj) -> bool {
|
||||
match (a, b) {
|
||||
(Obj::Null, Obj::Null) | (Obj::Mark, Obj::Mark) => true,
|
||||
(Obj::Bool(x), Obj::Bool(y)) => x == y,
|
||||
(Obj::Array { items: x, .. }, Obj::Array { items: y, .. }) => Rc::ptr_eq(x, y),
|
||||
(Obj::Array { items: x, .. }, Obj::Array { items: y, .. }) => x.same(y),
|
||||
(Obj::Dict(x), Obj::Dict(y)) => Rc::ptr_eq(x, y),
|
||||
(Obj::Op(x), Obj::Op(y)) => x == y,
|
||||
_ => match (a.as_num(), b.as_num()) {
|
||||
|
||||
@@ -192,7 +192,10 @@ fn finish(mut out: Out) -> Imported {
|
||||
|
||||
/// A document of `frame`'s size showing TIFF preview `tiff` stretched over it.
|
||||
fn preview(tiff: &[u8], frame: Rect) -> Option<Document> {
|
||||
let img = image::load_from_memory_with_format(tiff, image::ImageFormat::Tiff).ok()?.to_rgba8();
|
||||
let img = match image::load_from_memory_with_format(tiff, image::ImageFormat::Tiff) {
|
||||
Ok(img) => img.to_rgba8(),
|
||||
Err(_) => crate::tiff::palette_rgba(tiff)?,
|
||||
};
|
||||
let (w, h) = img.dimensions();
|
||||
if w == 0 || h == 0 {
|
||||
return None;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The PostScript objects the interpreter works with, its operators and its errors.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::cell::{Ref, RefCell};
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
|
||||
@@ -31,10 +31,10 @@ pub(crate) enum Obj {
|
||||
Name(Rc<str>),
|
||||
/// An executable name: looked up and run.
|
||||
Exec(Rc<str>),
|
||||
Str(Rc<RefCell<Vec<u8>>>),
|
||||
Str(Shared<u8>),
|
||||
/// An array; executable ones are procedures.
|
||||
Array {
|
||||
items: Rc<RefCell<Vec<Obj>>>,
|
||||
items: Shared<Obj>,
|
||||
exec: bool,
|
||||
},
|
||||
Dict(DictRef),
|
||||
@@ -53,15 +53,15 @@ impl Obj {
|
||||
}
|
||||
|
||||
pub fn string(bytes: Vec<u8>) -> Self {
|
||||
Self::Str(Rc::new(RefCell::new(bytes)))
|
||||
Self::Str(Shared::new(bytes))
|
||||
}
|
||||
|
||||
pub fn array(items: Vec<Obj>) -> Self {
|
||||
Self::Array { items: Rc::new(RefCell::new(items)), exec: false }
|
||||
Self::Array { items: Shared::new(items), exec: false }
|
||||
}
|
||||
|
||||
pub fn proc(items: Vec<Obj>) -> Self {
|
||||
Self::Array { items: Rc::new(RefCell::new(items)), exec: true }
|
||||
Self::Array { items: Shared::new(items), exec: true }
|
||||
}
|
||||
|
||||
pub fn dict(d: Dict) -> Self {
|
||||
@@ -98,7 +98,7 @@ impl Obj {
|
||||
}
|
||||
|
||||
/// The items of an array or procedure.
|
||||
pub fn items(&self) -> Option<&Rc<RefCell<Vec<Obj>>>> {
|
||||
pub fn items(&self) -> Option<&Shared<Obj>> {
|
||||
match self {
|
||||
Self::Array { items, .. } => Some(items),
|
||||
_ => None,
|
||||
@@ -125,6 +125,71 @@ impl Obj {
|
||||
}
|
||||
}
|
||||
|
||||
/// The values of a string or an array: a run of storage that other strings or arrays may share
|
||||
/// (`getinterval` makes a new object over part of the same values, as PostScript does).
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Shared<T> {
|
||||
buf: Rc<RefCell<Vec<T>>>,
|
||||
at: usize,
|
||||
len: usize,
|
||||
}
|
||||
|
||||
impl<T: Clone> Shared<T> {
|
||||
pub fn new(v: Vec<T>) -> Self {
|
||||
Self { len: v.len(), buf: Rc::new(RefCell::new(v)), at: 0 }
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.len
|
||||
}
|
||||
|
||||
/// The values. Only [`Self::write`] borrows the storage mutably, for the copy alone, so this
|
||||
/// never meets it.
|
||||
pub fn borrow(&self) -> Ref<'_, [T]> {
|
||||
let (at, end) = (self.at, self.at.saturating_add(self.len));
|
||||
Ref::map(self.buf.borrow(), |v| v.get(at..end).unwrap_or(&[]))
|
||||
}
|
||||
|
||||
pub fn get(&self, i: usize) -> Option<T> {
|
||||
self.borrow().get(i).cloned()
|
||||
}
|
||||
|
||||
pub fn to_vec(&self) -> Vec<T> {
|
||||
self.borrow().to_vec()
|
||||
}
|
||||
|
||||
/// `n` values from `at`, sharing the storage (`getinterval`); `None` past the end.
|
||||
pub fn sub(&self, at: usize, n: usize) -> Option<Self> {
|
||||
(at.checked_add(n)? <= self.len).then(|| Self { buf: self.buf.clone(), at: self.at + at, len: n })
|
||||
}
|
||||
|
||||
/// Write `vals` from `at`: false when they don't fit (or `vals` is borrowed from the storage).
|
||||
pub fn write(&self, at: usize, vals: &[T]) -> bool {
|
||||
if at.saturating_add(vals.len()) > self.len {
|
||||
return false;
|
||||
}
|
||||
let start = self.at + at;
|
||||
let Ok(mut v) = self.buf.try_borrow_mut() else { return false };
|
||||
match v.get_mut(start..start + vals.len()) {
|
||||
Some(d) => {
|
||||
d.clone_from_slice(vals);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The same object (`eq` on arrays): the same values of the same storage.
|
||||
pub fn same(&self, o: &Self) -> bool {
|
||||
Rc::ptr_eq(&self.buf, &o.buf) && self.at == o.at && self.len == o.len
|
||||
}
|
||||
|
||||
/// Do both see the same storage?
|
||||
pub fn shares(&self, o: &Self) -> bool {
|
||||
Rc::ptr_eq(&self.buf, &o.buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl Key {
|
||||
pub fn name(s: &str) -> Self {
|
||||
Self::Name(Rc::from(s))
|
||||
@@ -209,7 +274,7 @@ ops! {
|
||||
ErrorDict = "errordict", DollarError = "$error", Bind = "bind",
|
||||
// Virtual memory and the environment.
|
||||
Save = "save", Restore = "restore", SetGlobal = "setglobal", CurrentGlobal = "currentglobal", VmStatus = "vmstatus",
|
||||
LanguageLevel = "languagelevel", Version = "version", Product = "product", RealTime = "realtime", UserTime = "usertime",
|
||||
Version = "version", Product = "product", RealTime = "realtime", UserTime = "usertime",
|
||||
Print = "print", EqPrint = "=", EqEqPrint = "==", Pstack = "pstack", Stack = "stack", Flush = "flush",
|
||||
// Graphics state.
|
||||
Gsave = "gsave", Grestore = "grestore", GrestoreAll = "grestoreall", InitGraphics = "initgraphics",
|
||||
@@ -242,8 +307,8 @@ ops! {
|
||||
RLineTo = "rlineto", CurveTo = "curveto", RCurveTo = "rcurveto", Arc = "arc", Arcn = "arcn", Arct = "arct",
|
||||
Arcto = "arcto", ClosePath = "closepath", FlattenPath = "flattenpath", ReversePath = "reversepath",
|
||||
PathBBox = "pathbbox", ClipPath = "clippath", InitClip = "initclip", Clip = "clip", EoClip = "eoclip",
|
||||
RectClip = "rectclip", Fill = "fill", EoFill = "eofill", Stroke = "stroke", RectFill = "rectfill",
|
||||
RectStroke = "rectstroke", ShFill = "shfill",
|
||||
RectClip = "rectclip", ClipSave = "clipsave", ClipRestore = "cliprestore", Fill = "fill", EoFill = "eofill",
|
||||
Stroke = "stroke", RectFill = "rectfill", RectStroke = "rectstroke", ShFill = "shfill",
|
||||
// Images.
|
||||
Image = "image", ImageMask = "imagemask", ColorImage = "colorimage",
|
||||
// Files.
|
||||
@@ -252,7 +317,8 @@ ops! {
|
||||
// Fonts and type.
|
||||
FindFont = "findfont", ScaleFont = "scalefont", MakeFont = "makefont", SetFont = "setfont", SelectFont = "selectfont",
|
||||
CurrentFont = "currentfont", DefineFont = "definefont", UndefineFont = "undefinefont", FindResource = "findresource",
|
||||
DefineResource = "defineresource", ResourceStatus = "resourcestatus", Show = "show", AShow = "ashow",
|
||||
DefineResource = "defineresource", UndefineResource = "undefineresource", ResourceStatus = "resourcestatus",
|
||||
ResourceForAll = "resourceforall", Show = "show", AShow = "ashow",
|
||||
WidthShow = "widthshow", AWidthShow = "awidthshow", XShow = "xshow", YShow = "yshow", XYShow = "xyshow",
|
||||
KShow = "kshow", GlyphShow = "glyphshow", StringWidth = "stringwidth", CharPath = "charpath",
|
||||
SetCacheDevice = "setcachedevice", SetCharWidth = "setcharwidth",
|
||||
|
||||
@@ -452,3 +452,234 @@ fn device_settings_are_accepted_and_change_nothing() {
|
||||
assert_eq!(objects(&r.document).len(), 1, "{:?}", r.warnings);
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// Check that `body` draws one square, blue: it sets blue when what it checks holds, else red.
|
||||
fn blue(body: &str) {
|
||||
let r = read(&format!("{body} 10 10 80 80 rectfill"));
|
||||
let o = objects(&r.document);
|
||||
assert_eq!(o.len(), 1, "{body}: {:?}", r.warnings);
|
||||
assert_eq!(fill(&o[0]), Some(&Paint::solid(Color::rgb(0.0, 0.0, 1.0))), "{body}");
|
||||
assert!(r.warnings.is_empty(), "{body}: {:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// Check that PostScript `cond` leaves `true`.
|
||||
fn check(cond: &str) {
|
||||
blue(&format!("{cond} {{ 0 0 1 setrgbcolor }} {{ 1 0 0 setrgbcolor }} ifelse"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_generic_category_defines_new_categories() {
|
||||
// #234: the reference app's prolog.
|
||||
blue("/Generic /Category findresource pop 0 0 1 setrgbcolor");
|
||||
blue(
|
||||
"[/CSA /Gradient /Procedure] { /Generic /Category findresource dup length dict copy /Category defineresource pop } forall 0 0 1 setrgbcolor",
|
||||
);
|
||||
let define = "[/CSA /Gradient] { /Generic /Category findresource dup length dict copy /Category defineresource pop } forall \
|
||||
/G1 << /Kind 7 >> /Gradient defineresource pop";
|
||||
check(&format!("{define} /G1 /Gradient findresource /Kind get 7 eq"));
|
||||
check(&format!("{define} /Gradient /Category resourcestatus {{ pop pop true }} {{ false }} ifelse"));
|
||||
check(&format!("{define} /G1 /Gradient resourcestatus {{ pop pop true }} {{ false }} ifelse"));
|
||||
check(&format!("{define} /G1 /Gradient undefineresource /G1 /Gradient resourcestatus not"));
|
||||
check(&format!("{define} {{ /G2 /Gradient findresource }} stopped"));
|
||||
// Unknown categories aren't instances of Category; a category is a dictionary.
|
||||
check("/Nonsense /Category resourcestatus not");
|
||||
check("{ /Nonsense /Category findresource } stopped");
|
||||
check("{ /X 5 /Category defineresource } stopped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipsave_and_cliprestore_bring_the_clip_back() {
|
||||
// #234.
|
||||
blue("clipsave 0 0 50 50 rectclip cliprestore 0 0 1 setrgbcolor");
|
||||
// The fill after cliprestore covers the whole square, unclipped.
|
||||
let r = read("clipsave 0 0 50 50 rectclip 1 0 0 setrgbcolor 0 0 10 10 rectfill cliprestore 0 0 1 setrgbcolor 10 10 80 80 rectfill");
|
||||
let o = objects(&r.document);
|
||||
assert_eq!(o.len(), 2, "{:?}", r.warnings);
|
||||
assert!(matches!(o[0].kind, NodeKind::Group { clip: true, .. }), "{:?}", o[0].kind);
|
||||
assert!(matches!(o[1].kind, NodeKind::Path { clipping: false, .. }), "{:?}", o[1].kind);
|
||||
assert!(near(bounds(&o[1]), Rect::new(10.0, 10.0, 90.0, 90.0)), "{:?}", bounds(&o[1]));
|
||||
// Nested, leaving the rest of the graphics state alone.
|
||||
let r = read(
|
||||
"0 0 60 60 rectclip clipsave 0 0 20 20 rectclip clipsave 0 0 5 5 rectclip cliprestore 0 1 0 setrgbcolor cliprestore \
|
||||
10 10 80 80 rectfill",
|
||||
);
|
||||
let NodeKind::Group { children, clip: true } = &objects(&r.document)[0].kind else { panic!("{:?}", r.warnings) };
|
||||
assert!(near(bounds(&children[0]), Rect::new(0.0, 40.0, 60.0, 100.0)), "{:?}", bounds(&children[0]));
|
||||
assert_eq!(fill(&children[1]), Some(&Paint::solid(Color::rgb(0.0, 1.0, 0.0))));
|
||||
// grestore undoes the clipsaves after its gsave; cliprestore with nothing saved does nothing.
|
||||
let o = objects(&read("cliprestore gsave clipsave 0 0 50 50 rectclip grestore cliprestore 10 10 80 80 rectfill").document);
|
||||
assert!(matches!(o[0].kind, NodeKind::Path { .. }), "{:?}", o[0].kind);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn languagelevel_is_an_integer_and_runs() {
|
||||
// #234.
|
||||
blue(
|
||||
"/Level2? systemdict /languagelevel known dup { pop systemdict /languagelevel get 2 ge } if def \
|
||||
Level2? { 0 0 1 setrgbcolor } { 1 0 0 setrgbcolor } ifelse",
|
||||
);
|
||||
check("languagelevel 3 eq");
|
||||
check("{ languagelevel } bind exec 3 eq");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intervals_share_their_arrays_and_strings() {
|
||||
// #234: astore into a subarray stores into the array.
|
||||
blue("/a 4 array def 1 2 a 0 2 getinterval astore pop a 0 get 1 eq { 0 0 1 setrgbcolor } { 1 0 0 setrgbcolor } ifelse");
|
||||
// put, putinterval and copy into an interval; an interval of an interval.
|
||||
check("/a [0 0 0 0 0] def a 1 3 getinterval 1 2 getinterval 0 7 put a 2 get 7 eq");
|
||||
check("/a [0 0 0 0] def a 2 2 getinterval 0 [8 9] putinterval a 3 get 9 eq");
|
||||
check("/a [0 0 0 0] def [5 6] a 1 2 getinterval copy pop a 2 get 6 eq");
|
||||
check("/s (abcd) def s 1 2 getinterval 0 (XY) putinterval s (aXYd) eq");
|
||||
check("/s (abcd) def s 2 1 getinterval 0 90 put s (abZd) eq");
|
||||
check("/s 8 string def 42 s 2 6 getinterval cvs pop s 2 2 getinterval (42) eq");
|
||||
// The same interval is the same object; another one is not.
|
||||
check("/a 4 array def a 0 2 getinterval a 0 2 getinterval eq");
|
||||
check("/a 4 array def a 0 2 getinterval a 0 3 getinterval ne a 0 2 getinterval a 1 2 getinterval ne and");
|
||||
// Reads and writes stay inside the interval.
|
||||
check("/a [1 2 3 4] def { a 1 2 getinterval 2 get } stopped { a 1 2 getinterval 2 0 put } stopped and");
|
||||
check("/a [1 2 3 4] def a 1 2 getinterval length 2 eq a 1 2 getinterval aload pop 3 eq exch 2 eq and and");
|
||||
check("/n 0 def [1 2 3 4] 1 2 getinterval { n add /n exch def } forall n 5 eq");
|
||||
// A procedure from an interval runs only its part.
|
||||
check("{ 1 2 3 } 1 2 getinterval cvx exec add 5 eq");
|
||||
// The prolog's stack save: the operands into part of one array, and back.
|
||||
check(
|
||||
"/stk 10 array def /lev 3 array def 1 2 3 count dup lev exch 0 exch put stk exch 0 exch getinterval astore pop \
|
||||
clear stk 0 lev 0 get getinterval aload pop add add 6 eq",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resourceforall_enumerates_matching_names() {
|
||||
// #234.
|
||||
blue("(*) { pop } 128 string /Category resourceforall 0 0 1 setrgbcolor");
|
||||
let count = |template: &str| format!("/n 0 def ({template}) {{ pop /n n 1 add def }} 128 string /ProcSet resourceforall n");
|
||||
let define = "[/Foo /Fop /Bar /F*x] { 1 dict /ProcSet defineresource pop } forall";
|
||||
check(&format!("{define} {} 4 eq", count("*")));
|
||||
check(&format!("{define} {} 2 eq", count("Fo?")));
|
||||
check(&format!("{define} {} 3 eq", count("F*")));
|
||||
check(&format!("{define} {} 1 eq", count("F\\\\*x")));
|
||||
check(&format!("{define} {} 0 eq", count("Q*")));
|
||||
check(&format!("{define} {} 1 eq", count("*a*")));
|
||||
// Each name is copied into the scratch string; exit stops.
|
||||
check(&format!("{define} /last () def (B*) {{ dup length string copy /last exch def }} 8 string /ProcSet resourceforall last (Bar) eq"));
|
||||
check(&format!("{define} /n 0 def (*) {{ pop /n n 1 add def exit }} 8 string /ProcSet resourceforall n 1 eq"));
|
||||
// A scratch string too small for the names, as the prolog probes it.
|
||||
check(&format!("{define} {{ (*) {{ pop }} 2 string /ProcSet resourceforall }} stopped"));
|
||||
check("/n 0 def (Gen*) { pop /n n 1 add def } 16 string /Category resourceforall n 1 eq");
|
||||
}
|
||||
|
||||
/// A `w` × `h` 8-bit palette TIFF with an alpha sample (`ExtraSamples` [1]), uncompressed in
|
||||
/// strips of `per` rows, in either byte order, as the reference app writes its EPS previews:
|
||||
/// pixel (x, y) is entry `(x + y) % 3` of a red, green, blue map, at alpha `12 x`.
|
||||
fn palette_tiff(w: u16, h: u16, per: u16, big: bool) -> Vec<u8> {
|
||||
let u16b = |v: u16| if big { v.to_be_bytes() } else { v.to_le_bytes() };
|
||||
let u32b = |v: u32| if big { v.to_be_bytes() } else { v.to_le_bytes() };
|
||||
// Shorts in an entry, left-justified.
|
||||
let shorts = |v: &[u16]| -> [u8; 4] {
|
||||
let mut f = [0; 4];
|
||||
for (i, s) in v.iter().enumerate() {
|
||||
f[2 * i..2 * i + 2].copy_from_slice(&u16b(*s));
|
||||
}
|
||||
f
|
||||
};
|
||||
let row = u32::from(w) * 2;
|
||||
let mut out = vec![];
|
||||
out.extend(if big { *b"MM\0*" } else { *b"II*\0" });
|
||||
out.extend([0; 4]);
|
||||
let data_at = out.len() as u32;
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
out.extend([((x + y) % 3) as u8, (x * 12) as u8]);
|
||||
}
|
||||
}
|
||||
let map_at = out.len() as u32;
|
||||
for (c, full) in [0xFFFF, 0x8000, 0xFFFF].into_iter().enumerate() {
|
||||
for i in 0..256 {
|
||||
out.extend(u16b(if i == c { full } else { 0 }));
|
||||
}
|
||||
}
|
||||
let strips: Vec<u16> = (0..h.div_ceil(per)).collect();
|
||||
let offsets_at = out.len() as u32;
|
||||
for k in &strips {
|
||||
out.extend(u32b(data_at + u32::from(k * per) * row));
|
||||
}
|
||||
let counts_at = out.len() as u32;
|
||||
for k in &strips {
|
||||
out.extend(u32b(u32::from(per.min(h - k * per)) * row));
|
||||
}
|
||||
let ifd = out.len() as u32;
|
||||
out[4..8].copy_from_slice(&u32b(ifd));
|
||||
let n = strips.len() as u32;
|
||||
let entries: [(u16, u16, u32, [u8; 4]); 12] = [
|
||||
(256, 3, 1, shorts(&[w])),
|
||||
(257, 3, 1, shorts(&[h])),
|
||||
(258, 3, 2, shorts(&[8, 8])),
|
||||
(259, 3, 1, shorts(&[1])),
|
||||
(262, 3, 1, shorts(&[3])),
|
||||
(273, 4, n, u32b(if n == 1 { data_at } else { offsets_at })),
|
||||
(277, 3, 1, shorts(&[2])),
|
||||
(278, 3, 1, shorts(&[per])),
|
||||
(279, 4, n, u32b(if n == 1 { u32::from(h) * row } else { counts_at })),
|
||||
(284, 3, 1, shorts(&[1])),
|
||||
(320, 3, 768, u32b(map_at)),
|
||||
(338, 3, 1, shorts(&[1])),
|
||||
];
|
||||
out.extend(u16b(entries.len() as u16));
|
||||
for (tag, kind, count, field) in entries {
|
||||
out.extend(u16b(tag));
|
||||
out.extend(u16b(kind));
|
||||
out.extend(u32b(count));
|
||||
out.extend(field);
|
||||
}
|
||||
out.extend([0; 4]);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_palette_preview_with_alpha_is_placed() {
|
||||
// #234: the program fails, so the preview is placed.
|
||||
let ps = eps("0 0 1 setrgbcolor 10 10 80 80 rectfill frobnicate");
|
||||
for (big, per) in [(false, 5), (true, 5), (false, 10), (true, 3)] {
|
||||
let tiff = palette_tiff(20, 10, per, big);
|
||||
let r = import(&crate::dos_eps(&ps, &tiff).unwrap()).unwrap();
|
||||
assert!(r.preview, "{big} {per}");
|
||||
assert!(r.warnings[0].contains("preview"), "{:?}", r.warnings);
|
||||
let o = objects(&r.document);
|
||||
let NodeKind::Image(im) = &o[0].kind else { panic!("{:?}", o[0].kind) };
|
||||
assert_eq!((im.width, im.height), (20, 10));
|
||||
assert!(near(bounds(&o[0]), Rect::new(0.0, 0.0, 100.0, 100.0)));
|
||||
let png = image::load_from_memory(&r.document.images[&im.key].bytes).unwrap().to_rgba8();
|
||||
for (x, y) in [(0, 0), (1, 0), (2, 0), (19, 9), (7, 4), (3, 6)] {
|
||||
let [r, g, b] = [[255, 0, 0], [0, 128, 0], [0, 0, 255]][((x + y) % 3) as usize];
|
||||
assert_eq!(png.get_pixel(x, y).0, [r, g, b, (x * 12) as u8], "({x}, {y}) {big} {per}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_palette_preview_is_refused_without_panicking() {
|
||||
let tiff = palette_tiff(20, 10, 5, false);
|
||||
assert!(crate::tiff::palette_rgba(&tiff).is_some());
|
||||
// Cut anywhere before the last entry's value (the directory is at the end): nothing.
|
||||
for cut in 0..tiff.len() - 6 {
|
||||
assert!(crate::tiff::palette_rgba(&tiff[..cut]).is_none(), "{cut}");
|
||||
}
|
||||
// Any byte changed: a picture or nothing.
|
||||
for big in [false, true] {
|
||||
let tiff = palette_tiff(20, 10, 5, big);
|
||||
for at in 0..tiff.len() {
|
||||
for v in [0, 1, 2, 0x7f, 0x80, 0xff] {
|
||||
let mut t = tiff.clone();
|
||||
t[at] = v;
|
||||
let _ = crate::tiff::palette_rgba(&t);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A damaged preview of an unreadable program: the hard error.
|
||||
let ps = eps("frobnicate");
|
||||
for t in [&tiff[..tiff.len() / 2], b"II*\0garbage", &[0; 64]] {
|
||||
let e = import(&crate::dos_eps(&ps, t).unwrap()).unwrap_err();
|
||||
assert!(e.contains("can't be read"), "{e}");
|
||||
}
|
||||
}
|
||||
@@ -110,38 +110,46 @@ impl Interp<'_> {
|
||||
let category = self.pop()?;
|
||||
let v = self.pop()?;
|
||||
let k = self.pop()?.key().ok_or(PsError::Ps("typecheck", "defineresource".into()))?;
|
||||
let dict = if category.text().as_deref() == Some("Font") { self.fonts.clone() } else { self.category(&category)? };
|
||||
dict.borrow_mut().insert(k, v.clone());
|
||||
// A category is defined by its implementation dictionary.
|
||||
if category.text().as_deref() == Some("Category") && !matches!(v, Obj::Dict(_)) {
|
||||
return ps_err("typecheck", "defineresource");
|
||||
}
|
||||
self.instances(&category)?.borrow_mut().insert(k, v.clone());
|
||||
self.push(v)?;
|
||||
}
|
||||
UndefineResource => {
|
||||
let category = self.pop()?;
|
||||
let k = self.pop()?.key().ok_or(PsError::Ps("typecheck", "undefineresource".into()))?;
|
||||
self.instances(&category)?.borrow_mut().remove(&k);
|
||||
}
|
||||
ResourceStatus => {
|
||||
let category = self.pop()?;
|
||||
let k = self.pop()?.key().ok_or(PsError::Ps("typecheck", "resourcestatus".into()))?;
|
||||
let dict = if category.text().as_deref() == Some("Font") { self.fonts.clone() } else { self.category(&category)? };
|
||||
let known = dict.borrow().contains_key(&k);
|
||||
let known = self.instances(&category)?.borrow().contains_key(&k);
|
||||
if known {
|
||||
self.push(Obj::Int(1))?;
|
||||
self.push(Obj::Int(0))?;
|
||||
}
|
||||
self.push(Obj::Bool(known))?;
|
||||
}
|
||||
ResourceForAll => self.resource_for_all()?,
|
||||
Show => {
|
||||
let s = self.pop_str()?.borrow().clone();
|
||||
let s = self.pop_str()?.to_vec();
|
||||
self.show(&s)?;
|
||||
}
|
||||
AShow => {
|
||||
let s = self.pop_str()?.borrow().clone();
|
||||
let s = self.pop_str()?.to_vec();
|
||||
self.nums::<2>()?;
|
||||
self.show(&s)?;
|
||||
}
|
||||
WidthShow => {
|
||||
let s = self.pop_str()?.borrow().clone();
|
||||
let s = self.pop_str()?.to_vec();
|
||||
self.pop()?;
|
||||
self.nums::<2>()?;
|
||||
self.show(&s)?;
|
||||
}
|
||||
AWidthShow => {
|
||||
let s = self.pop_str()?.borrow().clone();
|
||||
let s = self.pop_str()?.to_vec();
|
||||
self.nums::<2>()?;
|
||||
self.pop()?;
|
||||
self.nums::<2>()?;
|
||||
@@ -149,11 +157,11 @@ impl Interp<'_> {
|
||||
}
|
||||
XShow | YShow | XYShow => {
|
||||
self.pop()?;
|
||||
let s = self.pop_str()?.borrow().clone();
|
||||
let s = self.pop_str()?.to_vec();
|
||||
self.show(&s)?;
|
||||
}
|
||||
KShow => {
|
||||
let s = self.pop_str()?.borrow().clone();
|
||||
let s = self.pop_str()?.to_vec();
|
||||
self.pop()?;
|
||||
self.show(&s)?;
|
||||
}
|
||||
@@ -163,14 +171,14 @@ impl Interp<'_> {
|
||||
self.show(ch.encode_utf8(&mut [0; 4]).as_bytes())?;
|
||||
}
|
||||
StringWidth => {
|
||||
let s = self.pop_str()?.borrow().clone();
|
||||
let s = self.pop_str()?.to_vec();
|
||||
let (_, _, v) = self.set_type(&s, Point::ZERO)?;
|
||||
self.push_num(v.x)?;
|
||||
self.push_num(v.y)?;
|
||||
}
|
||||
CharPath => {
|
||||
self.pop_bool()?;
|
||||
let s = self.pop_str()?.borrow().clone();
|
||||
let s = self.pop_str()?.to_vec();
|
||||
self.char_path(&s)?;
|
||||
}
|
||||
SetCacheDevice => {
|
||||
|
||||
+110
-1
@@ -1,5 +1,6 @@
|
||||
//! The TIFF preview (little-endian, PackBits strips, written by the raster exports' TIFF writer):
|
||||
//! 1-bit black and white, or 8-bit RGB with or without an alpha channel. Also the PNG thumbnail.
|
||||
//! 1-bit black and white, or 8-bit RGB with or without an alpha channel. Also the PNG thumbnail,
|
||||
//! and a reader for the palette previews other apps write that the `image` crate can't decode.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
@@ -50,3 +51,111 @@ pub(crate) fn png(img: &Raster) -> Result<Vec<u8>, String> {
|
||||
rgba.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).map_err(|e| e.to_string())?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Most pixels a palette preview may have.
|
||||
const MAX_PIXELS: usize = 1 << 26;
|
||||
/// Most values a TIFF field may have.
|
||||
const MAX_VALUES: usize = 1 << 20;
|
||||
|
||||
/// A TIFF's bytes in its byte order.
|
||||
struct Tiff<'a> {
|
||||
b: &'a [u8],
|
||||
big: bool,
|
||||
}
|
||||
|
||||
impl Tiff<'_> {
|
||||
fn u16(&self, at: usize) -> Option<u16> {
|
||||
let b: [u8; 2] = self.b.get(at..at.checked_add(2)?)?.try_into().ok()?;
|
||||
Some(if self.big { u16::from_be_bytes(b) } else { u16::from_le_bytes(b) })
|
||||
}
|
||||
|
||||
fn u32(&self, at: usize) -> Option<u32> {
|
||||
let b: [u8; 4] = self.b.get(at..at.checked_add(4)?)?.try_into().ok()?;
|
||||
Some(if self.big { u32::from_be_bytes(b) } else { u32::from_le_bytes(b) })
|
||||
}
|
||||
|
||||
/// The values of the directory entry at `at` (bytes, shorts or longs; in the entry when they
|
||||
/// fit in four bytes).
|
||||
fn values(&self, at: usize) -> Option<Vec<u32>> {
|
||||
let (kind, n) = (self.u16(at.checked_add(2)?)?, self.u32(at.checked_add(4)?)? as usize);
|
||||
let size = match kind {
|
||||
1 => 1,
|
||||
3 => 2,
|
||||
4 => 4,
|
||||
_ => return None,
|
||||
};
|
||||
if n > MAX_VALUES {
|
||||
return None;
|
||||
}
|
||||
let field = at.checked_add(8)?;
|
||||
let start = if n * size <= 4 { field } else { self.u32(field)? as usize };
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let at = start.checked_add(i * size)?;
|
||||
match size {
|
||||
1 => self.b.get(at).map(|v| u32::from(*v)),
|
||||
2 => self.u16(at).map(u32::from),
|
||||
_ => self.u32(at),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// An 8-bit palette TIFF, with or without an alpha sample (`ExtraSamples`), uncompressed and
|
||||
/// interleaved, as RGBA: the layout of other apps' EPS previews. `None` for anything else.
|
||||
pub(crate) fn palette_rgba(bytes: &[u8]) -> Option<image::RgbaImage> {
|
||||
let big = match bytes.get(..4)? {
|
||||
b"II*\0" => false,
|
||||
b"MM\0*" => true,
|
||||
_ => return None,
|
||||
};
|
||||
let t = Tiff { b: bytes, big };
|
||||
let ifd = t.u32(4)? as usize;
|
||||
let entries = usize::from(t.u16(ifd)?);
|
||||
let mut fields = std::collections::HashMap::new();
|
||||
for i in 0..entries {
|
||||
let at = ifd.checked_add(2 + i * 12)?;
|
||||
fields.insert(t.u16(at)?, at);
|
||||
}
|
||||
let get = |tag: u16| fields.get(&tag).and_then(|at| t.values(*at));
|
||||
let one = |tag: u16| get(tag).and_then(|v| v.first().copied());
|
||||
let (w, h) = (one(256)? as usize, one(257)? as usize);
|
||||
let samples = one(277).unwrap_or(1) as usize;
|
||||
let alpha = samples == 2 && matches!(get(338)?.as_slice(), [0..=2]);
|
||||
let plain = one(259).unwrap_or(1) == 1 && one(262)? == 3 && one(284).unwrap_or(1) == 1;
|
||||
if !plain || !(samples == 1 || alpha) || get(258)?.iter().any(|b| *b != 8) || w == 0 || h == 0 || w.checked_mul(h)? > MAX_PIXELS {
|
||||
return None;
|
||||
}
|
||||
let map = get(320)?;
|
||||
if map.len() != 3 * 256 {
|
||||
return None;
|
||||
}
|
||||
// Strips of `per` rows each, in order.
|
||||
let (offsets, counts) = (get(273)?, get(279)?);
|
||||
let per = (one(278).unwrap_or(u32::MAX) as usize).min(h);
|
||||
let row = w * samples;
|
||||
// The strips are in the file: so is all that's allocated.
|
||||
if per == 0 || offsets.len() != counts.len() || offsets.len() < h.div_ceil(per) || row * h > bytes.len() {
|
||||
return None;
|
||||
}
|
||||
let mut data = Vec::with_capacity(row * h);
|
||||
for (k, (at, n)) in offsets.iter().zip(&counts).take(h.div_ceil(per)).enumerate() {
|
||||
let want = per.min(h - k * per) * row;
|
||||
if (*n as usize) < want {
|
||||
return None;
|
||||
}
|
||||
let at = *at as usize;
|
||||
data.extend_from_slice(bytes.get(at..at.checked_add(want)?)?);
|
||||
}
|
||||
// Map entries are 16-bit.
|
||||
let channel = |c: usize, i: usize| map.get(c * 256 + i).map_or(0, |v| ((u64::from(*v).min(65_535) * 255 + 32_767) / 65_535) as u8);
|
||||
let rgba = data
|
||||
.chunks_exact(samples)
|
||||
.flat_map(|px| {
|
||||
let i = usize::from(px.first().copied().unwrap_or(0));
|
||||
[channel(0, i), channel(1, i), channel(2, i), px.get(1).copied().unwrap_or(255)]
|
||||
})
|
||||
.collect();
|
||||
image::RgbaImage::from_raw(w as u32, h as u32, rgba)
|
||||
}
|
||||
@@ -16,11 +16,16 @@
|
||||
|
||||
mod backend;
|
||||
mod headless;
|
||||
pub mod logging;
|
||||
mod prompts;
|
||||
mod resources;
|
||||
mod server;
|
||||
mod tools;
|
||||
|
||||
pub use backend::{Backend, Remote};
|
||||
pub use headless::Headless;
|
||||
pub use prompts::{PROMPTS, PromptArg, PromptDef};
|
||||
pub use resources::{DOC_JSON_URI, DOC_URI, TEMPLATES};
|
||||
pub use server::{PROTOCOL_VERSION, Server};
|
||||
pub use tools::{ToolResult, call_tool, tool_definitions};
|
||||
|
||||
@@ -48,4 +53,6 @@ mod tests_persp;
|
||||
#[cfg(test)]
|
||||
mod tests_place;
|
||||
#[cfg(test)]
|
||||
mod tests_protocol;
|
||||
#[cfg(test)]
|
||||
mod tests_svg;
|
||||
@@ -0,0 +1,220 @@
|
||||
//! `logging/setLevel` and the `notifications/message` it turns on.
|
||||
//!
|
||||
//! VectorCraft's crates log through the `log` facade, and until now nothing installed a logger, so
|
||||
//! every `log::debug!` went nowhere. This module is the sink for them: a `log` logger that turns
|
||||
//! records into MCP `notifications/message`, filtered to the level the client asked for.
|
||||
//!
|
||||
//! The **binary** owns the choice of logger and calls [`install`]; the library never does. A
|
||||
//! library that installed a process-wide logger and set the max level would silence whatever
|
||||
//! logger an embedder had already set up, without being asked — so `Server::new` only forwards a
|
||||
//! `logging/setLevel` the client explicitly sent.
|
||||
//!
|
||||
//! Nothing is sent before the client asks for it, because MCP has no business pushing log lines at
|
||||
//! a client that never asked.
|
||||
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
|
||||
use log::{Level, LevelFilter};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// The severity from the last `logging/setLevel`, as an index into [`LEVELS`]; [`OFF`] means silent.
|
||||
static LEVEL: AtomicU8 = AtomicU8::new(OFF);
|
||||
|
||||
/// The stored value that means "no logging".
|
||||
const OFF: u8 = u8::MAX;
|
||||
|
||||
/// Records waiting to be drained into the next `notifications/message` batch.
|
||||
static QUEUE: Mutex<Vec<Value>> = Mutex::new(Vec::new());
|
||||
|
||||
/// RFC 5424 severities, from the least to the most severe, as the spec orders them.
|
||||
const LEVELS: [&str; 8] = ["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"];
|
||||
|
||||
/// The severity a `logging/setLevel` level names, and the `log` level it filters at. `None` for
|
||||
/// anything the spec doesn't name.
|
||||
///
|
||||
/// Four of the eight severities collapse onto `log`'s Error, so the index is kept as well: a
|
||||
/// client that asked for `emergency` must be able to hear that it is `emergency`, not `error`.
|
||||
fn parse(name: &str) -> Option<(u8, Level)> {
|
||||
let level = match name {
|
||||
"debug" => Level::Debug,
|
||||
"info" | "notice" => Level::Info,
|
||||
"warning" => Level::Warn,
|
||||
"error" | "critical" | "alert" | "emergency" => Level::Error,
|
||||
_ => return None,
|
||||
};
|
||||
Some((LEVELS.iter().position(|l| *l == name)? as u8, level))
|
||||
}
|
||||
|
||||
/// The severity name a `log` record is reported under.
|
||||
fn severity(level: Level) -> &'static str {
|
||||
match level {
|
||||
Level::Trace | Level::Debug => "debug",
|
||||
Level::Info => "info",
|
||||
Level::Warn => "warning",
|
||||
Level::Error => "error",
|
||||
}
|
||||
}
|
||||
|
||||
/// A `logging/setLevel` level the spec does not name. Nothing was changed.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct UnknownLevel;
|
||||
|
||||
/// Turn logging on at `level`, or off with `None`.
|
||||
///
|
||||
/// `Err(UnknownLevel)` means the name is not one the spec uses and nothing changed; a rejected
|
||||
/// level must leave the accepted one in place rather than silencing logging entirely.
|
||||
pub fn set_level(level: Option<&str>) -> Result<(), UnknownLevel> {
|
||||
let Some(name) = level else {
|
||||
LEVEL.store(OFF, Ordering::Relaxed);
|
||||
log::set_max_level(LevelFilter::Off);
|
||||
clear();
|
||||
return Ok(());
|
||||
};
|
||||
let Some((index, parsed)) = parse(name) else { return Err(UnknownLevel) };
|
||||
LEVEL.store(index, Ordering::Relaxed);
|
||||
log::set_max_level(parsed.to_level_filter());
|
||||
// Records already queued at the old level are dropped rather than leaked at the new one.
|
||||
clear();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The level currently requested, if any.
|
||||
pub fn level() -> Option<&'static str> {
|
||||
let index = LEVEL.load(Ordering::Relaxed);
|
||||
if index == OFF {
|
||||
return None;
|
||||
}
|
||||
LEVELS.get(usize::from(index)).copied()
|
||||
}
|
||||
|
||||
/// Take the queued records, so the server can send them as `notifications/message`.
|
||||
pub fn drain() -> Vec<Value> {
|
||||
match QUEUE.lock() {
|
||||
Ok(mut q) => std::mem::take(&mut *q),
|
||||
// A poisoned lock only means some other thread panicked while logging; the records in it
|
||||
// are still readable and worth sending.
|
||||
Err(poisoned) => std::mem::take(&mut *poisoned.into_inner()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget whatever is queued (used when the client stops listening).
|
||||
pub fn clear() {
|
||||
if let Ok(mut q) = QUEUE.lock() {
|
||||
q.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// The `log` logger that feeds [`QUEUE`].
|
||||
struct Sink;
|
||||
|
||||
impl log::Log for Sink {
|
||||
fn enabled(&self, metadata: &log::Metadata) -> bool {
|
||||
LEVEL.load(Ordering::Relaxed) != OFF && metadata.level() <= log::max_level()
|
||||
}
|
||||
|
||||
fn log(&self, record: &log::Record) {
|
||||
if !self.enabled(record.metadata()) {
|
||||
return;
|
||||
}
|
||||
let message = json!({
|
||||
"level": severity(record.level()),
|
||||
"logger": record.target(),
|
||||
"data": record.args().to_string(),
|
||||
});
|
||||
// A full queue means the client is not draining; drop rather than grow without bound.
|
||||
if let Ok(mut q) = QUEUE.lock()
|
||||
&& q.len() < MAX_QUEUED
|
||||
{
|
||||
q.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
/// Records kept while the client is behind.
|
||||
const MAX_QUEUED: usize = 512;
|
||||
|
||||
static SINK: Sink = Sink;
|
||||
|
||||
/// Install the sink once per process. The binary calls this; the library does not.
|
||||
///
|
||||
/// Later calls are ignored: the second `Server` in a test run finds it already there, which is
|
||||
/// exactly what it wants.
|
||||
pub fn install() {
|
||||
// `set_logger` fails only when a logger is already installed; keeping the first one is correct.
|
||||
let _already_installed = log::set_logger(&SINK);
|
||||
// Silent until a client sends `logging/setLevel`, so an embedder that installs this sink for
|
||||
// its own reasons gets no records it did not ask for.
|
||||
log::set_max_level(LevelFilter::Off);
|
||||
}
|
||||
|
||||
/// The level and the queue are process-wide, so the tests that move them take turns. The server
|
||||
/// itself is one thread, so nothing else has to.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_turn() -> std::sync::MutexGuard<'static, ()> {
|
||||
match TEST_LOCK.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
static TEST_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn levels_round_trip_and_reject_nonsense() {
|
||||
let _turn = test_turn();
|
||||
install();
|
||||
assert_eq!(set_level(Some("warning")), Ok(()));
|
||||
assert_eq!(level(), Some("warning"));
|
||||
assert_eq!(set_level(Some("nope")), Err(UnknownLevel), "an unknown level changes nothing");
|
||||
assert_eq!(level(), Some("warning"), "the old level survives a bad request");
|
||||
assert_eq!(set_level(Some("emergency")), Ok(()));
|
||||
assert_eq!(level(), Some("emergency"));
|
||||
assert_eq!(set_level(None), Ok(()));
|
||||
assert_eq!(level(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_spec_level_is_understood() {
|
||||
let _turn = test_turn();
|
||||
install();
|
||||
for name in LEVELS {
|
||||
assert_eq!(set_level(Some(name)), Ok(()), "{name}");
|
||||
assert_eq!(level(), Some(name), "{name}");
|
||||
}
|
||||
set_level(None).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_appear_as_messages_only_when_asked_for() {
|
||||
let _turn = test_turn();
|
||||
install();
|
||||
set_level(None).ok();
|
||||
clear();
|
||||
log::error!("before logging was enabled");
|
||||
assert!(drain().is_empty(), "silent until set_level");
|
||||
|
||||
set_level(Some("info")).ok();
|
||||
clear();
|
||||
log::warn!("hello {name}", name = "world");
|
||||
// Another test thread may log while logging is on, so look for ours rather than counting.
|
||||
let got = drain();
|
||||
let ours = got.iter().find(|m| m["data"] == "hello world").unwrap_or_else(|| panic!("not in {got:?}"));
|
||||
assert_eq!(ours["level"], "warning");
|
||||
assert!(ours["logger"].as_str().unwrap_or_default().starts_with("vectorcraft_mcp"), "{ours}");
|
||||
|
||||
// Below the requested level stays out.
|
||||
set_level(Some("error")).ok();
|
||||
clear();
|
||||
log::info!("too chatty");
|
||||
assert!(drain().is_empty());
|
||||
set_level(None).ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
//! MCP prompts (`prompts/list`, `prompts/get`) and `completion/complete`.
|
||||
//!
|
||||
//! A prompt is a reusable VectorCraft workflow: `prompts/get` hands the model one templated user
|
||||
//! message, so a client can offer "make a poster" the way it offers a slash command. Every prompt's
|
||||
//! arguments double as completion sources, and the resource templates in [`crate::resources`] share
|
||||
//! the same [`Live`] catalogues, so `completion/complete` can suggest real command ids, effect ids,
|
||||
//! swatch names and export formats instead of a fixed list that goes stale.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::cmd::fileio::FORMATS;
|
||||
|
||||
use crate::backend::Backend;
|
||||
|
||||
/// How many values one `completion/complete` reply may carry (the spec's maximum).
|
||||
const MAX_VALUES: usize = 100;
|
||||
|
||||
/// A catalogue the completion values are read from, so they follow the app instead of a copy.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Live {
|
||||
/// Export formats the engine writes (`document.formats`).
|
||||
Formats,
|
||||
/// Image Trace preset names (`imageTrace.presets`).
|
||||
TracePresets,
|
||||
/// Live effect ids (`effect.list`).
|
||||
Effects,
|
||||
/// Every command id (`engine.commands`).
|
||||
Commands,
|
||||
/// Swatch and colour-group names in the document (`swatch.list`).
|
||||
Swatches,
|
||||
/// Layer and object ids in the active document (`document.inspect`).
|
||||
Objects,
|
||||
}
|
||||
|
||||
/// Where one prompt argument's suggestions come from.
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Values {
|
||||
/// A fixed list, written out here.
|
||||
Fixed(&'static [&'static str]),
|
||||
/// The live catalogue.
|
||||
Live(Live),
|
||||
}
|
||||
|
||||
impl Values {
|
||||
fn resolve(&self, b: &mut dyn Backend) -> Vec<String> {
|
||||
match self {
|
||||
Values::Fixed(v) => v.iter().map(|s| (*s).to_string()).collect(),
|
||||
Values::Live(live) => live_values(b, *live),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The values of one live catalogue, read fresh so a suggestion is never stale.
|
||||
pub(crate) fn live_values(b: &mut dyn Backend, live: Live) -> Vec<String> {
|
||||
match live {
|
||||
Live::Formats => FORMATS.iter().filter(|f| f.write).map(|f| f.id.to_string()).collect(),
|
||||
Live::TracePresets => names(b, "imageTrace.presets", Some("presets")),
|
||||
Live::Effects => names(b, "effect.list", Some("catalog")),
|
||||
// The command catalogue is a control method, not an engine command.
|
||||
Live::Commands => b.call("engine.commands", json!({})).map(|v| ids_in(&v, None)).unwrap_or_default(),
|
||||
Live::Swatches => names(b, "swatch.list", Some("swatches")),
|
||||
Live::Objects => object_ids(b),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `id` or `name` of every entry a query command returns, optionally under one key.
|
||||
///
|
||||
/// These are engine commands, so they go through `engine.execute`; that reaches the same commands
|
||||
/// in both backends, where a control method the headless session doesn't implement would not.
|
||||
fn names(b: &mut dyn Backend, command: &str, key: Option<&str>) -> Vec<String> {
|
||||
b.call("engine.execute", json!({"command": command, "params": {}})).map(|v| ids_in(&v, key)).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn ids_in(reply: &Value, key: Option<&str>) -> Vec<String> {
|
||||
let items = match key {
|
||||
Some(k) => reply.get(k).and_then(Value::as_array).map_or(&[][..], Vec::as_slice),
|
||||
None => reply.as_array().map_or(&[][..], Vec::as_slice),
|
||||
};
|
||||
// Catalogs are small, but never trust a document that grew one.
|
||||
items.iter().take(MAX_VALUES * 8).filter_map(|i| i.get("id").or_else(|| i.get("name"))).filter_map(Value::as_str).map(str::to_string).collect()
|
||||
}
|
||||
|
||||
/// Ids of every layer and object in the active document, for `vectorcraft://object/{id}`.
|
||||
fn object_ids(b: &mut dyn Backend) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let Ok(v) = b.call("document.inspect", json!({})) else { return out };
|
||||
let Some(layers) = v.get("layers").and_then(Value::as_array) else { return out };
|
||||
let mut stack: Vec<&Value> = layers.iter().rev().collect();
|
||||
while let Some(node) = stack.pop() {
|
||||
if let Some(id) = node.get("id").and_then(Value::as_i64) {
|
||||
out.push(id.to_string());
|
||||
}
|
||||
if let Some(kids) = node.get("children").and_then(Value::as_array) {
|
||||
stack.extend(kids.iter().rev());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One prompt argument.
|
||||
pub struct PromptArg {
|
||||
pub name: &'static str,
|
||||
pub description: &'static str,
|
||||
pub required: bool,
|
||||
/// Substituted for `{name}` when the caller leaves the argument out.
|
||||
pub default: &'static str,
|
||||
pub values: Values,
|
||||
}
|
||||
|
||||
/// One prompt: a workflow with templated arguments.
|
||||
pub struct PromptDef {
|
||||
pub name: &'static str,
|
||||
pub title: &'static str,
|
||||
pub description: &'static str,
|
||||
pub arguments: &'static [PromptArg],
|
||||
/// The message body; every `{argument}` is replaced with its value.
|
||||
pub text: &'static str,
|
||||
}
|
||||
|
||||
/// Recolour methods (`recolor.apply`'s `method`), written here because they are a closed set.
|
||||
const RECOLOR_METHODS: &[&str] = &["exact", "preserveTints", "scaleTints", "tintsShades", "hueShift"];
|
||||
|
||||
static NONE: &[&str] = &[];
|
||||
|
||||
/// Every prompt, in `prompts/list` order.
|
||||
pub static PROMPTS: &[PromptDef] = &[
|
||||
PromptDef {
|
||||
name: "poster",
|
||||
title: "Design a poster",
|
||||
description: "Lay out a poster from a brief: live shapes, gradients, type and live effects, checked with screenshots before export.",
|
||||
arguments: &[
|
||||
PromptArg {
|
||||
name: "brief",
|
||||
description: "What the poster is for, and the words it must carry",
|
||||
required: true,
|
||||
default: "",
|
||||
values: Values::Fixed(NONE),
|
||||
},
|
||||
PromptArg {
|
||||
name: "palette",
|
||||
description: "The colours to use, or how to choose them",
|
||||
required: false,
|
||||
default: "a limited scheme of three to five colours taken from the brief",
|
||||
values: Values::Fixed(&[
|
||||
"a limited scheme of three to five colours taken from the brief",
|
||||
"a monochrome scheme in one hue, varying value and opacity",
|
||||
"a two-colour complementary scheme",
|
||||
"an analogous scheme across neighbouring hues",
|
||||
]),
|
||||
},
|
||||
PromptArg {
|
||||
name: "text",
|
||||
description: "The exact headline and supporting lines to set",
|
||||
required: false,
|
||||
default: "invent short, concrete wording that fits the brief",
|
||||
values: Values::Fixed(NONE),
|
||||
},
|
||||
],
|
||||
text: "\
|
||||
Design a poster in VectorCraft.
|
||||
|
||||
Brief: {brief}
|
||||
Palette: {palette}
|
||||
Wording: {text}
|
||||
|
||||
Coordinates are points in document space: y points down and the origin is the first
|
||||
artboard's top-left. Work in the document that is already open (inspect_document); only
|
||||
run file.new when the brief asks for a different artboard size.
|
||||
|
||||
Method:
|
||||
1. Plan the layout before drawing — a hierarchy of shapes, then the type on top.
|
||||
2. draw_shape for geometry (rectangle with `radius` for corners, ellipse, polygon, star,
|
||||
line); draw_path for anything with curves, from `points` or from SVG `d`.
|
||||
3. set_paint for colour. A fill may be \"#rrggbb\", [r,g,b], {c,m,y,k}, {gray} or a whole
|
||||
paint.setFill params object — pass {\"gradient\": {…}} for a gradient.
|
||||
4. add_text for type: point type at (x, y), area type with width and height, or mode
|
||||
\"area\"/\"onPath\" to flow it in or along a path.
|
||||
5. apply_effect for live effects; call it with no `effect` first to get the catalogue with
|
||||
each effect's parameters and defaults.
|
||||
6. screenshot to look at what you made, fix what is wrong, and only then export.
|
||||
|
||||
Keep it editable: live shapes, live effects and live text. Do not flatten and do not
|
||||
rasterise unless the brief asks for it.",
|
||||
},
|
||||
PromptDef {
|
||||
name: "icon-set",
|
||||
title: "Draw an icon set",
|
||||
description: "Build a row of consistent icons as live vector shapes on a grid, then check them as a set.",
|
||||
arguments: &[
|
||||
PromptArg { name: "subject", description: "What the icons depict", required: true, default: "", values: Values::Fixed(NONE) },
|
||||
PromptArg {
|
||||
name: "count",
|
||||
description: "How many icons in the set",
|
||||
required: false,
|
||||
default: "6",
|
||||
values: Values::Fixed(&["4", "6", "8", "12", "16"]),
|
||||
},
|
||||
PromptArg {
|
||||
name: "detail",
|
||||
description: "How detailed each icon should be",
|
||||
required: false,
|
||||
default: "a single flat silhouette, no strokes",
|
||||
values: Values::Fixed(&[
|
||||
"a single flat silhouette, no strokes",
|
||||
"a filled silhouette with a stroke around it",
|
||||
"an outlined shape with its strokes only",
|
||||
"two-tone: a filled shape and a smaller accent shape",
|
||||
]),
|
||||
},
|
||||
],
|
||||
text: "\
|
||||
Draw a set of {count} icons of {subject} in VectorCraft, {detail}.
|
||||
|
||||
Coordinates are points in document space (y down, origin at the first artboard's
|
||||
top-left).
|
||||
|
||||
Method:
|
||||
1. Decide the cell size and the gap first, then lay every icon into its own cell so the set
|
||||
reads as one family: the same stroke weight, the same optical size, the same corner radius.
|
||||
2. Build each icon from draw_shape and draw_path — live shapes, not strokes traced from
|
||||
somewhere else. Use `points` with `in`/`out` handles for the curves a silhouette needs.
|
||||
3. set_paint the whole set the same way, then break one colour out on the object that needs
|
||||
the accent.
|
||||
4. screenshot the set and judge it as a set: equal weight, aligned baselines, consistent
|
||||
padding. Fix what looks off rather than adding more detail.
|
||||
|
||||
Return the ids of the icons in a grid, and leave them as separate objects on one layer.",
|
||||
},
|
||||
PromptDef {
|
||||
name: "recolor",
|
||||
title: "Recolour artwork",
|
||||
description: "Recolour the selection with explicit rows, or cluster its colours first with recolor.colors.",
|
||||
arguments: &[
|
||||
PromptArg {
|
||||
name: "palette",
|
||||
description: "The colours to map onto, or 'cluster' to group similar colours first",
|
||||
required: true,
|
||||
default: "",
|
||||
values: Values::Fixed(&[
|
||||
"cluster",
|
||||
"a monochrome ramp from light to dark",
|
||||
"a complementary two-colour scheme",
|
||||
"one hue per layer, keeping the original values",
|
||||
]),
|
||||
},
|
||||
PromptArg {
|
||||
name: "method",
|
||||
description: "How a row's colours take their new colour (recolor.apply's own default is exact; scaleTints suits a recolour driven from recolor.reduce)",
|
||||
required: false,
|
||||
default: "exact",
|
||||
values: Values::Fixed(RECOLOR_METHODS),
|
||||
},
|
||||
],
|
||||
text: "\
|
||||
Recolour the current selection in VectorCraft with {palette}, using the `{method}` recolour method.
|
||||
|
||||
Method:
|
||||
1. inspect_document to see what is selected and what colours it actually uses.
|
||||
2. If you do not know the colours, run recolor.colors first: it groups similar colours into
|
||||
rows (k-means in Lab, weighted by use) and hands back the map. Then edit the `to` colour of
|
||||
each row and apply it. That is the reliable route for artwork you did not draw.
|
||||
3. If you know the colours, run recolor.apply directly with `map`: one row per source colour,
|
||||
`from` as keys or \"#rrggbb\", `to` as a colour. Each new colour keeps the model of the one it
|
||||
replaces, and gradients, meshes, text and pattern tiles follow.
|
||||
4. Group the colours with `group` so the result stays editable, and use `limitTo` to snap new
|
||||
colours to a swatch library.
|
||||
5. screenshot to check the result against the original intent.
|
||||
|
||||
One undo step covers the whole change; gradients and linked swatches stay linked.",
|
||||
},
|
||||
PromptDef {
|
||||
name: "trace-and-style",
|
||||
title: "Trace an image and style it",
|
||||
description: "Trace a placed raster with Image Trace, then apply a live effect and paint over the result.",
|
||||
arguments: &[
|
||||
PromptArg { name: "path", description: "Path to the image file", required: true, default: "", values: Values::Fixed(NONE) },
|
||||
PromptArg {
|
||||
name: "preset",
|
||||
description: "Image Trace preset to start from",
|
||||
required: false,
|
||||
default: "Default",
|
||||
values: Values::Live(Live::TracePresets),
|
||||
},
|
||||
PromptArg {
|
||||
name: "effect",
|
||||
description: "A live effect to apply to the traced shapes",
|
||||
required: false,
|
||||
default: "stylize.scribble",
|
||||
values: Values::Live(Live::Effects),
|
||||
},
|
||||
],
|
||||
text: "\
|
||||
Trace the image at {path} in VectorCraft with the \"{preset}\" preset, then style the result
|
||||
with the {effect} effect.
|
||||
|
||||
Method:
|
||||
1. open_file the image: it becomes a document of its own pixel size.
|
||||
2. imageTrace.make with the preset name, adjusting `params` — mode blackAndWhite, grayscale or
|
||||
color, then threshold, colors, paths, corners, noise, method abutting or overlapping,
|
||||
ignoreWhite, snapCurvesToLines. Run imageTrace.presets to see every preset with its numbers.
|
||||
Lower paths and higher corners give the clean, logo-like result; high fidelity needs paths
|
||||
near 90.
|
||||
3. apply_effect {effect} on the traced shapes. Call apply_effect with no `effect` for the
|
||||
catalogue and each effect's parameters.
|
||||
4. set_paint over the traced shapes to give the artwork its own colours.
|
||||
5. screenshot, then remove the placed image if it is still underneath.
|
||||
|
||||
The trace is real geometry: every traced shape stays selectable and editable.",
|
||||
},
|
||||
PromptDef {
|
||||
name: "export-set",
|
||||
title: "Export an artboard set",
|
||||
description: "Export every artboard in several formats at once, with per-format options, and report what each one lost.",
|
||||
arguments: &[
|
||||
PromptArg {
|
||||
name: "formats",
|
||||
description: "Formats to write, in order",
|
||||
required: false,
|
||||
default: "svg, pdf, png",
|
||||
values: Values::Live(Live::Formats),
|
||||
},
|
||||
PromptArg {
|
||||
name: "directory",
|
||||
description: "Where the files go",
|
||||
required: false,
|
||||
default: "the current directory",
|
||||
values: Values::Fixed(NONE),
|
||||
},
|
||||
PromptArg {
|
||||
name: "scale",
|
||||
description: "Raster scale in pixels per point (raster formats only)",
|
||||
required: false,
|
||||
default: "2",
|
||||
values: Values::Fixed(&["1", "2", "3", "4"]),
|
||||
},
|
||||
],
|
||||
text: "\
|
||||
Export the document in VectorCraft to {formats}, into {directory}, at {scale} pixels per
|
||||
point for the raster formats.
|
||||
|
||||
Method:
|
||||
1. document.formats lists every format the engine reads and writes, with each one's options and
|
||||
their defaults. Read it before exporting so the options you pass are real ones.
|
||||
2. export once per format. A path's extension picks the format; `options` carries the rest, for
|
||||
example {\"quality\": 80} for jpg. pdf writes one page per artboard — all of them, or
|
||||
`artboard` (0-based) / `range` (\"1-3, 5\", 1-based); the other formats write one artboard.
|
||||
3. export without a path to get the bytes back as dataBase64 instead of a file.
|
||||
4. Report the `warnings` each export returns: they name what that format cannot carry.
|
||||
|
||||
Live effects are kept on export (geometry baked; shadows, glows and blur become SVG filters),
|
||||
and text stays text unless you pass outlineText.",
|
||||
},
|
||||
];
|
||||
|
||||
/// The message body as a client should receive it.
|
||||
///
|
||||
/// Our source wraps these texts at the column limit, which would leave line breaks in the middle
|
||||
/// of a sentence — and a substituted argument landing on one of them. So each source line is
|
||||
/// folded into the paragraph it belongs to, while list items keep a line of their own.
|
||||
fn flow(text: &str) -> String {
|
||||
text.split("\n\n")
|
||||
.map(|paragraph| {
|
||||
let mut out = String::new();
|
||||
for line in paragraph.lines().map(str::trim).filter(|l| !l.is_empty()) {
|
||||
if !out.is_empty() {
|
||||
out.push(if is_item(line) { '\n' } else { ' ' });
|
||||
}
|
||||
out.push_str(line);
|
||||
}
|
||||
out
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n")
|
||||
}
|
||||
|
||||
/// A numbered or dashed list item, which starts a line instead of continuing one.
|
||||
fn is_item(line: &str) -> bool {
|
||||
if let Some(body) = line.strip_prefix("- ") {
|
||||
return !body.is_empty();
|
||||
}
|
||||
line.split_once(". ").is_some_and(|(number, _)| !number.is_empty() && number.len() <= 3 && number.chars().all(|c| c.is_ascii_digit()))
|
||||
}
|
||||
|
||||
/// `prompts/list`.
|
||||
pub fn list() -> Value {
|
||||
let prompts: Vec<Value> = PROMPTS
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let arguments: Vec<Value> =
|
||||
p.arguments.iter().map(|a| json!({"name": a.name, "description": a.description, "required": a.required})).collect();
|
||||
json!({"name": p.name, "title": p.title, "description": p.description, "arguments": arguments})
|
||||
})
|
||||
.collect();
|
||||
json!({ "prompts": prompts })
|
||||
}
|
||||
|
||||
/// `prompts/get`: the prompt's messages with `{argument}` filled in.
|
||||
pub fn get(name: &str, args: &Value) -> Result<Value, String> {
|
||||
let Some(p) = PROMPTS.iter().find(|p| p.name == name) else {
|
||||
let ids: Vec<&str> = PROMPTS.iter().map(|p| p.name).collect();
|
||||
return Err(format!("unknown prompt `{name}` ({})", ids.join("|")));
|
||||
};
|
||||
let mut text = flow(p.text);
|
||||
for a in p.arguments {
|
||||
let given = args.get(a.name).filter(|v| !v.is_null()).map(|v| match v {
|
||||
Value::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
});
|
||||
let value = match given {
|
||||
Some(v) if !v.is_empty() => v,
|
||||
_ if a.required => return Err(format!("prompt `{name}` needs the `{a_name}` argument", a_name = a.name)),
|
||||
_ => a.default.to_string(),
|
||||
};
|
||||
text = text.replace(&format!("{{{}}}", a.name), &value);
|
||||
}
|
||||
Ok(json!({
|
||||
"description": p.description,
|
||||
"messages": [{"role": "user", "content": {"type": "text", "text": text}}],
|
||||
}))
|
||||
}
|
||||
|
||||
/// `completion/complete`: values for a prompt argument or a resource-template variable.
|
||||
///
|
||||
/// Unknown references and unknown argument names come back empty rather than as an error: a
|
||||
/// completion is a convenience, and the caller is usually mid-typing.
|
||||
pub fn complete(b: &mut dyn Backend, params: &Value) -> Value {
|
||||
let empty = json!({"completion": {"values": [], "total": 0, "hasMore": false}});
|
||||
let Some(reference) = params.get("ref") else { return empty };
|
||||
let typed = params.get("argument").and_then(|a| a.get("value")).and_then(Value::as_str).unwrap_or("");
|
||||
let name = params.get("argument").and_then(|a| a.get("name")).and_then(Value::as_str).unwrap_or("");
|
||||
|
||||
let values = match reference.get("type").and_then(Value::as_str) {
|
||||
Some("ref/prompt") => match reference.get("name").and_then(Value::as_str).and_then(|n| PROMPTS.iter().find(|p| p.name == n)) {
|
||||
Some(p) => match p.arguments.iter().find(|a| a.name == name) {
|
||||
Some(a) => a.values.resolve(b),
|
||||
None => Vec::new(),
|
||||
},
|
||||
None => Vec::new(),
|
||||
},
|
||||
Some("ref/resource") => match reference.get("uri").and_then(Value::as_str).map(crate::resources::template_source) {
|
||||
Some((_, Some(live))) => live_values(b, live),
|
||||
_ => Vec::new(),
|
||||
},
|
||||
_ => Vec::new(),
|
||||
};
|
||||
|
||||
// Rank by prefix first (what the user is typing), then by substring.
|
||||
let needle = typed.to_lowercase();
|
||||
let mut matches: Vec<String> = values.iter().filter(|v| v.to_lowercase().starts_with(&needle)).cloned().collect();
|
||||
if matches.is_empty() && !needle.is_empty() {
|
||||
matches = values.iter().filter(|v| v.to_lowercase().contains(&needle)).cloned().collect();
|
||||
}
|
||||
let total = matches.len();
|
||||
let has_more = total > MAX_VALUES;
|
||||
matches.truncate(MAX_VALUES);
|
||||
json!({"completion": {"values": matches, "total": total, "hasMore": has_more}})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn b() -> Box<dyn Backend> {
|
||||
Box::new(crate::Headless::with_document())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_prompt_lists_and_renders() {
|
||||
let listed = list();
|
||||
let entries = listed["prompts"].as_array().expect("prompts");
|
||||
assert_eq!(entries.len(), PROMPTS.len());
|
||||
for (p, entry) in PROMPTS.iter().zip(entries) {
|
||||
assert!(!p.name.is_empty() && !p.text.is_empty(), "{}", p.name);
|
||||
assert_eq!(entry["name"], p.name);
|
||||
assert_eq!(entry["description"], p.description);
|
||||
// Arguments are advertised with the names prompts/get will accept.
|
||||
let names: Vec<&str> = p.arguments.iter().map(|a| a.name).collect();
|
||||
let listed: Vec<String> =
|
||||
entry["arguments"].as_array().map_or(Vec::new(), |a| a.iter().filter_map(|x| x["name"].as_str().map(str::to_string)).collect());
|
||||
let names: Vec<String> = names.into_iter().map(str::to_string).collect();
|
||||
assert_eq!(names, listed, "{}: listed arguments", p.name);
|
||||
|
||||
// Every required argument supplied: the prompt renders.
|
||||
let args: Value = json!(p.arguments.iter().filter(|a| a.required).map(|a| (a.name, "x")).collect::<std::collections::BTreeMap<_, _>>());
|
||||
let v = get(p.name, &args).unwrap_or_else(|e| panic!("{} renders: {e}", p.name));
|
||||
assert_eq!(v["messages"].as_array().map(Vec::len), Some(1));
|
||||
assert_eq!(v["messages"][0]["role"], "user");
|
||||
let text = v["messages"][0]["content"]["text"].as_str().unwrap_or_default();
|
||||
assert!(text.len() > 200, "{}: too short", p.name);
|
||||
// Templating left nothing behind, and the source's line wrapping is gone.
|
||||
// Only a declared argument's braces are placeholders; JSON examples carry braces too.
|
||||
for a in p.arguments {
|
||||
assert!(!text.contains(&format!("{{{}}}", a.name)), "{}: `{}` left in place", p.name, a.name);
|
||||
}
|
||||
assert!(!text.contains('\n') || text.contains("\n\n") || p.arguments.is_empty(), "{}: stray line break", p.name);
|
||||
assert!(!text.contains(" "), "{}: double space", p.name);
|
||||
|
||||
// Only a missing required argument may stop it.
|
||||
let missing: Value = json!({});
|
||||
assert_eq!(get(p.name, &missing).is_err(), p.arguments.iter().any(|a| a.required), "{}", p.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flow_keeps_paragraphs_and_list_items() {
|
||||
let flowed = flow("first line\nsecond line\n\nnew paragraph\n1. one\n2. two\n- bullet");
|
||||
assert_eq!(flowed, "first line second line\n\nnew paragraph\n1. one\n2. two\n- bullet");
|
||||
assert_eq!(flow(""), "");
|
||||
assert_eq!(flow("no breaks"), "no breaks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_arguments_are_filled_in() {
|
||||
let v = get("export-set", &json!({"formats": "pdf, tiff", "scale": "4"})).unwrap();
|
||||
let text = v["messages"][0]["content"]["text"].as_str().unwrap_or_default();
|
||||
assert!(text.contains("pdf, tiff"), "{text}");
|
||||
assert!(text.contains("4 pixels per point"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_prompt_and_missing_argument_are_errors() {
|
||||
assert!(get("nope", &json!({})).is_err());
|
||||
let e = get("poster", &json!({"brief": ""})).unwrap_err();
|
||||
assert!(e.contains("brief"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completions_rank_prefix_first_and_cap_at_100() {
|
||||
let mut b = b();
|
||||
let v = complete(
|
||||
b.as_mut(),
|
||||
&json!({"ref": {"type": "ref/prompt", "name": "trace-and-style"}, "argument": {"name": "preset", "value": "photo"}}),
|
||||
);
|
||||
let values = v["completion"]["values"].as_array().unwrap();
|
||||
assert!(!values.is_empty(), "imageTrace.presets should answer: {v}");
|
||||
assert!(values.iter().all(|x| x.as_str().unwrap_or_default().to_lowercase().contains("photo")), "{v}");
|
||||
|
||||
// A nonsense prefix falls back to substring matches, and never exceeds 100.
|
||||
let v = complete(b.as_mut(), &json!({"ref": {"type": "ref/prompt", "name": "export-set"}, "argument": {"name": "formats", "value": ""}}));
|
||||
let all = v["completion"]["values"].as_array().unwrap().len();
|
||||
assert!(all > 3, "formats should answer: {v}");
|
||||
assert!(v["completion"]["total"].as_u64().unwrap() >= all as u64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_completion_targets_are_empty_not_errors() {
|
||||
let mut b = b();
|
||||
for params in [
|
||||
json!({"ref": {"type": "ref/prompt", "name": "nope"}, "argument": {"name": "x", "value": ""}}),
|
||||
json!({"ref": {"type": "ref/prompt", "name": "poster"}, "argument": {"name": "nope", "value": ""}}),
|
||||
json!({"ref": {"type": "ref/resource", "uri": "vectorcraft://nope/{id}"}, "argument": {"name": "id", "value": ""}}),
|
||||
json!({"ref": {"type": "ref/other"}, "argument": {"name": "x", "value": ""}}),
|
||||
json!({}),
|
||||
] {
|
||||
let v = complete(b.as_mut(), ¶ms);
|
||||
assert_eq!(v["completion"]["values"], json!([]), "{params}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
//! MCP resources: the fixed documents, the templates an agent can address parts of the
|
||||
//! document by, and reading either.
|
||||
//!
|
||||
//! The templates matter for long sessions. `vectorcraft://document/json` returns the whole model,
|
||||
//! which on a real document is thousands of lines an agent pays for again on every change; the
|
||||
//! templates hand out one object, one command definition or one effect's parameters instead, and
|
||||
//! they are what `completion/complete` suggests ids and names from.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::backend::Backend;
|
||||
use crate::prompts::Live;
|
||||
|
||||
/// The active document's summary: artboards, layer tree, selection, history.
|
||||
pub const DOC_URI: &str = "vectorcraft://document";
|
||||
/// The whole document model as JSON.
|
||||
pub const DOC_JSON_URI: &str = "vectorcraft://document/json";
|
||||
|
||||
/// Why a resource could not be read. The distinction matters: the server turns it into `-32002`
|
||||
/// (no such resource) or `-32602` (bad URI), and guessing from the message text would not hold.
|
||||
#[derive(Debug)]
|
||||
pub enum ReadError {
|
||||
/// The URI names no resource or template this server serves.
|
||||
NotFound(String),
|
||||
/// The template has no value, or the value names nothing.
|
||||
Invalid(String),
|
||||
/// The backend could not be asked at all.
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
/// One addressable template: the URI with `{var}`, and what its variable can be.
|
||||
pub struct Template {
|
||||
pub uri: &'static str,
|
||||
pub name: &'static str,
|
||||
pub title: &'static str,
|
||||
pub description: &'static str,
|
||||
pub mime: &'static str,
|
||||
pub live: Live,
|
||||
}
|
||||
|
||||
/// Every template, in `resources/templates/list` order.
|
||||
pub static TEMPLATES: &[Template] = &[
|
||||
Template {
|
||||
uri: "vectorcraft://object/{id}",
|
||||
name: "object",
|
||||
title: "One layer or object",
|
||||
description: "The layer or object with this id, with its children, bounds and paint (document.inspect)",
|
||||
mime: "application/json",
|
||||
live: Live::Objects,
|
||||
},
|
||||
Template {
|
||||
uri: "vectorcraft://command/{id}",
|
||||
name: "command",
|
||||
title: "One command",
|
||||
description: "The command with this id: label, menu path, shortcut, parameter description and whether it is enabled now (engine.commands)",
|
||||
mime: "application/json",
|
||||
live: Live::Commands,
|
||||
},
|
||||
Template {
|
||||
uri: "vectorcraft://effect/{id}",
|
||||
name: "effect",
|
||||
title: "One live effect",
|
||||
description: "The live effect with this id and its parameters with their defaults (effect.list)",
|
||||
mime: "application/json",
|
||||
live: Live::Effects,
|
||||
},
|
||||
Template {
|
||||
uri: "vectorcraft://swatch/{name}",
|
||||
name: "swatch",
|
||||
title: "One swatch",
|
||||
description: "The swatch or gradient/pattern swatch with this name (swatch.list)",
|
||||
mime: "application/json",
|
||||
live: Live::Swatches,
|
||||
},
|
||||
];
|
||||
|
||||
/// `resources/list`.
|
||||
pub fn list() -> Value {
|
||||
json!({"resources": [
|
||||
{"uri": DOC_URI, "name": "document", "title": "Active document (summary)", "description": "Layer tree, artboards, selection and history of the active document (document.inspect)", "mimeType": "application/json"},
|
||||
{"uri": DOC_JSON_URI, "name": "document-json", "title": "Active document (full model)", "description": "The complete document model as JSON (document.json)", "mimeType": "application/json"},
|
||||
]})
|
||||
}
|
||||
|
||||
/// `resources/templates/list`.
|
||||
pub fn templates() -> Value {
|
||||
let list: Vec<Value> = TEMPLATES
|
||||
.iter()
|
||||
.map(|t| json!({"uriTemplate": t.uri, "name": t.name, "title": t.title, "description": t.description, "mimeType": t.mime}))
|
||||
.collect();
|
||||
json!({ "resourceTemplates": list })
|
||||
}
|
||||
|
||||
/// Which template a URI is an instance of, and what its variable can be.
|
||||
pub fn template_source(uri: &str) -> (&str, Option<Live>) {
|
||||
for t in TEMPLATES {
|
||||
let Some((prefix, _)) = t.uri.split_once('{') else { continue };
|
||||
if uri.starts_with(prefix) {
|
||||
return (t.uri, Some(t.live));
|
||||
}
|
||||
}
|
||||
(uri, None)
|
||||
}
|
||||
|
||||
/// `resources/read`: the document behind a resource URI.
|
||||
pub fn read(b: &mut dyn Backend, uri: &str) -> Result<Value, ReadError> {
|
||||
if uri == DOC_URI {
|
||||
return b.call("document.inspect", json!({})).map_err(ReadError::Backend);
|
||||
}
|
||||
if uri == DOC_JSON_URI {
|
||||
return exec(b, "document.json").map_err(ReadError::Backend);
|
||||
}
|
||||
let (template, var) = match TEMPLATES.iter().find(|t| template_source(uri).0 == t.uri) {
|
||||
Some(t) => (t, variable(uri, t.uri)),
|
||||
None => return Err(ReadError::NotFound(uri.to_string())),
|
||||
};
|
||||
if var.is_empty() {
|
||||
return Err(ReadError::Invalid(format!("{} needs a value for `{}`", template.uri, variable_name(template.uri))));
|
||||
}
|
||||
let found = match template.name {
|
||||
"object" => find_object(b, &var).map_err(ReadError::Backend)?,
|
||||
// The command catalogue is a control method; the rest are engine commands.
|
||||
"command" => find_in(b.call("engine.commands", json!({})).map_err(ReadError::Backend)?, None, "id", &var),
|
||||
"effect" => find_in(exec(b, "effect.list").map_err(ReadError::Backend)?, Some("catalog"), "id", &var),
|
||||
"swatch" => find_in(exec(b, "swatch.list").map_err(ReadError::Backend)?, Some("swatches"), "name", &var),
|
||||
other => return Err(ReadError::NotFound(format!("no reader for template `{other}`"))),
|
||||
};
|
||||
Ok(match found {
|
||||
Some(v) => v,
|
||||
None => return Err(ReadError::Invalid(format!("no {} named `{var}` in the active document", template.name))),
|
||||
})
|
||||
}
|
||||
|
||||
/// Run a query engine command. That reaches both backends, where a control method the headless
|
||||
/// session doesn't implement would not.
|
||||
fn exec(b: &mut dyn Backend, command: &str) -> Result<Value, String> {
|
||||
b.call("engine.execute", json!({"command": command, "params": {}}))
|
||||
}
|
||||
|
||||
/// The part of a URI between the template's `{` and its `}`.
|
||||
fn variable(uri: &str, template: &str) -> String {
|
||||
let Some(open) = template.find('{') else { return String::new() };
|
||||
let prefix = &template[..open];
|
||||
let rest = uri.strip_prefix(prefix).unwrap_or("");
|
||||
let raw = rest.split('}').next().unwrap_or("");
|
||||
decode(raw)
|
||||
}
|
||||
|
||||
/// The variable's name, e.g. `id` or `name`.
|
||||
fn variable_name(template: &str) -> &str {
|
||||
template.split_once('{').and_then(|(_, r)| r.split_once('}')).map_or("", |(n, _)| n)
|
||||
}
|
||||
|
||||
/// Percent-decoding, so a swatch called "Brand 40%" can be addressed as `Brand%2040%25`.
|
||||
fn decode(s: &str) -> String {
|
||||
let bytes = s.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while let Some(b) = bytes.get(i).copied() {
|
||||
if b == b'%'
|
||||
&& i + 2 < bytes.len()
|
||||
&& let Ok(byte) = u8::from_str_radix(s.get(i + 1..i + 3).unwrap_or(""), 16)
|
||||
{
|
||||
out.push(byte);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
out.push(b);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
/// The layer or object with this id in the active document, with its subtree.
|
||||
fn find_object(b: &mut dyn Backend, id: &str) -> Result<Option<Value>, String> {
|
||||
let doc = exec(b, "document.inspect")?;
|
||||
Ok(find_node(doc.get("layers"), id))
|
||||
}
|
||||
|
||||
/// Depth-first search of the layer tree for a node whose `id` matches.
|
||||
fn find_node(node: Option<&Value>, id: &str) -> Option<Value> {
|
||||
let nodes = node?.as_array()?;
|
||||
let mut stack: Vec<&Value> = nodes.iter().rev().collect();
|
||||
while let Some(n) = stack.pop() {
|
||||
let hit = match n.get("id") {
|
||||
Some(Value::String(s)) => s == id,
|
||||
Some(Value::Number(number)) => number.to_string() == id,
|
||||
_ => false,
|
||||
};
|
||||
if hit {
|
||||
return Some(n.clone());
|
||||
}
|
||||
if let Some(kids) = n.get("children").and_then(Value::as_array) {
|
||||
stack.extend(kids.iter().rev());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The item in a query command's array whose `field` equals `value`.
|
||||
fn find_in(reply: Value, key: Option<&str>, field: &str, value: &str) -> Option<Value> {
|
||||
let items = match key {
|
||||
Some(k) => reply.get(k).and_then(Value::as_array).map_or(&[][..], Vec::as_slice),
|
||||
None => reply.as_array().map_or(&[][..], Vec::as_slice),
|
||||
};
|
||||
items.iter().take(MAX_CATALOGUE).find(|i| i.get(field).and_then(Value::as_str) == Some(value)).cloned()
|
||||
}
|
||||
|
||||
/// Catalogs come from the app, but never trust one that grew without bound.
|
||||
const MAX_CATALOGUE: usize = 20_000;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn variables_are_split_out_and_decoded() {
|
||||
assert_eq!(variable("vectorcraft://object/42", "vectorcraft://object/{id}"), "42");
|
||||
assert_eq!(variable("vectorcraft://swatch/Brand%2040%25", "vectorcraft://swatch/{name}"), "Brand 40%");
|
||||
assert_eq!(variable("vectorcraft://swatch/a%zzb", "vectorcraft://swatch/{name}"), "a%zzb");
|
||||
assert_eq!(variable("vectorcraft://other", "vectorcraft://object/{id}"), "");
|
||||
assert_eq!(variable_name("vectorcraft://swatch/{name}"), "name");
|
||||
assert_eq!(variable_name("no template"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn templates_are_addressable() {
|
||||
assert_eq!(template_source("vectorcraft://object/7"), ("vectorcraft://object/{id}", Some(Live::Objects)));
|
||||
assert_eq!(template_source("vectorcraft://command/paint.setFill").1, Some(Live::Commands));
|
||||
assert_eq!(template_source("vectorcraft://document").1, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_are_well_formed() {
|
||||
let l = list();
|
||||
assert_eq!(l["resources"].as_array().map(Vec::len), Some(2));
|
||||
let t = templates();
|
||||
let items = t["resourceTemplates"].as_array().expect("templates");
|
||||
assert_eq!(items.len(), TEMPLATES.len());
|
||||
for (entry, t) in items.iter().zip(TEMPLATES) {
|
||||
assert_eq!(entry["uriTemplate"], t.uri);
|
||||
assert!(t.uri.contains('{'), "{} has no variable", t.uri);
|
||||
assert!(t.description.len() > 20, "{} needs a description", t.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_and_unknown_uris_are_errors() {
|
||||
let mut b = Box::new(crate::Headless::with_document());
|
||||
assert!(read(b.as_mut(), "vectorcraft://nope").is_err());
|
||||
assert!(read(b.as_mut(), "vectorcraft://object/").is_err());
|
||||
assert!(read(b.as_mut(), "vectorcraft://object/99999").is_err());
|
||||
assert!(read(b.as_mut(), DOC_URI).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_object_is_readable_once_it_exists() {
|
||||
let mut b = Box::new(crate::Headless::with_document());
|
||||
let made = b.call("engine.execute", json!({"command": "shape.rectangle", "params": {"x": 10, "y": 10, "width": 50, "height": 40}})).unwrap();
|
||||
let id = made["id"].as_i64().unwrap_or_default().to_string();
|
||||
let v = read(b.as_mut(), &format!("vectorcraft://object/{id}")).unwrap();
|
||||
assert_eq!(v["id"].to_string(), id);
|
||||
assert!(v["bounds"].is_object(), "{v}");
|
||||
}
|
||||
}
|
||||
+63
-25
@@ -1,10 +1,12 @@
|
||||
//! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources methods.
|
||||
//! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources / prompts methods.
|
||||
|
||||
use std::io::{BufRead, Write};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::backend::Backend;
|
||||
use crate::prompts;
|
||||
use crate::resources;
|
||||
use crate::tools::{call_tool, tool_definitions};
|
||||
|
||||
/// The MCP revision we implement.
|
||||
@@ -18,15 +20,14 @@ const INVALID_PARAMS: i64 = -32602;
|
||||
const INTERNAL_ERROR: i64 = -32603;
|
||||
const RESOURCE_NOT_FOUND: i64 = -32002;
|
||||
|
||||
const INSTRUCTIONS: &str = "VectorCraft is a professional vector illustration app. Coordinates are points in \
|
||||
document space (y down, origin at the first artboard's top-left; a new document is 612×792). Draw with draw_shape / \
|
||||
draw_path, change colours with set_paint, look with screenshot and inspect_document. Every menu action is a command: \
|
||||
find it with list_commands and run it with run_command. New objects become the selection, and most commands act on the \
|
||||
selection (or on explicit `ids`).";
|
||||
|
||||
/// Resource URIs.
|
||||
pub const DOC_URI: &str = "vectorcraft://document";
|
||||
pub const DOC_JSON_URI: &str = "vectorcraft://document/json";
|
||||
const INSTRUCTIONS: &str = "VectorCraft is a professional vector illustration app. Coordinates are \
|
||||
points in document space (y down, origin at the first artboard's top-left; a new document is 612×792). \
|
||||
Draw with draw_shape / draw_path, change colours with set_paint, look with screenshot and inspect_document. \
|
||||
Every menu action is a command: find it with list_commands and run it with run_command. New objects become \
|
||||
the selection, and most commands act on the selection (or on explicit `ids`). prompts/list has ready-made \
|
||||
workflows, completion/complete finishes a command id, a format, an effect or a swatch name, and \
|
||||
vectorcraft://object/{id}, vectorcraft://command/{id}, vectorcraft://effect/{id} and \
|
||||
vectorcraft://swatch/{name} read one thing at a time instead of the whole document.";
|
||||
|
||||
/// An MCP server bound to one backend.
|
||||
pub struct Server {
|
||||
@@ -56,16 +57,30 @@ impl Server {
|
||||
self.initialized
|
||||
}
|
||||
|
||||
/// The `notifications/message` records queued since the last call.
|
||||
///
|
||||
/// Log records belong to no particular request, so these go out after the reply to whatever
|
||||
/// line produced them rather than before it. [`serve`] drains this itself; a caller driving
|
||||
/// [`handle_line`] by hand does the same.
|
||||
pub fn take_notifications(&mut self) -> Vec<String> {
|
||||
crate::logging::drain()
|
||||
.into_iter()
|
||||
.map(|record| json!({"jsonrpc": "2.0", "method": "notifications/message", "params": record}).to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Serve newline-delimited JSON-RPC until `input` closes. Logs go to stderr only (stdout is
|
||||
/// the protocol stream).
|
||||
pub fn serve(&mut self, input: impl BufRead, mut output: impl Write) -> std::io::Result<()> {
|
||||
for line in input.lines() {
|
||||
let line = line?;
|
||||
if let Some(reply) = self.handle_line(&line) {
|
||||
output.write_all(reply.as_bytes())?;
|
||||
let reply = self.handle_line(&line);
|
||||
let notes = self.take_notifications();
|
||||
for line in reply.iter().chain(notes.iter()) {
|
||||
output.write_all(line.as_bytes())?;
|
||||
output.write_all(b"\n")?;
|
||||
output.flush()?;
|
||||
}
|
||||
output.flush()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -135,7 +150,13 @@ impl Server {
|
||||
let version = if SUPPORTED_VERSIONS.contains(&asked) { asked } else { PROTOCOL_VERSION };
|
||||
Ok(json!({
|
||||
"protocolVersion": version,
|
||||
"capabilities": {"tools": {}, "resources": {}},
|
||||
"capabilities": {
|
||||
"tools": {},
|
||||
"resources": {},
|
||||
"prompts": {"listChanged": false},
|
||||
"completions": {},
|
||||
"logging": {},
|
||||
},
|
||||
"serverInfo": {"name": "vectorcraft", "title": "VectorCraft", "version": env!("CARGO_PKG_VERSION")},
|
||||
"instructions": format!("{INSTRUCTIONS} Backend: {}.", self.backend.describe()),
|
||||
}))
|
||||
@@ -147,21 +168,38 @@ impl Server {
|
||||
let args = params.get("arguments").cloned().unwrap_or(Value::Null);
|
||||
Ok(call_tool(self.backend.as_mut(), name, &args).to_value())
|
||||
}
|
||||
"resources/list" => Ok(json!({"resources": [
|
||||
{"uri": DOC_URI, "name": "document", "title": "Active document (summary)", "description": "Layer tree, artboards, selection and history of the active document (document.inspect)", "mimeType": "application/json"},
|
||||
{"uri": DOC_JSON_URI, "name": "document-json", "title": "Active document (full model)", "description": "The complete document model as JSON (document.json)", "mimeType": "application/json"},
|
||||
]})),
|
||||
"resources/templates/list" => Ok(json!({"resourceTemplates": []})),
|
||||
"resources/list" => Ok(resources::list()),
|
||||
"resources/templates/list" => Ok(resources::templates()),
|
||||
"resources/read" => {
|
||||
let uri = params.get("uri").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing `uri`".to_string()))?;
|
||||
let v = match uri {
|
||||
DOC_URI => self.backend.call("document.inspect", json!({})),
|
||||
DOC_JSON_URI => self.backend.call("engine.execute", json!({"command": "document.json", "params": {}})),
|
||||
_ => return Err((RESOURCE_NOT_FOUND, format!("resource not found: {uri}"))),
|
||||
}
|
||||
.map_err(|e| (INTERNAL_ERROR, e))?;
|
||||
let v = resources::read(self.backend.as_mut(), uri).map_err(|e| match e {
|
||||
resources::ReadError::NotFound(uri) => (RESOURCE_NOT_FOUND, format!("resource not found: {uri}")),
|
||||
resources::ReadError::Invalid(why) => (INVALID_PARAMS, why),
|
||||
resources::ReadError::Backend(why) => (INTERNAL_ERROR, why),
|
||||
})?;
|
||||
Ok(json!({"contents": [{"uri": uri, "mimeType": "application/json", "text": serde_json::to_string_pretty(&v).unwrap_or_default()}]}))
|
||||
}
|
||||
"prompts/list" => Ok(prompts::list()),
|
||||
"prompts/get" => {
|
||||
let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing prompt `name`".to_string()))?;
|
||||
let args = params.get("arguments").cloned().unwrap_or(Value::Null);
|
||||
prompts::get(name, &args).map_err(|e| (INVALID_PARAMS, e))
|
||||
}
|
||||
"completion/complete" => Ok(prompts::complete(self.backend.as_mut(), params)),
|
||||
"logging/setLevel" => {
|
||||
// `level` is required. A null level is our own way of turning logging back off,
|
||||
// which the spec has no method for and a long session needs.
|
||||
let Some(asked) = params.get("level") else {
|
||||
return Err((INVALID_PARAMS, "missing `level`".into()));
|
||||
};
|
||||
let level = if asked.is_null() { None } else { asked.as_str() };
|
||||
crate::logging::set_level(level).map_err(|_| match level {
|
||||
Some(given) => (INVALID_PARAMS, format!("unknown level `{given}`")),
|
||||
None => (INVALID_PARAMS, "`level` must be a severity name or null".to_string()),
|
||||
})?;
|
||||
log::debug!("client set logging to {}", level.unwrap_or("off"));
|
||||
Ok(json!({}))
|
||||
}
|
||||
other => Err((METHOD_NOT_FOUND, format!("method not found: {other}"))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,6 +284,36 @@ fn headless_path_gesture_undo() {
|
||||
assert_eq!(&std::fs::read(tmp("x.png")).unwrap()[..4], b"\x89PNG");
|
||||
}
|
||||
|
||||
/// inspect_document reads back the paint type's characters show, as add_text and set_paint give it.
|
||||
#[test]
|
||||
fn inspect_document_reports_the_paint_of_type() {
|
||||
let mut s = server();
|
||||
let r = call(&mut s, 2, "add_text", json!({"text": "HI", "x": 50, "y": 60, "size": 24, "color": "#ff0000"}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
let id = serde_json::from_str::<Value>(&text_of(&r)).unwrap()["id"].clone();
|
||||
let node = |s: &mut Server, n: u64| {
|
||||
let v: Value = serde_json::from_str(&text_of(&call(s, n, "inspect_document", json!({})))).unwrap();
|
||||
v["layers"][0]["children"].as_array().unwrap().iter().find(|c| c["id"] == id).cloned().unwrap()
|
||||
};
|
||||
let t = node(&mut s, 3);
|
||||
assert_eq!((t["fill"].as_str(), t["stroke"].as_str()), (Some("#ff0000"), Some("None")), "{t}");
|
||||
let r = call(&mut s, 4, "set_paint", json!({"fill": "#0000ff", "stroke": "#00ff00", "strokeWidth": 3, "ids": [id]}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
let t = node(&mut s, 5);
|
||||
assert_eq!((t["fill"].as_str(), t["stroke"].as_str(), t["strokeWidth"].as_f64()), (Some("#0000ff"), Some("#00ff00"), Some(3.0)), "{t}");
|
||||
}
|
||||
|
||||
/// save_file says it saves in the document's own format or the one the path's extension picks.
|
||||
#[test]
|
||||
fn save_file_describes_the_formats_it_writes() {
|
||||
let tools = tool_definitions();
|
||||
let d = tools.iter().find(|t| t["name"] == "save_file").and_then(|t| t["description"].as_str()).unwrap();
|
||||
for ext in [".vectorcraft", ".vctemplate", ".pdf", ".svg", ".svgz", ".ai"] {
|
||||
assert!(d.contains(ext), "{ext}: {d}");
|
||||
}
|
||||
assert!(d.contains("its own format"), "{d}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_are_tool_results_not_crashes() {
|
||||
let mut s = server();
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
//! The protocol surface beyond tools: prompts, completions, resource templates, subscriptions,
|
||||
//! logging, progress and cancellation, driven the way a client drives them.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Headless, PROMPTS, Server};
|
||||
|
||||
fn server() -> Server {
|
||||
Server::new(Box::new(Headless::with_document()))
|
||||
}
|
||||
|
||||
/// One request, and whatever the server queued while answering it.
|
||||
fn rpc(s: &mut Server, id: u64, method: &str, params: Value) -> (Value, Vec<Value>) {
|
||||
let line = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string();
|
||||
let reply = s.handle_line(&line).and_then(|r| serde_json::from_str::<Value>(&r).ok());
|
||||
let notes: Vec<Value> = s.take_notifications().iter().filter_map(|l| serde_json::from_str(l).ok()).collect();
|
||||
let reply = reply.unwrap_or(Value::Null);
|
||||
assert_eq!(reply["jsonrpc"], "2.0", "{reply}");
|
||||
assert_eq!(reply["id"], id, "{reply}");
|
||||
(reply, notes)
|
||||
}
|
||||
|
||||
fn result(s: &mut Server, id: u64, method: &str, params: Value) -> Value {
|
||||
let (v, _) = rpc(s, id, method, params);
|
||||
assert!(v.get("error").is_none(), "{method}: {v}");
|
||||
v["result"].clone()
|
||||
}
|
||||
|
||||
/// Run a tool and parse the JSON it answers with.
|
||||
fn tool(s: &mut Server, id: u64, name: &str, args: Value) -> Value {
|
||||
let r = result(s, id, "tools/call", json!({"name": name, "arguments": args}));
|
||||
assert_eq!(r["isError"], false, "{name}: {r}");
|
||||
let text = r["content"][0]["text"].as_str().unwrap_or("null");
|
||||
serde_json::from_str(text).unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
/// Draw a rectangle and return its id.
|
||||
fn rectangle(s: &mut Server, id: u64) -> i64 {
|
||||
let made = tool(s, id, "draw_shape", json!({"shape": "rectangle", "x": 10, "y": 10, "width": 60, "height": 40}));
|
||||
made["id"].as_i64().unwrap_or_default()
|
||||
}
|
||||
|
||||
fn err_code(s: &mut Server, id: u64, method: &str, params: Value) -> i64 {
|
||||
let (v, _) = rpc(s, id, method, params);
|
||||
v.get("error").and_then(|e| e["code"].as_i64()).unwrap_or_else(|| panic!("{method} should have failed: {v}"))
|
||||
}
|
||||
|
||||
// ---------- capabilities ----------
|
||||
|
||||
#[test]
|
||||
fn initialize_advertises_what_the_server_actually_answers() {
|
||||
let mut s = server();
|
||||
let caps = result(&mut s, 1, "initialize", json!({}))["capabilities"].clone();
|
||||
assert!(caps["tools"].is_object(), "{caps}");
|
||||
assert!(caps["resources"].is_object(), "{caps}");
|
||||
// No `subscribe`: this server cannot observe a change while a call is running, so it does not
|
||||
// claim to. Advertising a capability that silently misses edits is worse than not having it.
|
||||
assert!(caps["resources"].get("subscribe").is_none(), "{caps}");
|
||||
assert!(caps["prompts"].is_object(), "prompts are implemented");
|
||||
assert!(caps["completions"].is_object(), "completion/complete is implemented");
|
||||
assert!(caps["logging"].is_object(), "logging/setLevel is implemented");
|
||||
// And the instructions point at them, since that is where a client looks first.
|
||||
let instructions = result(&mut s, 2, "initialize", json!({}))["instructions"].as_str().unwrap_or_default().to_string();
|
||||
assert!(instructions.contains("prompts/list"), "{instructions}");
|
||||
assert!(instructions.contains("points in document space"), "{instructions}");
|
||||
}
|
||||
|
||||
// ---------- prompts ----------
|
||||
|
||||
#[test]
|
||||
fn prompts_are_listed_and_rendered_over_the_protocol() {
|
||||
let mut s = server();
|
||||
let listed = result(&mut s, 1, "prompts/list", json!({}))["prompts"].clone();
|
||||
let entries = listed.as_array().expect("prompts");
|
||||
assert_eq!(entries.len(), PROMPTS.len());
|
||||
for e in entries {
|
||||
assert!(e["name"].as_str().is_some_and(|n| !n.is_empty()), "{e}");
|
||||
assert!(e["description"].as_str().is_some_and(|d| d.len() > 20), "{e}");
|
||||
// Arguments are advertised with the flag prompts/get honours.
|
||||
for a in e["arguments"].as_array().map_or(&[][..], Vec::as_slice) {
|
||||
assert!(a["required"].is_boolean(), "{a}");
|
||||
}
|
||||
}
|
||||
|
||||
let got = result(&mut s, 2, "prompts/get", json!({"name": "poster", "arguments": {"brief": "a jazz festival"}}));
|
||||
assert_eq!(got["messages"].as_array().map(Vec::len), Some(1));
|
||||
assert_eq!(got["messages"][0]["role"], "user");
|
||||
let text = got["messages"][0]["content"]["text"].as_str().unwrap_or_default();
|
||||
assert!(text.contains("a jazz festival"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_argument_errors_are_invalid_params() {
|
||||
let mut s = server();
|
||||
assert_eq!(err_code(&mut s, 1, "prompts/get", json!({"name": "poster"})), -32602);
|
||||
assert_eq!(err_code(&mut s, 2, "prompts/get", json!({"name": "nope"})), -32602);
|
||||
assert_eq!(err_code(&mut s, 3, "prompts/get", json!({})), -32602);
|
||||
}
|
||||
|
||||
// ---------- completions ----------
|
||||
|
||||
#[test]
|
||||
fn completions_come_from_the_live_catalogues() {
|
||||
let mut s = server();
|
||||
// A prompt argument fed by the export formats.
|
||||
let v = result(
|
||||
&mut s,
|
||||
1,
|
||||
"completion/complete",
|
||||
json!({"ref": {"type": "ref/prompt", "name": "export-set"}, "argument": {"name": "formats", "value": "sv"}}),
|
||||
);
|
||||
let values: Vec<&str> = v["completion"]["values"].as_array().map_or(Vec::new(), |a| a.iter().filter_map(Value::as_str).collect());
|
||||
assert!(values.contains(&"svg"), "{v}");
|
||||
assert_eq!(v["completion"]["hasMore"], false);
|
||||
|
||||
// A resource template variable, after something exists to point at.
|
||||
rectangle(&mut s, 2);
|
||||
let v = result(
|
||||
&mut s,
|
||||
3,
|
||||
"completion/complete",
|
||||
json!({"ref": {"type": "ref/resource", "uri": "vectorcraft://object/{id}"}, "argument": {"name": "id", "value": ""}}),
|
||||
);
|
||||
let values = v["completion"]["values"].as_array().map_or(0, Vec::len);
|
||||
assert!(values > 0, "{v}");
|
||||
assert!(v["completion"]["total"].as_u64().unwrap_or(0) >= values as u64);
|
||||
}
|
||||
|
||||
// ---------- resource templates ----------
|
||||
|
||||
#[test]
|
||||
fn templates_are_listed_and_readable() {
|
||||
let mut s = server();
|
||||
let listed = result(&mut s, 1, "resources/templates/list", json!({}));
|
||||
let templates = listed["resourceTemplates"].as_array().expect("templates");
|
||||
assert!(templates.len() >= 4, "{listed}");
|
||||
|
||||
let id = rectangle(&mut s, 2);
|
||||
|
||||
let read = result(&mut s, 3, "resources/read", json!({"uri": format!("vectorcraft://object/{id}")}));
|
||||
let body: Value = serde_json::from_str(read["contents"][0]["text"].as_str().unwrap_or("{}")).unwrap_or_default();
|
||||
assert_eq!(body["id"].as_i64(), Some(id), "{body}");
|
||||
assert!(body["bounds"].is_object(), "{body}");
|
||||
|
||||
// A command definition is one small read instead of the whole 400-command catalogue.
|
||||
let read = result(&mut s, 4, "resources/read", json!({"uri": "vectorcraft://command/paint.setFill"}));
|
||||
let body: Value = serde_json::from_str(read["contents"][0]["text"].as_str().unwrap_or("{}")).unwrap_or_default();
|
||||
assert_eq!(body["id"], "paint.setFill", "{body}");
|
||||
assert!(body["params"].as_str().is_some_and(|p| !p.is_empty()), "{body}");
|
||||
|
||||
// An effect's parameters and defaults.
|
||||
let read = result(&mut s, 5, "resources/read", json!({"uri": "vectorcraft://effect/stylize.dropShadow"}));
|
||||
let body: Value = serde_json::from_str(read["contents"][0]["text"].as_str().unwrap_or("{}")).unwrap_or_default();
|
||||
assert_eq!(body["id"], "stylize.dropShadow", "{body}");
|
||||
|
||||
// Missing values and unknown resources are distinguishable.
|
||||
assert_eq!(err_code(&mut s, 6, "resources/read", json!({"uri": "vectorcraft://object/"})), -32602);
|
||||
assert_eq!(err_code(&mut s, 7, "resources/read", json!({"uri": "vectorcraft://command/nope"})), -32602);
|
||||
assert_eq!(err_code(&mut s, 8, "resources/read", json!({"uri": "vectorcraft://nope"})), -32002);
|
||||
}
|
||||
|
||||
// ---------- logging ----------
|
||||
|
||||
#[test]
|
||||
fn logging_levels_are_validated_and_off_by_default() {
|
||||
let _turn = crate::logging::test_turn();
|
||||
let mut s = server();
|
||||
assert_eq!(crate::logging::level(), None, "silent until asked for");
|
||||
result(&mut s, 1, "logging/setLevel", json!({"level": "debug"}));
|
||||
assert_eq!(crate::logging::level(), Some("debug"));
|
||||
// A null level turns it back off; a missing one is a bad request.
|
||||
result(&mut s, 2, "logging/setLevel", json!({"level": null}));
|
||||
assert_eq!(crate::logging::level(), None);
|
||||
assert_eq!(err_code(&mut s, 3, "logging/setLevel", json!({"level": "shouty"})), -32602);
|
||||
assert_eq!(err_code(&mut s, 4, "logging/setLevel", json!({})), -32602);
|
||||
// A rejected level leaves the accepted one alone.
|
||||
result(&mut s, 5, "logging/setLevel", json!({"level": "warning"}));
|
||||
assert_eq!(err_code(&mut s, 6, "logging/setLevel", json!({"level": "shouty"})), -32602);
|
||||
assert_eq!(crate::logging::level(), Some("warning"));
|
||||
result(&mut s, 7, "logging/setLevel", json!({"level": null}));
|
||||
assert_eq!(crate::logging::level(), None);
|
||||
}
|
||||
|
||||
/// Log records reach the client as `notifications/message`, and only after it asked.
|
||||
#[test]
|
||||
fn log_records_go_out_as_notifications_once_asked_for() {
|
||||
let _turn = crate::logging::test_turn();
|
||||
// The binary installs the logger, not the library, so a test does it for itself.
|
||||
crate::logging::install();
|
||||
let mut s = server();
|
||||
|
||||
let input = [
|
||||
json!({"jsonrpc": "2.0", "id": 1, "method": "logging/setLevel", "params": {"level": "debug"}}).to_string(),
|
||||
json!({"jsonrpc": "2.0", "id": 2, "method": "prompts/list"}).to_string(),
|
||||
json!({"jsonrpc": "2.0", "id": 3, "method": "logging/setLevel", "params": {"level": null}}).to_string(),
|
||||
]
|
||||
.join("\n");
|
||||
let mut out = Vec::new();
|
||||
s.serve(input.as_bytes(), &mut out).unwrap();
|
||||
let lines: Vec<Value> = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
|
||||
let notes: Vec<&Value> = lines.iter().filter(|l| l["method"] == "notifications/message").collect();
|
||||
assert!(!notes.is_empty(), "the level change itself is logged: {lines:?}");
|
||||
for n in ¬es {
|
||||
assert_eq!(n["jsonrpc"], "2.0", "{n}");
|
||||
assert!(
|
||||
["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"]
|
||||
.contains(&n["params"]["level"].as_str().unwrap_or_default()),
|
||||
"{n}"
|
||||
);
|
||||
assert!(n["params"]["data"].is_string(), "{n}");
|
||||
}
|
||||
// Every reply still came before the notifications that followed it.
|
||||
let answered = lines.iter().filter(|l| l.get("id").is_some()).count();
|
||||
assert_eq!(answered, 3, "{lines:?}");
|
||||
let last_reply = lines.iter().rposition(|l| l.get("id").is_some()).unwrap_or(0);
|
||||
assert!(lines[last_reply + 1..].iter().all(|l| l.get("id").is_none()), "{lines:?}");
|
||||
result(&mut s, 4, "logging/setLevel", json!({"level": null}));
|
||||
}
|
||||
+13
-4
@@ -2,7 +2,7 @@
|
||||
//! [`Backend`].
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_engine::cmd::fileio::{ARTBOARD_PARAMS, FORMATS, OPEN_EXTS};
|
||||
use vectorcraft_engine::cmd::fileio::{ARTBOARD_PARAMS, FORMATS, OPEN_EXTS, SAVE_FORMATS, format};
|
||||
|
||||
use crate::backend::Backend;
|
||||
|
||||
@@ -57,6 +57,12 @@ fn obj(props: Value, required: &[&str]) -> Value {
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// The extension of each format Save writes, in [`SAVE_FORMATS`] order.
|
||||
fn save_extensions() -> Vec<&'static str> {
|
||||
SAVE_FORMATS.iter().filter_map(|id| format(id)?.extensions.first().copied()).collect()
|
||||
}
|
||||
|
||||
fn tool(name: &str, title: &str, desc: &str, schema: Value, read_only: bool) -> Value {
|
||||
json!({
|
||||
"name": name,
|
||||
@@ -259,14 +265,17 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"save_file",
|
||||
"Save file",
|
||||
"Save the active document in the native .vectorcraft format.",
|
||||
obj(json!({"path": string("Destination (default: the document's current path)")}), &[]),
|
||||
&format!(
|
||||
"Save the active document with the engine's document.save: by default to its own file in its own format (native .vectorcraft unless it was opened from or saved as SVG, PDF or a restorable .ai). With `path`, the extension picks the format: .{}. Other formats are exports (see export). The reply's `format` says what was written and `warnings` what that format loses.",
|
||||
save_extensions().join(", .")
|
||||
),
|
||||
obj(json!({"path": string("Destination; its extension picks the format (default: the document's own file)")}), &[]),
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"export",
|
||||
"Export",
|
||||
"Export the active document with the engine's document.export (the same bytes in the app and headless): svg (artboard viewBox), pdf (one page per artboard: all, or `artboard` / `range`), png/jpg/webp/gif/png8/tiff/bmp/tga/psd (one rendered artboard; png8 is an indexed .png; psd keeps the layers), txt (the stories, back to front) or vectorcraft (a native copy; the document keeps its path). `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.",
|
||||
"Export the active document with the engine's document.export (the same bytes in the app and headless), in any format of the `format` enum: vectors (svg/svgz with the artboard as viewBox, eps, dxf, emf, wmf), pdf (one page per artboard: all, or `artboard` / `range`), rasters (one rendered artboard: png, jpg, webp, gif, png8 as an indexed .png, tiff, bmp, tga, psd with its layers), txt (the stories, back to front), vectorcraft or template (a native copy or template; the document keeps its path). run_command document.formats lists each format's options. `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.",
|
||||
obj(
|
||||
json!({
|
||||
"format": {"type": "string", "enum": FORMATS.iter().filter(|f| f.write).map(|f| f.id).collect::<Vec<_>>(), "description": "Default: from the path's extension"},
|
||||
|
||||
@@ -182,6 +182,237 @@ fn resources_are_json() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- prompts, completions, templates, subscriptions ----------
|
||||
|
||||
/// Every prompt is well formed and renders whatever it is handed.
|
||||
#[test]
|
||||
fn prompts_are_valid_and_render_with_junk_arguments() {
|
||||
let mut s = server();
|
||||
let listed = rpc(&mut s, 1, "prompts/list", json!({}));
|
||||
let prompts = listed["result"]["prompts"].as_array().unwrap();
|
||||
assert!(!prompts.is_empty());
|
||||
let mut failures = vec![];
|
||||
for p in prompts {
|
||||
let name = p["name"].as_str().unwrap();
|
||||
assert!(p["description"].as_str().unwrap().len() > 20, "{name}");
|
||||
let args: Vec<&str> = p["arguments"].as_array().map_or(&[][..], Vec::as_slice).iter().filter_map(|a| a["name"].as_str()).collect();
|
||||
// Missing, empty, junk and hostile arguments all either render or fail cleanly.
|
||||
let mut cases = vec![json!({}), Value::Null, json!([]), json!("x")];
|
||||
for a in &args {
|
||||
for v in junk_values() {
|
||||
cases.push(json!({ *a: v }));
|
||||
cases.push(json!({ *a: { "nested": v.clone() } }));
|
||||
cases.push(json!({ *a: [v.clone()] }));
|
||||
}
|
||||
}
|
||||
// Filled with every argument, the prompt must render.
|
||||
let full: Value = json!(args.iter().map(|a| (*a, "value")).collect::<std::collections::BTreeMap<_, _>>());
|
||||
cases.push(full);
|
||||
for arguments in cases {
|
||||
let r = catch_quiet(|| rpc(&mut s, 2, "prompts/get", json!({"name": name, "arguments": arguments.clone()})));
|
||||
match r {
|
||||
Err(p) => failures.push(format!("PANIC {name} {arguments}: {p}")),
|
||||
Ok(v) => {
|
||||
assert!(v["result"].is_object() || v["error"]["code"] == -32602, "{name} {arguments}: {v}");
|
||||
// Whatever it returned is valid JSON text in a message.
|
||||
if let Some(messages) = v["result"]["messages"].as_array() {
|
||||
for m in messages {
|
||||
assert!(["user", "assistant"].contains(&m["role"].as_str().unwrap()), "{m}");
|
||||
assert!(m["content"]["text"].is_string(), "{m}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
|
||||
}
|
||||
|
||||
/// `completion/complete` answers for every prompt argument and never panics on junk.
|
||||
#[test]
|
||||
fn completions_are_well_formed_and_survive_junk() {
|
||||
let mut s = server();
|
||||
let listed = rpc(&mut s, 1, "prompts/list", json!({}));
|
||||
let mut cases = vec![];
|
||||
for p in listed["result"]["prompts"].as_array().unwrap() {
|
||||
let name = p["name"].as_str().unwrap();
|
||||
for a in p["arguments"].as_array().map_or(&[][..], Vec::as_slice) {
|
||||
let arg = a["name"].as_str().unwrap();
|
||||
for value in ["", "a", "svg", "%20", "..", &"x".repeat(300)] {
|
||||
cases.push(json!({"ref": {"type": "ref/prompt", "name": name}, "argument": {"name": arg, "value": value}}));
|
||||
}
|
||||
}
|
||||
// An argument the prompt does not have, and a prompt that does not exist.
|
||||
cases.push(json!({"ref": {"type": "ref/prompt", "name": name}, "argument": {"name": "nope", "value": ""}}));
|
||||
cases.push(json!({"ref": {"type": "ref/prompt", "name": "nope"}, "argument": {"name": "x", "value": ""}}));
|
||||
}
|
||||
for t in templates(&mut s) {
|
||||
for v in ["", "1", "%", "vectorcraft://x"] {
|
||||
cases.push(json!({"ref": {"type": "ref/resource", "uri": t["uriTemplate"]}, "argument": {"name": "id", "value": v}}));
|
||||
}
|
||||
}
|
||||
// And malformed references of every shape.
|
||||
for junk in [json!({}), json!(null), json!([]), json!("x")] {
|
||||
cases.push(junk);
|
||||
}
|
||||
for junk in junk_values().into_iter().take(12) {
|
||||
cases.push(junk);
|
||||
}
|
||||
|
||||
let mut failures = vec![];
|
||||
for params in cases {
|
||||
let r = catch_quiet(|| rpc(&mut s, 2, "completion/complete", params.clone()));
|
||||
match r {
|
||||
Err(p) => failures.push(format!("PANIC {params}: {p}")),
|
||||
Ok(v) => {
|
||||
assert!(v.get("error").is_none(), "{params}: {v}");
|
||||
let completion = &v["result"]["completion"];
|
||||
let values = completion["values"].as_array().unwrap_or_else(|| panic!("{params}: no values"));
|
||||
assert!(values.len() <= 100, "{params}: {} values", values.len());
|
||||
assert!(values.iter().all(Value::is_string), "{params}: {v}");
|
||||
if let Some(total) = completion["total"].as_u64() {
|
||||
assert!(total >= values.len() as u64, "{params}: {v}");
|
||||
}
|
||||
assert_eq!(completion["hasMore"], total_exceeds(completion), "{params}: {v}");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
|
||||
}
|
||||
|
||||
/// `hasMore` is set exactly when the catalogue held more than we sent.
|
||||
fn total_exceeds(completion: &Value) -> Value {
|
||||
let sent = completion["values"].as_array().map_or(0, Vec::len);
|
||||
json!(completion["total"].as_u64().is_some_and(|t| t > sent as u64))
|
||||
}
|
||||
|
||||
fn templates(s: &mut Server) -> Vec<Value> {
|
||||
rpc(s, 9, "resources/templates/list", json!({}))["result"]["resourceTemplates"].as_array().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Every template lists with a variable, and reading it answers JSON or a clean error.
|
||||
#[test]
|
||||
fn resource_templates_are_addressable() {
|
||||
let mut s = server();
|
||||
let mut failures = vec![];
|
||||
for t in templates(&mut s) {
|
||||
let uri = t["uriTemplate"].as_str().unwrap();
|
||||
assert!(uri.contains('{') && uri.ends_with('}'), "{t}");
|
||||
assert!(t["description"].as_str().unwrap().len() > 20, "{t}");
|
||||
assert!(t["mimeType"].is_string(), "{t}");
|
||||
// A template with its variable left empty, filled with junk, and with a real value.
|
||||
let prefix = uri.split('{').next().unwrap();
|
||||
let mut uris = vec![prefix.to_string(), format!("{prefix}{{"), format!("{prefix}%20"), format!("{prefix}-1")];
|
||||
let made = call(&mut s, "draw_shape", json!({"shape": "rectangle", "x": 1, "y": 1, "width": 20, "height": 20}));
|
||||
if let Some(id) =
|
||||
made["result"]["content"][0]["text"].as_str().and_then(|t| serde_json::from_str::<Value>(t).ok()).and_then(|v| v["id"].as_i64())
|
||||
{
|
||||
uris.push(format!("{prefix}{id}"));
|
||||
}
|
||||
for uri in uris {
|
||||
let r = catch_quiet(|| rpc(&mut s, 10, "resources/read", json!({"uri": uri.clone()})));
|
||||
match r {
|
||||
Err(p) => failures.push(format!("PANIC {uri}: {p}")),
|
||||
Ok(v) => {
|
||||
if let Some(text) = v["result"]["contents"][0]["text"].as_str() {
|
||||
serde_json::from_str::<Value>(text).unwrap_or_else(|e| panic!("{uri} is not JSON: {e}"));
|
||||
} else {
|
||||
let code = v["error"]["code"].as_i64().unwrap_or_default();
|
||||
assert!(code == -32602 || code == -32002, "{uri}: {v}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
/// Anything a client may send for the prompts, completion, logging and template methods leaves the
|
||||
/// server answering and the document valid.
|
||||
#[test]
|
||||
fn the_new_methods_survive_junk_and_keep_the_document_valid() {
|
||||
let mut failures = vec![];
|
||||
let mut s = server();
|
||||
rpc(&mut s, 1, "tools/call", json!({"name": "draw_shape", "arguments": {"shape": "rectangle", "x": 0, "y": 0, "width": 30, "height": 20}}));
|
||||
let before = doc_json(&document(&mut s));
|
||||
|
||||
let mut cases: Vec<(&str, Value)> = vec![];
|
||||
for uri in ["vectorcraft://document", "vectorcraft://object/1", "vectorcraft://nope", "", "x://y", "%"] {
|
||||
cases.push(("resources/read", json!({"uri": uri})));
|
||||
cases.push(("resources/read", json!({})));
|
||||
cases.push(("resources/read", json!({"uri": 5})));
|
||||
}
|
||||
for level in [json!("debug"), json!("emergency"), json!(null), json!("shouty"), json!(5), json!([]), json!({})] {
|
||||
cases.push(("logging/setLevel", json!({"level": level})));
|
||||
}
|
||||
cases.push(("logging/setLevel", json!({})));
|
||||
for name in [json!("poster"), json!("nope"), json!(""), json!(5), json!(null), json!([])] {
|
||||
cases.push(("prompts/get", json!({"name": name})));
|
||||
cases.push(("prompts/get", json!({"name": name, "arguments": []})));
|
||||
}
|
||||
for ref_ in [
|
||||
json!({"type": "ref/prompt", "name": "poster"}),
|
||||
json!({"type": "ref/resource", "uri": "vectorcraft://object/{id}"}),
|
||||
json!({"type": "ref/other"}),
|
||||
json!("x"),
|
||||
json!([]),
|
||||
] {
|
||||
cases.push(("completion/complete", json!({"ref": ref_, "argument": {"name": "brief", "value": ""}})));
|
||||
cases.push(("completion/complete", json!({"ref": ref_})));
|
||||
}
|
||||
|
||||
for (method, params) in cases {
|
||||
let r = catch_quiet(|| rpc(&mut s, 11, method, params.clone()));
|
||||
match r {
|
||||
Err(p) => failures.push(format!("PANIC {method} {params}: {p}")),
|
||||
Ok(v) => {
|
||||
assert!(v.get("result").is_some() != v.get("error").is_some(), "{method} {params}: {v}");
|
||||
// Whatever it answered, every notification it queued is a JSON-RPC message.
|
||||
for line in s.take_notifications() {
|
||||
let n: Value = serde_json::from_str(&line).unwrap_or_else(|e| panic!("bad notification {line}: {e}"));
|
||||
assert_eq!(n["jsonrpc"], "2.0", "{n}");
|
||||
assert_eq!(n["method"], "notifications/message", "{n}");
|
||||
assert!(n["params"]["level"].is_string() && n["params"]["data"].is_string(), "{n}");
|
||||
}
|
||||
check_document(&document(&mut s)).unwrap_or_else(|e| failures.push(format!("{method} {params}: {e}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
// None of these edit the document, so it must be exactly as it was.
|
||||
assert_eq!(doc_json(&document(&mut s)), before, "a read-only method changed the document");
|
||||
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
|
||||
}
|
||||
|
||||
/// Every capability the server advertises is one it actually answers.
|
||||
#[test]
|
||||
fn advertised_capabilities_all_answer() {
|
||||
let mut s = server();
|
||||
let caps = rpc(&mut s, 1, "initialize", json!({}))["result"]["capabilities"].clone();
|
||||
let checks: Vec<(&str, &str, Value)> = vec![
|
||||
("tools", "tools/list", json!({})),
|
||||
("prompts", "prompts/list", json!({})),
|
||||
("completions", "completion/complete", json!({"ref": {"type": "ref/prompt", "name": "poster"}, "argument": {"name": "brief", "value": ""}})),
|
||||
("logging", "logging/setLevel", json!({"level": null})),
|
||||
("resources", "resources/list", json!({})),
|
||||
("resources", "resources/templates/list", json!({})),
|
||||
("resources", "resources/read", json!({"uri": doc_uri_json(&mut s)})),
|
||||
];
|
||||
for (capability, method, params) in checks {
|
||||
assert!(caps[capability].is_object(), "{capability} is not advertised: {caps}");
|
||||
assert!(rpc(&mut s, 2, method, params).get("error").is_none(), "{method} is advertised but does not answer");
|
||||
}
|
||||
// And nothing is advertised that the server does not answer.
|
||||
assert!(caps["resources"].get("subscribe").is_none(), "subscribe is advertised but not implemented: {caps}");
|
||||
assert_eq!(err_code(&mut s, 3, "resources/subscribe", json!({"uri": "vectorcraft://document"})), -32601);
|
||||
}
|
||||
|
||||
/// A capability the server advertises must be one it answers, and one it does not advertise must
|
||||
/// not be there.
|
||||
fn err_code(s: &mut Server, id: u64, method: &str, params: Value) -> i64 {
|
||||
let v = rpc(s, id, method, params);
|
||||
v.get("error").and_then(|e| e["code"].as_i64()).unwrap_or_else(|| panic!("{method} should have failed: {v}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_redo_tools_restore_documents() {
|
||||
let mut s = server();
|
||||
|
||||
@@ -32,4 +32,6 @@ sha2 = { version = "0.10", default-features = false }
|
||||
|
||||
[dev-dependencies]
|
||||
vectorcraft-testkit = { workspace = true }
|
||||
# A variable font built in code (no font file is committed).
|
||||
vectorcraft-text = { workspace = true, features = ["test-fonts"] }
|
||||
serde_json = { workspace = true }
|
||||
@@ -1444,7 +1444,11 @@ impl Exporter<'_> {
|
||||
let name = format!("{} {}", face.family, face.style);
|
||||
let font = match face.embedding() {
|
||||
Embedding::Subset => {
|
||||
let font = krilla::text::Font::new(face.file_data().to_vec().into(), face.face_index()).map(|f| (f, face.units_per_em()));
|
||||
// A named instance of a variable font is embedded instanced (its own outlines and
|
||||
// widths, not the default instance's).
|
||||
let coords: Vec<(krilla::text::Tag, f32)> = face.variations().iter().map(|(t, v)| (krilla::text::Tag::new(t), *v)).collect();
|
||||
let font =
|
||||
krilla::text::Font::new_variable(face.file_data().to_vec().into(), face.face_index(), &coords).map(|f| (f, face.units_per_em()));
|
||||
if font.is_none() {
|
||||
self.warn(format!("the font “{name}” can't be embedded: its text is exported as outlines"));
|
||||
}
|
||||
|
||||
@@ -111,7 +111,16 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
||||
let scan = scan_page(page, &mut ocgs, all_on, &mut b.fonts);
|
||||
b.begin_page(scan);
|
||||
interpret_page(page, &mut ctx, &mut b);
|
||||
let parts = b.end_page();
|
||||
let mut parts = b.end_page();
|
||||
// A file with several artboards writes, on each page, the art of its neighbours that
|
||||
// reaches into the page's box: art lying wholly outside this page is theirs (each page
|
||||
// draws it shifted by its own artboard spacing), so keep it only where it belongs.
|
||||
if picked.len() > 1 {
|
||||
for (_, art) in &mut parts {
|
||||
art.retain(|n| n.visual_bounds().is_none_or(|r| r.x0 <= ab.x1 && r.x1 >= ab.x0 && r.y0 <= ab.y1 && r.y1 >= ab.y0));
|
||||
}
|
||||
parts.retain(|(_, art)| !art.is_empty());
|
||||
}
|
||||
let children: Vec<Arc<Node>> = parts.iter().flat_map(|(_, v)| v.iter().cloned()).collect();
|
||||
placeholder &= crate::pages::has_private_data(page) && only_text(&children);
|
||||
let mut right = ab.x1;
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
//! fifth of the size reads as a space). Fonts are named from the file's base font name (its
|
||||
//! subset prefix dropped, the family matched against the fonts available); a font that isn't
|
||||
//! available keeps its name, and the text shows in the fallback font until it is.
|
||||
//!
|
||||
//! Glyphs each turned a little further along a curve (type set on a path: apps write each glyph
|
||||
//! with its own placement) make one type-on-a-path object, its path through the glyphs' baseline.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use kurbo::{Affine, Point, Vec2};
|
||||
use kurbo::{Affine, BezPath, Point, Vec2};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{CharStyle, TextObject, TextRun};
|
||||
use vectorcraft_doc::{CharStyle, TextKind, TextObject, TextRun};
|
||||
|
||||
/// One glyph's placement: its baseline origin, advance direction, size and horizontal scale.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -93,12 +96,19 @@ pub(crate) struct TextLine {
|
||||
next: Point,
|
||||
/// The last glyph's origin.
|
||||
last: Point,
|
||||
/// The last glyph's baseline direction.
|
||||
last_dir: Vec2,
|
||||
/// Each glyph's baseline origin, then where the last one ends.
|
||||
baseline: Vec<Point>,
|
||||
runs: Vec<(Look, String)>,
|
||||
}
|
||||
|
||||
/// The most a glyph on a curve turns from the one before it (about 20°).
|
||||
const CURVE_TURN_COS: f64 = 0.94;
|
||||
|
||||
impl TextLine {
|
||||
pub fn new(at: Placement, opacity: f32) -> Self {
|
||||
Self { at, opacity, next: at.origin, last: at.origin, runs: vec![] }
|
||||
Self { at, opacity, next: at.origin, last: at.origin, last_dir: at.dir, baseline: vec![], runs: vec![] }
|
||||
}
|
||||
|
||||
/// Add glyph `text` drawn with `look` at `at` (advancing `advance` points) at `opacity` if
|
||||
@@ -106,13 +116,20 @@ impl TextLine {
|
||||
pub fn push(&mut self, look: &Look, at: Placement, opacity: f32, advance: f64, text: &str) -> bool {
|
||||
if let Some((last, run)) = self.runs.last_mut() {
|
||||
let size = self.at.size.max(at.size);
|
||||
let gap = (at.origin - self.next).dot(self.at.dir);
|
||||
let gap = (at.origin - self.next).dot(self.last_dir);
|
||||
let on_line = opacity == self.opacity
|
||||
&& at.dir.dot(self.at.dir) > 0.9995
|
||||
&& (at.origin - self.at.origin).dot(self.at.up()).abs() < size * 0.15
|
||||
&& gap > -size * 0.3
|
||||
&& gap < size * 3.0;
|
||||
if !on_line {
|
||||
// Or on a curve: turned a little from the glyph before it, and starting about where
|
||||
// that one ends.
|
||||
let on_curve = !on_line
|
||||
&& opacity == self.opacity
|
||||
&& at.dir.dot(self.last_dir) > CURVE_TURN_COS
|
||||
&& at.dir.dot(self.last_dir) < 0.99999
|
||||
&& (at.origin - self.next).hypot() < size * 0.6;
|
||||
if !on_line && !on_curve {
|
||||
return false;
|
||||
}
|
||||
// A gap wider than a fifth of an em reads as a space.
|
||||
@@ -127,11 +144,42 @@ impl TextLine {
|
||||
} else {
|
||||
self.runs.push((look.clone(), text.to_string()));
|
||||
}
|
||||
self.next = at.origin + self.at.dir * advance;
|
||||
self.next = at.origin + at.dir * advance;
|
||||
self.last = at.origin;
|
||||
self.last_dir = at.dir;
|
||||
self.baseline.push(at.origin);
|
||||
true
|
||||
}
|
||||
|
||||
/// Has the baseline turned (more than about 3° from the first glyph to the last)?
|
||||
fn curved(&self) -> bool {
|
||||
self.last_dir.dot(self.at.dir) < 0.9986
|
||||
}
|
||||
|
||||
/// A smooth path through the glyphs' baseline origins and the end of the last glyph, an em
|
||||
/// further (Catmull-Rom through the points, as cubic Béziers).
|
||||
fn baseline_path(&self) -> Option<BezPath> {
|
||||
let mut pts = self.baseline.clone();
|
||||
// On past the end of the last glyph by an em, so rounding in the spacing doesn't push it
|
||||
// off the end of the path (where type on a path stops).
|
||||
pts.push(self.next);
|
||||
pts.push(self.next + self.last_dir * self.at.size);
|
||||
let first = *pts.first()?;
|
||||
// Finite placements far out can still overflow once an em is added: no path then.
|
||||
if pts.len() < 3 || !pts.iter().all(|p| p.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to(first);
|
||||
for (i, seg) in pts.windows(2).enumerate() {
|
||||
let &[p1, p2] = seg else { continue };
|
||||
let p0 = *pts.get(i.wrapping_sub(1)).unwrap_or(&p1);
|
||||
let p3 = *pts.get(i + 2).unwrap_or(&p2);
|
||||
bp.curve_to(p1 + (p2 - p0) / 6.0, p2 - (p3 - p1) / 6.0, p2);
|
||||
}
|
||||
Some(bp)
|
||||
}
|
||||
|
||||
/// A stroke (`stroke`: paint and width) over the glyph just drawn at `at` in `font` (fill
|
||||
/// and stroke rendering): the run is stroked. `false`: it isn't that glyph.
|
||||
pub fn stroke_last(&mut self, font: u128, at: Placement, stroke: (Paint, f64)) -> bool {
|
||||
@@ -144,6 +192,8 @@ impl TextLine {
|
||||
|
||||
/// The point type object and its opacity.
|
||||
pub fn finish(mut self) -> Option<(TextObject, f32)> {
|
||||
// Turned along a curve: type on a path through the glyphs.
|
||||
let path = if self.curved() { self.baseline_path() } else { None };
|
||||
if let Some((_, last)) = self.runs.last_mut() {
|
||||
last.truncate(last.trim_end().len());
|
||||
}
|
||||
@@ -156,6 +206,10 @@ impl TextLine {
|
||||
let mut t = TextObject::point(Point::ORIGIN, &first.text, first.style);
|
||||
t.runs.extend(runs);
|
||||
t.xf = Affine::translate(self.at.origin.to_vec2()) * Affine::rotate(self.at.dir.atan2());
|
||||
if let Some(path) = path {
|
||||
t.kind = TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&path), start: 0.0 };
|
||||
t.xf = Affine::IDENTITY;
|
||||
}
|
||||
Some((t, self.opacity))
|
||||
}
|
||||
}
|
||||
@@ -206,6 +260,10 @@ impl Families {
|
||||
Some((tag, rest)) if tag.len() == 6 && tag.chars().all(|c| c.is_ascii_uppercase()) => rest,
|
||||
_ => name,
|
||||
};
|
||||
// An installed face of that exact PostScript name: its own family and style.
|
||||
if let Some((family, style)) = vectorcraft_text::FontDb::global().by_postscript_name(name) {
|
||||
return (family, style, true);
|
||||
}
|
||||
let (fam, style) = name.split_once(['-', ',']).unwrap_or((name, ""));
|
||||
let found = [fam, strip_ps(fam)].iter().find_map(|f| self.0.get(&norm(f)).cloned());
|
||||
let style = match spaced(strip_ps(style)).as_str() {
|
||||
|
||||
@@ -359,3 +359,34 @@ fn appearance_items_are_untouched_for_plain_art() {
|
||||
assert!(nodes.iter().all(|n| n.mask.is_none() && !matches!(n.kind, NodeKind::Group { .. })));
|
||||
assert!(matches!(nodes.iter().find(|n| n.appearance.stroke().is_some()).unwrap().appearance.items[0], AppearanceItem::Stroke(_)));
|
||||
}
|
||||
|
||||
/// Type set on a path (each glyph placed and turned along a curve, as apps write it) comes back
|
||||
/// as type on a path through the glyphs, not as a straight line; straight type stays point type.
|
||||
#[test]
|
||||
fn glyphs_turned_along_a_curve_become_type_on_a_path() {
|
||||
// "ARCHING" along an arc of radius 100 around (150, 0), each glyph where the one before
|
||||
// it ends (Helvetica's advances), clockwise from 135° (PDF y up: the arc bulges upwards).
|
||||
let mut content = String::from("BT /F1 18 Tf ");
|
||||
let mut a = 135f64.to_radians();
|
||||
for (c, adv) in "ARCHING".chars().zip([667.0, 722.0, 722.0, 722.0, 278.0, 722.0, 778.0]) {
|
||||
let (x, y) = (150.0 + 100.0 * a.cos(), 100.0 * a.sin());
|
||||
// The baseline's direction is the tangent, clockwise along the arc.
|
||||
let (dx, dy) = (a.sin(), -a.cos());
|
||||
content.push_str(&format!("{dx:.5} {dy:.5} {:.5} {dx:.5} {x:.3} {y:.3} Tm ({c}) Tj ", -dy));
|
||||
a -= adv / 1000.0 * 18.0 / 100.0;
|
||||
}
|
||||
content.push_str("ET");
|
||||
let t = texts(&open(&one_page(&content, HELVETICA, &[])));
|
||||
assert_eq!(t.len(), 1, "one text object: {t:?}");
|
||||
let text: String = t[0].runs.iter().map(|r| r.text.as_str()).collect();
|
||||
assert_eq!(text, "ARCHING");
|
||||
let vectorcraft_doc::TextKind::OnPath { path, .. } = &t[0].kind else { panic!("type on a path: {:?}", t[0].kind) };
|
||||
let b = path.bounds().unwrap();
|
||||
assert!(b.height() > 15.0, "the path curves: {b:?}");
|
||||
// Every glyph fits on the path, the last one too.
|
||||
assert_eq!(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t[0]).glyphs.len(), 7);
|
||||
// Straight type with a slight rotation stays one point type object.
|
||||
let straight = texts(&open(&one_page("BT /F1 18 Tf 0.98481 0.17365 -0.17365 0.98481 20 40 Tm (Straight) Tj ET", HELVETICA, &[])));
|
||||
assert_eq!(straight.len(), 1);
|
||||
assert!(matches!(straight[0].kind, vectorcraft_doc::TextKind::Point), "{:?}", straight[0].kind);
|
||||
}
|
||||
@@ -106,6 +106,19 @@ fn bounding_boxes_of_several_pages_do_not_overlap() {
|
||||
assert!(b.x0 >= a.x1 + 36.0, "{a:?} {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_of_neighbouring_artboards_off_a_page_is_left_to_its_own_page() {
|
||||
// Page 2 also draws page 1's square, off to its left (outside its box), as files with several
|
||||
// artboards are written: only page 1 keeps it, so it doesn't come back twice.
|
||||
let pages = [PdfPage::new(100.0, 100.0, "0 g 10 10 20 20 re f"), PdfPage::new(100.0, 100.0, "0 g -90 10 20 20 re f 1 0 0 rg 50 10 20 20 re f")];
|
||||
let d = open(&pdf(&pages, None), ImportOptions::default());
|
||||
let count = |i: usize| d.layers[i].children().map_or(0, |c| c.len());
|
||||
assert_eq!((count(0), count(1)), (1, 1));
|
||||
// A single page keeps its art off the page (the pasteboard).
|
||||
let d = open(&pdf(&pages[1..], None), ImportOptions::default());
|
||||
assert_eq!(d.layers[0].children().map_or(0, |c| c.len()), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_encrypted_pdf_opens_with_its_password_only() {
|
||||
let bytes = pdf(&[PdfPage::new(200.0, 100.0, "1 0 0 rg 10 10 50 50 re f")], Some("secret"));
|
||||
|
||||
@@ -110,6 +110,28 @@ fn real_text_lands_within_half_a_point_of_the_outlines() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A named instance of a variable font is real text in that instance: its embedded subset draws
|
||||
/// the instance's glyphs where the outlines would be, not the default instance's (#296).
|
||||
#[test]
|
||||
fn variable_font_instances_are_real_text_in_their_own_glyphs() {
|
||||
use vectorcraft_text::test_fonts::{VARIABLE_CHARS, VARIABLE_FAMILY, variable_font};
|
||||
FontDb::global().add_font(variable_font().unwrap());
|
||||
let mut widths = vec![];
|
||||
for s in ["Regular", "Bold"] {
|
||||
let st = CharStyle { font_family: VARIABLE_FAMILY.into(), font_style: s.into(), ..style(48.0) };
|
||||
let d = doc(vec![TextObject::point(Point::new(20.0, 80.0), VARIABLE_CHARS, st)]);
|
||||
let r = pdf(&d, false);
|
||||
assert!(r.warnings.is_empty(), "{s}: {:?}", r.warnings);
|
||||
assert_eq!(texts(&import_as(&r.bytes, TextAs::Text)), [VARIABLE_CHARS], "{s}: real text");
|
||||
let (real, outlined) = (ink(&import_as(&r.bytes, TextAs::Outlines)), ink(&import_as(&pdf(&d, true).bytes, TextAs::Outlines)));
|
||||
let off =
|
||||
[real.x0 - outlined.x0, real.y0 - outlined.y0, real.x1 - outlined.x1, real.y1 - outlined.y1].iter().fold(0.0f64, |m, v| m.max(v.abs()));
|
||||
assert!(off < 0.5, "{s}: {real:?} vs {outlined:?}");
|
||||
widths.push(real.width());
|
||||
}
|
||||
assert!(widths[1] > widths[0] * 1.2, "Bold is wider: {widths:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradients_on_real_text_stay_where_they_were() {
|
||||
let fill = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
|
||||
|
||||
@@ -1488,8 +1488,14 @@ fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> TextGeom {
|
||||
let mut runs = vec![BezPath::new(); t.runs.len()];
|
||||
let mut all = BezPath::new();
|
||||
// Per run: is its family missing, and the face its glyphs should come from.
|
||||
let faces: Vec<(bool, Option<u32>)> =
|
||||
t.runs.iter().map(|r| (!db.has_family(&r.style.font_family), db.face(&r.style.font_family, &r.style.font_style).map(|f| f.id()))).collect();
|
||||
let faces: Vec<(bool, Option<u32>)> = t
|
||||
.runs
|
||||
.iter()
|
||||
.map(|r| match db.resolve(&r.style.font_family, &r.style.font_style) {
|
||||
Some((f, m)) => (m == vectorcraft_text::FontMatch::Missing, Some(f.id())),
|
||||
None => (true, None),
|
||||
})
|
||||
.collect();
|
||||
let (mut substituted_fonts, mut substituted_glyphs) = (BezPath::new(), BezPath::new());
|
||||
for g in &layout.glyphs {
|
||||
if let Some(r) = runs.get_mut(g.run) {
|
||||
|
||||
@@ -30,6 +30,8 @@ svgtypes = "0.16"
|
||||
[dev-dependencies]
|
||||
# Reading embedded font subsets back.
|
||||
skrifa = { workspace = true }
|
||||
# A variable font built in code (no font file is committed).
|
||||
vectorcraft-text = { workspace = true, features = ["test-fonts"] }
|
||||
resvg = { version = "0.45", default-features = false }
|
||||
vectorcraft-testkit = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
|
||||
@@ -216,6 +216,31 @@ fn embedded_fonts_hold_only_the_glyphs_used() {
|
||||
assert!(!export_full(&d, &ExportOptions { embed_fonts: true, outline_text: true, ..Default::default() }, None).svg.contains("@font-face"));
|
||||
}
|
||||
|
||||
/// A named instance of a variable font embeds as a static font of that instance: the variable
|
||||
/// file would show its default instance in browsers (#296).
|
||||
#[test]
|
||||
fn variable_font_instances_embed_as_themselves() {
|
||||
use vectorcraft_text::test_fonts::{VARIABLE_FAMILY, variable_font};
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
db.add_font(variable_font().unwrap());
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
add(&mut d, text(Point::new(20.0, 60.0), &[("ll", style(VARIABLE_FAMILY, "Regular")), ("ll", style(VARIABLE_FAMILY, "Bold"))]));
|
||||
let o = export_full(&d, &ExportOptions { embed_fonts: true, ..Default::default() }, None);
|
||||
let fonts = embedded(&o.svg);
|
||||
assert_eq!(fonts.iter().map(|f| f.1).collect::<Vec<_>>(), [400, 700], "{:?}", o.warnings);
|
||||
let mut advances = vec![];
|
||||
for ((_, w, data), style) in fonts.iter().zip(["Regular", "Bold"]) {
|
||||
let f = skrifa::FontRef::new(data).unwrap();
|
||||
assert!(f.fvar().is_err() && f.gvar().is_err(), "{w}: a static font");
|
||||
let gid = f.cmap().unwrap().map_codepoint('l').unwrap();
|
||||
let adv = f.hmtx().unwrap().advance(gid).unwrap();
|
||||
let face = db.face(VARIABLE_FAMILY, style).unwrap();
|
||||
assert_eq!(f64::from(adv), face.advance(face.glyph_for('l')).round(), "{style}");
|
||||
advances.push(adv);
|
||||
}
|
||||
assert!(advances[1] > advances[0], "{advances:?}");
|
||||
}
|
||||
|
||||
/// The bundled Source Sans 3 Regular renamed `family` (13 characters, as long as the original
|
||||
/// name) with OS/2 `fsType` set to `fs_type`.
|
||||
fn licensed_font(family: &str, fs_type: u16) -> Vec<u8> {
|
||||
|
||||
+13
-2
@@ -15,5 +15,16 @@ kurbo = { workspace = true }
|
||||
skrifa = { workspace = true }
|
||||
harfrust = { workspace = true }
|
||||
log = { workspace = true }
|
||||
# Font subsets for embedding (SVG @font-face); MIT OR Apache-2.0, already used by the PDF writer.
|
||||
subsetter = { version = "0.2", default-features = false }
|
||||
# Font subsets for embedding (SVG @font-face); MIT OR Apache-2.0, already used by the PDF writer
|
||||
# (which turns on `variable-fonts` too: named instances of variable fonts are instanced).
|
||||
subsetter = { version = "0.2", default-features = false, features = ["variable-fonts"] }
|
||||
# Builds a small variable font for tests (`test_fonts`; no font file is committed). MIT OR
|
||||
# Apache-2.0, already in the build through subsetter.
|
||||
write-fonts = { version = "0.48", optional = true }
|
||||
|
||||
[features]
|
||||
# `vectorcraft_text::test_fonts` for other crates' tests (dev-dependencies only).
|
||||
test-fonts = ["dep:write-fonts"]
|
||||
|
||||
[dev-dependencies]
|
||||
write-fonts = "0.48"
|
||||
+33
-10
@@ -60,19 +60,29 @@ impl FontFace {
|
||||
|
||||
/// The face as a font file to embed: the glyphs of `chars` alone when its licence allows
|
||||
/// subsetting, else the whole face. Ligatures, kerning and other layout features are left out
|
||||
/// of subsets (viewers draw each character's own glyph). `None` when the licence forbids
|
||||
/// embedding or the font can't be read.
|
||||
/// of subsets (viewers draw each character's own glyph). A named instance of a variable font
|
||||
/// ([`FontFace::variations`]) is embedded as a static font of that instance (the variable file
|
||||
/// would show its default instance), CFF2 outlines becoming TrueType ones. `None` when the
|
||||
/// licence forbids embedding or the font can't be read.
|
||||
pub fn embed(&self, chars: &[char]) -> Option<EmbeddedFont> {
|
||||
let font = self.skrifa()?;
|
||||
let cff = [b"CFF ", b"CFF2"].iter().any(|t| font.table_data(Tag::new(t)).is_some());
|
||||
let whole = || Some(EmbeddedFont { data: sfnt(font.table_directory.sfnt_version(), tables(&font))?, cff, subset: false });
|
||||
let instance = !self.variations().is_empty();
|
||||
match self.embedding() {
|
||||
Embedding::Forbidden => None,
|
||||
// Every glyph of the instance, keeping their ids.
|
||||
Embedding::Whole if instance => {
|
||||
let all: Vec<char> = font.charmap().mappings().filter_map(|(c, _)| char::from_u32(c)).collect();
|
||||
subset(self, &font, &all, true).map(|(data, cff)| EmbeddedFont { data, cff, subset: false })
|
||||
}
|
||||
Embedding::Whole => whole(),
|
||||
Embedding::Subset => match subset(self, &font, chars) {
|
||||
Some(data) => Some(EmbeddedFont { data, cff, subset: true }),
|
||||
// Outlines the subsetter can't read (CFF2): what the licence allows anyway.
|
||||
None => whole(),
|
||||
Embedding::Subset => match subset(self, &font, chars, false) {
|
||||
Some((data, cff)) => Some(EmbeddedFont { data, cff, subset: true }),
|
||||
// Outlines the subsetter can't read: what the licence allows anyway (a named
|
||||
// instance can't be embedded whole, which would show the default instance).
|
||||
None if !instance => whole(),
|
||||
None => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -83,10 +93,17 @@ fn tables<'a>(font: &skrifa::FontRef<'a>) -> Vec<(Tag, Cow<'a, [u8]>)> {
|
||||
font.table_directory.table_records().iter().filter_map(|r| font.table_data(r.tag()).map(|d| (r.tag(), Cow::Borrowed(d.as_bytes())))).collect()
|
||||
}
|
||||
|
||||
/// `face` reduced to the glyphs of `chars`, with a `cmap` mapping them and the face's `OS/2`.
|
||||
fn subset(face: &FontFace, font: &skrifa::FontRef<'_>, chars: &[char]) -> Option<Vec<u8>> {
|
||||
/// `face` reduced to the glyphs of `chars` (every glyph, keeping their ids, when `all`), with a
|
||||
/// `cmap` mapping them and the face's `OS/2`; a named instance instanced. Returns the font and
|
||||
/// whether its outlines are CFF ones.
|
||||
fn subset(face: &FontFace, font: &skrifa::FontRef<'_>, chars: &[char], all: bool) -> Option<(Vec<u8>, bool)> {
|
||||
let charmap = font.charmap();
|
||||
let mut remapper = subsetter::GlyphRemapper::new();
|
||||
if all {
|
||||
for gid in 0..font.maxp().ok()?.num_glyphs() {
|
||||
remapper.remap(gid);
|
||||
}
|
||||
}
|
||||
let mut map: Vec<(u32, u16)> = chars
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
@@ -96,14 +113,20 @@ fn subset(face: &FontFace, font: &skrifa::FontRef<'_>, chars: &[char]) -> Option
|
||||
.collect();
|
||||
map.sort_unstable();
|
||||
map.dedup_by_key(|m| m.0);
|
||||
let data = subsetter::subset(face.data(), face.index(), &remapper).ok()?;
|
||||
let data = if face.variations().is_empty() {
|
||||
subsetter::subset(face.data(), face.index(), &remapper).ok()?
|
||||
} else {
|
||||
let coords: Vec<(subsetter::Tag, f32)> = face.variations().iter().map(|(t, v)| (subsetter::Tag::new(t), *v)).collect();
|
||||
subsetter::subset_with_variations(face.data(), face.index(), &coords, &remapper).ok()?
|
||||
};
|
||||
let sub = skrifa::FontRef::new(&data).ok()?;
|
||||
let cff = [b"CFF ", b"CFF2"].iter().any(|t| sub.table_data(Tag::new(t)).is_some());
|
||||
let mut t: Vec<(Tag, Cow<'_, [u8]>)> = tables(&sub).into_iter().filter(|(tag, _)| *tag != CMAP && *tag != OS2).collect();
|
||||
t.push((CMAP, Cow::Owned(cmap(&map)?)));
|
||||
if let Some(os2) = font.table_data(OS2) {
|
||||
t.push((OS2, Cow::Owned(os2_for(os2.as_bytes(), &map))));
|
||||
}
|
||||
sfnt(sub.table_directory.sfnt_version(), t)
|
||||
Some((sfnt(sub.table_directory.sfnt_version(), t)?, cff))
|
||||
}
|
||||
|
||||
/// The face's `OS/2` table with its first and last character fitted to the subset's.
|
||||
|
||||
@@ -7,7 +7,7 @@ use vectorcraft_doc::CharStyle;
|
||||
/// by the character style (`kerning`, `all_caps`, `tracking`); the rest are layout-wide options.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct OtFeatures {
|
||||
/// Vertical glyph alternates (vert/vrt2), set by the writing direction.
|
||||
/// Vertical glyph alternates (`vert`), set by the writing direction.
|
||||
pub vertical: bool,
|
||||
/// Standard ligatures (`liga`, `clig`). Suppressed automatically when tracking is non-zero.
|
||||
pub ligatures: bool,
|
||||
@@ -142,8 +142,9 @@ impl OtFeatures {
|
||||
}
|
||||
}
|
||||
if self.vertical {
|
||||
// Not `vrt2`: its glyphs are already turned on their side, and the layout turns Latin
|
||||
// and digits itself (they would lie upside down, and tate-chu-yoko break).
|
||||
v.push(f(b"vert", true));
|
||||
v.push(f(b"vrt2", true));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
+567
-57
@@ -7,11 +7,11 @@ use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
use kurbo::BezPath;
|
||||
use skrifa::instance::{LocationRef, Size};
|
||||
use skrifa::instance::{Location, LocationRef, Size};
|
||||
use skrifa::outline::{DrawSettings, OutlinePen};
|
||||
use skrifa::raw::FileRef;
|
||||
use skrifa::string::StringId;
|
||||
use skrifa::{GlyphId, MetadataProvider};
|
||||
use skrifa::{GlyphId, MetadataProvider, Tag};
|
||||
|
||||
/// The family used when a requested family is unknown (Illustrator's Myriad Pro analogue).
|
||||
pub const FALLBACK_FAMILY: &str = "Source Sans 3";
|
||||
@@ -28,6 +28,14 @@ static BUNDLED: &[&[u8]] = &[
|
||||
include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf"),
|
||||
];
|
||||
|
||||
/// An axis setting of a variable font: the axis tag (`wght`) and its value in user units (700).
|
||||
pub type Variation = ([u8; 4], f32);
|
||||
|
||||
/// Caps on what a (possibly damaged) variable font can make the database read: axes per font and
|
||||
/// named instances listed per face.
|
||||
const MAX_AXES: usize = 64;
|
||||
const MAX_INSTANCES: usize = 512;
|
||||
|
||||
enum FontBytes {
|
||||
Static(&'static [u8]),
|
||||
Owned(Arc<Vec<u8>>),
|
||||
@@ -35,6 +43,8 @@ enum FontBytes {
|
||||
|
||||
/// One loaded font face.
|
||||
pub struct FontFace {
|
||||
/// Every name the face answers to (normalized), in every language its name table has.
|
||||
keys: FaceKeys,
|
||||
id: u32,
|
||||
/// Typographic family name (e.g. "Source Sans 3").
|
||||
pub family: String,
|
||||
@@ -58,6 +68,13 @@ pub struct FontFace {
|
||||
pub(crate) cap_height: f64,
|
||||
pub(crate) x_height: f64,
|
||||
pub(crate) shaper: harfrust::ShaperData,
|
||||
/// The axis settings of the variable font's named instance this face is (user units: `wght`
|
||||
/// 700); empty for a static face or a variable font's default instance.
|
||||
variations: Vec<Variation>,
|
||||
/// [`Self::variations`] in the font's normalized design space (outlines and metrics).
|
||||
location: Location,
|
||||
/// [`Self::variations`] for the shaper (advances, kerning, feature variations).
|
||||
pub(crate) instance: Option<harfrust::ShaperInstance>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for FontFace {
|
||||
@@ -83,6 +100,17 @@ impl FontFace {
|
||||
pub(crate) fn index(&self) -> u32 {
|
||||
self.index
|
||||
}
|
||||
/// Where in a variable font's design space the face's outlines and metrics are taken (the
|
||||
/// default location for a static face).
|
||||
pub(crate) fn location(&self) -> LocationRef<'_> {
|
||||
LocationRef::from(&self.location)
|
||||
}
|
||||
/// The axis settings of the variable font's named instance this face is (`wght` 700 for
|
||||
/// Bold), in user units; empty for a static face and a variable font's default instance.
|
||||
/// The face's file holds the whole variable font: exporters that embed it pass these on.
|
||||
pub fn variations(&self) -> &[Variation] {
|
||||
&self.variations
|
||||
}
|
||||
/// Unique id of this face within the process.
|
||||
pub fn id(&self) -> u32 {
|
||||
self.id
|
||||
@@ -110,7 +138,7 @@ impl FontFace {
|
||||
/// Advance width of glyph `gid` in font units.
|
||||
pub fn advance(&self, gid: u32) -> f64 {
|
||||
self.skrifa()
|
||||
.and_then(|f| f.glyph_metrics(Size::unscaled(), LocationRef::default()).advance_width(GlyphId::new(gid)))
|
||||
.and_then(|f| f.glyph_metrics(Size::unscaled(), self.location()).advance_width(GlyphId::new(gid)))
|
||||
.map(|a| a as f64)
|
||||
.unwrap_or(self.upem * 0.5)
|
||||
}
|
||||
@@ -145,28 +173,46 @@ impl FontFace {
|
||||
#[derive(Debug, Default)]
|
||||
struct CatalogFamily {
|
||||
name: String,
|
||||
/// (style, file) of each face.
|
||||
faces: Vec<(String, PathBuf)>,
|
||||
faces: Vec<CatalogFace>,
|
||||
}
|
||||
|
||||
/// One style of an installed family: a face, or a named instance of a variable font.
|
||||
#[derive(Debug)]
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
struct CatalogFace {
|
||||
style: String,
|
||||
path: PathBuf,
|
||||
/// Weight and italic as the style lists them (by its name, or a named instance's axes).
|
||||
weight: f32,
|
||||
italic: bool,
|
||||
}
|
||||
|
||||
/// The installed fonts by ASCII-lowercased family name (lookups ignore ASCII case, as
|
||||
/// [`FontDb::face`] does). Always empty on wasm, which has no system fonts.
|
||||
type Catalog = HashMap<String, CatalogFamily>;
|
||||
|
||||
/// The installed faces' (family, style) by normalized PostScript name ([`norm`]).
|
||||
type PostScriptNames = HashMap<String, (String, String)>;
|
||||
|
||||
/// Process-wide font database.
|
||||
pub struct FontDb {
|
||||
faces: RwLock<Vec<Arc<FontFace>>>,
|
||||
outlines: Mutex<HashMap<(u32, u32), Arc<BezPath>>>,
|
||||
catalog: RwLock<Catalog>,
|
||||
/// The installed faces by PostScript name (documents name fonts so: PDF, EPS, .ai).
|
||||
postscript: RwLock<PostScriptNames>,
|
||||
/// The folders the system font scan reads (the platform's font folders for [`FontDb::global`]).
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
font_dirs: Vec<PathBuf>,
|
||||
/// The installed fonts' other names (localized, legacy, PostScript) by normalized name.
|
||||
aliases: RwLock<HashMap<String, Vec<Alias>>>,
|
||||
/// Set once the font folders have been scanned. Lookups by family name wait for the first
|
||||
/// scan, so what they find never depends on what ran before them.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
cataloged: std::sync::OnceLock<()>,
|
||||
/// [`FontDb::family_list`], built on demand and dropped when fonts are added or rescanned.
|
||||
family_cache: Mutex<Option<Arc<[String]>>>,
|
||||
menu_cache: Mutex<Option<MenuFamilies>>,
|
||||
generation: AtomicU64,
|
||||
/// System fallback state: characters no system font covers.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -217,6 +263,116 @@ fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option<String> {
|
||||
ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty()))
|
||||
}
|
||||
|
||||
/// Every localized string of `id`, with its language.
|
||||
fn localized(font: &skrifa::FontRef<'_>, id: StringId) -> Vec<(Option<String>, String)> {
|
||||
font.localized_strings(id).map(|s| (s.language().map(str::to_owned), s.to_string())).filter(|(_, s)| !s.is_empty()).collect()
|
||||
}
|
||||
|
||||
/// Every localized string of `id`.
|
||||
fn all_names(font: &skrifa::FontRef<'_>, id: StringId) -> Vec<String> {
|
||||
localized(font, id).into_iter().map(|(_, s)| s).collect()
|
||||
}
|
||||
|
||||
/// The names a face answers to besides its (English) family and style, normalized ([`norm`]):
|
||||
/// its family in every language (typographic, else legacy: `ヒラギノ角ゴシック`), its style likewise
|
||||
/// (`ミディアム`), its legacy family names (`Hiragino Sans W3`) with their legacy styles, paired per
|
||||
/// language record (the Mac and Windows records of a face differ), and its PostScript names
|
||||
/// (`HiraginoSans-W3`, as PDF and `.ai` files name fonts).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct FaceKeys {
|
||||
families: Vec<String>,
|
||||
styles: Vec<String>,
|
||||
legacy: Vec<(String, String)>,
|
||||
postscript: Vec<String>,
|
||||
}
|
||||
|
||||
impl FaceKeys {
|
||||
fn of(font: &skrifa::FontRef<'_>, family: &str, style: &str) -> Self {
|
||||
let (typo_family, typo_style) = (all_names(font, StringId::TYPOGRAPHIC_FAMILY_NAME), all_names(font, StringId::TYPOGRAPHIC_SUBFAMILY_NAME));
|
||||
let (legacy_family, legacy_style) = (localized(font, StringId::FAMILY_NAME), localized(font, StringId::SUBFAMILY_NAME));
|
||||
// Without typographic names, the legacy ones are the family's.
|
||||
let family_names: Vec<&str> = if typo_family.is_empty() {
|
||||
legacy_family.iter().map(|(_, f)| f.as_str()).collect()
|
||||
} else {
|
||||
typo_family.iter().map(String::as_str).collect()
|
||||
};
|
||||
let mut families: Vec<String> = std::iter::once(family).chain(family_names).map(norm).collect();
|
||||
// A legacy style only names the face beside its legacy family when the font has a
|
||||
// typographic style: every weight of `Hiragino Sans` is `Regular` in its own legacy family.
|
||||
let style_names: Vec<&str> = if typo_style.is_empty() {
|
||||
legacy_style.iter().map(|(_, s)| s.as_str()).collect()
|
||||
} else {
|
||||
typo_style.iter().map(String::as_str).collect()
|
||||
};
|
||||
let mut styles: Vec<String> = std::iter::once(style).chain(style_names).map(norm).collect();
|
||||
let mut legacy: Vec<(String, String)> = if typo_family.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
legacy_family
|
||||
.iter()
|
||||
.flat_map(|(lf, f)| legacy_style.iter().filter(move |(ls, _)| ls == lf).map(move |(_, s)| (norm(f), norm(s))))
|
||||
.filter(|(f, s)| !f.is_empty() && !s.is_empty())
|
||||
.collect()
|
||||
};
|
||||
let mut postscript: Vec<String> = all_names(font, StringId::POSTSCRIPT_NAME).iter().map(|n| norm(n)).collect();
|
||||
for v in [&mut families, &mut styles, &mut postscript] {
|
||||
v.retain(|k| !k.is_empty());
|
||||
v.sort();
|
||||
v.dedup();
|
||||
}
|
||||
legacy.sort();
|
||||
legacy.dedup();
|
||||
FaceKeys { families, styles, legacy, postscript }
|
||||
}
|
||||
}
|
||||
|
||||
/// What a name other than a family's own one stands for ([`FontDb::canonical`]).
|
||||
#[derive(Clone, Debug)]
|
||||
struct Alias {
|
||||
/// The family's own name.
|
||||
family: String,
|
||||
/// The face the name names, for legacy family names and PostScript names.
|
||||
style: Option<String>,
|
||||
/// A legacy family name's legacy style (`Regular` beside `Hiragino Sans W3`).
|
||||
paired: Option<String>,
|
||||
}
|
||||
|
||||
impl Alias {
|
||||
/// The aliases of a face `family` / `style` with `keys`, by normalized name.
|
||||
fn of(family: &str, style: &str, keys: &FaceKeys) -> Vec<(String, Alias)> {
|
||||
let mut v: Vec<(String, Alias)> =
|
||||
keys.families.iter().map(|k| (k.clone(), Alias { family: family.to_string(), style: None, paired: None })).collect();
|
||||
v.extend(
|
||||
keys.legacy
|
||||
.iter()
|
||||
.map(|(f, s)| (f.clone(), Alias { family: family.to_string(), style: Some(style.to_string()), paired: Some(s.clone()) })),
|
||||
);
|
||||
v.extend(keys.postscript.iter().map(|k| (k.clone(), Alias { family: family.to_string(), style: Some(style.to_string()), paired: None })));
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
/// How a requested family and style resolved ([`FontDb::resolve`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FontMatch {
|
||||
/// The face of that family and style (by any of its names, in any language).
|
||||
Exact,
|
||||
/// The family is there but not the style: its closest style stands in.
|
||||
Style,
|
||||
/// The family is unknown: the fallback family stands in.
|
||||
Missing,
|
||||
}
|
||||
|
||||
impl FontMatch {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
FontMatch::Exact => "exact",
|
||||
FontMatch::Style => "substitute",
|
||||
FontMatch::Missing => "missing",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A face's (family, style) names.
|
||||
fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String)> {
|
||||
let family = name(f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME])?;
|
||||
@@ -224,25 +380,103 @@ fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String)> {
|
||||
Some((family, style))
|
||||
}
|
||||
|
||||
/// Parse every face in `data` (a font file or collection). Returns `(index, family, style)`.
|
||||
fn enumerate_faces(data: &[u8]) -> Vec<(u32, String, String)> {
|
||||
/// One style a font file offers: one of its faces, or a named instance of a variable font
|
||||
/// (Figtree's `Figtree[wght].ttf` holds Light, the default, and Regular to Black as instances).
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct FaceStyle {
|
||||
family: String,
|
||||
style: String,
|
||||
keys: FaceKeys,
|
||||
/// A named instance's axis settings (user units); empty for the face itself.
|
||||
variations: Vec<Variation>,
|
||||
/// A named instance's weight (its `wght` setting); `None` for the face itself (its OS/2 table
|
||||
/// says) or an instance of a font without a weight axis.
|
||||
weight: Option<f32>,
|
||||
/// A named instance is italic (by its `ital` or `slnt` setting, or its name).
|
||||
italic: bool,
|
||||
}
|
||||
|
||||
/// The styles of a face: the face itself, then its named instances when it is a variable font.
|
||||
pub(crate) fn face_styles(f: &skrifa::FontRef<'_>) -> Vec<FaceStyle> {
|
||||
let Some((family, style)) = face_names(f) else { return vec![] };
|
||||
let mut keys = FaceKeys::of(f, &family, &style);
|
||||
let instances = named_instances(f, &family, &style, &mut keys);
|
||||
let face = FaceStyle { family, style, keys, variations: vec![], weight: None, italic: false };
|
||||
std::iter::once(face).chain(instances).collect()
|
||||
}
|
||||
|
||||
/// The named instances of a variable font face (its `fvar` table) as styles of the face's family,
|
||||
/// named by their subfamily names, without the default instance (the face itself) and without
|
||||
/// names already taken. The default instance's name, when it isn't the face's style name (a face
|
||||
/// `Regular` whose default instance is `Book`), names the face too (`keys`). Damaged tables give
|
||||
/// fewer instances or none; the counts read are capped.
|
||||
fn named_instances(f: &skrifa::FontRef<'_>, family: &str, style: &str, keys: &mut FaceKeys) -> Vec<FaceStyle> {
|
||||
let axes = f.axes();
|
||||
if axes.is_empty() || axes.len() > MAX_AXES {
|
||||
return vec![];
|
||||
}
|
||||
let tags: Vec<Tag> = axes.iter().map(|a| a.tag()).collect();
|
||||
let defaults: Vec<f32> = axes.iter().map(|a| a.default_value()).collect();
|
||||
// Instances without a PostScript name of their own are named `<prefix>-<style>` (Adobe
|
||||
// Technical Note #5902), as PDF and `.ai` files name them.
|
||||
let prefix = name(f, &[StringId::VARIATIONS_POSTSCRIPT_NAME_PREFIX]).unwrap_or_else(|| family.to_string());
|
||||
let mut taken = vec![norm(style)];
|
||||
let mut out = Vec::new();
|
||||
for inst in f.named_instances().iter().take(MAX_INSTANCES) {
|
||||
let coords: Vec<f32> = inst.user_coords().take(MAX_AXES).collect();
|
||||
if coords.len() != tags.len() || coords.iter().any(|c| !c.is_finite()) {
|
||||
continue;
|
||||
}
|
||||
let Some(style) = name(f, &[inst.subfamily_name_id()]) else { continue };
|
||||
let ns = norm(&style);
|
||||
if ns.is_empty() || taken.contains(&ns) {
|
||||
continue;
|
||||
}
|
||||
// The default instance is the face itself.
|
||||
if coords.iter().zip(&defaults).all(|(c, d)| (c - d).abs() < 1e-3) {
|
||||
keys.styles.push(ns.clone());
|
||||
keys.postscript.push(norm(&format!("{prefix}{style}")));
|
||||
taken.push(ns);
|
||||
continue;
|
||||
}
|
||||
taken.push(ns);
|
||||
let axis = |tag: &[u8; 4]| tags.iter().zip(&coords).find(|(t, _)| t.to_be_bytes() == *tag).map(|(_, v)| *v);
|
||||
let weight = axis(b"wght").map(|w| w.clamp(1.0, 1000.0));
|
||||
let italic = style_italic(&style) || axis(b"ital").is_some_and(|v| v >= 0.5) || axis(b"slnt").is_some_and(|v| v != 0.0);
|
||||
let mut styles: Vec<String> = std::iter::once(style.clone()).chain(all_names(f, inst.subfamily_name_id())).map(|s| norm(&s)).collect();
|
||||
let mut postscript: Vec<String> = inst.postscript_name_id().map(|id| all_names(f, id)).unwrap_or_default().iter().map(|n| norm(n)).collect();
|
||||
postscript.push(norm(&format!("{prefix}{style}")));
|
||||
for v in [&mut styles, &mut postscript] {
|
||||
v.retain(|k| !k.is_empty());
|
||||
v.sort();
|
||||
v.dedup();
|
||||
}
|
||||
let keys = FaceKeys { families: keys.families.clone(), styles, legacy: vec![], postscript };
|
||||
let variations = tags.iter().zip(&coords).map(|(t, v)| (t.to_be_bytes(), *v)).collect();
|
||||
out.push(FaceStyle { family: family.to_string(), style, keys, variations, weight, italic });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse every face in `data` (a font file or collection) and the named instances of its variable
|
||||
/// ones. Returns each style with the index of its face.
|
||||
fn enumerate_faces(data: &[u8]) -> Vec<(u32, FaceStyle)> {
|
||||
let count = match FileRef::new(data) {
|
||||
Ok(FileRef::Font(_)) => 1,
|
||||
Ok(FileRef::Collection(c)) => c.len(),
|
||||
Err(_) => 0,
|
||||
};
|
||||
(0..count)
|
||||
.filter_map(|i| {
|
||||
let (family, style) = face_names(&skrifa::FontRef::from_index(data, i).ok()?)?;
|
||||
Some((i, family, style))
|
||||
})
|
||||
.filter_map(|i| Some((i, skrifa::FontRef::from_index(data, i).ok()?)))
|
||||
.flat_map(|(i, f)| face_styles(&f).into_iter().map(move |s| (i, s)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// (family, style) of every face in the font file at `path`, reading only its table directories
|
||||
/// and `name` tables: a scan opens hundreds of font files, many of them megabytes long.
|
||||
/// The styles of every face in the font file at `path` ([`face_styles`]), reading only its table
|
||||
/// directories and `name` and `fvar` tables: a scan opens hundreds of font files, many of them
|
||||
/// megabytes long.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn file_face_names(path: &Path) -> Vec<(String, String)> {
|
||||
fn file_face_names(path: &Path) -> Vec<FaceStyle> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
/// Caps on what a (possibly damaged) file can make the scan read.
|
||||
const MAX_FACES: u32 = 256;
|
||||
@@ -269,26 +503,55 @@ fn file_face_names(path: &Path) -> Vec<(String, String)> {
|
||||
let dir = read_at(start.into(), 12)?;
|
||||
let tables = u16::from_be_bytes(dir.get(4..6)?.try_into().ok()?) as usize;
|
||||
let records = read_at(u64::from(start) + 12, tables * 16)?;
|
||||
let rec: &[u8] = records.as_chunks::<16>().0.iter().find(|r| r.starts_with(b"name"))?;
|
||||
let (offset, len) = (be32(rec, 8)?, be32(rec, 12)?);
|
||||
if len > MAX_NAME_TABLE {
|
||||
return None;
|
||||
let mut table = |tag: &[u8; 4]| -> Option<Vec<u8>> {
|
||||
let rec: &[u8] = records.as_chunks::<16>().0.iter().find(|r| r.starts_with(tag))?;
|
||||
let (offset, len) = (be32(rec, 8)?, be32(rec, 12)?);
|
||||
if len > MAX_NAME_TABLE {
|
||||
return None;
|
||||
}
|
||||
read_at(offset.into(), len as usize)
|
||||
};
|
||||
let name = table(b"name")?;
|
||||
// A variable font's named instances are styles too.
|
||||
let fvar = table(b"fvar");
|
||||
// A font holding just these tables, to read them as the font itself would.
|
||||
let mut tables: Vec<(&[u8; 4], &[u8])> = vec![(b"name", &name)];
|
||||
if let Some(fvar) = &fvar {
|
||||
tables.push((b"fvar", fvar));
|
||||
}
|
||||
let table = read_at(offset.into(), len as usize)?;
|
||||
// A one-table font holding just the `name` table, to read it as the font itself would.
|
||||
let mut font = Vec::with_capacity(28 + table.len());
|
||||
font.extend_from_slice(&0x0001_0000_u32.to_be_bytes());
|
||||
font.extend_from_slice(&[0, 1, 0, 16, 0, 0, 0, 0]);
|
||||
font.extend_from_slice(b"name");
|
||||
for v in [0, 28, len] {
|
||||
font.extend_from_slice(&u32::to_be_bytes(v));
|
||||
}
|
||||
font.extend_from_slice(&table);
|
||||
face_names(&skrifa::FontRef::new(&font).ok()?)
|
||||
let font = sfnt_of(&tables)?;
|
||||
Some(face_styles(&skrifa::FontRef::new(&font).ok()?))
|
||||
})
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A font file holding `tables` (tag, data), sorted by tag as table directories are.
|
||||
#[cfg(any(test, not(target_arch = "wasm32")))]
|
||||
pub(crate) fn sfnt_of(tables: &[(&[u8; 4], &[u8])]) -> Option<Vec<u8>> {
|
||||
let mut tables = tables.to_vec();
|
||||
tables.sort_by_key(|t| *t.0);
|
||||
let n = u16::try_from(tables.len()).ok()?;
|
||||
let mut font = Vec::new();
|
||||
font.extend_from_slice(&0x0001_0000_u32.to_be_bytes());
|
||||
for v in [n, 0, 0, 0] {
|
||||
font.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
let mut offset = 12 + 16 * tables.len();
|
||||
for (tag, data) in &tables {
|
||||
font.extend_from_slice(*tag);
|
||||
for v in [0, u32::try_from(offset).ok()?, u32::try_from(data.len()).ok()?] {
|
||||
font.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
offset += data.len().next_multiple_of(4);
|
||||
}
|
||||
for (_, data) in &tables {
|
||||
font.extend_from_slice(data);
|
||||
font.resize(font.len().next_multiple_of(4), 0);
|
||||
}
|
||||
Some(font)
|
||||
}
|
||||
|
||||
/// The platform's font folders (the system's and the user's), scanned by [`FontDb::global`].
|
||||
pub fn system_font_dirs() -> Vec<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> = Vec::new();
|
||||
@@ -301,6 +564,13 @@ pub fn system_font_dirs() -> Vec<PathBuf> {
|
||||
if let Some(h) = &home {
|
||||
dirs.push(h.join("Library/Fonts"));
|
||||
}
|
||||
// Fonts macOS downloads on demand (Yu Gothic, Yu Mincho, Tsukushi Maru Gothic…): one
|
||||
// asset folder per font, under a catalog folder whose number changes with macOS.
|
||||
for e in std::fs::read_dir("/System/Library/AssetsV2").into_iter().flatten().flatten() {
|
||||
if e.file_name().to_string_lossy().starts_with("com_apple_MobileAsset_Font") {
|
||||
dirs.push(e.path());
|
||||
}
|
||||
}
|
||||
} else if cfg!(windows) {
|
||||
let root = std::env::var_os("WINDIR").map(PathBuf::from).unwrap_or_else(|| "C:\\Windows".into());
|
||||
dirs.push(root.join("Fonts"));
|
||||
@@ -317,21 +587,31 @@ pub fn system_font_dirs() -> Vec<PathBuf> {
|
||||
dirs
|
||||
}
|
||||
|
||||
fn make_face(bytes: FontBytes, index: u32, family: String, style: String, path: Option<std::path::PathBuf>) -> Option<FontFace> {
|
||||
fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option<std::path::PathBuf>) -> Option<FontFace> {
|
||||
let data: &[u8] = match &bytes {
|
||||
FontBytes::Static(b) => b,
|
||||
FontBytes::Owned(v) => v.as_slice(),
|
||||
};
|
||||
let f = skrifa::FontRef::from_index(data, index).ok()?;
|
||||
let m = f.metrics(Size::unscaled(), LocationRef::default());
|
||||
let FaceStyle { family, style, keys, variations, weight, italic } = spec;
|
||||
// A named instance: outlines, metrics and advances at its axis settings.
|
||||
let location = if variations.is_empty() { Location::default() } else { f.axes().location(variations.iter().map(|(t, v)| (Tag::new(t), *v))) };
|
||||
let m = f.metrics(Size::unscaled(), &location);
|
||||
let a = f.attributes();
|
||||
let shaper = harfrust::ShaperData::new(&harfrust::FontRef::from_index(data, index).ok()?);
|
||||
let hb = harfrust::FontRef::from_index(data, index).ok()?;
|
||||
let shaper = harfrust::ShaperData::new(&hb);
|
||||
let instance =
|
||||
(!variations.is_empty()).then(|| harfrust::ShaperInstance::from_variations(&hb, variations.iter().map(|(t, v)| (harfrust::Tag::new(t), *v))));
|
||||
Some(FontFace {
|
||||
keys,
|
||||
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
|
||||
family,
|
||||
style,
|
||||
weight: a.weight.value(),
|
||||
italic: !matches!(a.style, skrifa::attribute::Style::Normal),
|
||||
weight: weight.unwrap_or(a.weight.value()),
|
||||
italic: italic || !matches!(a.style, skrifa::attribute::Style::Normal),
|
||||
variations,
|
||||
location,
|
||||
instance,
|
||||
upem: m.units_per_em.max(1) as f64,
|
||||
ascent: m.ascent as f64,
|
||||
descent: -(m.descent as f64),
|
||||
@@ -379,6 +659,9 @@ fn style_italic(style: &str) -> bool {
|
||||
s.contains("italic") || s.contains("oblique") || s == "it"
|
||||
}
|
||||
|
||||
/// [`FontDb::menu_family_list`], with the [`FontDb::family_list`] it was built from.
|
||||
type MenuFamilies = (Arc<[String]>, Arc<[String]>);
|
||||
|
||||
impl FontDb {
|
||||
/// A database holding the bundled fonts, whose system font scan reads `font_dirs`.
|
||||
pub fn with_font_dirs(font_dirs: Vec<PathBuf>) -> Self {
|
||||
@@ -387,8 +670,8 @@ impl FontDb {
|
||||
// first: fallbacks for Japanese text after the requested and bundled fonts.
|
||||
let craft = crate::craft_fonts::japanese_document_fonts().into_iter().map(|f| f.bytes);
|
||||
for data in BUNDLED.iter().copied().chain(craft) {
|
||||
for (i, family, style) in enumerate_faces(data) {
|
||||
if let Some(f) = make_face(FontBytes::Static(data), i, family, style, None) {
|
||||
for (i, spec) in enumerate_faces(data) {
|
||||
if let Some(f) = make_face(FontBytes::Static(data), i, spec, None) {
|
||||
faces.push(Arc::new(f));
|
||||
}
|
||||
}
|
||||
@@ -397,10 +680,13 @@ impl FontDb {
|
||||
faces: RwLock::new(faces),
|
||||
outlines: Mutex::new(HashMap::new()),
|
||||
catalog: RwLock::new(Catalog::new()),
|
||||
postscript: RwLock::new(PostScriptNames::new()),
|
||||
font_dirs,
|
||||
aliases: RwLock::new(HashMap::new()),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
cataloged: std::sync::OnceLock::new(),
|
||||
family_cache: Mutex::new(None),
|
||||
menu_cache: Mutex::new(None),
|
||||
generation: AtomicU64::new(0),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
sys: Mutex::new(SysFallback { enabled: true, ..Default::default() }),
|
||||
@@ -468,14 +754,33 @@ impl FontDb {
|
||||
list
|
||||
}
|
||||
|
||||
/// The families font menus and lists show: [`FontDb::family_list`] without the system's hidden
|
||||
/// families, whose names start with "." (macOS keeps ".SF NS", ".LastResort" and others for its
|
||||
/// own interface, and Mac apps don't list them). Those still resolve by name, for documents and
|
||||
/// fallbacks that name them. Cheap to call every frame.
|
||||
pub fn menu_family_list(&self) -> Arc<[String]> {
|
||||
let all = self.family_list();
|
||||
let mut cache = self.menu_cache.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some((from, list)) = cache.as_ref()
|
||||
&& Arc::ptr_eq(from, &all)
|
||||
{
|
||||
return list.clone();
|
||||
}
|
||||
let list: Arc<[String]> = all.iter().filter(|f| !f.starts_with('.')).cloned().collect();
|
||||
*cache = Some((all, list.clone()));
|
||||
list
|
||||
}
|
||||
|
||||
/// Style names available for `family`: upright styles by weight, then italics.
|
||||
pub fn styles(&self, family: &str) -> Vec<String> {
|
||||
let (family, _) = self.canonical(family, "");
|
||||
let family = family.as_str();
|
||||
let mut v: Vec<(bool, f32, String)> =
|
||||
self.read_faces().iter().filter(|f| f.family.eq_ignore_ascii_case(family)).map(|f| (f.italic, f.weight, f.style.clone())).collect();
|
||||
if let Some(c) = self.read_catalog().get(&family.to_ascii_lowercase()) {
|
||||
for (style, _) in &c.faces {
|
||||
if !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(style)) {
|
||||
v.push((style_italic(style), style_weight(style), style.clone()));
|
||||
for cf in &c.faces {
|
||||
if !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&cf.style)) {
|
||||
v.push((cf.italic, cf.weight, cf.style.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -494,11 +799,11 @@ impl FontDb {
|
||||
fn add_font_from(&self, bytes: Vec<u8>, path: Option<&Path>) -> usize {
|
||||
let data = Arc::new(bytes);
|
||||
let mut added = 0;
|
||||
for (i, family, style) in enumerate_faces(&data) {
|
||||
if self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(&family) && f.style.eq_ignore_ascii_case(&style)) {
|
||||
for (i, spec) in enumerate_faces(&data) {
|
||||
if self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(&spec.family) && f.style.eq_ignore_ascii_case(&spec.style)) {
|
||||
continue;
|
||||
}
|
||||
if let Some(f) = make_face(FontBytes::Owned(data.clone()), i, family, style, path.map(Path::to_path_buf)) {
|
||||
if let Some(f) = make_face(FontBytes::Owned(data.clone()), i, spec, path.map(Path::to_path_buf)) {
|
||||
self.faces.write().unwrap_or_else(|e| e.into_inner()).push(Arc::new(f));
|
||||
added += 1;
|
||||
}
|
||||
@@ -543,6 +848,8 @@ impl FontDb {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn scan_font_dirs(&self) -> usize {
|
||||
let mut catalog = Catalog::new();
|
||||
let mut aliases: HashMap<String, Vec<Alias>> = HashMap::new();
|
||||
let mut postscript = PostScriptNames::new();
|
||||
let mut n = 0;
|
||||
let mut stack = self.font_dirs.clone();
|
||||
// Each folder once, however links lead back to it.
|
||||
@@ -562,27 +869,110 @@ impl FontDb {
|
||||
if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
|
||||
continue;
|
||||
}
|
||||
for (family, style) in file_face_names(&p) {
|
||||
for FaceStyle { family, style, keys, weight, italic, .. } in file_face_names(&p) {
|
||||
for (k, a) in Alias::of(&family, &style, &keys) {
|
||||
aliases.entry(k).or_default().push(a);
|
||||
}
|
||||
// `keys.postscript` is normalized ([`norm`]), as `by_postscript_name` looks it up.
|
||||
for ps in &keys.postscript {
|
||||
postscript.insert(ps.clone(), (family.clone(), style.clone()));
|
||||
}
|
||||
let entry = catalog.entry(family.to_ascii_lowercase()).or_default();
|
||||
if entry.name.is_empty() {
|
||||
entry.name = family;
|
||||
}
|
||||
entry.faces.push((style, p.clone()));
|
||||
let (weight, italic) = (weight.unwrap_or_else(|| style_weight(&style)), italic || style_italic(&style));
|
||||
entry.faces.push(CatalogFace { style, path: p.clone(), weight, italic });
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
log::debug!("cataloged {n} system font faces");
|
||||
*self.catalog.write().unwrap_or_else(|e| e.into_inner()) = catalog;
|
||||
*self.aliases.write().unwrap_or_else(|e| e.into_inner()) = aliases;
|
||||
*self.postscript.write().unwrap_or_else(|e| e.into_inner()) = postscript;
|
||||
self.changed();
|
||||
n
|
||||
}
|
||||
|
||||
/// The family's own name and the style for a name given in a document: any of a family's
|
||||
/// names (`ヒラギノ角ゴシック` is Hiragino Sans), a legacy family name (`Hiragino Sans W6` with its
|
||||
/// legacy style names the W6 face) or a PostScript name (`HiraginoSans-W6`, as PDF and `.ai`
|
||||
/// files name fonts). Case and anything but letters and digits are ignored. A name nothing
|
||||
/// answers to comes back as it is.
|
||||
pub fn canonical(&self, family: &str, style: &str) -> (String, String) {
|
||||
if self.is_loaded(family) || self.read_catalog().contains_key(&family.to_ascii_lowercase()) {
|
||||
return (family.to_string(), style.to_string());
|
||||
}
|
||||
let key = norm(family);
|
||||
let mut found: Vec<Alias> =
|
||||
self.read_faces().iter().flat_map(|f| Alias::of(&f.family, &f.style, &f.keys)).filter(|(k, _)| *k == key).map(|(_, a)| a).collect();
|
||||
if let Some(v) = self.aliases.read().unwrap_or_else(|e| e.into_inner()).get(&key) {
|
||||
found.extend(v.iter().cloned());
|
||||
}
|
||||
let ns = norm(style);
|
||||
// A family name keeps the style asked for; a legacy pair names its face; a PostScript name
|
||||
// its face whatever style is asked for.
|
||||
if let Some(a) = found.iter().find(|a| a.style.is_none()) {
|
||||
return (a.family.clone(), style.to_string());
|
||||
}
|
||||
if let Some(a) = found.iter().find(|a| a.paired.as_ref().is_some_and(|p| *p == ns) || a.paired.is_none()) {
|
||||
return (a.family.clone(), a.style.clone().unwrap_or_else(|| style.to_string()));
|
||||
}
|
||||
match found.first() {
|
||||
Some(a) => (a.family.clone(), style.to_string()),
|
||||
None => (family.to_string(), style.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`face`](Self::face) by any of the family's names ([`canonical`](Self::canonical)), and how
|
||||
/// it matched.
|
||||
pub fn resolve(&self, family: &str, style: &str) -> Option<(Arc<FontFace>, FontMatch)> {
|
||||
let (family, style) = self.canonical(family, style);
|
||||
let f = self.face(&family, &style)?;
|
||||
let m = if !f.family.eq_ignore_ascii_case(&family) {
|
||||
FontMatch::Missing
|
||||
} else if norm(&f.style) == norm(&style) || f.keys.styles.contains(&norm(&style)) {
|
||||
FontMatch::Exact
|
||||
} else {
|
||||
FontMatch::Style
|
||||
};
|
||||
Some((f, m))
|
||||
}
|
||||
|
||||
/// The installed face whose PostScript name is `name` (`HiraginoSans-W3`; any case), loaded
|
||||
/// from the system fonts if need be. `None` when no installed font has the name.
|
||||
pub fn find_postscript(&self, name: &str) -> Option<Arc<FontFace>> {
|
||||
let key = norm(name);
|
||||
if key.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let loaded = |db: &FontDb| db.read_faces().iter().find(|f| f.keys.postscript.contains(&key)).cloned();
|
||||
if let Some(f) = loaded(self) {
|
||||
return Some(f);
|
||||
}
|
||||
self.ensure_catalog();
|
||||
let face = self.aliases.read().unwrap_or_else(|e| e.into_inner()).get(&key).and_then(|v| {
|
||||
v.iter().find(|a| a.paired.is_none() && a.style.is_some()).map(|a| (a.family.clone(), a.style.clone().unwrap_or_default()))
|
||||
});
|
||||
let (family, style) = face?;
|
||||
self.face(&family, &style)?;
|
||||
loaded(self)
|
||||
}
|
||||
|
||||
/// The (family, style) of the installed face whose PostScript name is `name` (ignoring ASCII
|
||||
/// case). Documents name fonts this way, and a family can hold hyphens
|
||||
/// ("Rounded-X-Mplus-1c-black" is Rounded-X M+ 1c, black), so the name can't be split.
|
||||
pub fn by_postscript_name(&self, name: &str) -> Option<(String, String)> {
|
||||
self.ensure_catalog();
|
||||
self.postscript.read().unwrap_or_else(|e| e.into_inner()).get(&norm(name)).cloned()
|
||||
}
|
||||
|
||||
/// Load the files of the installed `family`. Returns whether any face was added.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn load_cataloged(&self, family: &str) -> bool {
|
||||
let mut paths: Vec<PathBuf> =
|
||||
self.read_catalog().get(&family.to_ascii_lowercase()).map(|c| c.faces.iter().map(|(_, p)| p.clone()).collect()).unwrap_or_default();
|
||||
self.read_catalog().get(&family.to_ascii_lowercase()).map(|c| c.faces.iter().map(|cf| cf.path.clone()).collect()).unwrap_or_default();
|
||||
paths.sort();
|
||||
paths.dedup();
|
||||
let mut any = false;
|
||||
@@ -599,17 +989,21 @@ impl FontDb {
|
||||
/// only if no font at all is loaded (the bundled fonts failed to parse), in which case text has
|
||||
/// no glyphs.
|
||||
pub fn face(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
|
||||
let (family, style) = self.canonical(family, style);
|
||||
let (family, style) = (family.as_str(), style.as_str());
|
||||
let found = self.find(family, style);
|
||||
if found.as_ref().is_some_and(|f| norm(&f.style) == norm(style)) {
|
||||
return found;
|
||||
}
|
||||
// The family, or this style of it, is installed but not loaded yet.
|
||||
// The family, or this style of it, is installed but not loaded yet. Looked for again
|
||||
// whether or not this call loaded it: another thread may have just done so (then this
|
||||
// load adds nothing, but the face is there).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if (found.is_none() || self.is_cataloged(family, style))
|
||||
&& self.load_cataloged(family)
|
||||
&& let Some(f) = self.find(family, style)
|
||||
{
|
||||
return Some(f);
|
||||
if found.is_none() || self.is_cataloged(family, style) {
|
||||
self.load_cataloged(family);
|
||||
if let Some(f) = self.find(family, style) {
|
||||
return Some(f);
|
||||
}
|
||||
}
|
||||
found
|
||||
.or_else(|| self.find(FALLBACK_FAMILY, style))
|
||||
@@ -621,7 +1015,7 @@ impl FontDb {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn is_cataloged(&self, family: &str, style: &str) -> bool {
|
||||
let ns = norm(style);
|
||||
self.read_catalog().get(&family.to_ascii_lowercase()).is_some_and(|c| c.faces.iter().any(|(s, _)| norm(s) == ns))
|
||||
self.read_catalog().get(&family.to_ascii_lowercase()).is_some_and(|c| c.faces.iter().any(|cf| norm(&cf.style) == ns))
|
||||
}
|
||||
|
||||
/// The loaded face with [`FontFace::id`] `id` (the face a laid-out glyph came from).
|
||||
@@ -637,7 +1031,8 @@ impl FontDb {
|
||||
|
||||
/// Is `family` available (loaded, or installed on the system)?
|
||||
pub fn has_family(&self, family: &str) -> bool {
|
||||
self.is_loaded(family) || self.read_catalog().contains_key(&family.to_ascii_lowercase())
|
||||
let (family, _) = self.canonical(family, "");
|
||||
self.is_loaded(&family) || self.read_catalog().contains_key(&family.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
pub(crate) fn is_loaded(&self, family: &str) -> bool {
|
||||
@@ -654,6 +1049,11 @@ impl FontDb {
|
||||
if let Some(f) = cands.iter().find(|f| norm(&f.style) == ns) {
|
||||
return Some((*f).clone());
|
||||
}
|
||||
// Another of a face's style names: in another language, or a variable font's default
|
||||
// instance's name.
|
||||
if let Some(f) = cands.iter().find(|f| f.keys.styles.contains(&ns)) {
|
||||
return Some((*f).clone());
|
||||
}
|
||||
let (tw, ti) = (style_weight(style), style_italic(style));
|
||||
cands
|
||||
.iter()
|
||||
@@ -711,7 +1111,7 @@ impl FontDb {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
let mut paths: Vec<PathBuf> = self.read_catalog().values().flat_map(|c| c.faces.iter().map(|(_, p)| p.clone())).collect();
|
||||
let mut paths: Vec<PathBuf> = self.read_catalog().values().flat_map(|c| c.faces.iter().map(|cf| cf.path.clone())).collect();
|
||||
paths.sort();
|
||||
paths.dedup();
|
||||
for p in paths {
|
||||
@@ -719,8 +1119,7 @@ impl FontDb {
|
||||
continue;
|
||||
}
|
||||
let Ok(data) = std::fs::read(&p) else { continue };
|
||||
let hit =
|
||||
enumerate_faces(&data).iter().any(|(i, _, _)| skrifa::FontRef::from_index(&data, *i).is_ok_and(|f| f.charmap().map(c).is_some()));
|
||||
let hit = enumerate_faces(&data).iter().any(|(i, _)| skrifa::FontRef::from_index(&data, *i).is_ok_and(|f| f.charmap().map(c).is_some()));
|
||||
if hit && self.add_font_from(data, Some(&p)) > 0 && covered(self) {
|
||||
return true;
|
||||
}
|
||||
@@ -739,7 +1138,7 @@ impl FontDb {
|
||||
if let Some(f) = face.skrifa()
|
||||
&& let Some(g) = f.outline_glyphs().get(GlyphId::new(gid))
|
||||
{
|
||||
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), LocationRef::default()), &mut pen);
|
||||
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), face.location()), &mut pen);
|
||||
}
|
||||
let p = Arc::new(pen.0);
|
||||
let mut cache = self.outlines.lock().unwrap_or_else(|e| e.into_inner());
|
||||
@@ -770,3 +1169,114 @@ impl OutlinePen for FlipPen {
|
||||
self.0.close_path();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod alias_tests {
|
||||
use super::*;
|
||||
|
||||
/// A one-table font holding a name table from (platform, language, name id, string) records:
|
||||
/// Windows Unicode (UTF-16) or Mac Roman (ASCII here).
|
||||
fn name_font(records: &[(u16, u16, u16, &str)]) -> Vec<u8> {
|
||||
let (mut head, mut strings) = (vec![], vec![]);
|
||||
let n = records.len() as u16;
|
||||
for v in [0, n, 6 + 12 * n] {
|
||||
head.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
for &(platform, lang, id, s) in records {
|
||||
let bytes: Vec<u8> = if platform == 3 { s.encode_utf16().flat_map(u16::to_be_bytes).collect() } else { s.as_bytes().to_vec() };
|
||||
let encoding = if platform == 3 { 1 } else { 0 };
|
||||
for v in [platform, encoding, lang, id, bytes.len() as u16, strings.len() as u16] {
|
||||
head.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
strings.extend(bytes);
|
||||
}
|
||||
head.extend(strings);
|
||||
let mut font = vec![0, 1, 0, 0, 0, 1, 0, 16, 0, 0, 0, 0];
|
||||
font.extend_from_slice(b"name");
|
||||
for v in [0u32, 28, head.len() as u32] {
|
||||
font.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
font.extend(head);
|
||||
font
|
||||
}
|
||||
|
||||
fn keys(records: &[(u16, u16, u16, &str)]) -> (String, String, FaceKeys) {
|
||||
let font = name_font(records);
|
||||
let f = skrifa::FontRef::new(&font).unwrap();
|
||||
let (family, style) = face_names(&f).unwrap();
|
||||
let k = FaceKeys::of(&f, &family, &style);
|
||||
(family, style, k)
|
||||
}
|
||||
|
||||
const EN: u16 = 0x0409;
|
||||
const JA: u16 = 0x0411;
|
||||
|
||||
/// Laid out like the name table of Hiragino Sans W3.
|
||||
const HIRAGINO_W3: &[(u16, u16, u16, &str)] = &[
|
||||
(1, 0, 1, "Hiragino Sans"),
|
||||
(1, 0, 2, "W3"),
|
||||
(3, EN, 1, "Hiragino Sans W3"),
|
||||
(3, EN, 2, "Regular"),
|
||||
(3, JA, 1, "ヒラギノ角ゴシック W3"),
|
||||
(3, JA, 2, "Regular"),
|
||||
(3, EN, 6, "HiraginoSans-W3"),
|
||||
(3, EN, 16, "Hiragino Sans"),
|
||||
(3, JA, 16, "ヒラギノ角ゴシック"),
|
||||
(3, EN, 17, "W3"),
|
||||
(3, JA, 17, "W3"),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn a_face_answers_to_its_names_in_every_language() {
|
||||
let (family, style, k) = keys(HIRAGINO_W3);
|
||||
assert_eq!((family.as_str(), style.as_str()), ("Hiragino Sans", "W3"));
|
||||
let aliases = Alias::of(&family, &style, &k);
|
||||
let find = |name: &str| aliases.iter().filter(|(n, _)| *n == norm(name)).map(|(_, a)| a.clone()).collect::<Vec<_>>();
|
||||
assert!(find("ヒラギノ 角ゴシック").iter().any(|a| a.family == "Hiragino Sans" && a.style.is_none()), "a family name in Japanese");
|
||||
let legacy = find("Hiragino Sans W3");
|
||||
assert!(legacy.iter().any(|a| a.style.as_deref() == Some("W3") && a.paired.as_deref() == Some("regular")), "{legacy:?}");
|
||||
assert!(!legacy.iter().any(|a| a.paired.as_deref() == Some("w3")), "Mac family + Windows style is no pair");
|
||||
assert!(find("HiraginoSans-W3").iter().any(|a| a.style.as_deref() == Some("W3") && a.paired.is_none()), "the PostScript name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_typographic_names_the_legacy_ones_are_the_familys() {
|
||||
let (family, style, k) = keys(&[(3, EN, 1, "Example Serif"), (3, EN, 2, "Bold Italic"), (3, JA, 1, "例セリフ"), (3, JA, 2, "太字斜体")]);
|
||||
assert_eq!((family.as_str(), style.as_str()), ("Example Serif", "Bold Italic"));
|
||||
assert!(k.families.contains(&norm("例セリフ")) && k.styles.contains(&norm("太字斜体")) && k.legacy.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_says_how_a_font_matched() {
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
let m = |f: &str, s: &str| db.resolve(f, s).map(|(face, m)| (face.family.clone(), face.style.clone(), m)).unwrap();
|
||||
assert_eq!(m("Source Sans 3", "Semibold"), ("Source Sans 3".into(), "Semibold".into(), FontMatch::Exact));
|
||||
assert_eq!(m("Source Sans 3", "Black").2, FontMatch::Style);
|
||||
assert_eq!(m("No Such Family", "Regular"), (FALLBACK_FAMILY.into(), "Regular".into(), FontMatch::Missing));
|
||||
// A PostScript name names its face, whatever style is asked for (text opened from a PDF).
|
||||
assert_eq!(m("SourceSans3-Semibold", "Regular"), ("Source Sans 3".into(), "Semibold".into(), FontMatch::Exact));
|
||||
let semi = db.find_postscript("sourcesans3-semibold").unwrap();
|
||||
assert_eq!(semi.style, "Semibold");
|
||||
assert!(db.find_postscript("NoSuchFont-Regular").is_none());
|
||||
assert!(db.has_family("SourceSans3-Regular"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod race_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_family_loaded_by_another_thread_meanwhile_is_still_found() {
|
||||
// Two threads ask for the same installed family at once: each must get it, not the
|
||||
// fallback (the second one's load finds every face already added).
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts");
|
||||
let db: &'static FontDb = Box::leak(Box::new(FontDb::with_font_dirs(vec![dir])));
|
||||
// Only the catalog knows Inter (the bundled faces are dropped).
|
||||
db.faces.write().unwrap().retain(|f| f.family != "Inter");
|
||||
let threads: Vec<_> = (0..8).map(|_| std::thread::spawn(move || db.face("Inter", "Regular").map(|f| f.family.clone()))).collect();
|
||||
for t in threads {
|
||||
assert_eq!(t.join().unwrap().as_deref(), Some("Inter"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+177
-53
@@ -8,19 +8,22 @@ use vectorcraft_doc::{CharStyle, Justify, ParaStyle, PathEffect, TextKind, TextO
|
||||
use crate::composer::{Breakpoint, compose};
|
||||
use crate::fontdb::FontDb;
|
||||
use crate::hyphen::hyphen_points;
|
||||
use crate::shape::{SGlyph, cap_x_heights, hyphen_glyph, shape_range, style_metrics};
|
||||
use crate::{Composer, FirstBaseline, LayoutOptions, LineInfo, PositionedGlyph, TextLayout};
|
||||
use crate::shape::{SGlyph, Tcy, cap_x_heights, hyphen_glyph, no_line_end, no_line_start, shape_range, style_metrics};
|
||||
use crate::{Composer, FirstBaseline, LayoutOptions, LineInfo, OtFeatures, PositionedGlyph, TextLayout};
|
||||
|
||||
const EPS: f64 = 1e-6;
|
||||
|
||||
struct Ctx<'a> {
|
||||
vertical: bool,
|
||||
on_path: bool,
|
||||
db: &'a FontDb,
|
||||
text: &'a str,
|
||||
runs: Vec<(Range<usize>, &'a CharStyle)>,
|
||||
default: CharStyle,
|
||||
opts: &'a LayoutOptions,
|
||||
/// Vertical type: lines are laid out as horizontal lines in line space, upright glyphs turned
|
||||
/// a quarter turn back (see [`stands_upright`]); [`TextLayout::line_xf`] then stands the lines
|
||||
/// up as columns.
|
||||
vertical: bool,
|
||||
out: TextLayout,
|
||||
}
|
||||
|
||||
@@ -33,6 +36,16 @@ impl Ctx<'_> {
|
||||
fn shape_para(&self, r: Range<usize>) -> Vec<SGlyph> {
|
||||
let mut v = Vec::with_capacity(r.len());
|
||||
shape_range(self.db, self.text, r, &self.runs, &self.opts.features, &mut v);
|
||||
if self.vertical {
|
||||
tate_chu_yoko(&mut v, |g| self.style_at(g.byte).size);
|
||||
// An upright glyph advances at least one em down the column (the vertical advance of
|
||||
// CJK fonts), centred in it: a narrow mark like § must not overlap its neighbours.
|
||||
for g in v.iter_mut().filter(|g| g.adv > 0.0 && stands_upright(g)) {
|
||||
let extra = upright_cell(g) - g.face.advance(g.gid) * g.sx;
|
||||
g.dx += extra * 0.5;
|
||||
g.adv += extra;
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
@@ -40,20 +53,24 @@ impl Ctx<'_> {
|
||||
let src = self.db.outline(&g.face, g.gid);
|
||||
let local = Affine::rotate(-g.rotation.to_radians()) * Affine::translate((g.dx, g.dy - g.bshift)) * Affine::scale_non_uniform(g.sx, g.sy);
|
||||
let mut m = pre * local;
|
||||
let mut origin = origin;
|
||||
let mut angle = angle;
|
||||
if self.vertical {
|
||||
let writing = Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]);
|
||||
if self.on_path {
|
||||
// Vertical path type keeps the baseline path and turns each glyph across it.
|
||||
m = Affine::translate(origin.to_vec2()) * Affine::rotate(std::f64::consts::FRAC_PI_2) * Affine::translate(-origin.to_vec2()) * m;
|
||||
} else {
|
||||
let physical = writing * origin;
|
||||
let upright = matches!(g.ch as u32, 0x3000..=0x30ff | 0x3400..=0x9fff | 0xf900..=0xfaff | 0xfe10..=0xfe4f | 0xff01..=0xff60 | 0x20000..=0x3134f);
|
||||
m = if upright { Affine::translate((physical - origin) + Vec2::new(-g.descent, g.ascent)) * m } else { writing * m };
|
||||
origin = physical;
|
||||
angle += std::f64::consts::FRAC_PI_2;
|
||||
}
|
||||
if self.vertical && self.on_path {
|
||||
// Vertical path type keeps the baseline path and turns each glyph across it.
|
||||
m = Affine::rotate_about(std::f64::consts::FRAC_PI_2, origin) * m;
|
||||
} else if let (true, Some(t)) = (self.vertical, g.tcy) {
|
||||
// Tate-chu-yoko: the block set across the column, centred on its em of it.
|
||||
let em = self.style_at(g.byte).size;
|
||||
let start = origin.x - t.pen;
|
||||
let centre = Point::new(start + em * 0.5, origin.y - EM_CENTER * em);
|
||||
let across = Affine::translate((centre.x - t.width * 0.5 + t.ink - origin.x, 0.0));
|
||||
let squeeze = Affine::translate((centre.x, 0.0)) * Affine::scale_non_uniform(t.squeeze, 1.0) * Affine::translate((-centre.x, 0.0));
|
||||
m = Affine::rotate_about(-std::f64::consts::FRAC_PI_2, centre) * squeeze * across * m;
|
||||
} else if self.vertical && stands_upright(g) {
|
||||
// Turned about the centre of its em box, which the column's centre line runs through:
|
||||
// the middle of its own cell, not of its advance (tracking and justification add space
|
||||
// after the cell, and must not push the glyph off the centre line).
|
||||
let em = self.style_at(g.byte).size;
|
||||
let cell = if g.adv > 0.0 { upright_cell(g) } else { advance };
|
||||
m = Affine::rotate_about(-std::f64::consts::FRAC_PI_2, Point::new(origin.x + cell * 0.5, origin.y - EM_CENTER * em)) * m;
|
||||
}
|
||||
// Control characters (tabs) and soft hyphens draw nothing (fonts map them to .notdef).
|
||||
let outline = if src.elements().is_empty() || g.is_soft_hyphen() || g.ch.is_control() {
|
||||
@@ -116,48 +133,128 @@ pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLay
|
||||
}
|
||||
}
|
||||
paras.push(s..text.len());
|
||||
let mut vertical_opts = opts.clone();
|
||||
vertical_opts.features.vertical = t.vertical;
|
||||
let opts = &vertical_opts;
|
||||
let vertical = t.vertical;
|
||||
let is_on_path = matches!(&t.kind, TextKind::OnPath { .. });
|
||||
let mut cx = Ctx {
|
||||
vertical: t.vertical,
|
||||
on_path: is_on_path,
|
||||
db,
|
||||
text: &text,
|
||||
runs,
|
||||
default: CharStyle::default(),
|
||||
opts,
|
||||
out: TextLayout { vertical: t.vertical && !is_on_path, ..Default::default() },
|
||||
let vopts;
|
||||
let opts = if vertical {
|
||||
vopts = LayoutOptions { features: OtFeatures { vertical: true, ..opts.features }, ..opts.clone() };
|
||||
&vopts
|
||||
} else {
|
||||
opts
|
||||
};
|
||||
let mut cx = Ctx { on_path: is_on_path, db, text: &text, runs, default: CharStyle::default(), opts, vertical, out: TextLayout::default() };
|
||||
// Vertical type: line space turned a quarter turn clockwise (lines become columns, each next
|
||||
// one to the left). Point type's anchor is on the first column's centre line. Vertical path
|
||||
// type stays on its path (each glyph turned across it in `emit`).
|
||||
let line_xf = match &t.kind {
|
||||
_ if !vertical => Affine::IDENTITY,
|
||||
TextKind::OnPath { .. } => Affine::IDENTITY,
|
||||
TextKind::Point => Affine::translate((-EM_CENTER * cx.style_at(0).size, 0.0)) * QUARTER_TURN,
|
||||
_ => QUARTER_TURN,
|
||||
};
|
||||
match &t.kind {
|
||||
TextKind::Point => flow(&mut cx, ¶s, &t.para, None),
|
||||
TextKind::Area { frame } => {
|
||||
let inverse = Affine::new([0.0, -1.0, 1.0, 0.0, 0.0, 0.0]);
|
||||
let frame = if t.vertical { frame.transformed(inverse) } else { frame.clone() };
|
||||
let mut wrap = t.wrap.clone();
|
||||
if t.vertical {
|
||||
for w in &mut wrap {
|
||||
w.path.transform(inverse);
|
||||
}
|
||||
}
|
||||
let regions = Region::cells(&frame.to_bezpath(), opts, &wrap);
|
||||
cx.out.frames = regions.iter().map(|r| r.cell).collect();
|
||||
let to_lines = line_xf.inverse();
|
||||
let wrap: Vec<vectorcraft_doc::WrapShape> = if vertical {
|
||||
t.wrap.iter().map(|w| vectorcraft_doc::WrapShape { path: w.path.transformed(to_lines), ..w.clone() }).collect()
|
||||
} else {
|
||||
t.wrap.clone()
|
||||
};
|
||||
let regions = Region::cells(&(to_lines * frame.to_bezpath()), opts, &wrap);
|
||||
cx.out.frames = regions.iter().map(|r| line_xf.transform_rect_bbox(r.cell)).collect();
|
||||
flow(&mut cx, ¶s, &t.para, Some(®ions));
|
||||
}
|
||||
TextKind::OnPath { path, start } => on_path(&mut cx, ¶s, &t.para, &path.to_bezpath(), *start, path.is_closed(), t.path_effect),
|
||||
}
|
||||
if cx.out.vertical {
|
||||
let writing = Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]);
|
||||
for frame in &mut cx.out.frames {
|
||||
*frame = writing.transform_rect_bbox(*frame);
|
||||
if vertical && !is_on_path {
|
||||
// Glyph origins, outlines and transforms to text space (lines stay in line space).
|
||||
for g in &mut cx.out.glyphs {
|
||||
g.outline.apply_affine(line_xf);
|
||||
g.xf = line_xf * g.xf;
|
||||
g.origin = line_xf * g.origin;
|
||||
g.angle += std::f64::consts::FRAC_PI_2;
|
||||
}
|
||||
cx.out.vertical = true;
|
||||
cx.out.line_xf = line_xf;
|
||||
}
|
||||
finish_bounds(&mut cx.out);
|
||||
cx.out
|
||||
}
|
||||
|
||||
/// Tate-chu-yoko: a run of two or three half-width digits (`10`月, `100`年) stands upright across
|
||||
/// the column as one block one em long, squeezed to the em when wider; longer numbers (`2026`)
|
||||
/// stay on their side.
|
||||
fn tate_chu_yoko(g: &mut [SGlyph], size: impl Fn(&SGlyph) -> f64) {
|
||||
let digits: Vec<bool> = g.iter().map(|g| g.ch.is_ascii_digit()).collect();
|
||||
let digit = |i: usize| digits.get(i).copied().unwrap_or(false);
|
||||
let mut i = 0;
|
||||
while i < g.len() {
|
||||
if !digit(i) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let mut end = i;
|
||||
while digit(end) {
|
||||
end += 1;
|
||||
}
|
||||
if (2..=3).contains(&(end - i)) {
|
||||
let em = size(&g[i]);
|
||||
let width: f64 = g[i..end].iter().map(|g| g.adv).sum();
|
||||
let squeeze = if width > em && width > 0.0 { em / width } else { 1.0 };
|
||||
let mut ink = 0.0;
|
||||
for (k, gl) in g[i..end].iter_mut().enumerate() {
|
||||
let adv = gl.adv;
|
||||
gl.tcy = Some(Tcy { pen: if k == 0 { 0.0 } else { em }, ink, width, squeeze });
|
||||
gl.adv = if k == 0 { em } else { 0.0 };
|
||||
ink += adv;
|
||||
}
|
||||
}
|
||||
i = end;
|
||||
}
|
||||
}
|
||||
|
||||
/// The length an upright glyph takes down the column before tracking and justification: its
|
||||
/// advance, at least one em.
|
||||
fn upright_cell(g: &SGlyph) -> f64 {
|
||||
(g.face.advance(g.gid) * g.sx).max(g.face.units_per_em() * g.sx)
|
||||
}
|
||||
|
||||
/// Height of the centre of the ideographic em box above the baseline, in ems (the em box runs
|
||||
/// from 0.12 em below the baseline to 0.88 em above it).
|
||||
const EM_CENTER: f64 = 0.38;
|
||||
|
||||
/// A quarter turn clockwise (y down), exact: line space → text space for vertical type.
|
||||
const QUARTER_TURN: Affine = Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]);
|
||||
|
||||
/// Does the glyph stand upright in vertical type? CJK characters and symbols do (their vertical
|
||||
/// forms come from the font's `vert` feature); Latin letters, digits and other characters lie on
|
||||
/// their side. Brackets, the long vowel mark and similar marks that need a vertical form lie on
|
||||
/// their side when the font has none (Unicode's Vertical_Orientation Tr).
|
||||
fn stands_upright(g: &SGlyph) -> bool {
|
||||
let c = g.ch as u32;
|
||||
let upright = matches!(c,
|
||||
0x00A7 | 0x00A9 | 0x00AE | 0x00B1 | 0x00BC..=0x00BE | 0x00D7 | 0x00F7
|
||||
| 0x2016 | 0x2020 | 0x2021 | 0x2030 | 0x2031 | 0x203B | 0x203C | 0x2042 | 0x2047..=0x2049 | 0x2051
|
||||
| 0x20DD..=0x20E0 | 0x20E2..=0x20E4 | 0x2100..=0x2101 | 0x2103..=0x2109 | 0x210F | 0x2113 | 0x2116 | 0x2117
|
||||
| 0x211E..=0x2123 | 0x2125 | 0x2127 | 0x2129 | 0x212E | 0x2135..=0x213F | 0x2145..=0x214A | 0x214C | 0x214D | 0x214F..=0x2189
|
||||
| 0x2460..=0x24FF | 0x25A0..=0x27BF | 0x2B12..=0x2B2F | 0x2B50..=0x2B59
|
||||
| 0x1100..=0x11FF | 0x2E80..=0x303F | 0x3040..=0xA4CF | 0xA960..=0xA97F | 0xAC00..=0xD7FF
|
||||
| 0xE000..=0xFAFF | 0xFE10..=0xFE1F | 0xFE30..=0xFE4F | 0xFE50..=0xFE6F | 0xFF00..=0xFFE7
|
||||
| 0x1F000..=0x1FAFF | 0x20000..=0x3FFFF);
|
||||
if !upright {
|
||||
return false;
|
||||
}
|
||||
let needs_vertical_form = matches!(c,
|
||||
0x3001 | 0x3002 | 0x3008..=0x3011 | 0x3013..=0x301F | 0x3030 | 0x30A0 | 0x30FC
|
||||
| 0xFE50..=0xFE52 | 0xFE59..=0xFE5E | 0xFF01 | 0xFF08 | 0xFF09 | 0xFF0C | 0xFF0E | 0xFF1A | 0xFF1B | 0xFF1F
|
||||
| 0xFF3B | 0xFF3D | 0xFF3F | 0xFF5B..=0xFF60 | 0xFFE3);
|
||||
// Without a vertical alternate the shaped glyph is the nominal one.
|
||||
!needs_vertical_form || g.gid != g.face.glyph_for(g.ch)
|
||||
}
|
||||
|
||||
fn finish_bounds(out: &mut TextLayout) {
|
||||
let xf = out.line_xf;
|
||||
let mut b: Option<Rect> = None;
|
||||
let mut add = |r: Rect| b = Some(b.map_or(r, |b| b.union(r)));
|
||||
for g in &out.glyphs {
|
||||
@@ -167,8 +264,7 @@ fn finish_bounds(out: &mut TextLayout) {
|
||||
}
|
||||
if !out.on_path {
|
||||
for l in &out.lines {
|
||||
let r = Rect::new(l.x0.min(l.x1), l.baseline - l.ascent, l.x0.max(l.x1), l.baseline + l.descent);
|
||||
add(if out.vertical { Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]).transform_rect_bbox(r) } else { r });
|
||||
add(xf.transform_rect_bbox(Rect::new(l.x0.min(l.x1), l.baseline - l.ascent, l.x0.max(l.x1), l.baseline + l.descent)));
|
||||
}
|
||||
}
|
||||
out.bounds = b.unwrap_or_default();
|
||||
@@ -502,7 +598,7 @@ fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool) -
|
||||
break;
|
||||
}
|
||||
x += gl.adv;
|
||||
if gl.break_after() {
|
||||
if gl.break_after() && kinsoku_allows(g, j) {
|
||||
if gl.is_soft_hyphen() {
|
||||
if x + hyphen_glyph(gl).adv <= width + EPS {
|
||||
last_break = Some((j + 1, true));
|
||||
@@ -524,7 +620,7 @@ fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool) -
|
||||
we += 1;
|
||||
}
|
||||
let x_ws: f64 = g[i..word_start].iter().map(|g| g.adv).sum();
|
||||
let pts = hyphen_breaks(text, g, word_start, we);
|
||||
let pts: Vec<usize> = hyphen_breaks(text, g, word_start, we).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1)).collect();
|
||||
for &k in pts.iter().rev() {
|
||||
if k <= j && k > i {
|
||||
let w = x_ws + g[word_start..k].iter().map(|g| g.adv).sum::<f64>() + hyphen_glyph(&g[k - 1]).adv;
|
||||
@@ -545,6 +641,30 @@ fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool) -
|
||||
(end, hy)
|
||||
}
|
||||
|
||||
/// Does kinsoku allow a line break after glyph `j`? Not after an opening bracket, nor before a
|
||||
/// closing one, a comma, a full stop, a small kana… (the character is pushed to the next line
|
||||
/// with the one before it).
|
||||
fn kinsoku_allows(g: &[SGlyph], j: usize) -> bool {
|
||||
g.get(j).is_some_and(|gl| kinsoku_between(gl.ch, g.get(j + 1).map(|n| n.ch)))
|
||||
}
|
||||
|
||||
/// Kinsoku for a break between `before` and `after` (none: the end of the paragraph).
|
||||
fn kinsoku_between(before: char, after: Option<char>) -> bool {
|
||||
!no_line_end(before) && after.is_none_or(|c| !no_line_start(c))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn kinsoku_keeps_closing_marks_off_line_starts_and_opening_ones_off_line_ends() {
|
||||
for (a, b) in [('字', '。'), ('弧', '」'), ('」', '、'), ('ャ', 'ー'), ('カ', 'ッ'), ('「', 'か'), ('(', '雅')] {
|
||||
assert!(!kinsoku_between(a, Some(b)), "{a}{b}");
|
||||
}
|
||||
for (a, b) in [('。', '雅'), ('」', 'は'), ('字', '「'), ('の', '演')] {
|
||||
assert!(kinsoku_between(a, Some(b)), "{a}{b}");
|
||||
}
|
||||
assert!(kinsoku_between('。', None));
|
||||
}
|
||||
|
||||
/// Glyph indices inside `g[ws..we]` (a word) where a hyphenated break may go.
|
||||
fn hyphen_breaks(text: &str, g: &[SGlyph], ws: usize, we: usize) -> Vec<usize> {
|
||||
// Strip leading/trailing punctuation (quotes, commas).
|
||||
@@ -579,19 +699,19 @@ fn candidates(text: &str, g: &[SGlyph], hyphenate: bool) -> Vec<Breakpoint> {
|
||||
let end_of_word = gl.is_space() || gl.break_after();
|
||||
if end_of_word {
|
||||
if hyphenate && j > ws {
|
||||
for k in hyphen_breaks(text, g, ws, j) {
|
||||
for k in hyphen_breaks(text, g, ws, j).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1)) {
|
||||
v.push(Breakpoint { end: k, hyphen: hyphen_glyph(&g[k - 1]).adv });
|
||||
}
|
||||
}
|
||||
let hy = if gl.is_soft_hyphen() { hyphen_glyph(gl).adv } else { 0.0 };
|
||||
if j + 1 < g.len() && g[j + 1].byte != gl.byte {
|
||||
if j + 1 < g.len() && g[j + 1].byte != gl.byte && kinsoku_allows(g, j) {
|
||||
v.push(Breakpoint { end: j + 1, hyphen: hy });
|
||||
}
|
||||
ws = j + 1;
|
||||
}
|
||||
}
|
||||
if hyphenate && g.len() > ws {
|
||||
for k in hyphen_breaks(text, g, ws, g.len()) {
|
||||
for k in hyphen_breaks(text, g, ws, g.len()).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1)) {
|
||||
v.push(Breakpoint { end: k, hyphen: hyphen_glyph(&g[k - 1]).adv });
|
||||
}
|
||||
}
|
||||
@@ -715,8 +835,11 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
|
||||
// Composed lines may shrink word spaces (never below zero).
|
||||
per_space =
|
||||
((width - w) / spaces as f64).max(-sg[i..trimmed].iter().filter(|g| g.is_space()).map(|g| g.adv).fold(f64::MAX, f64::min));
|
||||
} else if para.justify == Justify::JustifyAll && trimmed - i > 1 && width > w {
|
||||
per_gap = (width - w) / (trimmed - i - 1) as f64;
|
||||
} else if para.justify == Justify::JustifyAll && width > w {
|
||||
let gaps = sg.get(i + 1..trimmed).map_or(0, |s| s.iter().filter(|g| !g.continues_tcy()).count());
|
||||
if gaps > 0 {
|
||||
per_gap = (width - w) / gaps as f64;
|
||||
}
|
||||
}
|
||||
}
|
||||
let start_x = if justify {
|
||||
@@ -747,7 +870,8 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
|
||||
} else if j < trimmed {
|
||||
if g.is_space() {
|
||||
adv += per_space;
|
||||
} else if j + 1 < trimmed {
|
||||
} else if j + 1 < trimmed && sg.get(j + 1).is_some_and(|next| !next.continues_tcy()) {
|
||||
// Between glyphs, never inside a tate-chu-yoko block (one cell).
|
||||
adv += per_gap;
|
||||
}
|
||||
}
|
||||
|
||||
+13
-4
@@ -18,11 +18,13 @@ mod fontdb;
|
||||
pub mod hyphen;
|
||||
mod layout;
|
||||
mod shape;
|
||||
#[cfg(any(test, feature = "test-fonts"))]
|
||||
pub mod test_fonts;
|
||||
pub mod thread;
|
||||
|
||||
pub use craft_fonts::{CRAFT_FONTS, CraftFont};
|
||||
pub use features::OtFeatures;
|
||||
pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace, style_weight, system_font_dirs};
|
||||
pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace, FontMatch, style_weight, system_font_dirs};
|
||||
use kurbo::{Affine, BezPath, Point, Rect, Vec2};
|
||||
pub use layout::{layout, layout_with};
|
||||
pub use vectorcraft_doc::TextObject;
|
||||
@@ -128,6 +130,9 @@ pub struct LineInfo {
|
||||
pub struct TextLayout {
|
||||
/// Lines retain inline/block coordinates; glyph geometry is in physical text space.
|
||||
pub vertical: bool,
|
||||
/// Inline/block (line) space → physical text space: identity for horizontal type, a quarter
|
||||
/// turn clockwise for vertical type (point type also centres its first column on the anchor).
|
||||
pub line_xf: Affine,
|
||||
pub glyphs: Vec<PositionedGlyph>,
|
||||
pub lines: Vec<LineInfo>,
|
||||
/// Ink/advance bounds in text space.
|
||||
@@ -144,10 +149,10 @@ pub struct TextLayout {
|
||||
impl TextLayout {
|
||||
/// Convert physical text coordinates to inline/block coordinates.
|
||||
pub fn logical_point(&self, p: Point) -> Point {
|
||||
if self.vertical { Point::new(p.y, -p.x) } else { p }
|
||||
if self.vertical { self.line_xf.inverse() * p } else { p }
|
||||
}
|
||||
pub fn physical_point(&self, p: Point) -> Point {
|
||||
if self.vertical { Point::new(-p.y, p.x) } else { p }
|
||||
if self.vertical { self.line_xf * p } else { p }
|
||||
}
|
||||
/// All glyph outlines combined (e.g. for Create Outlines).
|
||||
pub fn to_bezpath(&self) -> BezPath {
|
||||
@@ -168,7 +173,7 @@ impl TextLayout {
|
||||
/// the layout changed.
|
||||
pub fn snap_to_pixels(&mut self, to_device: Affine) -> bool {
|
||||
let [a, b, c, d, _, _] = to_device.as_coeffs();
|
||||
if self.on_path || b.abs() > 1e-9 || c.abs() > 1e-9 || a.abs() < 1e-12 || d.abs() < 1e-12 {
|
||||
if self.on_path || self.vertical || b.abs() > 1e-9 || c.abs() > 1e-9 || a.abs() < 1e-12 || d.abs() < 1e-12 {
|
||||
return false;
|
||||
}
|
||||
let mut moved = false;
|
||||
@@ -381,3 +386,7 @@ mod tests_snap;
|
||||
mod tests_sysfonts;
|
||||
#[cfg(test)]
|
||||
mod tests_typo;
|
||||
#[cfg(test)]
|
||||
mod tests_variable;
|
||||
#[cfg(test)]
|
||||
mod tests_vertical;
|
||||
@@ -5,7 +5,7 @@ use std::sync::Arc;
|
||||
|
||||
use harfrust::{Direction, Feature, ShapeOptions, UnicodeBuffer};
|
||||
use skrifa::MetadataProvider;
|
||||
use skrifa::instance::{LocationRef, Size};
|
||||
use skrifa::instance::Size;
|
||||
use vectorcraft_doc::CharStyle;
|
||||
|
||||
use crate::features::OtFeatures;
|
||||
@@ -40,6 +40,22 @@ pub(crate) struct SGlyph {
|
||||
pub xh: f64,
|
||||
/// First source character of the cluster.
|
||||
pub ch: char,
|
||||
/// Vertical type: part of a tate-chu-yoko block (set across the column, upright).
|
||||
pub tcy: Option<Tcy>,
|
||||
}
|
||||
|
||||
/// A glyph's place in a tate-chu-yoko block: the block takes one em of the column, its glyphs side
|
||||
/// by side across it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct Tcy {
|
||||
/// Laid-out distance from the block's start to this glyph's pen position.
|
||||
pub pen: f64,
|
||||
/// Distance from the block's start to the glyph in the block's own (horizontal) setting.
|
||||
pub ink: f64,
|
||||
/// Width of the block's own setting.
|
||||
pub width: f64,
|
||||
/// Horizontal scale that fits the block into one em (1 when it fits as it is).
|
||||
pub squeeze: f64,
|
||||
}
|
||||
|
||||
impl SGlyph {
|
||||
@@ -58,6 +74,10 @@ impl SGlyph {
|
||||
pub fn is_letter(&self) -> bool {
|
||||
self.ch.is_alphabetic() || matches!(self.ch, '\'' | '’')
|
||||
}
|
||||
/// Vertical type: a tate-chu-yoko glyph after its block's first, sharing that glyph's cell.
|
||||
pub fn continues_tcy(&self) -> bool {
|
||||
self.tcy.is_some_and(|t| t.pen > 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const SOFT_HYPHEN: char = '\u{00AD}';
|
||||
@@ -77,6 +97,46 @@ pub(crate) fn hyphen_glyph(g: &SGlyph) -> SGlyph {
|
||||
h
|
||||
}
|
||||
|
||||
/// Kinsoku (Japanese line breaking, the strict set): a character that can't start a line —
|
||||
/// closing brackets, the Japanese comma and full stop, middle dots, colons, ! and ?, the long
|
||||
/// vowel mark, iteration marks and small kana.
|
||||
pub(crate) fn no_line_start(c: char) -> bool {
|
||||
matches!(c,
|
||||
')' | ']' | '}' | ',' | '.' | ':' | ';' | '!' | '?' | '»' | '’' | '”' | '‐' | '–' | '‼' | '⁇' | '⁈' | '⁉'
|
||||
| '、' | '。' | '〉' | '》' | '」' | '』' | '】' | '〕' | '〗' | '〙' | '〛' | '〟' | '〜' | '゠' | '・' | '⦆'
|
||||
| 'ー' | 'ゝ' | 'ゞ' | 'ヽ' | 'ヾ' | '々' | '〻'
|
||||
| 'ぁ' | 'ぃ' | 'ぅ' | 'ぇ' | 'ぉ' | 'っ' | 'ゃ' | 'ゅ' | 'ょ' | 'ゎ' | 'ゕ' | 'ゖ'
|
||||
| 'ァ' | 'ィ' | 'ゥ' | 'ェ' | 'ォ' | 'ッ' | 'ャ' | 'ュ' | 'ョ' | 'ヮ' | 'ヵ' | 'ヶ' | 'ㇰ'..='ㇿ'
|
||||
| '!' | ')' | ',' | '.' | ':' | ';' | '?' | ']' | '}' | '~' | '。' | '」' | '、' | '・' | 'ー' | 'ァ'..='ッ')
|
||||
}
|
||||
|
||||
/// Kinsoku: a character that can't end a line (opening brackets).
|
||||
pub(crate) fn no_line_end(c: char) -> bool {
|
||||
matches!(
|
||||
c,
|
||||
'(' | '['
|
||||
| '{'
|
||||
| '«'
|
||||
| '‘'
|
||||
| '“'
|
||||
| '〈'
|
||||
| '《'
|
||||
| '「'
|
||||
| '『'
|
||||
| '【'
|
||||
| '〔'
|
||||
| '〖'
|
||||
| '〘'
|
||||
| '〚'
|
||||
| '〝'
|
||||
| '('
|
||||
| '['
|
||||
| '{'
|
||||
| '⦅'
|
||||
| '「'
|
||||
)
|
||||
}
|
||||
|
||||
fn is_cjk(c: char) -> bool {
|
||||
matches!(c as u32, 0x2E80..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF | 0x20000..=0x2FFFF)
|
||||
}
|
||||
@@ -187,7 +247,7 @@ fn shape_segment(text: &str, seg: &Segment, feats: &OtFeatures, out: &mut Vec<SG
|
||||
|
||||
let mut raw: Vec<(u32, u32, i32, i32, i32)> = Vec::with_capacity(text_seg.len()); // gid, cluster, xadv, xoff, yoff
|
||||
let shaped = face.hb().map(|hb| {
|
||||
let shaper = face.shaper.shaper(&hb).build();
|
||||
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
|
||||
let mut buf = UnicodeBuffer::new();
|
||||
for (i, c) in text_seg.char_indices() {
|
||||
let cl = (range.start + i) as u32;
|
||||
@@ -211,7 +271,7 @@ fn shape_segment(text: &str, seg: &Segment, feats: &OtFeatures, out: &mut Vec<SG
|
||||
// Fallback: nominal glyphs and hmtx advances, no shaping.
|
||||
if let Some(f) = face.skrifa() {
|
||||
let cmap = f.charmap();
|
||||
let gm = f.glyph_metrics(Size::unscaled(), LocationRef::default());
|
||||
let gm = f.glyph_metrics(Size::unscaled(), face.location());
|
||||
for (i, c) in text_seg.char_indices() {
|
||||
let cl = (range.start + i) as u32;
|
||||
let chars: Vec<char> = if upper { c.to_uppercase().collect() } else { vec![c] };
|
||||
@@ -255,6 +315,7 @@ fn shape_segment(text: &str, seg: &Segment, feats: &OtFeatures, out: &mut Vec<SG
|
||||
cap,
|
||||
xh,
|
||||
ch,
|
||||
tcy: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Fonts built in code for tests, so no font file is committed: a small variable font (#296).
|
||||
//! Compiled for this crate's tests and, with the `test-fonts` feature, for other crates' tests.
|
||||
|
||||
use skrifa::instance::{LocationRef, Size};
|
||||
use skrifa::raw::TableProvider;
|
||||
use skrifa::raw::tables::glyf::Glyph;
|
||||
use skrifa::{GlyphId, MetadataProvider};
|
||||
use write_fonts::FontBuilder;
|
||||
use write_fonts::tables::fvar::{AxisInstanceArrays, Fvar, InstanceRecord, VariationAxisRecord};
|
||||
use write_fonts::tables::gvar::{GlyphDelta, GlyphDeltas, GlyphVariations, Gvar, Tent};
|
||||
use write_fonts::types::{F2Dot14, Fixed, NameId, Tag};
|
||||
|
||||
/// The family of [`variable_font`].
|
||||
pub const VARIABLE_FAMILY: &str = "Varitest Sans";
|
||||
|
||||
/// The characters [`variable_font`] widens towards its heaviest weight (simple glyphs in Source Sans
|
||||
/// 3; the composite ones, like `H`, would need their components varied).
|
||||
pub const VARIABLE_CHARS: &str = "lIn";
|
||||
|
||||
/// How much wider [`VARIABLE_CHARS`] are at the heaviest weight (outlines and advances).
|
||||
pub const VARIABLE_WIDEN: f32 = 0.25;
|
||||
|
||||
/// The bundled Source Sans 3 Regular as a variable font of family [`VARIABLE_FAMILY`]: one weight
|
||||
/// axis (400–700, default 400, the face itself being Regular) whose maximum makes the glyphs of
|
||||
/// [`VARIABLE_CHARS`] [`VARIABLE_WIDEN`] wider (outlines and advances, through `gvar`), with the
|
||||
/// named instances Normal (the default, the face Regular), SemiBold (550) and Bold (700), plus an instance without
|
||||
/// a name and one named like another (`BOLD`), which style lists leave out. `None` when it can't
|
||||
/// be built.
|
||||
pub fn variable_font() -> Option<Vec<u8>> {
|
||||
const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf");
|
||||
let font = skrifa::FontRef::new(BASE).ok()?;
|
||||
let (glyf, loca) = (font.glyf().ok()?, font.loca(None).ok()?);
|
||||
let metrics = font.glyph_metrics(Size::unscaled(), LocationRef::default());
|
||||
let charmap = font.charmap();
|
||||
let widened: Vec<GlyphId> = VARIABLE_CHARS.chars().filter_map(|c| charmap.map(c)).collect();
|
||||
let mut variations = Vec::new();
|
||||
for g in 0..u32::from(font.maxp().ok()?.num_glyphs()) {
|
||||
let gid = write_fonts::types::GlyphId::new(g);
|
||||
let id = GlyphId::new(g);
|
||||
let deltas = if widened.contains(&id) {
|
||||
let Ok(Some(Glyph::Simple(s))) = loca.get_glyf(id, &glyf) else { return None };
|
||||
let wider = |x: f32| (x * VARIABLE_WIDEN).round() as i16;
|
||||
let mut d: Vec<GlyphDelta> = s.points().map(|p| GlyphDelta::required(wider(f32::from(p.x)), 0)).collect();
|
||||
// The phantom points: left side bearing, advance, top, bottom.
|
||||
let advance = wider(metrics.advance_width(id)?);
|
||||
d.extend([(0, 0), (advance, 0), (0, 0), (0, 0)].map(|(x, y)| GlyphDelta::required(x, y)));
|
||||
vec![GlyphDeltas::new(vec![Tent::new(F2Dot14::from_f32(1.0), None)], d)]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
variations.push(GlyphVariations::new(gid, deltas));
|
||||
}
|
||||
let gvar = Gvar::new(variations, 1).ok()?;
|
||||
let axis = VariationAxisRecord {
|
||||
axis_tag: Tag::new(b"wght"),
|
||||
min_value: Fixed::from_i32(400),
|
||||
default_value: Fixed::from_i32(400),
|
||||
max_value: Fixed::from_i32(700),
|
||||
flags: 0,
|
||||
axis_name_id: NameId::new(256),
|
||||
};
|
||||
let instance = |name: u16, wght: i32| InstanceRecord {
|
||||
subfamily_name_id: NameId::new(name),
|
||||
flags: 0,
|
||||
coordinates: vec![Fixed::from_i32(wght)],
|
||||
post_script_name_id: None,
|
||||
};
|
||||
let instances = vec![instance(257, 400), instance(258, 550), instance(259, 700), instance(260, 600), instance(261, 650)];
|
||||
let fvar = Fvar::new(AxisInstanceArrays::new(vec![axis], instances));
|
||||
let name = name_table(&[
|
||||
(1, VARIABLE_FAMILY),
|
||||
(2, "Regular"),
|
||||
(4, "Varitest Sans Regular"),
|
||||
(6, "VaritestSans-Regular"),
|
||||
(256, "Weight"),
|
||||
(257, "Normal"),
|
||||
(258, "SemiBold"),
|
||||
(259, "Bold"),
|
||||
(261, "BOLD"),
|
||||
])?;
|
||||
let mut builder = FontBuilder::new();
|
||||
builder.add_raw(Tag::new(b"name"), name);
|
||||
builder.add_table(&fvar).ok()?.add_table(&gvar).ok()?;
|
||||
// The rest of Source Sans 3's tables as they are.
|
||||
for r in font.table_directory.table_records() {
|
||||
let tag = Tag::new(&r.tag().to_be_bytes());
|
||||
if !builder.contains(tag) {
|
||||
builder.add_raw(tag, font.table_data(r.tag())?.as_bytes());
|
||||
}
|
||||
}
|
||||
Some(builder.build())
|
||||
}
|
||||
|
||||
/// A `name` table of Windows English (US) records (name id, string).
|
||||
pub fn name_table(records: &[(u16, &str)]) -> Option<Vec<u8>> {
|
||||
let mut records = records.to_vec();
|
||||
records.sort_by_key(|r| r.0);
|
||||
let n = u16::try_from(records.len()).ok()?;
|
||||
let (mut head, mut strings) = (Vec::new(), Vec::new());
|
||||
for v in [0, n, 6 + 12 * n] {
|
||||
head.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
for (id, s) in records {
|
||||
let bytes: Vec<u8> = s.encode_utf16().flat_map(u16::to_be_bytes).collect();
|
||||
for v in [3, 1, 0x0409, id, u16::try_from(bytes.len()).ok()?, u16::try_from(strings.len()).ok()?] {
|
||||
head.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
strings.extend(bytes);
|
||||
}
|
||||
head.extend(strings);
|
||||
Some(head)
|
||||
}
|
||||
@@ -9,9 +9,15 @@ const FAMILY: &str = "Sysfont Sans3";
|
||||
|
||||
/// A bundled Source Sans 3 file renamed [`FAMILY`] (as long as the original name).
|
||||
fn renamed(file: &str) -> Vec<u8> {
|
||||
renamed_to(file, FAMILY)
|
||||
}
|
||||
|
||||
/// A bundled Source Sans 3 file renamed `family`, which is as long as the original name.
|
||||
fn renamed_to(file: &str, family: &str) -> Vec<u8> {
|
||||
assert_eq!(family.len(), "Source Sans 3".len());
|
||||
let mut data = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts").join(file)).unwrap();
|
||||
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
|
||||
for (from, to) in [(utf16("Source Sans 3"), utf16(FAMILY)), (b"Source Sans 3".to_vec(), FAMILY.as_bytes().to_vec())] {
|
||||
for (from, to) in [(utf16("Source Sans 3"), utf16(family)), (b"Source Sans 3".to_vec(), family.as_bytes().to_vec())] {
|
||||
let mut i = 0;
|
||||
while let Some(at) = data[i..].windows(from.len()).position(|w| w == from) {
|
||||
data[i + at..i + at + to.len()].copy_from_slice(&to);
|
||||
@@ -132,6 +138,30 @@ fn the_family_list_is_shared_until_fonts_change() {
|
||||
assert!(!Arc::ptr_eq(&a, &b) && has(&b, FAMILY) && !has(&a, FAMILY));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_lists_leave_out_hidden_system_families_which_still_resolve() {
|
||||
// macOS names the faces it keeps for its own interface with a leading "." (".SF NS").
|
||||
const HIDDEN: &str = ".Sysfont Sans";
|
||||
const DOTTED: &str = "Sysfont.Sans3";
|
||||
let dir = font_dir("hidden");
|
||||
std::fs::write(dir.join("Hidden.ttf"), renamed_to("SourceSans3-Regular.ttf", HIDDEN)).unwrap();
|
||||
std::fs::write(dir.join("Dotted.ttf"), renamed_to("SourceSans3-Regular.ttf", DOTTED)).unwrap();
|
||||
let db = FontDb::with_font_dirs(vec![dir]);
|
||||
let listed = db.menu_family_list();
|
||||
assert!(has(&listed, FAMILY) && has(&listed, FALLBACK_FAMILY));
|
||||
assert!(!has(&listed, HIDDEN), "a leading dot hides a family: {listed:?}");
|
||||
assert!(has(&listed, DOTTED), "a dot elsewhere doesn't");
|
||||
assert_eq!(listed.len() + 1, db.family_list().len(), "only the hidden family is left out");
|
||||
assert!(Arc::ptr_eq(&listed, &db.menu_family_list()), "shared until the fonts change");
|
||||
// Documents and fallbacks that name it still find it.
|
||||
assert!(has(&db.families(), HIDDEN) && db.has_family(HIDDEN));
|
||||
assert_eq!(db.find_family(HIDDEN).as_deref(), Some(HIDDEN));
|
||||
assert_eq!(db.face(HIDDEN, "Regular").unwrap().family, HIDDEN);
|
||||
// Loading it changed the fonts: the list is built again, still without it.
|
||||
let again = db.menu_family_list();
|
||||
assert!(!Arc::ptr_eq(&listed, &again) && *listed == *again);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_styles_of_a_loaded_family_load_when_asked_for() {
|
||||
let dir = font_dir("styles");
|
||||
@@ -142,3 +172,15 @@ fn installed_styles_of_a_loaded_family_load_when_asked_for() {
|
||||
// A style nobody has still gets the closest one.
|
||||
assert_eq!(db.face(FAMILY, "Black").unwrap().style, "Bold");
|
||||
}
|
||||
|
||||
/// Documents name fonts by PostScript name, and families can hold hyphens: the installed face of
|
||||
/// that exact name gives the family and style, however the name splits.
|
||||
#[test]
|
||||
fn installed_faces_are_found_by_postscript_name() {
|
||||
let dir = font_dir("postscript");
|
||||
let db = FontDb::with_font_dirs(vec![dir]);
|
||||
// The renamed Source Sans 3 files keep their PostScript names.
|
||||
assert_eq!(db.by_postscript_name("SourceSans3-Bold"), Some((FAMILY.to_string(), "Bold".to_string())));
|
||||
assert_eq!(db.by_postscript_name("sourcesans3-regular"), Some((FAMILY.to_string(), "Regular".to_string())), "any case");
|
||||
assert_eq!(db.by_postscript_name("Rounded-X-Mplus-1c-black"), None);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
//! Variable fonts: each named instance is a style of its own, listed, resolved, shaped, drawn and
|
||||
//! embedded at its axis settings (#296: Figtree listed only Light, its default instance).
|
||||
|
||||
use kurbo::{Point, Shape};
|
||||
use skrifa::MetadataProvider;
|
||||
use skrifa::instance::{LocationRef, Size};
|
||||
use skrifa::raw::TableProvider;
|
||||
use vectorcraft_doc::CharStyle;
|
||||
|
||||
use super::test_fonts::{VARIABLE_CHARS, VARIABLE_FAMILY as FAMILY, VARIABLE_WIDEN, name_table, variable_font};
|
||||
use super::*;
|
||||
|
||||
fn font() -> Vec<u8> {
|
||||
variable_font().expect("the test font builds")
|
||||
}
|
||||
|
||||
/// A database of the bundled fonts and the test variable font.
|
||||
fn db() -> FontDb {
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
assert_eq!(db.add_font(font()), 3, "Regular, SemiBold and Bold");
|
||||
db
|
||||
}
|
||||
|
||||
/// The width of `c`'s outline in font units.
|
||||
fn ink_width(db: &FontDb, face: &FontFace, c: char) -> f64 {
|
||||
db.outline(face, face.glyph_for(c)).bounding_box().width()
|
||||
}
|
||||
|
||||
fn text(family: &str, style: &str, s: &str) -> TextObject {
|
||||
TextObject::point(Point::ZERO, s, CharStyle { font_family: family.into(), font_style: style.into(), size: 100.0, ..CharStyle::default() })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_instances_are_styles_of_the_family() {
|
||||
let db = db();
|
||||
// The default instance is the face itself; the unnamed and the twice-named ones are left out.
|
||||
assert_eq!(db.styles(FAMILY), ["Regular", "SemiBold", "Bold"]);
|
||||
let bold = db.face(FAMILY, "Bold").unwrap();
|
||||
assert_eq!((bold.family.as_str(), bold.style.as_str(), bold.weight, bold.italic), (FAMILY, "Bold", 700.0, false));
|
||||
assert_eq!(bold.variations(), [(*b"wght", 700.0)]);
|
||||
let regular = db.face(FAMILY, "Regular").unwrap();
|
||||
assert!(regular.variations().is_empty() && regular.weight == 400.0);
|
||||
assert_eq!(db.face(FAMILY, "SemiBold").unwrap().weight, 550.0);
|
||||
// Fonts added again add nothing.
|
||||
assert_eq!(db.add_font(font()), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_instance_is_found_by_its_names_and_by_weight() {
|
||||
let db = db();
|
||||
let m = |f: &str, s: &str| db.resolve(f, s).map(|(face, m)| (face.style.clone(), m)).unwrap();
|
||||
assert_eq!(m(FAMILY, "Bold"), ("Bold".into(), FontMatch::Exact));
|
||||
assert_eq!(m("varitest sans", "semi bold"), ("SemiBold".into(), FontMatch::Exact), "names are normalized");
|
||||
// The default instance's own name names the face.
|
||||
assert_eq!(m(FAMILY, "Normal"), ("Regular".into(), FontMatch::Exact));
|
||||
assert!(!db.styles(FAMILY).contains(&"Normal".to_string()));
|
||||
// A style the font hasn't: the closest weight.
|
||||
assert_eq!(m(FAMILY, "Black"), ("Bold".into(), FontMatch::Style));
|
||||
assert_eq!(m(FAMILY, "Medium"), ("SemiBold".into(), FontMatch::Style));
|
||||
// PostScript names (as PDF and .ai files name fonts): `<family>-<style>` without spaces.
|
||||
assert_eq!(m("VaritestSans-Bold", "Regular"), ("Bold".into(), FontMatch::Exact));
|
||||
assert_eq!(db.find_postscript("VaritestSans-SemiBold").unwrap().style, "SemiBold");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_instance_draws_and_measures_its_own_glyphs() {
|
||||
let db = db();
|
||||
let (regular, semi, bold) = (db.face(FAMILY, "Regular").unwrap(), db.face(FAMILY, "SemiBold").unwrap(), db.face(FAMILY, "Bold").unwrap());
|
||||
let widen = f64::from(VARIABLE_WIDEN);
|
||||
for c in VARIABLE_CHARS.chars() {
|
||||
let g = regular.glyph_for(c);
|
||||
assert_eq!(bold.glyph_for(c), g);
|
||||
// Advances (gvar phantom points) and outlines: a quarter wider at Bold, half that at SemiBold.
|
||||
let (r, b, s) = (regular.advance(g), bold.advance(g), semi.advance(g));
|
||||
assert!((b - r * (1.0 + widen)).abs() <= 1.0, "{c}: advance {r} -> {b}");
|
||||
assert!((s - r * (1.0 + widen / 2.0)).abs() <= 1.0, "{c}: SemiBold advance {s}");
|
||||
let (ri, bi) = (ink_width(&db, ®ular, c), ink_width(&db, &bold, c));
|
||||
assert!((bi - ri * (1.0 + widen)).abs() <= 2.0, "{c}: outline {ri} -> {bi}");
|
||||
}
|
||||
// Characters the axis doesn't change stay as they are.
|
||||
let o = regular.glyph_for('o');
|
||||
assert_eq!(regular.advance(o), bold.advance(o));
|
||||
assert_eq!(db.outline(®ular, o).to_svg(), db.outline(&bold, o).to_svg());
|
||||
|
||||
// Laid out (shaped with the instance): the glyphs come from the Bold face, advance as it does
|
||||
// and are drawn with its outlines (what the canvas, Create Outlines and hit tests use).
|
||||
let (lr, lb) = (layout(&db, &text(FAMILY, "Regular", VARIABLE_CHARS)), layout(&db, &text(FAMILY, "Bold", VARIABLE_CHARS)));
|
||||
assert!(lb.glyphs.iter().all(|g| g.font_id == bold.id()));
|
||||
let width = |l: &TextLayout| l.glyphs.iter().map(|g| g.advance).sum::<f64>();
|
||||
let expected: f64 = VARIABLE_CHARS.chars().map(|c| bold.advance(bold.glyph_for(c))).sum::<f64>() * 100.0 / bold.units_per_em();
|
||||
assert!((width(&lb) - expected).abs() < 0.5, "shaped {} vs {expected}", width(&lb));
|
||||
assert!(width(&lb) > width(&lr) * (1.0 + widen * 0.9));
|
||||
let ink = |l: &TextLayout| l.to_bezpath().bounding_box().width();
|
||||
assert!(ink(&lb) > ink(&lr) * (1.0 + widen * 0.9), "{} vs {}", ink(&lb), ink(&lr));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_embedded_instance_is_a_static_font_of_that_instance() {
|
||||
let db = db();
|
||||
let bold = db.face(FAMILY, "Bold").unwrap();
|
||||
let e = bold.embed(&['l', 'o']).unwrap();
|
||||
assert!(e.subset && !e.cff);
|
||||
let f = skrifa::FontRef::new(&e.data).unwrap();
|
||||
assert!(f.fvar().is_err() && f.gvar().is_err(), "no variations left");
|
||||
let g = f.charmap().map('l').unwrap();
|
||||
let adv = f.glyph_metrics(Size::unscaled(), LocationRef::default()).advance_width(g).unwrap();
|
||||
assert!((f64::from(adv) - bold.advance(bold.glyph_for('l'))).abs() <= 1.0, "the Bold advance: {adv}");
|
||||
// The default face embeds as before.
|
||||
let regular = db.face(FAMILY, "Regular").unwrap();
|
||||
let data = regular.embed(&['l']).unwrap().data;
|
||||
let r = skrifa::FontRef::new(&data).unwrap();
|
||||
let rl = r.glyph_metrics(Size::unscaled(), LocationRef::default()).advance_width(r.charmap().map('l').unwrap()).unwrap();
|
||||
assert!((f64::from(rl) - regular.advance(regular.glyph_for('l'))).abs() <= 1.0 && rl < adv, "{rl} vs {adv}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_variable_fonts_list_their_instances_before_loading() {
|
||||
let dir = std::env::temp_dir().join(format!("vc-varfonts-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("Varitest[wght].ttf"), font()).unwrap();
|
||||
let db = FontDb::with_font_dirs(vec![dir]);
|
||||
assert_eq!(db.styles(FAMILY), ["Regular", "SemiBold", "Bold"]);
|
||||
assert!(!db.is_loaded(FAMILY), "listed from the name and fvar tables alone");
|
||||
assert_eq!(db.by_postscript_name("VaritestSans-Bold"), Some((FAMILY.to_string(), "Bold".to_string())));
|
||||
let (bold, m) = db.resolve(FAMILY, "Bold").unwrap();
|
||||
assert_eq!((bold.style.as_str(), m), ("Bold", FontMatch::Exact));
|
||||
assert_eq!(bold.variations(), [(*b"wght", 700.0)]);
|
||||
assert!(bold.path().is_some());
|
||||
}
|
||||
|
||||
/// Damaged or hostile `fvar` tables give fewer styles, never a panic, and the face still works.
|
||||
#[test]
|
||||
fn damaged_variation_tables_never_panic() {
|
||||
let name = name_table(&[(1, "Broken Var"), (2, "Regular"), (256, "W")]).unwrap();
|
||||
let be = |v: &[u32]| v.iter().flat_map(|x| x.to_be_bytes()).collect::<Vec<u8>>();
|
||||
// Header: version 1.0, axes offset 16, reserved, axis count, axis size 20, instance count,
|
||||
// instance size.
|
||||
let header = |axes: u16, axis_size: u16, instances: u16, instance_size: u16| {
|
||||
let mut h = be(&[0x0001_0000]);
|
||||
for v in [16, 2, axes, axis_size, instances, instance_size] {
|
||||
h.extend(v.to_be_bytes());
|
||||
}
|
||||
h
|
||||
};
|
||||
let axis = |tag: &[u8; 4], min: i32, def: i32, max: i32| {
|
||||
let mut a = tag.to_vec();
|
||||
a.extend(be(&[(min << 16) as u32, (def << 16) as u32, (max << 16) as u32]));
|
||||
a.extend([0, 0, 1, 0]);
|
||||
a
|
||||
};
|
||||
let mut damaged: Vec<Vec<u8>> = vec![
|
||||
vec![],
|
||||
vec![0; 3],
|
||||
header(0xffff, 20, 0xffff, 8),
|
||||
[header(1, 20, 0xffff, 8), axis(b"wght", 100, 400, 900)].concat(),
|
||||
[header(1, 20, 3, 0xffff), axis(b"wght", 100, 400, 900)].concat(),
|
||||
[header(1, 0, 3, 8), axis(b"wght", 100, 400, 900)].concat(),
|
||||
[header(200, 20, 1, 804), vec![0; 4000]].concat(),
|
||||
];
|
||||
// Many instances (all named): the list is capped.
|
||||
let mut many = [header(1, 20, 2000, 8), axis(b"wght", 0, 0, 30000)].concat();
|
||||
for i in 0..2000u32 {
|
||||
many.extend(be(&[256 << 16, (i + 1) << 16]));
|
||||
}
|
||||
damaged.push(many);
|
||||
for fvar in &damaged {
|
||||
let f = fontdb::sfnt_of(&[(b"name", &name), (b"fvar", fvar)]).unwrap();
|
||||
let styles = fontdb::face_styles(&skrifa::FontRef::new(&f).unwrap());
|
||||
assert!(!styles.is_empty() && styles.len() <= 2, "{} styles", styles.len());
|
||||
}
|
||||
// A real font with a damaged fvar (and its gvar) still loads and lays out.
|
||||
let good = font();
|
||||
let at = |tag: &[u8; 4]| {
|
||||
let tables = u16::from_be_bytes([good[4], good[5]]) as usize;
|
||||
let r = (0..tables).map(|i| 12 + 16 * i).find(|r| &good[*r..*r + 4] == tag).unwrap();
|
||||
let off = u32::from_be_bytes(good[r + 8..r + 12].try_into().unwrap()) as usize;
|
||||
let len = u32::from_be_bytes(good[r + 12..r + 16].try_into().unwrap()) as usize;
|
||||
off..off + len
|
||||
};
|
||||
let (fvar, gvar) = (at(b"fvar"), at(b"gvar"));
|
||||
// Deterministic byte damage across both tables.
|
||||
let mut seed = 0x2545_f491_u32;
|
||||
for round in 0..300 {
|
||||
let mut bad = good.clone();
|
||||
for _ in 0..1 + round % 8 {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
let range = if seed.is_multiple_of(2) { fvar.clone() } else { gvar.clone() };
|
||||
let i = range.start + (seed as usize >> 3) % range.len();
|
||||
bad[i] = (seed >> 24) as u8;
|
||||
}
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
db.add_font(bad);
|
||||
for style in db.styles(FAMILY) {
|
||||
let l = layout(&db, &text(FAMILY, &style, "lHio"));
|
||||
assert!(l.glyphs.len() <= 8, "{style}: {} glyphs", l.glyphs.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows ships variable system fonts: Bahnschrift (weight and width axes) lists its named
|
||||
/// instances and draws them. Skipped where it isn't installed.
|
||||
#[test]
|
||||
fn bahnschrift_lists_and_draws_its_instances() {
|
||||
let db = FontDb::with_font_dirs(system_font_dirs());
|
||||
if !db.has_family("Bahnschrift") {
|
||||
eprintln!("skipped: Bahnschrift is not installed");
|
||||
return;
|
||||
}
|
||||
let styles = db.styles("Bahnschrift");
|
||||
eprintln!("Bahnschrift: {styles:?}");
|
||||
for s in ["Light", "Regular", "SemiBold", "Bold"] {
|
||||
assert!(styles.iter().any(|x| x == s), "{s} in {styles:?}");
|
||||
}
|
||||
let light = db.face("Bahnschrift", "Light").unwrap();
|
||||
let bold = db.face("Bahnschrift", "Bold").unwrap();
|
||||
assert_eq!((light.style.as_str(), bold.style.as_str()), ("Light", "Bold"));
|
||||
assert!(bold.weight > light.weight, "{} vs {}", bold.weight, light.weight);
|
||||
let (lw, bw) = (ink_width(&db, &light, 'l'), ink_width(&db, &bold, 'l'));
|
||||
assert!(bw > lw * 1.05, "the Bold l is wider: {lw} -> {bw}");
|
||||
let w = |s: &str| layout(&db, &text("Bahnschrift", s, "Hamburgefonstiv")).to_bezpath().bounding_box().width();
|
||||
assert!(w("Bold") > w("Light"), "{} vs {}", w("Bold"), w("Light"));
|
||||
}
|
||||
|
||||
/// Segoe UI Variable (Windows 11; optical size and weight axes). Skipped where it isn't installed.
|
||||
#[test]
|
||||
fn segoe_ui_variable_lists_its_instances() {
|
||||
let db = FontDb::with_font_dirs(system_font_dirs());
|
||||
let families: Vec<String> = db.families().into_iter().filter(|f| f.starts_with("Segoe UI Variable")).collect();
|
||||
if families.is_empty() {
|
||||
eprintln!("skipped: Segoe UI Variable is not installed");
|
||||
return;
|
||||
}
|
||||
for f in &families {
|
||||
let styles = db.styles(f);
|
||||
eprintln!("{f}: {styles:?}");
|
||||
assert!(styles.len() > 1, "{f}: {styles:?}");
|
||||
}
|
||||
if families.iter().any(|f| f == "Segoe UI Variable") {
|
||||
let (light, bold) = (db.face("Segoe UI Variable", "Light Text").unwrap(), db.face("Segoe UI Variable", "Bold Text").unwrap());
|
||||
assert_eq!((light.style.as_str(), bold.style.as_str()), ("Light Text", "Bold Text"));
|
||||
assert!(bold.weight > light.weight && !bold.variations().is_empty());
|
||||
let w = |f: &FontFace| ink_width(&db, f, 'm');
|
||||
assert!(w(&bold) > w(&light), "{} vs {}", w(&bold), w(&light));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
//! Vertical type (bundled fonts: `§` and `×` stand upright like CJK, letters lie on their side;
|
||||
//! Japanese text only with craft-fonts' faces, skipped without them).
|
||||
use super::*;
|
||||
use kurbo::Shape;
|
||||
use vectorcraft_doc::{CharStyle, Justify, TextKind};
|
||||
use vectorcraft_geom::PathData;
|
||||
|
||||
fn vertical(text: &str) -> TextObject {
|
||||
vertical_styled(text, CharStyle { size: 20.0, ..CharStyle::default() })
|
||||
}
|
||||
|
||||
fn vertical_styled(text: &str, style: CharStyle) -> TextObject {
|
||||
let mut t = TextObject::point(Point::ZERO, text, style);
|
||||
t.xf = Affine::IDENTITY;
|
||||
t.vertical = true;
|
||||
t
|
||||
}
|
||||
|
||||
fn ink(l: &TextLayout, i: usize) -> Rect {
|
||||
l.glyphs[i].outline.bounding_box()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_run_down_and_follow_each_other_to_the_left() {
|
||||
let l = layout(FontDb::global(), &vertical("§§\n§"));
|
||||
assert!(l.vertical);
|
||||
let (a, b, c) = (ink(&l, 0), ink(&l, 1), ink(&l, 2));
|
||||
assert!(b.center().y > a.center().y + 15.0, "down the column: {a:?} {b:?}");
|
||||
assert!((a.center().x - b.center().x).abs() < 0.5, "one column");
|
||||
assert!(c.center().x < a.center().x - 15.0, "the next column is to the left: {c:?}");
|
||||
// Point type: the anchor is on the first column's centre line.
|
||||
assert!(a.center().x.abs() < 4.0, "{a:?}");
|
||||
assert!(l.bounds.contains(a.center()) && l.bounds.contains(c.center()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upright_marks_stand_and_letters_lie_on_their_side() {
|
||||
let h = layout(FontDb::global(), &TextObject::point(Point::ZERO, "§l", CharStyle { size: 20.0, ..CharStyle::default() }));
|
||||
let v = layout(FontDb::global(), &vertical("§l"));
|
||||
let (hs, hl, vs, vl) = (ink(&h, 0), ink(&h, 1), ink(&v, 0), ink(&v, 1));
|
||||
assert!((vs.width() - hs.width()).abs() < 0.01 && (vs.height() - hs.height()).abs() < 0.01, "§ upright: {hs:?} {vs:?}");
|
||||
assert!((vl.width() - hl.height()).abs() < 0.01 && (vl.height() - hl.width()).abs() < 0.01, "l turned: {hl:?} {vl:?}");
|
||||
assert!(vl.width() > vl.height(), "a tall l lies across the column");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caret_and_selection_turn_with_the_columns() {
|
||||
let l = layout(FontDb::global(), &vertical("§§§"));
|
||||
let (top, bottom) = caret_position(&l, "§".len());
|
||||
assert!((top.y - bottom.y).abs() < 1e-6 && (top.x - bottom.x).abs() > 10.0, "a horizontal caret across the column: {top:?} {bottom:?}");
|
||||
let between = (ink(&l, 0).center().y + ink(&l, 1).center().y) / 2.0;
|
||||
assert!((top.y - between).abs() < 3.0, "between the first two marks: {top:?} {between} {:?} {:?}", ink(&l, 0), ink(&l, 1));
|
||||
let q = selection_quads(&l, 0, "§§".len());
|
||||
let r = Rect::from_points(q[0][0], q[0][2]);
|
||||
assert!(r.height() > r.width() && r.contains(ink(&l, 0).center()) && r.contains(ink(&l, 1).center()) && !r.contains(ink(&l, 2).center()));
|
||||
// Clicking a mark finds it.
|
||||
let c = ink(&l, 2).center();
|
||||
assert_eq!(hit_byte(&l, c + Vec2::new(0.0, 6.0)), "§§§".len());
|
||||
assert_eq!(hit_byte(&l, c - Vec2::new(0.0, 6.0)), "§§".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_type_starts_at_the_right_edge_and_wraps_to_the_left() {
|
||||
let mut t = vertical(&"§".repeat(12));
|
||||
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 100.0, 105.0).to_path(0.1)) };
|
||||
let l = layout(FontDb::global(), &t);
|
||||
let first = ink(&l, 0);
|
||||
assert!(first.x1 <= 100.0 + 1e-6 && first.x1 > 80.0 && first.y0 >= 0.0, "top right: {first:?}");
|
||||
let cols: Vec<f64> = l.glyphs.iter().map(|g| (g.outline.bounding_box().center().x * 10.0).round()).collect();
|
||||
let mut distinct = cols.clone();
|
||||
distinct.dedup();
|
||||
assert!(distinct.len() >= 2 && distinct.windows(2).all(|w| w[1] < w[0]), "columns right to left: {distinct:?}");
|
||||
assert!(l.glyphs.iter().all(|g| g.outline.bounding_box().y1 <= 105.0 + 1e-6), "inside the frame");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_on_a_path_stays_horizontal() {
|
||||
let mut t = vertical("ab");
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Line::new((0.0, 0.0), (100.0, 0.0)).to_path(0.1)), start: 0.0 };
|
||||
assert!(!layout(FontDb::global(), &t).vertical);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_digits_stand_across_the_column_squeezed_into_one_em() {
|
||||
let l = layout(FontDb::global(), &vertical("§100§"));
|
||||
let block: Vec<Rect> = (1..4).map(|i| ink(&l, i)).collect();
|
||||
assert!(block.windows(2).all(|w| w[1].center().x > w[0].center().x + 2.0 && (w[1].center().y - w[0].center().y).abs() < 1.0), "{block:?}");
|
||||
let span = block[0].union(block[2]);
|
||||
assert!(span.width() <= 20.0 + 1e-6, "squeezed into the 20 pt em: {span:?}");
|
||||
assert!(ink(&l, 4).center().y - ink(&l, 0).center().y < 45.0, "one em of the column");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_digits_stand_across_the_column_and_longer_numbers_lie_on_their_side() {
|
||||
let l = layout(FontDb::global(), &vertical("§12§2026"));
|
||||
let (one, two) = (ink(&l, 1), ink(&l, 2));
|
||||
assert!((one.center().y - two.center().y).abs() < 1.0 && two.center().x > one.center().x + 3.0, "12 side by side: {one:?} {two:?}");
|
||||
assert!(one.height() > one.width(), "upright digits are taller than wide: {one:?}");
|
||||
let (before, after) = (ink(&l, 0), ink(&l, 3));
|
||||
assert!(after.center().y - before.center().y < 45.0, "the block takes one em (20 pt) of the column");
|
||||
let year: Vec<Rect> = (4..8).map(|i| ink(&l, i)).collect();
|
||||
assert!(year.windows(2).all(|w| w[1].center().y > w[0].center().y + 5.0), "2026 runs down the column on its side");
|
||||
}
|
||||
|
||||
/// Each line's text, from the layout's line ranges.
|
||||
fn line_texts(t: &TextObject, l: &TextLayout) -> Vec<String> {
|
||||
let text = t.plain_text();
|
||||
l.lines.iter().map(|li| text.get(li.start..li.end).unwrap_or("").trim_end().to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kinsoku_keeps_closing_marks_off_line_starts_and_opening_brackets_off_line_ends() {
|
||||
for vertical_type in [false, true] {
|
||||
// Five characters fit a line: without kinsoku "。" would start the second line.
|
||||
let text = "あいうえお。かきくけ「こさしすせそ」";
|
||||
let mut t = TextObject::point(Point::ZERO, text, CharStyle { size: 20.0, ..CharStyle::default() });
|
||||
t.xf = Affine::IDENTITY;
|
||||
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 101.0, 400.0).to_path(0.1)) };
|
||||
if vertical_type {
|
||||
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 400.0, 101.0).to_path(0.1)) };
|
||||
t.vertical = true;
|
||||
}
|
||||
let l = layout(FontDb::global(), &t);
|
||||
let lines = line_texts(&t, &l);
|
||||
assert!(lines.len() > 2, "{lines:?}");
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
if i > 0 {
|
||||
let first = line.chars().next().unwrap_or(' ');
|
||||
assert!(!crate::shape::no_line_start(first), "line {i} starts with {first}: {lines:?}");
|
||||
}
|
||||
let last = line.chars().last().unwrap_or(' ');
|
||||
assert!(!crate::shape::no_line_end(last), "line {i} ends with {last}: {lines:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Each glyph's cell down the (single) column: from its origin over its advance. A glyph that
|
||||
/// doesn't advance (the rest of a tate-chu-yoko block) shares the cell of the glyph before it.
|
||||
fn column_cells(l: &TextLayout) -> Vec<(f64, f64)> {
|
||||
let mut cells: Vec<(f64, f64)> = Vec::with_capacity(l.glyphs.len());
|
||||
for g in &l.glyphs {
|
||||
let cell = match cells.last() {
|
||||
Some(&prev) if g.advance == 0.0 => prev,
|
||||
_ => (g.origin.y, g.origin.y + g.advance),
|
||||
};
|
||||
cells.push(cell);
|
||||
}
|
||||
cells
|
||||
}
|
||||
|
||||
/// One column, cells one after the other, each glyph's ink inside its own cell (within `tol`)
|
||||
/// and no two glyphs' ink overlapping.
|
||||
fn assert_own_cells(l: &TextLayout, text: &str, tol: f64) {
|
||||
let label = |i: usize| &text[l.glyphs[i].byte..l.glyphs[i].byte + l.glyphs[i].len];
|
||||
let cells = column_cells(l);
|
||||
for (i, g) in l.glyphs.iter().enumerate() {
|
||||
assert!((g.origin.x - l.glyphs[0].origin.x).abs() < 1e-9, "{:?} leaves the column: {g:?}", label(i));
|
||||
if let Some(next) = l.glyphs.get(i + 1) {
|
||||
assert!((next.origin.y - (g.origin.y + g.advance)).abs() < 1e-9, "{:?} starts where {:?} ends", label(i + 1), label(i));
|
||||
}
|
||||
if g.outline.elements().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (r, (top, bottom)) = (ink(l, i), cells[i]);
|
||||
assert!(r.y0 >= top - tol && r.y1 <= bottom + tol, "{:?}: ink {r:?} outside its cell {top}..{bottom}", label(i));
|
||||
for j in (0..i).filter(|&j| !l.glyphs[j].outline.elements().is_empty()) {
|
||||
let o = ink(l, j);
|
||||
let (ox, oy) = (r.x1.min(o.x1) - r.x0.max(o.x0), r.y1.min(o.y1) - r.y0.max(o.y0));
|
||||
assert!(ox <= tol || oy <= tol, "{:?} {r:?} overlaps {:?} {o:?}", label(i), label(j));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Index of the glyph of `needle`'s first byte in `text`.
|
||||
fn glyph_of(l: &TextLayout, text: &str, needle: &str) -> usize {
|
||||
let b = text.find(needle).unwrap();
|
||||
l.glyphs.iter().position(|g| g.byte == b).unwrap()
|
||||
}
|
||||
|
||||
/// Issue #260 with bundled glyphs (`§` stands in for the upright CJK characters).
|
||||
#[test]
|
||||
fn numbers_keep_their_own_cells_down_the_column() {
|
||||
let text = "§§ §§§ 10§22§§ 1§ 100§ 2026§";
|
||||
let l = layout(FontDb::global(), &vertical(text));
|
||||
assert_own_cells(&l, text, 0.25);
|
||||
// Tate-chu-yoko: one em of the column, centred on the column's centre line (x = 0), the next
|
||||
// character right after it.
|
||||
for block in ["10§", "22§", "100§"] {
|
||||
let first = glyph_of(&l, text, block);
|
||||
let digits = block.len() - '§'.len_utf8();
|
||||
let after = first + digits;
|
||||
let g = &l.glyphs[first];
|
||||
assert!((g.advance - 20.0).abs() < 1e-9, "{block}: {g:?}");
|
||||
assert!(l.glyphs[first + 1..after].iter().all(|d| d.advance == 0.0), "{block}");
|
||||
assert!((l.glyphs[after].origin.y - (g.origin.y + 20.0)).abs() < 1e-9, "{block}: the next character follows the block's em");
|
||||
let inks = (first..after).map(|i| ink(&l, i)).reduce(|a, b| a.union(b)).unwrap();
|
||||
assert!(inks.center().x.abs() < 1.0, "{block} centred on the column: {inks:?}");
|
||||
assert!((inks.center().y - (g.origin.y + 10.0)).abs() < 1.5, "{block} centred in its em: {inks:?} {g:?}");
|
||||
}
|
||||
// A single digit and longer numbers lie on their side, advancing by their own length.
|
||||
let one = glyph_of(&l, text, "1§");
|
||||
let r = ink(&l, one);
|
||||
assert!(r.width() > r.height() && l.glyphs[one].advance < 20.0, "1 on its side: {r:?}");
|
||||
let year = glyph_of(&l, text, "2026");
|
||||
let len: f64 = l.glyphs[year..year + 4].iter().map(|g| g.advance).sum();
|
||||
assert!(len > 30.0 && (l.glyphs[year + 4].origin.y - l.glyphs[year].origin.y - len).abs() < 1e-9, "2026 takes its length: {len}");
|
||||
// Half-width spaces take their own (narrow) width.
|
||||
for (i, g) in l.glyphs.iter().enumerate().filter(|(_, g)| text[g.byte..].starts_with(' ')) {
|
||||
assert!(g.advance > 2.0 && g.advance < 10.0, "space {i}: {g:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn justified_columns_keep_tate_chu_yoko_blocks_whole_and_marks_centred() {
|
||||
let text = "§10§§";
|
||||
let mut t = vertical(text);
|
||||
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 40.0, 200.0).to_path(0.1)) };
|
||||
t.para.justify = Justify::JustifyAll;
|
||||
let l = layout(FontDb::global(), &t);
|
||||
assert_eq!(l.lines.len(), 1);
|
||||
// 200 pt of column, four 20 pt cells: three gaps of 40 pt between them, none inside "10".
|
||||
let (one, zero) = (ink(&l, 1), ink(&l, 2));
|
||||
assert!((one.center().y - zero.center().y).abs() < 0.5 && zero.x0 > one.x1, "10 side by side: {one:?} {zero:?}");
|
||||
let marks: Vec<Rect> = [0, 3, 4].iter().map(|&i| ink(&l, i)).collect();
|
||||
assert!((marks[1].center().y - marks[0].center().y - 120.0).abs() < 1e-6, "{marks:?}");
|
||||
assert!((marks[2].center().y - marks[1].center().y - 60.0).abs() < 1e-6, "{marks:?}");
|
||||
assert!(marks.iter().all(|m| (m.center().x - marks[0].center().x).abs() < 1e-6), "upright marks stay on the centre line: {marks:?}");
|
||||
assert!(marks[2].y1 <= 200.0 + 1e-6, "inside the frame: {marks:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracking_spaces_upright_marks_without_moving_them_off_the_centre_line() {
|
||||
let plain = layout(FontDb::global(), &vertical("§§"));
|
||||
let tracked = layout(FontDb::global(), &vertical_styled("§§", CharStyle { size: 20.0, tracking: 200.0, ..CharStyle::default() }));
|
||||
for i in 0..2 {
|
||||
assert!((ink(&tracked, i).center().x - ink(&plain, i).center().x).abs() < 1e-6, "{:?} {:?}", ink(&tracked, i), ink(&plain, i));
|
||||
}
|
||||
let step = ink(&tracked, 1).center().y - ink(&tracked, 0).center().y;
|
||||
assert!((step - 24.0).abs() < 1e-6, "one em plus 200/1000 em of tracking: {step}");
|
||||
}
|
||||
|
||||
/// Issue #260 as reported, with a Japanese face of craft-fonts (whose own digits are proportional).
|
||||
#[test]
|
||||
fn japanese_numbers_keep_their_own_cells_down_the_column() {
|
||||
let Some(face) = crate::craft_fonts::japanese_ui_fonts(false).into_iter().next() else {
|
||||
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)");
|
||||
return;
|
||||
};
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
let text = "はただ商店 秋のセール 10月22日まで";
|
||||
let t = vertical_styled(text, CharStyle { size: 28.0, font_family: face.family.into(), font_style: face.style.into(), ..CharStyle::default() });
|
||||
let l = layout(&db, &t);
|
||||
assert!(l.glyphs.iter().filter(|g| !text[g.byte..].starts_with(' ')).all(|g| g.gid != 0), "real glyphs, no tofu");
|
||||
assert_own_cells(&l, text, 0.5);
|
||||
for block in ["10月", "22日"] {
|
||||
let first = glyph_of(&l, text, block);
|
||||
let g = &l.glyphs[first];
|
||||
assert!((g.advance - 28.0).abs() < 1e-9 && l.glyphs[first + 1].advance == 0.0, "{block}: one em");
|
||||
assert!((l.glyphs[first + 2].origin.y - (g.origin.y + 28.0)).abs() < 1e-9, "{block}: the next character follows the em");
|
||||
let inks = ink(&l, first).union(ink(&l, first + 1));
|
||||
assert!(inks.center().x.abs() < 2.8, "{block} centred on the column: {inks:?}");
|
||||
}
|
||||
}
|
||||
@@ -93,14 +93,15 @@ impl CornerDrag {
|
||||
Some(Self { widgets, corner, start: p, began: false, radius: widgets.radii[corner], at: p })
|
||||
}
|
||||
|
||||
/// The start radius plus the pointer's travel along the corner's inward diagonal (in the
|
||||
/// shape's own units), from square to fully round.
|
||||
/// The start radius (as drawn: no larger than fits) plus the pointer's travel along the
|
||||
/// corner's inward diagonal (in the shape's own units), from square to fully round.
|
||||
fn radius_at(&self, p: Point) -> f64 {
|
||||
let w = &self.widgets;
|
||||
let inv = w.xf.inverse();
|
||||
let d = inv * p - inv * self.start;
|
||||
let (_, (sx, sy)) = CORNERS[self.corner];
|
||||
(w.radii[self.corner] + (d.x * sx + d.y * sy) / 2.0).clamp(0.0, w.w.min(w.h) / 2.0)
|
||||
let max = (w.w.abs().min(w.h.abs()) / 2.0).max(0.0);
|
||||
(w.radii[self.corner].min(max) + (d.x * sx + d.y * sy) / 2.0).clamp(0.0, max)
|
||||
}
|
||||
|
||||
pub fn drag(&mut self, cx: &ToolContext, p: Point) -> Vec<Action> {
|
||||
|
||||
+133
-30
@@ -7,16 +7,20 @@
|
||||
//! Both pick the key objects of a blend. Direct Selection also edits a blend's spine: drag its
|
||||
//! points (a key object on a point moves with it) and, once a point is clicked, its handles; and
|
||||
//! the points of selected gradient meshes and mesh envelopes and their handles ([`MeshEdit`]).
|
||||
//! Dragging a corner or an edge of area type's frame reshapes the type area (`text.reshapeArea`):
|
||||
//! the text reflows at its size.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::hit::hit_test;
|
||||
use vectorcraft_doc::{AnchorRef, NodeId, NodeKind};
|
||||
use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{PathData, Point, Rect};
|
||||
|
||||
use crate::bbox::move_delta;
|
||||
use crate::corners::{CornerDrag, over_widget};
|
||||
use crate::meshedit::MeshEdit;
|
||||
use crate::select::matrix_json;
|
||||
use crate::select::{is_area_type, matrix_json};
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -58,6 +62,13 @@ enum State {
|
||||
},
|
||||
/// Dragging a mesh point or handle ([`MeshEdit`]).
|
||||
Mesh,
|
||||
/// Dragging anchors of area type's frame (a corner, or the two ends of an edge).
|
||||
TypeArea {
|
||||
id: NodeId,
|
||||
anchors: Vec<AnchorRef>,
|
||||
start: Point,
|
||||
began: bool,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct DirectSelectionTool {
|
||||
@@ -115,33 +126,45 @@ fn spine_anchor(cx: &ToolContext, (id, ai): (NodeId, usize)) -> Option<vectorcra
|
||||
}
|
||||
}
|
||||
|
||||
/// Anchor (or handle) of any selected/visible path under `p`.
|
||||
fn hit_anchor(cx: &ToolContext, p: Point, tol: f64, selected_only: bool) -> Option<(NodeId, usize, usize)> {
|
||||
let ids: Vec<NodeId> = if selected_only {
|
||||
cx.selection.objects.clone()
|
||||
} else {
|
||||
let mut v = vec![];
|
||||
cx.doc.walk(|n| {
|
||||
if matches!(n.kind, NodeKind::Path { .. }) {
|
||||
v.push(n.id)
|
||||
}
|
||||
});
|
||||
v.reverse();
|
||||
v
|
||||
};
|
||||
for id in ids {
|
||||
if !cx.doc.is_editable(id) {
|
||||
continue;
|
||||
}
|
||||
if let Some(pd) = cx.doc.node(id).and_then(|n| n.path_data()) {
|
||||
for (si, ai, a) in pd.anchors() {
|
||||
if a.p.distance(p) <= tol {
|
||||
return Some((id, si, ai));
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The anchors Direct Selection edits on `n`, in document space: a path's, or area type's frame.
|
||||
fn editable_path(n: &Node) -> Option<Cow<'_, PathData>> {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, .. } => Some(Cow::Borrowed(path)),
|
||||
NodeKind::Text(t) => t.area_frame().map(Cow::Owned),
|
||||
_ => None,
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The editable objects `f` accepts, topmost first.
|
||||
fn anchor_owners(cx: &ToolContext, f: fn(&Node) -> bool) -> Vec<NodeId> {
|
||||
let mut v = vec![];
|
||||
cx.doc.walk(|n| {
|
||||
if f(n) {
|
||||
v.push(n.id)
|
||||
}
|
||||
});
|
||||
v.reverse();
|
||||
v.retain(|id| cx.doc.is_editable(*id));
|
||||
v
|
||||
}
|
||||
|
||||
/// Anchor of any visible path or area type frame under `p`, topmost first.
|
||||
fn hit_anchor(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize)> {
|
||||
let owners = anchor_owners(cx, |n| matches!(n.kind, NodeKind::Path { .. }) || is_area_type(n));
|
||||
owners.into_iter().find_map(|id| {
|
||||
let pd = cx.doc.node(id).and_then(editable_path)?;
|
||||
pd.anchors().find(|(_, _, a)| a.p.distance(p) <= tol).map(|(si, ai, _)| (id, si, ai))
|
||||
})
|
||||
}
|
||||
|
||||
/// An edge of an area type frame under `p`, topmost first: (text, the edge's two anchors).
|
||||
fn hit_frame_edge(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, Vec<AnchorRef>)> {
|
||||
anchor_owners(cx, is_area_type).into_iter().find_map(|id| {
|
||||
let frame = cx.doc.node(id).and_then(editable_path)?;
|
||||
let (si, seg, _, _, d) = frame.nearest(p)?;
|
||||
let n = frame.subpaths.get(si)?.anchors.len();
|
||||
(d <= tol && n > 0).then(|| (id, vec![(si, seg % n), (si, (seg + 1) % n)]))
|
||||
})
|
||||
}
|
||||
|
||||
/// Direction handle of a partially selected anchor under `p`: (id, si, ai, is_out).
|
||||
@@ -223,7 +246,10 @@ impl Tool for DirectSelectionTool {
|
||||
self.state = State::SpinePoint { id, anchor, from, start: p, began: false };
|
||||
return if cx.selection.contains(id) { vec![] } else { vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))] };
|
||||
}
|
||||
if let Some((id, si, ai)) = hit_anchor(cx, p, tol, false) {
|
||||
if let Some((id, si, ai)) = hit_anchor(cx, p, tol) {
|
||||
if cx.doc.node(id).is_some_and(is_area_type) {
|
||||
return self.press_type_area(cx, id, vec![(si, ai)], p, ev.mods.shift);
|
||||
}
|
||||
let already = cx.selection.partial(id).is_some_and(|s| s.contains(&(si, ai)));
|
||||
self.state = State::MoveAnchors { start: p, began: false };
|
||||
if ev.mods.shift {
|
||||
@@ -234,6 +260,9 @@ impl Tool for DirectSelectionTool {
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
if let Some((id, anchors)) = hit_frame_edge(cx, p, tol) {
|
||||
return self.press_type_area(cx, id, anchors, p, ev.mods.shift);
|
||||
}
|
||||
if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) {
|
||||
// Clicking a segment/fill selects the whole leaf path (all anchors).
|
||||
self.state = State::MoveObject { start: p, began: false };
|
||||
@@ -277,6 +306,19 @@ impl Tool for DirectSelectionTool {
|
||||
));
|
||||
out
|
||||
}
|
||||
(PointerKind::Drag, State::TypeArea { id, anchors, start, began }) => {
|
||||
let mut out = vec![];
|
||||
if !began {
|
||||
if p.distance(start) < cx.tol(3.0) {
|
||||
return out;
|
||||
}
|
||||
out.push(Action::Begin("Reshape Type Area".into()));
|
||||
}
|
||||
let d = move_delta(start, p, ev.mods.shift);
|
||||
out.push(Action::Preview("text.reshapeArea".into(), json!({"id": id.0, "anchors": anchors_json(&anchors), "dx": d.x, "dy": d.y})));
|
||||
self.state = State::TypeArea { id, anchors, start, began: true };
|
||||
out
|
||||
}
|
||||
(PointerKind::Drag, State::Handle { id, si, ai, out }) => {
|
||||
vec![Action::Preview(
|
||||
"path.setHandle".into(),
|
||||
@@ -318,7 +360,10 @@ impl Tool for DirectSelectionTool {
|
||||
self.state = State::Idle;
|
||||
c.finish()
|
||||
}
|
||||
(PointerKind::Up, State::MoveAnchors { began, .. } | State::MoveObject { began, .. } | State::SpinePoint { began, .. }) => {
|
||||
(
|
||||
PointerKind::Up,
|
||||
State::MoveAnchors { began, .. } | State::MoveObject { began, .. } | State::SpinePoint { began, .. } | State::TypeArea { began, .. },
|
||||
) => {
|
||||
self.state = State::Idle;
|
||||
if began { vec![Action::Commit] } else { vec![] }
|
||||
}
|
||||
@@ -356,6 +401,9 @@ impl Tool for DirectSelectionTool {
|
||||
_ => {
|
||||
let mut out = self.spine_overlays(cx);
|
||||
out.extend(self.mesh.overlays(cx));
|
||||
if !self.group {
|
||||
out.extend(frame_overlays(cx));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
@@ -368,7 +416,31 @@ impl Tool for DirectSelectionTool {
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame anchors of the selected area type, which Direct Selection drags.
|
||||
fn frame_overlays(cx: &ToolContext) -> Vec<Overlay> {
|
||||
let mut out = vec![];
|
||||
for id in &cx.selection.objects {
|
||||
let Some(frame) = cx.doc.node(*id).filter(|n| is_area_type(n)).and_then(editable_path) else { continue };
|
||||
let color = cx.doc.layer_color(*id);
|
||||
out.extend(frame.anchors().map(|(_, _, a)| Overlay::Anchor { p: a.p, color, filled: false, size: 5.0 }));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl DirectSelectionTool {
|
||||
/// Press on area type's frame anchors (a corner, or an edge's two ends): select the text and
|
||||
/// get ready to drag them.
|
||||
fn press_type_area(&mut self, cx: &ToolContext, id: NodeId, anchors: Vec<AnchorRef>, p: Point, shift: bool) -> Vec<Action> {
|
||||
self.state = State::TypeArea { id, anchors, start: p, began: false };
|
||||
if cx.selection.contains(id) {
|
||||
vec![]
|
||||
} else if shift {
|
||||
vec![Action::Exec("select.add".into(), json!({"ids": [id.0]}))]
|
||||
} else {
|
||||
vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]
|
||||
}
|
||||
}
|
||||
|
||||
/// The clicked spine point, filled, with its handles.
|
||||
fn spine_overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let Some(sel) = self.spine.filter(|(id, _)| cx.selection.contains(*id)) else { return vec![] };
|
||||
@@ -435,6 +507,37 @@ mod tests {
|
||||
assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [g.0]}))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_frame_corner_or_edge_reshapes_area_type() {
|
||||
let (d, text) = doc_with_area_type();
|
||||
let p = paint();
|
||||
let s = Selection::default();
|
||||
let cx1 = cx(&d, &s, &p);
|
||||
let mut t = DirectSelectionTool::new(false);
|
||||
// The bottom-right corner of the 120 × 40 frame at (300, 300) selects the text...
|
||||
assert_eq!(
|
||||
t.pointer(&cx1, &PointerEvent::new(PointerKind::Down, 421.0, 339.0)),
|
||||
vec![Action::Exec("select.set".into(), json!({"ids": [text.0]}))]
|
||||
);
|
||||
// ...and drags that corner.
|
||||
let a = t.pointer(&cx1, &PointerEvent::new(PointerKind::Drag, 461.0, 399.0));
|
||||
assert_eq!(a[0], Action::Begin("Reshape Type Area".into()));
|
||||
assert_eq!(a[1], Action::Preview("text.reshapeArea".into(), json!({"id": text.0, "anchors": [[0, 2]], "dx": 40.0, "dy": 60.0})));
|
||||
assert_eq!(t.pointer(&cx1, &PointerEvent::new(PointerKind::Up, 461.0, 399.0)), vec![Action::Commit]);
|
||||
// The top edge drags both its ends; a click alone changes nothing.
|
||||
let mut s = Selection::default();
|
||||
s.add(text);
|
||||
let cx2 = cx(&d, &s, &p);
|
||||
assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 350.0, 301.0)).is_empty());
|
||||
let a = t.pointer(&cx2, &PointerEvent::new(PointerKind::Drag, 350.0, 281.0));
|
||||
assert_eq!(a[1], Action::Preview("text.reshapeArea".into(), json!({"id": text.0, "anchors": [[0, 0], [0, 1]], "dx": 0.0, "dy": -20.0})));
|
||||
t.pointer(&cx2, &PointerEvent::new(PointerKind::Up, 350.0, 281.0));
|
||||
assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 300.0, 340.0)).is_empty());
|
||||
assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Up, 300.0, 340.0)).is_empty());
|
||||
// The selected frame's corners show.
|
||||
assert_eq!(t.overlays(&cx2).iter().filter(|o| matches!(o, Overlay::Anchor { filled: false, .. })).count(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn click_anchor_selects_it() {
|
||||
let (d, id) = doc_with_rect();
|
||||
|
||||
@@ -385,6 +385,21 @@ pub trait Tool: Send {
|
||||
fn text_input(&mut self, _cx: &ToolContext, _s: &str) -> Vec<Action> {
|
||||
vec![]
|
||||
}
|
||||
/// IME composition (marked text) for the tool that wants text: `text` replaces the previous
|
||||
/// marked text (or the selection); an empty `text` ends the composition. `active_chars` is the
|
||||
/// clause being converted, in characters of `text`. The committed result arrives through
|
||||
/// [`Tool::text_input`].
|
||||
fn ime_preedit(&mut self, _cx: &ToolContext, _text: &str, _active_chars: Option<std::ops::Range<usize>>) -> Vec<Action> {
|
||||
vec![]
|
||||
}
|
||||
/// Is uncommitted IME text being shown? Keys, shortcuts and Undo wait while it is.
|
||||
fn composing(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// Where the IME candidate window goes: the caret line (top, bottom) in document space.
|
||||
fn ime_caret(&self, _cx: &ToolContext) -> Option<(Point, Point)> {
|
||||
None
|
||||
}
|
||||
fn notify(&mut self, _cx: &ToolContext, _what: &str) {}
|
||||
/// Called when the user switches away (finish pending work).
|
||||
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||||
@@ -462,6 +477,17 @@ pub(crate) mod testutil {
|
||||
(d, id)
|
||||
}
|
||||
|
||||
/// [`doc_with_rect`] plus 120 × 40 area type at (300, 300).
|
||||
pub fn doc_with_area_type() -> (Document, NodeId) {
|
||||
let (mut d, _) = doc_with_rect();
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let mut t = vectorcraft_doc::TextObject::point(Point::new(300.0, 300.0), "Some words", Default::default());
|
||||
t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 120.0, 40.0)) };
|
||||
d.insert(Some(l), 1, Node::new(id, vectorcraft_doc::NodeKind::Text(Box::new(t)))).unwrap();
|
||||
(d, id)
|
||||
}
|
||||
|
||||
pub fn paint() -> PaintDefaults {
|
||||
PaintDefaults::default()
|
||||
}
|
||||
|
||||
+112
-13
@@ -2,7 +2,8 @@
|
||||
//! bounding-box scale (Shift proportional, Alt from centre) and rotate (outside corners, Shift 45°),
|
||||
//! drag a live rectangle's corner widget to round its corners, double-click to enter isolation mode.
|
||||
//! The bounding box stands at the selection's own angle after a rotation, so its handles scale
|
||||
//! along the objects' axes.
|
||||
//! along the objects' axes. A handle drag resizes area type's frame (the type area) instead of
|
||||
//! scaling its type: the text reflows at its size.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::hit::{hit_test, marquee};
|
||||
@@ -21,10 +22,15 @@ enum State {
|
||||
Moving {
|
||||
start: Point,
|
||||
began: bool,
|
||||
/// Shift-pressed on this selected object: released without a drag, it leaves the
|
||||
/// selection.
|
||||
deselect: Option<NodeId>,
|
||||
},
|
||||
Scaling {
|
||||
handle: Handle,
|
||||
bx: OrientedBox,
|
||||
/// Area type is selected: its frame resizes (`typeAreas`), its type keeps its size.
|
||||
areas: bool,
|
||||
},
|
||||
Rotating {
|
||||
center: Point,
|
||||
@@ -126,8 +132,12 @@ impl Tool for SelectionTool {
|
||||
{
|
||||
match box_hit(cx, &bx, p) {
|
||||
Some(BoxHit::Handle(handle)) => {
|
||||
self.state = State::Scaling { handle, bx };
|
||||
return vec![Action::Begin("Scale".into())];
|
||||
let is_area = |id: &NodeId| cx.doc.node(*id).is_some_and(is_area_type);
|
||||
let areas = cx.selection.objects.iter().any(is_area);
|
||||
// Only area type: the step resizes type areas; anything else scales.
|
||||
let label = if areas && cx.selection.objects.iter().all(is_area) { "Resize Type Area" } else { "Scale" };
|
||||
self.state = State::Scaling { handle, bx, areas };
|
||||
return vec![Action::Begin(label.into())];
|
||||
}
|
||||
Some(BoxHit::Rotate) => {
|
||||
self.state = State::Rotating { center: bx.center(), start: p };
|
||||
@@ -141,16 +151,19 @@ impl Tool for SelectionTool {
|
||||
Some(h) => {
|
||||
let top = h.top_object(cx.isolation);
|
||||
let mut out = vec![];
|
||||
let mut deselect = None;
|
||||
if m.shift {
|
||||
out.push(Action::Exec("select.toggle".into(), json!({ "id": top.0 })));
|
||||
// A Shift-click takes a selected object out of the selection when it
|
||||
// is released; a Shift-drag moves the selection, constrained.
|
||||
if cx.selection.contains(top) {
|
||||
self.state = State::Idle;
|
||||
return out;
|
||||
deselect = Some(top);
|
||||
} else {
|
||||
out.push(Action::Exec("select.toggle".into(), json!({ "id": top.0 })));
|
||||
}
|
||||
} else if !cx.selection.contains(top) {
|
||||
out.push(Action::Exec("select.set".into(), json!({ "ids": [top.0] })));
|
||||
}
|
||||
self.state = State::Moving { start: p, began: false };
|
||||
self.state = State::Moving { start: p, began: false, deselect };
|
||||
out
|
||||
}
|
||||
None => {
|
||||
@@ -185,17 +198,21 @@ impl Tool for SelectionTool {
|
||||
d = vectorcraft_geom::Vec2::new((b.x0 + d.x).round() - b.x0, (b.y0 + d.y).round() - b.y0);
|
||||
self.guides.clear();
|
||||
}
|
||||
self.state = State::Moving { start, began: true };
|
||||
self.state = State::Moving { start, began: true, deselect: None };
|
||||
self.measure = Some((p, cx.offset_label(d.x, d.y)));
|
||||
out.push(Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt })));
|
||||
out
|
||||
}
|
||||
(PointerKind::Drag, State::Scaling { handle, bx }) => {
|
||||
(PointerKind::Drag, State::Scaling { handle, bx, areas }) => {
|
||||
// Scale in the box's own frame: along the objects' axes when it is rotated.
|
||||
let a = scale_for_drag(bx.rect, handle, bx.to_local(p), m.shift, m.alt);
|
||||
let nr = a.transform_rect_bbox(bx.rect);
|
||||
self.measure = Some((p, cx.size_label(nr.width(), nr.height())));
|
||||
vec![Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(bx.conjugate(a)), "copy": false }))]
|
||||
let mut params = json!({ "matrix": matrix_json(bx.conjugate(a)), "copy": false });
|
||||
if areas {
|
||||
params["typeAreas"] = json!(true);
|
||||
}
|
||||
vec![Action::Preview("object.transform".into(), params)]
|
||||
}
|
||||
(PointerKind::Drag, State::Rotating { center, start, .. }) => {
|
||||
let (a, deg) = rotate_for_drag(center, start, p, m.shift);
|
||||
@@ -215,12 +232,16 @@ impl Tool for SelectionTool {
|
||||
self.state = State::Idle;
|
||||
c.finish()
|
||||
}
|
||||
(PointerKind::Up, State::Moving { began, .. }) => {
|
||||
(PointerKind::Up, State::Moving { began, deselect, .. }) => {
|
||||
self.state = State::Idle;
|
||||
self.measure = None;
|
||||
self.guides.clear();
|
||||
self.targets = None;
|
||||
if began { vec![Action::Commit] } else { vec![] }
|
||||
match (began, deselect) {
|
||||
(true, _) => vec![Action::Commit],
|
||||
(false, Some(id)) => vec![Action::Exec("select.toggle".into(), json!({ "id": id.0 }))],
|
||||
(false, None) => vec![],
|
||||
}
|
||||
}
|
||||
(PointerKind::Up, State::Scaling { .. } | State::Rotating { .. }) => {
|
||||
self.state = State::Idle;
|
||||
@@ -261,7 +282,7 @@ impl Tool for SelectionTool {
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
|
||||
match self.state {
|
||||
State::Rotating { .. } => return Cursor::Rotate,
|
||||
State::Scaling { handle, bx } => return handle_cursor(handle, bx.angle),
|
||||
State::Scaling { handle, bx, .. } => return handle_cursor(handle, bx.angle),
|
||||
State::Moving { began: true, .. } => return Cursor::Arrow,
|
||||
State::Corner(_) => return Cursor::CornerRadius,
|
||||
_ => {}
|
||||
@@ -288,6 +309,12 @@ impl Tool for SelectionTool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `n` area type (text in a frame) whose frame the tools reshape? Type in perspective isn't:
|
||||
/// it transforms whole.
|
||||
pub(crate) fn is_area_type(n: &vectorcraft_doc::Node) -> bool {
|
||||
n.perspective.is_none() && matches!(&n.kind, vectorcraft_doc::NodeKind::Text(t) if matches!(t.kind, vectorcraft_doc::TextKind::Area { .. }))
|
||||
}
|
||||
|
||||
/// The resize cursor for handle `h` of a box turned by `angle` (counter-clockwise degrees): the
|
||||
/// handle that sits where `h` appears on screen, in 45° steps.
|
||||
fn handle_cursor(h: Handle, angle: f64) -> Cursor {
|
||||
@@ -339,6 +366,48 @@ mod tests {
|
||||
assert_eq!(a, vec![Action::Commit]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_drag_on_a_selected_object_moves_it_constrained() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = SelectionTool::default();
|
||||
let shift = |kind, x, y| ev(kind, x, y).with_mods(Mods { shift: true, ..Mods::default() });
|
||||
// Pressing keeps the selection: nothing is toggled yet.
|
||||
assert!(t.pointer(&cx, &shift(PointerKind::Down, 150.0, 150.0)).is_empty());
|
||||
let a = t.pointer(&cx, &shift(PointerKind::Drag, 190.0, 160.0));
|
||||
assert_eq!(a[0], Action::Begin("Move".into()));
|
||||
assert!(
|
||||
matches!(&a[1], Action::Preview(c, v) if c == "object.transform"
|
||||
&& v["matrix"][4].as_f64().is_some_and(|x| x > 39.0) && v["matrix"][5] == 0.0),
|
||||
"moved right, constrained to horizontal: {a:?}"
|
||||
);
|
||||
assert_eq!(t.pointer(&cx, &shift(PointerKind::Up, 190.0, 160.0)), vec![Action::Commit]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_click_toggles_an_object_on_release() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let p = paint();
|
||||
let shift = |kind, x, y| ev(kind, x, y).with_mods(Mods { shift: true, ..Mods::default() });
|
||||
let toggle = vec![Action::Exec("select.toggle".into(), json!({"id": id.0}))];
|
||||
// Selected: it leaves the selection when the click is released.
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let cx1 = cx(&d, &s, &p);
|
||||
let mut t = SelectionTool::default();
|
||||
assert!(t.pointer(&cx1, &shift(PointerKind::Down, 150.0, 150.0)).is_empty());
|
||||
assert_eq!(t.pointer(&cx1, &shift(PointerKind::Up, 150.0, 150.0)), toggle);
|
||||
// Not selected: it joins the selection at once, ready to be dragged.
|
||||
let s = Selection::default();
|
||||
let cx2 = cx(&d, &s, &p);
|
||||
let mut t = SelectionTool::default();
|
||||
assert_eq!(t.pointer(&cx2, &shift(PointerKind::Down, 150.0, 150.0)), toggle);
|
||||
assert!(t.pointer(&cx2, &shift(PointerKind::Up, 150.0, 150.0)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marquee_selects() {
|
||||
let (d, id) = doc_with_rect();
|
||||
@@ -368,6 +437,36 @@ mod tests {
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 300.0, 300.0)), vec![Action::Commit]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_drag_on_area_type_resizes_its_frame() {
|
||||
let (d, text) = doc_with_area_type();
|
||||
let p = paint();
|
||||
let mut s = Selection::default();
|
||||
s.add(text);
|
||||
let cx1 = cx(&d, &s, &p);
|
||||
let mut t = SelectionTool::default();
|
||||
// Bottom-right handle of the 120 × 40 frame at (300, 300).
|
||||
assert_eq!(t.pointer(&cx1, &ev(PointerKind::Down, 420.0, 340.0)), vec![Action::Begin("Resize Type Area".into())]);
|
||||
let a = t.pointer(&cx1, &ev(PointerKind::Drag, 460.0, 400.0));
|
||||
assert!(matches!(&a[0], Action::Preview(c, v) if c == "object.transform" && v["typeAreas"] == true), "{a:?}");
|
||||
assert_eq!(t.pointer(&cx1, &ev(PointerKind::Up, 460.0, 400.0)), vec![Action::Commit]);
|
||||
// With another object the step is a scale, still resizing the type area.
|
||||
let rect = d.layers[0].children().unwrap()[0].id;
|
||||
s.add(rect);
|
||||
let cx2 = cx(&d, &s, &p);
|
||||
assert_eq!(t.pointer(&cx2, &ev(PointerKind::Down, 420.0, 340.0)), vec![Action::Begin("Scale".into())]);
|
||||
let a = t.pointer(&cx2, &ev(PointerKind::Drag, 460.0, 400.0));
|
||||
assert!(matches!(&a[0], Action::Preview(_, v) if v["typeAreas"] == true), "{a:?}");
|
||||
t.pointer(&cx2, &ev(PointerKind::Up, 460.0, 400.0));
|
||||
// Without area type the drag scales as before.
|
||||
let mut s = Selection::default();
|
||||
s.add(rect);
|
||||
let cx3 = cx(&d, &s, &p);
|
||||
t.pointer(&cx3, &ev(PointerKind::Down, 200.0, 200.0));
|
||||
let a = t.pointer(&cx3, &ev(PointerKind::Drag, 300.0, 300.0));
|
||||
assert!(matches!(&a[0], Action::Preview(_, v) if v.get("typeAreas").is_none()), "{a:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turned_box_handles_turn_and_scale_along_the_object() {
|
||||
let (mut d, id) = doc_with_rect();
|
||||
|
||||
+134
-16
@@ -12,7 +12,11 @@
|
||||
//! `text.editRange` (the changed span, with its styled runs) against the session snapshot.
|
||||
//! Styling a selected range goes through `text.setRangeStyle` (the Character panel reads the
|
||||
//! selection from [`Tool::options`]).
|
||||
//!
|
||||
//! IME: the marked text of a composition is part of the typing session (so it lays out in place),
|
||||
//! underlined, until the IME commits it (it then goes through [`Tool::text_input`]) or clears it.
|
||||
|
||||
use std::ops::Range;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -38,6 +42,15 @@ struct Typing {
|
||||
cur: Vec<TextRun>,
|
||||
}
|
||||
|
||||
/// IME marked text inside the typing session, not yet committed.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct Preedit {
|
||||
/// Byte range of the marked text in the session's runs.
|
||||
range: Range<usize>,
|
||||
/// The clause being converted, in bytes relative to `range.start` (empty: the IME's cursor).
|
||||
active: Option<Range<usize>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct TypeTool {
|
||||
vertical: bool,
|
||||
@@ -52,6 +65,7 @@ pub struct TypeTool {
|
||||
/// Horizontal position kept by Up/Down (text space).
|
||||
goal_x: Option<f64>,
|
||||
typing: Option<Typing>,
|
||||
preedit: Option<Preedit>,
|
||||
press: Option<Point>,
|
||||
drag: Option<Point>,
|
||||
/// The press landed in the edited text: dragging selects.
|
||||
@@ -113,8 +127,10 @@ impl TypeTool {
|
||||
(self.caret.min(self.anchor), self.caret.max(self.anchor))
|
||||
}
|
||||
|
||||
/// End the typing session (one undo step).
|
||||
/// End the typing session (one undo step). Marked text left by an IME stays as typed (as
|
||||
/// clicking away from a composition does in macOS text views); the UI interrupts the IME.
|
||||
fn commit(&mut self) -> Vec<Action> {
|
||||
self.preedit = None;
|
||||
if self.typing.take().is_some() { vec![Action::Commit] } else { vec![] }
|
||||
}
|
||||
|
||||
@@ -187,6 +203,7 @@ impl TypeTool {
|
||||
self.anchor = caret;
|
||||
self.goal_x = None;
|
||||
self.typing = None;
|
||||
self.preedit = None;
|
||||
}
|
||||
|
||||
/// Byte under `p` in the edited text if `p` is inside it (frame, path band or layout bounds).
|
||||
@@ -218,19 +235,26 @@ impl TypeTool {
|
||||
}
|
||||
}
|
||||
|
||||
fn on_up(&mut self, cx: &ToolContext, start: Point) -> Vec<Action> {
|
||||
/// Start editing the type under `p` with the caret there → the actions (ending the previous
|
||||
/// edit, selecting the type); None when no type is under `p`.
|
||||
fn edit_at(&mut self, cx: &ToolContext, p: Point) -> Option<Vec<Action>> {
|
||||
let h = hit_test(cx.doc, p, cx.hit_options())?;
|
||||
let Some(NodeKind::Text(t)) = cx.doc.node(h.leaf).map(|n| &n.kind) else { return None };
|
||||
let lay = self.layout(t);
|
||||
let byte = vectorcraft_text::hit_byte(&lay, t.xf.inverse() * p);
|
||||
let mut out = self.finish(cx);
|
||||
self.start_editing(h.leaf, byte);
|
||||
self.clicks = (Some(p), 1);
|
||||
out.push(Action::Exec("select.set".into(), json!({"ids": [h.leaf.0]})));
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn on_up(&mut self, cx: &ToolContext, start: Point) -> Vec<Action> {
|
||||
// Click into existing text: place the caret.
|
||||
if let Some(h) = hit_test(cx.doc, start, cx.hit_options())
|
||||
&& let Some(NodeKind::Text(t)) = cx.doc.node(h.leaf).map(|n| &n.kind)
|
||||
{
|
||||
let lay = self.layout(t);
|
||||
let byte = vectorcraft_text::hit_byte(&lay, t.xf.inverse() * start);
|
||||
self.start_editing(h.leaf, byte);
|
||||
self.clicks = (Some(start), 1);
|
||||
out.push(Action::Exec("select.set".into(), json!({"ids": [h.leaf.0]})));
|
||||
if let Some(out) = self.edit_at(cx, start) {
|
||||
return out;
|
||||
}
|
||||
let mut out = self.finish(cx);
|
||||
let drag = self.drag.take();
|
||||
// Area Type / Type on a Path: click a path.
|
||||
if self.mode != Mode::Type && drag.is_none() {
|
||||
@@ -247,7 +271,7 @@ impl TypeTool {
|
||||
}
|
||||
let area = drag.map(|d| Rect::from_points(start, d)).filter(|r| r.width() > cx.tol(6.0) && r.height() > cx.tol(6.0));
|
||||
let mut params = match area {
|
||||
Some(r) => json!({"x": r.x0, "y": r.y0 + 12.0, "text": "", "area": {"width": r.width(), "height": r.height()}}),
|
||||
Some(r) => json!({"x": r.x0, "y": r.y0, "text": "", "area": {"width": r.width(), "height": r.height()}}),
|
||||
None => json!({"x": start.x, "y": start.y, "text": ""}),
|
||||
};
|
||||
params["vertical"] = json!(self.vertical);
|
||||
@@ -277,6 +301,14 @@ impl TypeTool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Byte range in `s` of the characters `r` (IME ranges count characters; carets count bytes).
|
||||
/// `None` when `r` runs past the end of `s` or backwards.
|
||||
fn char_range_to_bytes(s: &str, r: Range<usize>) -> Option<Range<usize>> {
|
||||
let byte = |c: usize| if c == s.chars().count() { Some(s.len()) } else { s.char_indices().nth(c).map(|(i, _)| i) };
|
||||
let (a, b) = (byte(r.start)?, byte(r.end)?);
|
||||
(a <= b).then_some(a..b)
|
||||
}
|
||||
|
||||
/// Lengths of the common prefix and suffix of `a` and `b` (on char boundaries, not overlapping).
|
||||
fn common_affixes(a: &str, b: &str) -> (usize, usize) {
|
||||
let mut p = 0;
|
||||
@@ -332,6 +364,12 @@ impl Tool for TypeTool {
|
||||
return out;
|
||||
}
|
||||
self.clicks = (None, 0);
|
||||
// A press on other type edits it from there, so a drag selects its text at once,
|
||||
// as it does in the type being edited.
|
||||
if let Some(out) = self.edit_at(cx, ev.pos) {
|
||||
self.selecting = true;
|
||||
return out;
|
||||
}
|
||||
self.press = Some(ev.pos);
|
||||
self.drag = None;
|
||||
vec![]
|
||||
@@ -371,6 +409,15 @@ impl Tool for TypeTool {
|
||||
return vec![];
|
||||
}
|
||||
let s: String = s.chars().filter(|c| !c.is_control() || matches!(c, '\n' | '\t')).map(|c| if c == '\r' { '\n' } else { c }).collect();
|
||||
// An IME commit replaces its marked text (an empty commit just removes it).
|
||||
let committing = self.preedit.is_some();
|
||||
if committing && s.is_empty() {
|
||||
return self.ime_preedit(cx, "", None);
|
||||
}
|
||||
if let Some(p) = self.preedit.take() {
|
||||
self.anchor = p.range.start;
|
||||
self.caret = p.range.end;
|
||||
}
|
||||
if s.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
@@ -387,13 +434,17 @@ impl Tool for TypeTool {
|
||||
self.clicks = (None, 0);
|
||||
// Pasting what we copied keeps its formatting (unless pasted text is kept plain).
|
||||
let clip: String = self.clipboard.iter().map(|r| r.text.as_str()).collect();
|
||||
if s.len() > 1 && s == clip && !cx.paste_plain_text {
|
||||
if !committing && s.len() > 1 && s == clip && !cx.paste_plain_text {
|
||||
let runs = self.clipboard.clone();
|
||||
return self.replace(cx, "", Some(runs));
|
||||
}
|
||||
self.replace(cx, &s, None)
|
||||
}
|
||||
fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec<Action> {
|
||||
// The IME owns the keys while it composes (the UI doesn't send them; this is a guard).
|
||||
if self.preedit.is_some() {
|
||||
return vec![];
|
||||
}
|
||||
let Some(t) = self.current(cx) else {
|
||||
self.editing = None;
|
||||
return vec![];
|
||||
@@ -485,6 +536,51 @@ impl Tool for TypeTool {
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
fn ime_preedit(&mut self, cx: &ToolContext, text: &str, active_chars: Option<Range<usize>>) -> Vec<Action> {
|
||||
if self.editing.is_none() {
|
||||
return vec![];
|
||||
}
|
||||
// A clear with nothing marked (some integrations send one when the IME is enabled) must
|
||||
// not delete the selection.
|
||||
if text.is_empty() && self.preedit.is_none() {
|
||||
return vec![];
|
||||
}
|
||||
let text: String = text.chars().filter(|c| !c.is_control()).collect();
|
||||
// The new marked text replaces the previous one, or the selection when it starts.
|
||||
if let Some(p) = self.preedit.take() {
|
||||
self.anchor = p.range.start;
|
||||
self.caret = p.range.end;
|
||||
}
|
||||
self.clicks = (None, 0);
|
||||
let mut out = self.replace(cx, &text, None);
|
||||
if text.is_empty() {
|
||||
// Cancelled before anything else was typed: no empty undo step.
|
||||
if self.typing.as_ref().is_some_and(|ty| ty.cur == ty.base) {
|
||||
self.typing = None;
|
||||
out.push(Action::Cancel);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
let end = self.caret;
|
||||
let start = end.saturating_sub(text.len());
|
||||
self.preedit = Some(Preedit { range: start..end, active: active_chars.and_then(|r| char_range_to_bytes(&text, r)) });
|
||||
out
|
||||
}
|
||||
fn composing(&self) -> bool {
|
||||
self.preedit.is_some()
|
||||
}
|
||||
fn ime_caret(&self, cx: &ToolContext) -> Option<(Point, Point)> {
|
||||
let t = self.current(cx)?;
|
||||
let lay = self.layout(&t);
|
||||
let len = t.plain_text().len();
|
||||
// The candidate window follows the clause being converted, else the caret.
|
||||
let at = match &self.preedit {
|
||||
Some(p) => p.range.start + p.active.as_ref().map_or(0, |r| r.start),
|
||||
None => self.caret.min(self.anchor),
|
||||
};
|
||||
let (a, b) = vectorcraft_text::caret_position(&lay, at.min(len));
|
||||
Some((t.xf * a, t.xf * b))
|
||||
}
|
||||
fn deactivate(&mut self, cx: &ToolContext) -> Vec<Action> {
|
||||
self.finish(cx)
|
||||
}
|
||||
@@ -510,13 +606,14 @@ impl Tool for TypeTool {
|
||||
self.fresh = Self::text(cx, id).filter(|t| matches!(t.kind, TextKind::Point)).map(|_| id);
|
||||
}
|
||||
}
|
||||
/// `{editing, start, end, caret, anchor, typing}` — the Character panel styles `start..end`.
|
||||
/// `{editing, start, end, caret, anchor, typing, composing}` — the Character panel styles
|
||||
/// `start..end`.
|
||||
fn options(&self) -> Value {
|
||||
match self.editing {
|
||||
Some(id) => {
|
||||
// Offsets may exceed the text after Select All; callers clamp to the text length.
|
||||
let (a, b) = self.sel();
|
||||
json!({"editing": id.0, "start": a, "end": b, "caret": self.caret, "anchor": self.anchor, "typing": self.typing.is_some()})
|
||||
json!({"editing": id.0, "start": a, "end": b, "caret": self.caret, "anchor": self.anchor, "typing": self.typing.is_some(), "composing": self.preedit.is_some()})
|
||||
}
|
||||
None => json!({"editing": null}),
|
||||
}
|
||||
@@ -525,7 +622,10 @@ impl Tool for TypeTool {
|
||||
/// `commitTyping` (the caller commits the engine interaction).
|
||||
fn set_option(&mut self, key: &str, v: &Value) {
|
||||
match key {
|
||||
"commitTyping" | "endTyping" => self.typing = None,
|
||||
"commitTyping" | "endTyping" => {
|
||||
self.typing = None;
|
||||
self.preedit = None;
|
||||
}
|
||||
"selectAll" if self.editing.is_some() => {
|
||||
self.anchor = 0;
|
||||
self.caret = usize::MAX / 4;
|
||||
@@ -544,6 +644,7 @@ impl Tool for TypeTool {
|
||||
}
|
||||
"stopEditing" => {
|
||||
self.typing = None;
|
||||
self.preedit = None;
|
||||
self.editing = None;
|
||||
}
|
||||
_ => {}
|
||||
@@ -578,7 +679,24 @@ impl Tool for TypeTool {
|
||||
o.push(Overlay::Path { path: f, color: [79, 128, 255], width: 1.0, dashed: false });
|
||||
}
|
||||
let (a, b) = (self.caret.min(self.anchor).min(len), self.caret.max(self.anchor).min(len));
|
||||
if a == b {
|
||||
if let Some(pe) = &self.preedit {
|
||||
// Marked text: a thin underline, a thick one under the clause being converted.
|
||||
let under = |o: &mut Vec<Overlay>, r: Range<usize>, width: f32| {
|
||||
for q in vectorcraft_text::selection_quads(&lay, r.start.min(len), r.end.min(len)) {
|
||||
let (l, rr) = (t.xf * q[3], t.xf * q[2]);
|
||||
o.push(Overlay::Path { path: vectorcraft_geom::Line::new(l, rr).to_path(0.1), color: [0, 0, 0], width, dashed: false });
|
||||
}
|
||||
};
|
||||
under(&mut o, pe.range.clone(), 1.0);
|
||||
match &pe.active {
|
||||
Some(r) if !r.is_empty() => under(&mut o, pe.range.start + r.start..pe.range.start + r.end, 2.5),
|
||||
_ => {
|
||||
let at = pe.range.start + pe.active.as_ref().map_or(pe.range.len(), |r| r.start);
|
||||
let (p, q) = vectorcraft_text::caret_position(&lay, at.min(len));
|
||||
o.push(Overlay::Line { a: t.xf * p, b: t.xf * q, color: [0, 0, 0], dashed: false });
|
||||
}
|
||||
}
|
||||
} else if a == b {
|
||||
let (p, q) = vectorcraft_text::caret_position(&lay, self.caret.min(len));
|
||||
o.push(Overlay::Line { a: t.xf * p, b: t.xf * q, color: [0, 0, 0], dashed: false });
|
||||
} else {
|
||||
|
||||
@@ -176,6 +176,33 @@ fn drag_selects_and_overlay_highlights() {
|
||||
assert_eq!((opts["start"].as_u64(), opts["end"].as_u64()), (Some(7), Some(9)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drag_across_type_not_being_edited_selects_its_text() {
|
||||
let (d, id) = doc_with_text("Select me please");
|
||||
let mut tool = TypeTool::new("type");
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
let c = cx(&d, &sel, &p);
|
||||
let t = TypeTool::text(&c, id).unwrap().clone();
|
||||
let lay = vectorcraft_text::layout(FontDb::global(), &t);
|
||||
let pt = |b: usize| {
|
||||
let (a, bb) = vectorcraft_text::caret_position(&lay, b);
|
||||
t.xf * a.midpoint(bb)
|
||||
};
|
||||
let (a, b) = (pt(7), pt(9));
|
||||
// The press starts editing the type and the drag selects, in one gesture.
|
||||
let out = tool.pointer(&c, &PointerEvent::new(PointerKind::Down, a.x + 0.1, a.y));
|
||||
assert!(out.contains(&Action::Exec("select.set".into(), json!({"ids": [id.0]}))));
|
||||
tool.pointer(&c, &PointerEvent::new(PointerKind::Drag, b.x + 0.1, b.y));
|
||||
tool.pointer(&c, &PointerEvent::new(PointerKind::Up, b.x + 0.1, b.y));
|
||||
assert_eq!(tool.editing, Some(id));
|
||||
assert_eq!(tool.sel(), (7, 9));
|
||||
// A plain click still just places the caret.
|
||||
let mut tool = TypeTool::new("type");
|
||||
tool.pointer(&c, &PointerEvent::new(PointerKind::Down, a.x + 0.1, a.y));
|
||||
tool.pointer(&c, &PointerEvent::new(PointerKind::Up, a.x + 0.1, a.y));
|
||||
assert_eq!((tool.editing, tool.sel()), (Some(id), (7, 7)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_all_and_styled_paste() {
|
||||
let (d, _, mut tool) = editing("abc");
|
||||
@@ -227,3 +254,68 @@ fn vertical_tools_preserve_their_ids_and_create_vertical_text() {
|
||||
assert!(tool.vertical);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ime_ranges_convert_characters_to_bytes() {
|
||||
assert_eq!(char_range_to_bytes("ががく", 1..3), Some(3..9));
|
||||
assert_eq!(char_range_to_bytes("ががく", 3..3), Some(9..9));
|
||||
assert_eq!(char_range_to_bytes("a雅b", 1..2), Some(1..4));
|
||||
assert_eq!(char_range_to_bytes("ががく", 2..4), None, "past the end");
|
||||
#[allow(clippy::reversed_empty_ranges)]
|
||||
let backwards = 2..1;
|
||||
assert_eq!(char_range_to_bytes("ががく", backwards), None, "backwards");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marked_text_is_underlined_with_the_converting_clause_thick() {
|
||||
let (mut d, id, mut tool) = editing("曲");
|
||||
let snap = d.clone();
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
tool.caret = 3;
|
||||
tool.anchor = 3;
|
||||
let acts = tool.ime_preedit(&cx(&d, &sel, &p), "雅楽演奏", Some(2..4));
|
||||
apply(&mut d, &snap, &acts);
|
||||
let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!() };
|
||||
assert_eq!(t.plain_text(), "曲雅楽演奏");
|
||||
assert_eq!(tool.preedit, Some(Preedit { range: 3..15, active: Some(6..12) }));
|
||||
let widths: Vec<f32> = tool
|
||||
.overlays(&cx(&d, &sel, &p))
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
Overlay::Path { width, .. } => Some(*width),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(widths, vec![1.0, 2.5], "a thin underline, then the thick one under 演奏");
|
||||
// The candidate window follows the converting clause.
|
||||
let (top, _) = tool.ime_caret(&cx(&d, &sel, &p)).unwrap();
|
||||
let lay = vectorcraft_text::layout(FontDb::global(), t);
|
||||
let (at, _) = vectorcraft_text::caret_position(&lay, 9);
|
||||
assert!((top - t.xf * at).hypot() < 1e-6);
|
||||
// Keys wait for the IME; a commit replaces the marked text.
|
||||
assert!(key(&mut tool, &d, ToolKey::Backspace, Mods::default()).is_empty());
|
||||
let acts = tool.text_input(&cx(&d, &sel, &p), "雅楽演奏会");
|
||||
apply(&mut d, &snap, &acts);
|
||||
let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!() };
|
||||
assert_eq!(t.plain_text(), "曲雅楽演奏会");
|
||||
assert!(!tool.composing());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrows_follow_the_columns_of_vertical_type() {
|
||||
let (mut d, id) = doc_with_text("§§§\n§§§");
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
|
||||
t.vertical = true;
|
||||
}
|
||||
let mut tool = TypeTool::new("type");
|
||||
tool.start_editing(id, 0);
|
||||
let s = "§".len();
|
||||
key(&mut tool, &d, ToolKey::Down, Mods::default());
|
||||
assert_eq!(tool.caret, s, "↓ goes down the column to the next character");
|
||||
key(&mut tool, &d, ToolKey::Left, Mods::default());
|
||||
assert_eq!(tool.caret, "§§§\n§".len(), "← goes on to the next column, keeping the place in it");
|
||||
key(&mut tool, &d, ToolKey::Right, Mods::default());
|
||||
assert_eq!(tool.caret, s, "→ comes back");
|
||||
key(&mut tool, &d, ToolKey::Up, Mods::default());
|
||||
assert_eq!(tool.caret, 0);
|
||||
}
|
||||
@@ -44,6 +44,27 @@ enum Op {
|
||||
Corners(Handle, DistortMode),
|
||||
}
|
||||
|
||||
impl Op {
|
||||
/// The bounding-box handle being dragged, if any.
|
||||
fn handle(self) -> Option<Handle> {
|
||||
match self {
|
||||
Op::Scale(h) | Op::Shear(h) | Op::Corners(h, _) => Some(h),
|
||||
Op::Move | Op::Rotate => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The undo step's name.
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Op::Move => "Move",
|
||||
Op::Scale(_) => "Scale",
|
||||
Op::Shear(_) => "Shear",
|
||||
Op::Rotate => "Rotate",
|
||||
Op::Corners(..) => "Distort",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Drag {
|
||||
op: Op,
|
||||
@@ -125,22 +146,28 @@ pub fn distort_quad(r: Rect, h: Handle, d: Vec2, mode: DistortMode) -> [Point; 4
|
||||
}
|
||||
|
||||
impl FreeTransformTool {
|
||||
/// What dragging handle `h` with modifiers `m` does: Cmd on a corner distorts it freely,
|
||||
/// Cmd+Alt+Shift in perspective, Cmd on a side shears; otherwise the tool's mode.
|
||||
fn handle_op(&self, h: Handle, m: Mods) -> Op {
|
||||
let mode = if h.is_corner() && m.cmd && m.alt && m.shift {
|
||||
DistortMode::Perspective
|
||||
} else if h.is_corner() && m.cmd {
|
||||
DistortMode::Distort
|
||||
} else {
|
||||
self.mode
|
||||
};
|
||||
match mode {
|
||||
DistortMode::Free if !h.is_corner() && m.cmd => Op::Shear(h),
|
||||
DistortMode::Free => Op::Scale(h),
|
||||
DistortMode::Perspective if !h.is_corner() => Op::Scale(h),
|
||||
mode => Op::Corners(h, mode),
|
||||
}
|
||||
}
|
||||
|
||||
fn classify(&self, cx: &ToolContext, r: Rect, p: Point, m: Mods) -> Option<Op> {
|
||||
let tol = cx.tol(5.0);
|
||||
if let Some(h) = hit_handle(r, p, tol) {
|
||||
let mode = if h.is_corner() && m.cmd && m.alt && m.shift {
|
||||
DistortMode::Perspective
|
||||
} else if h.is_corner() && m.cmd {
|
||||
DistortMode::Distort
|
||||
} else {
|
||||
self.mode
|
||||
};
|
||||
return Some(match mode {
|
||||
DistortMode::Free if !h.is_corner() && m.cmd => Op::Shear(h),
|
||||
DistortMode::Free => Op::Scale(h),
|
||||
DistortMode::Perspective if !h.is_corner() => Op::Scale(h),
|
||||
mode => Op::Corners(h, mode),
|
||||
});
|
||||
return Some(self.handle_op(h, m));
|
||||
}
|
||||
if self.mode == DistortMode::Free && in_rotate_zone(r, p, tol, cx.tol(18.0)).is_some() {
|
||||
return Some(Op::Rotate);
|
||||
@@ -205,19 +232,24 @@ impl Tool for FreeTransformTool {
|
||||
PointerKind::Drag => {
|
||||
let Some(mut d) = self.drag else { return vec![] };
|
||||
let mut out = vec![];
|
||||
// A handle drag follows the modifiers held now: the gesture is to start dragging
|
||||
// and then hold Cmd (a Cmd press on the canvas is the temporary Selection tool).
|
||||
if let Some(h) = d.op.handle() {
|
||||
let op = self.handle_op(h, ev.mods);
|
||||
if d.began && op.label() != d.op.label() {
|
||||
// Another operation: it gets an undo step of its own.
|
||||
out.push(Action::Cancel);
|
||||
out.push(Action::Begin(op.label().into()));
|
||||
}
|
||||
d.op = op;
|
||||
}
|
||||
if !d.began {
|
||||
if p.distance(d.start) < cx.tol(2.0) {
|
||||
self.drag = Some(d);
|
||||
return out;
|
||||
}
|
||||
d.began = true;
|
||||
let label = match d.op {
|
||||
Op::Move => "Move",
|
||||
Op::Scale(_) => "Scale",
|
||||
Op::Shear(_) => "Shear",
|
||||
Op::Rotate => "Rotate",
|
||||
Op::Corners(..) => "Distort",
|
||||
};
|
||||
out.push(Action::Begin(label.into()));
|
||||
out.push(Action::Begin(d.op.label().into()));
|
||||
}
|
||||
self.drag = Some(d);
|
||||
out.push(self.preview(cx, d, p, ev.mods));
|
||||
@@ -353,6 +385,37 @@ mod tests {
|
||||
assert!((m * Point::new(100.0, 100.0)).distance(Point::new(120.0, 100.0)) < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modifiers_pressed_during_a_handle_drag_change_what_it_does() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = FreeTransformTool::default();
|
||||
let cmd = Mods { cmd: true, ..Default::default() };
|
||||
let perspective = Mods { cmd: true, alt: true, shift: true, ..Default::default() };
|
||||
// Start dragging the bottom-right corner, then hold Cmd: Free Distort, a step of its own.
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 200.0, 200.0));
|
||||
let a = t.pointer(&cx, &ev(PointerKind::Drag, 210.0, 210.0));
|
||||
assert_eq!(a[0], Action::Begin("Scale".into()));
|
||||
let a = t.pointer(&cx, &ev(PointerKind::Drag, 220.0, 230.0).with_mods(cmd));
|
||||
let corners = json!({"corners": [[100.0, 100.0], [200.0, 100.0], [220.0, 230.0], [100.0, 200.0]], "from": [100.0, 100.0, 200.0, 200.0]});
|
||||
assert_eq!(a, vec![Action::Cancel, Action::Begin("Distort".into()), Action::Preview("object.distort".into(), corners)]);
|
||||
// Cmd+Alt+Shift: Perspective Distort, still the same step.
|
||||
let a = t.pointer(&cx, &ev(PointerKind::Drag, 220.0, 230.0).with_mods(perspective));
|
||||
assert!(matches!(&a[..], [Action::Preview(c, _)] if c == "object.distort"), "{a:?}");
|
||||
// Letting go of the keys scales again.
|
||||
let a = t.pointer(&cx, &ev(PointerKind::Drag, 220.0, 230.0));
|
||||
assert_eq!(a[..2], [Action::Cancel, Action::Begin("Scale".into())]);
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 220.0, 230.0)), vec![Action::Commit]);
|
||||
// Cmd held during a side handle's drag shears.
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 150.0, 100.0));
|
||||
t.pointer(&cx, &ev(PointerKind::Drag, 155.0, 100.0));
|
||||
let a = t.pointer(&cx, &ev(PointerKind::Drag, 170.0, 100.0).with_mods(cmd));
|
||||
assert_eq!(a[..2], [Action::Cancel, Action::Begin("Shear".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distort_modes_emit_corners() {
|
||||
let (d, id) = doc_with_rect();
|
||||
|
||||
+528
-41
@@ -92,6 +92,11 @@ fn temp_tool_id() -> egui::Id {
|
||||
egui::Id::new("canvas-temp-tool")
|
||||
}
|
||||
|
||||
/// Selection, Direct Selection and Group Selection: the tools Cmd switches to for a drag.
|
||||
pub(crate) fn is_selection_tool(id: &str) -> bool {
|
||||
matches!(id, "selection" | "directSelection" | "groupSelection")
|
||||
}
|
||||
|
||||
/// The pen pressure (0..1) of the press in progress: the force of this frame's pen or touch
|
||||
/// input, else the last one seen since the press (`pressed`: a new press, whose mouse has none
|
||||
/// until a pen reports it). A mouse presses fully (1).
|
||||
@@ -120,12 +125,14 @@ pub fn fit(app: &mut VectorcraftApp, how: &str) {
|
||||
}
|
||||
return;
|
||||
};
|
||||
let current = app.view().map_or(0, |v| v.artboard);
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let target = match how {
|
||||
"view.fitAll" => {
|
||||
st.doc.art_bounds().map(|a| st.doc.artboards.iter().fold(a, |r, ab| r.union(ab.rect))).or(st.doc.artboards.first().map(|a| a.rect))
|
||||
}
|
||||
_ => st.doc.artboards.first().map(|a| a.rect),
|
||||
// The navigator's artboard (the first if it has gone).
|
||||
_ => st.doc.artboards.get(current).or(st.doc.artboards.first()).map(|a| a.rect),
|
||||
};
|
||||
let Some(target) = target else { return };
|
||||
let Some(v) = app.view_mut() else { return };
|
||||
@@ -158,10 +165,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
fit(app, "view.fitArtboard");
|
||||
}
|
||||
let resp = ui.interact(rect, egui::Id::new("canvas"), Sense::click_and_drag());
|
||||
handle_input(app, ui, &resp, rect);
|
||||
// A press on the canvas while the context menu is open only closes it (a drag too, which egui
|
||||
// alone would leave open); the tool doesn't get it.
|
||||
if resp.context_menu_opened() && resp.hovered() && ui.input(|i| i.pointer.any_pressed()) {
|
||||
egui::Popup::close_all(ui.ctx());
|
||||
} else {
|
||||
handle_input(app, ui, &resp, rect);
|
||||
}
|
||||
let v = *app.view().unwrap_or(&View::default());
|
||||
let xf = Xf::new(rect, &v);
|
||||
panel_drop(app, ui, &resp, &xf);
|
||||
context_menu(app, &resp, &xf);
|
||||
let painter = ui.painter_at(rect);
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let doc = st.doc.clone();
|
||||
@@ -330,10 +344,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let overlays = app.session.overlays(view_info);
|
||||
draw_overlays(&painter, &xf, &overlays, &t);
|
||||
}
|
||||
ime_output(app, ui.ctx(), &xf);
|
||||
crate::place::paint_drop_highlight(app, ui.ctx(), &painter, rect);
|
||||
|
||||
if app.ui.view.rulers && app.ui.screen_mode < 3 {
|
||||
rulers(ui, full, &xf, app.hover_doc, app.session.general_unit(), &t);
|
||||
ruler_guides(app, ui, full, rect, &xf, &t);
|
||||
}
|
||||
if app.ui.task_bar && !app.session.tool_busy() && app.ui.screen_mode < 3 {
|
||||
task_bar(app, ui, &xf);
|
||||
@@ -343,12 +359,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let m = ui.input(|i| i.modifiers);
|
||||
let space = ui.input(|i| i.key_down(egui::Key::Space));
|
||||
let panning = matches!(ui.data(|d| d.get_temp::<Drag>(drag_id())), Some(Drag::Pan { .. }));
|
||||
let zooming = if space { m.command } else { app.session.tool_id() == "zoom" };
|
||||
let cur = if panning {
|
||||
egui::CursorIcon::Grabbing
|
||||
} else if zooming {
|
||||
if m.alt { egui::CursorIcon::ZoomOut } else { egui::CursorIcon::ZoomIn }
|
||||
} else if space || app.session.tool_id() == "hand" {
|
||||
egui::CursorIcon::Grab
|
||||
} else if app.session.tool_id() == "zoom" {
|
||||
if m.alt { egui::CursorIcon::ZoomOut } else { egui::CursorIcon::ZoomIn }
|
||||
} else if let Some(p) = app.hover_doc {
|
||||
let c = app.session.cursor(p, mods(m, space), view_info);
|
||||
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("tool-cursor")));
|
||||
@@ -434,25 +451,34 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
||||
}
|
||||
|
||||
let tool = app.session.tool_id();
|
||||
let pan_mode = space || tool == "hand";
|
||||
// Held, Space is the Hand tool for the moment, and Cmd+Space the Zoom tool (with Alt, zooming
|
||||
// out), whatever the tool.
|
||||
let zoom_mode = if space { m.command } else { tool == "zoom" };
|
||||
let pan_mode = if space { !m.command } else { tool == "hand" };
|
||||
let middle_pan = matches!(drag, Some(Drag::Pan { middle: true, .. }));
|
||||
if pointer.primary_pressed() && resp.hovered() && !middle_pan {
|
||||
// egui counts a press a few pixels outside the canvas as on it (its interaction radius): only a
|
||||
// press on the canvas itself reaches the tools, else it would land at the canvas's centre.
|
||||
if pointer.primary_pressed()
|
||||
&& resp.hovered()
|
||||
&& !middle_pan
|
||||
&& let Some(p) = hover
|
||||
{
|
||||
ui.ctx().memory_mut(|mem| mem.stop_text_input());
|
||||
app.ui.flyout = None;
|
||||
let p = hover.unwrap_or(rect.center());
|
||||
let d = if pan_mode {
|
||||
Drag::Pan { start: p, center: v.center, middle: false }
|
||||
} else if tool == "zoom" {
|
||||
let d = if zoom_mode {
|
||||
Drag::ZoomBox { start: p }
|
||||
} else if pan_mode {
|
||||
Drag::Pan { start: p, center: v.center, middle: false }
|
||||
} else if tool == "rotateView" {
|
||||
let c = rect.center();
|
||||
Drag::RotateView { start_angle: (p.y - c.y).atan2(p.x - c.x) as f64, start_rot: v.rotation }
|
||||
} else {
|
||||
let selection_family = matches!(tool, "selection" | "directSelection" | "groupSelection");
|
||||
let mut kind = Drag::Tool;
|
||||
if m.command && !selection_family {
|
||||
// Cmd with another tool: drag with the selection tool used last.
|
||||
if m.command && !is_selection_tool(tool) {
|
||||
ui.data_mut(|d| d.insert_temp(temp_tool_id(), tool.to_string()));
|
||||
app.select_tool("selection");
|
||||
let last = app.ui.last_selection_tool.clone();
|
||||
app.select_tool(if is_selection_tool(&last) { &last } else { "selection" });
|
||||
kind = Drag::TempSelect;
|
||||
}
|
||||
let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, true) };
|
||||
@@ -460,9 +486,12 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
||||
kind
|
||||
};
|
||||
ui.data_mut(|dd| dd.insert_temp(drag_id(), d));
|
||||
} else if drag.is_none() && resp.hovered() && pointer.button_pressed(egui::PointerButton::Middle) {
|
||||
} else if drag.is_none()
|
||||
&& resp.hovered()
|
||||
&& pointer.button_pressed(egui::PointerButton::Middle)
|
||||
&& let Some(start) = hover
|
||||
{
|
||||
// Middle-button drag pans the view whatever the tool.
|
||||
let start = hover.unwrap_or(rect.center());
|
||||
ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Pan { start, center: v.center, middle: true }));
|
||||
} else if let Some(d) = drag {
|
||||
let p = pointer.interact_pos().unwrap_or(rect.center());
|
||||
@@ -547,7 +576,15 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
||||
&& let Some(p) = hover
|
||||
{
|
||||
let ev = PointerEvent { kind: PointerKind::DoubleClick, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 };
|
||||
let before = app.session.tool_id().to_string();
|
||||
dispatch(app, &ev, view);
|
||||
// A double-click on type switched a selection tool to the Type tool: the caret goes where
|
||||
// the type was clicked, so typing edits it at once.
|
||||
if before != "type" && app.session.tool_id() == "type" {
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
dispatch(app, &PointerEvent { kind, ..ev }, view);
|
||||
}
|
||||
}
|
||||
}
|
||||
if drag.is_some() || pointer.is_moving() {
|
||||
ui.ctx().request_repaint();
|
||||
@@ -664,11 +701,39 @@ pub(crate) fn ruler_label(v: f64, step: f64) -> String {
|
||||
}
|
||||
|
||||
/// The rulers, numbered in `unit` (the General unit).
|
||||
fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option<Point>, unit: Unit, t: &Tokens) {
|
||||
let p = ui.painter();
|
||||
/// The top ruler, the left ruler and the box where they meet.
|
||||
fn ruler_rects(full: egui::Rect) -> [egui::Rect; 3] {
|
||||
let top = egui::Rect::from_min_max(pos2(full.left() + RULER, full.top()), pos2(full.right(), full.top() + RULER));
|
||||
let left = egui::Rect::from_min_max(pos2(full.left(), full.top() + RULER), pos2(full.left() + RULER, full.bottom()));
|
||||
let corner = egui::Rect::from_min_size(full.min, vec2(RULER, RULER));
|
||||
[top, left, egui::Rect::from_min_size(full.min, vec2(RULER, RULER))]
|
||||
}
|
||||
|
||||
/// A drag from a ruler onto the canvas makes a guide where the button is released: a horizontal
|
||||
/// one from the top ruler, a vertical one from the left ruler. Released anywhere else, it makes
|
||||
/// none.
|
||||
fn ruler_guides(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, canvas: egui::Rect, xf: &Xf, t: &Tokens) {
|
||||
let [top, left, _] = ruler_rects(full);
|
||||
for (r, vertical, id) in [(top, false, "ruler-top"), (left, true, "ruler-left")] {
|
||||
let resp = ui.interact(r, egui::Id::new(id), Sense::drag());
|
||||
let Some(p) = resp.interact_pointer_pos().filter(|p| canvas.contains(*p)) else { continue };
|
||||
if resp.drag_stopped() {
|
||||
let d = xf.to_doc(p);
|
||||
// The new guide shows even if guides were hidden.
|
||||
app.ui.view.guides = true;
|
||||
if let Err(e) = app.run("guide.add", json!({ "vertical": vertical, "pos": if vertical { d.x } else { d.y } })) {
|
||||
app.status(e);
|
||||
}
|
||||
} else if resp.dragged() {
|
||||
let line =
|
||||
if vertical { [pos2(p.x, canvas.top()), pos2(p.x, canvas.bottom())] } else { [pos2(canvas.left(), p.y), pos2(canvas.right(), p.y)] };
|
||||
ui.painter_at(canvas).line_segment(line, Stroke::new(1.0, t.guide));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option<Point>, unit: Unit, t: &Tokens) {
|
||||
let p = ui.painter();
|
||||
let [top, left, corner] = ruler_rects(full);
|
||||
for r in [top, left, corner] {
|
||||
p.rect_filled(r, 0.0, t.ruler);
|
||||
}
|
||||
@@ -782,11 +847,18 @@ fn c32(rgb: [u8; 3]) -> Color32 {
|
||||
Color32::from_rgb(rgb[0], rgb[1], rgb[2])
|
||||
}
|
||||
|
||||
/// Outline of a node for highlighting (paths, compound children, text/image bounds).
|
||||
/// Outline of a node for highlighting (paths, compound children, area type frames, other
|
||||
/// text/image bounds).
|
||||
fn node_outline(n: &Node) -> BezPath {
|
||||
let mut bp = BezPath::new();
|
||||
walk_drawn(n, &mut |c| match &c.kind {
|
||||
NodeKind::Path { path, .. } => bp.extend(path.to_bezpath()),
|
||||
// Area type shows its frame: the type area Direct Selection reshapes.
|
||||
NodeKind::Text(t) if c.perspective.is_none() && matches!(t.kind, vectorcraft_doc::TextKind::Area { .. }) => {
|
||||
if let Some(frame) = t.area_frame() {
|
||||
bp.extend(frame.to_bezpath());
|
||||
}
|
||||
}
|
||||
NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => {
|
||||
if let Some(b) = c.geometric_bounds() {
|
||||
bp.extend(vectorcraft_geom::shapes::rectangle(b).to_bezpath());
|
||||
@@ -821,6 +893,25 @@ fn hit_at(app: &VectorcraftApp, p: Point, zoom: f64) -> Option<vectorcraft_doc::
|
||||
vectorcraft_doc::hit::hit_test(&app.session.active()?.doc, p, opt)
|
||||
}
|
||||
|
||||
/// Right-click: the object under the pointer is selected first unless it already is, then the
|
||||
/// context menu lists what applies to the selection ([`crate::menus::context_items`]).
|
||||
fn context_menu(app: &mut VectorcraftApp, resp: &egui::Response, xf: &Xf) {
|
||||
if resp.secondary_clicked()
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
&& let Some(st) = app.session.active()
|
||||
&& let Some(top) = hit_at(app, xf.to_doc(p), xf.zoom).map(|h| h.top_object(st.isolation))
|
||||
&& !st.selection.contains(top)
|
||||
{
|
||||
// A locked or hidden object can't be selected; the menu is then for the selection as is.
|
||||
let _ = app.run("select.set", json!({ "ids": [top.0] }));
|
||||
}
|
||||
let mut clicked = None;
|
||||
resp.context_menu(|ui| crate::menus::context_menu_body(app, ui, &mut clicked));
|
||||
if let Some((id, p)) = clicked {
|
||||
crate::menus::invoke(app, &id, p);
|
||||
}
|
||||
}
|
||||
|
||||
/// A panel drag ([`widgets::PanelDrag`]) dropped on art acts on the object under the pointer,
|
||||
/// selected or not: a paint (swatches, a Fill/Stroke proxy, the Gradient panel's thumbnail) goes
|
||||
/// to its active proxy (`paint.setFill`/`paint.setStroke` with its `ids`; a gradient fits it), the
|
||||
@@ -835,6 +926,14 @@ fn panel_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, xf: &Xf)
|
||||
return;
|
||||
}
|
||||
let Some(d) = resp.dnd_release_payload::<widgets::PanelDrag>() else { return };
|
||||
// A symbol from the Symbols panel: an instance centred where it is dropped, on art or not.
|
||||
if let widgets::PanelDrag::Symbol(name) = &*d {
|
||||
let at = xf.to_doc(pos);
|
||||
if let Err(e) = app.run("symbol.place", json!({"name": name, "x": at.x, "y": at.y})) {
|
||||
app.status(e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(hit) = hit_at(app, xf.to_doc(pos), xf.zoom) else { return };
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let (cmd, params) = match &*d {
|
||||
@@ -859,8 +958,9 @@ fn panel_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, xf: &Xf)
|
||||
let add = ui.input(|i| i.modifiers.alt);
|
||||
("graphicStyle.apply", json!({"name": name, "ids": [hit.top_object(st.isolation).0], "add": add}))
|
||||
}
|
||||
// Art dragged back onto the canvas: its move was already dropped.
|
||||
widgets::PanelDrag::Art(_) => return,
|
||||
// Art dragged back onto the canvas: its move was already dropped. (A symbol was placed
|
||||
// above.)
|
||||
widgets::PanelDrag::Art(_) | widgets::PanelDrag::Symbol(_) => return,
|
||||
};
|
||||
if let Err(e) = app.run(cmd, params) {
|
||||
app.status(e);
|
||||
@@ -1096,8 +1196,10 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
{
|
||||
anchor_square(p, xf.to_screen(b.center()), color, true, 4.0);
|
||||
}
|
||||
// Text: baseline marker.
|
||||
if let NodeKind::Text(tx) = &n.kind {
|
||||
// Text: baseline marker (area type shows its frame instead, which may not be a rectangle).
|
||||
if let NodeKind::Text(tx) = &n.kind
|
||||
&& !matches!(tx.kind, vectorcraft_doc::TextKind::Area { .. })
|
||||
{
|
||||
let o = xf.to_screen(tx.xf * Point::ZERO);
|
||||
let b = n.geometric_bounds().unwrap_or_default();
|
||||
let e = xf.to_screen(Point::new(b.x1, (tx.xf * Point::ZERO).y));
|
||||
@@ -1144,6 +1246,34 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
}
|
||||
}
|
||||
|
||||
/// While the Type tool edits, let the system IME compose (egui-winit allows it only in frames that
|
||||
/// set `ime`) and keep its candidate window under the caret.
|
||||
fn ime_output(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &Xf) {
|
||||
let ours = app.session.tool_wants_text() && !ctx.egui_wants_keyboard_input() && app.ui.dialog.is_none() && !app.ui.palette_open;
|
||||
let caret = if ours { app.session.tool_ime_caret(app.view_info()) } else { None };
|
||||
let Some((a, b)) = caret else {
|
||||
// The IME goes away with its marked text: keep that text as typed, like a click away.
|
||||
crate::shortcuts::keep_marked_text(app);
|
||||
return;
|
||||
};
|
||||
let r = egui::Rect::from_two_pos(xf.to_screen(a), xf.to_screen(b)).expand2(vec2(1.0, 0.0));
|
||||
// The tool ended the composition itself: the IME must drop what it still has marked.
|
||||
let interrupt = app.ime_marked.is_some() && !app.session.tool_composing();
|
||||
if interrupt {
|
||||
app.ime_marked = None;
|
||||
app.ime_discard = true;
|
||||
}
|
||||
ctx.output_mut(|o| {
|
||||
o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Normal, rect: r, cursor_rect: r, should_interrupt_composition: interrupt });
|
||||
});
|
||||
}
|
||||
|
||||
/// Is overlay label `text` the Artboard tool's "01 - <artboard name>"? It holds a name, so it is
|
||||
/// shown as it is; the tools' other labels ("anchor", "path", Puppet Warp's warning) are ours.
|
||||
fn names_an_artboard(text: &str) -> bool {
|
||||
text.split_once(" - ").is_some_and(|(n, _)| n.len() >= 2 && n.bytes().all(|b| b.is_ascii_digit()))
|
||||
}
|
||||
|
||||
fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens) {
|
||||
for o in overlays {
|
||||
match o {
|
||||
@@ -1179,7 +1309,13 @@ fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens) {
|
||||
}
|
||||
Overlay::Label { p: pt, text, color } => {
|
||||
let sp = xf.to_screen(*pt) + vec2(8.0, -14.0);
|
||||
p.text(sp, egui::Align2::LEFT_TOP, text, egui::FontId::proportional(11.0), c32(*color));
|
||||
p.text(
|
||||
sp,
|
||||
egui::Align2::LEFT_TOP,
|
||||
crate::panels::label_or_name(text, !names_an_artboard(text)),
|
||||
egui::FontId::proportional(11.0),
|
||||
c32(*color),
|
||||
);
|
||||
}
|
||||
Overlay::Highlight { quad, color } => {
|
||||
let c = Color32::from_rgba_unmultiplied(color[0], color[1], color[2], color[3]);
|
||||
@@ -1218,21 +1354,21 @@ fn home(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) {
|
||||
let inner = rect.shrink2(vec2((rect.width() - 820.0).max(40.0) / 2.0, 60.0));
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::top_down(egui::Align::Min)));
|
||||
let ui = &mut child;
|
||||
ui.label(egui::RichText::new("Welcome to VectorCraft").font(theme::semibold(26.0)).color(t.text));
|
||||
ui.label(egui::RichText::new(tl!("Welcome to VectorCraft")).font(theme::semibold(26.0)).color(t.text));
|
||||
ui.add_space(4.0);
|
||||
ui.label(egui::RichText::new("Vector illustration — fast, open, scriptable.").size(14.0).color(t.text_dim));
|
||||
ui.label(egui::RichText::new(tl!("Vector illustration — fast, open, scriptable.")).size(14.0).color(t.text_dim));
|
||||
ui.add_space(22.0);
|
||||
ui.horizontal(|ui| {
|
||||
if widgets::primary_button(ui, "New file").clicked() {
|
||||
if widgets::primary_button(ui, tl!("New file")).clicked() {
|
||||
app.run("file.newDialog", json!({})).ok();
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if widgets::secondary_button(ui, "Open").clicked() {
|
||||
if widgets::secondary_button(ui, tl!("Open")).clicked() {
|
||||
app.run("file.open", json!({})).ok();
|
||||
}
|
||||
});
|
||||
ui.add_space(28.0);
|
||||
ui.label(egui::RichText::new("Quickly start a new file").font(theme::semibold(14.0)).color(t.text));
|
||||
ui.label(egui::RichText::new(tl!("Quickly start a new file")).font(theme::semibold(14.0)).color(t.text));
|
||||
ui.add_space(10.0);
|
||||
// A few of New Document's presets (`file.newPresets`), as its cards.
|
||||
let presets = ["Letter", "A4", "Web 1920×1080", "Phone 390×844", "Postcard", "Social Square Post 1080×1080"];
|
||||
@@ -1246,7 +1382,7 @@ fn home(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) {
|
||||
}
|
||||
});
|
||||
ui.add_space(28.0);
|
||||
ui.label(egui::RichText::new("Community").font(theme::semibold(14.0)).color(t.text));
|
||||
ui.label(egui::RichText::new(tl!("Community")).font(theme::semibold(14.0)).color(t.text));
|
||||
ui.add_space(10.0);
|
||||
crate::community::links(app, ui);
|
||||
}
|
||||
@@ -1277,18 +1413,18 @@ fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
|
||||
let is_text = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Text(_)));
|
||||
let mut items: Vec<(&str, &str, &str)> = vec![]; // (label, icon, command)
|
||||
if n > 1 {
|
||||
items.push(("Group", "group", "object.group"));
|
||||
items.push(("Unite", "squares-unite", "object.pathfinder.unite"));
|
||||
items.push((tl!("Group"), "group", "object.group"));
|
||||
items.push((tl!("Unite"), "squares-unite", "object.pathfinder.unite"));
|
||||
} else if is_group {
|
||||
items.push(("Ungroup", "ungroup", "object.ungroup"));
|
||||
items.push(("Isolate", "square-dashed", "object.isolate"));
|
||||
items.push((tl!("Ungroup"), "ungroup", "object.ungroup"));
|
||||
items.push((tl!("Isolate"), "square-dashed", "object.isolate"));
|
||||
} else if is_text {
|
||||
items.push(("Create Outlines", "type", "type.createOutlines"));
|
||||
items.push((tl!("Create Outlines"), "type", "type.createOutlines"));
|
||||
} else {
|
||||
items.push(("Offset Path", "square-dashed", "object.path.offsetPath"));
|
||||
items.push(("Simplify", "spline", "object.path.simplify"));
|
||||
items.push((tl!("Offset Path"), "square-dashed", "object.path.offsetPath"));
|
||||
items.push((tl!("Simplify"), "spline", "object.path.simplify"));
|
||||
}
|
||||
items.push(("Duplicate", "copy", "edit.duplicate"));
|
||||
items.push((tl!("Duplicate"), "copy", "edit.duplicate"));
|
||||
let fill = first.as_ref().map(|f| f.appearance.fill_paint()).unwrap_or_default();
|
||||
let anchor = xf.to_screen(Point::new(b.center().x, b.y1));
|
||||
let est_w = 118.0 + items.iter().map(|(l, _, _)| l.len() as f32 * 7.2 + 44.0).sum::<f32>();
|
||||
@@ -1328,14 +1464,14 @@ fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
|
||||
Stroke::new(1.0, t.button_border),
|
||||
StrokeKind::Outside,
|
||||
);
|
||||
if resp.on_hover_text("Fill").clicked() {
|
||||
if resp.on_hover_text(tl!("Fill")).clicked() {
|
||||
app.session.fill_active = true;
|
||||
app.ui.open_panel = Some("swatches".into());
|
||||
}
|
||||
if widgets::icon_button(ui, "lock", "Lock (⌘2)", false, 30.0).clicked() {
|
||||
if widgets::icon_button(ui, "lock", tl!("Lock (⌘2)"), false, 30.0).clicked() {
|
||||
run = Some("object.lock".into());
|
||||
}
|
||||
if widgets::icon_button(ui, "ellipsis", "Hide Contextual Task Bar", false, 30.0).clicked() {
|
||||
if widgets::icon_button(ui, "ellipsis", tl!("Hide Contextual Task Bar"), false, 30.0).clicked() {
|
||||
run = Some("window.taskBar".into());
|
||||
}
|
||||
});
|
||||
@@ -1358,10 +1494,229 @@ mod tests {
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
/// The Artboard tool's label holds the artboard's name (never translated); the tools' own
|
||||
/// labels are interface text.
|
||||
#[test]
|
||||
fn artboard_labels_name_the_artboard() {
|
||||
assert!(names_an_artboard("01 - Layers") && names_an_artboard("12 - Artboard 12 - copy"));
|
||||
assert!(!names_an_artboard("anchor") && !names_an_artboard("1 - x") && !names_an_artboard("Off the mesh - move closer"));
|
||||
}
|
||||
|
||||
fn middle(pos: Pos2, pressed: bool) -> egui::Event {
|
||||
egui::Event::PointerButton { pos, button: egui::PointerButton::Middle, pressed, modifiers: Default::default() }
|
||||
}
|
||||
|
||||
/// One headless frame with the canvas under a 40-point bar, as it is under the document tabs.
|
||||
fn frame_under_bar(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) {
|
||||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
ui.add_space(40.0);
|
||||
show(app, ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_just_outside_the_canvas_does_not_reach_the_tool() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
// A square in the middle of the artboard, where the view is centred.
|
||||
let id = app.session.execute("shape.rectangle", &json!({"x": 180, "y": 130, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap();
|
||||
app.session.execute("select.none", &json!({})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
frame_under_bar(&mut app, &ctx, vec![]);
|
||||
let rect = app.canvas_rect.unwrap();
|
||||
let click = |app: &mut VectorcraftApp, p: Pos2| {
|
||||
let button =
|
||||
|pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame_under_bar(app, &ctx, vec![egui::Event::PointerMoved(p)]);
|
||||
frame_under_bar(app, &ctx, vec![button(true)]);
|
||||
frame_under_bar(app, &ctx, vec![button(false)]);
|
||||
};
|
||||
let selected = |app: &VectorcraftApp| app.session.active().unwrap().selection.objects.clone();
|
||||
// Two pixels above the canvas, within egui's interaction radius: nothing happens.
|
||||
click(&mut app, pos2(rect.center().x, rect.top() - 2.0));
|
||||
assert!(selected(&app).is_empty(), "a click above the canvas selected {:?}", selected(&app));
|
||||
// At the canvas's centre: the square.
|
||||
click(&mut app, rect.center());
|
||||
assert_eq!(selected(&app), vec![vectorcraft_doc::NodeId(id)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drag_from_a_ruler_onto_the_canvas_makes_a_guide() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.ui.view.rulers = true;
|
||||
app.ui.view.guides = false;
|
||||
// A drawing tool: the drag must not draw.
|
||||
app.select_tool("rectangle");
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let rect = app.canvas_rect.unwrap();
|
||||
let xf = Xf::new(rect, app.view().unwrap());
|
||||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
let drag = |app: &mut VectorcraftApp, from: Pos2, to: Pos2| {
|
||||
frame(app, &ctx, vec![egui::Event::PointerMoved(from)]);
|
||||
frame(app, &ctx, vec![button(from, true)]);
|
||||
frame(app, &ctx, vec![egui::Event::PointerMoved(to)]);
|
||||
frame(app, &ctx, vec![button(to, false)]);
|
||||
};
|
||||
let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect::<Vec<_>>();
|
||||
// From the top ruler: a horizontal guide where the button was released.
|
||||
let to = pos2(rect.center().x, rect.top() + 120.0);
|
||||
drag(&mut app, pos2(rect.center().x, rect.top() - RULER / 2.0), to);
|
||||
assert_eq!(guides(&app), [(false, xf.to_doc(to).y)]);
|
||||
assert!(app.ui.view.guides, "the new guide shows");
|
||||
// From the left ruler: a vertical one.
|
||||
let to2 = pos2(rect.left() + 200.0, rect.center().y);
|
||||
drag(&mut app, pos2(rect.left() - RULER / 2.0, rect.center().y), to2);
|
||||
assert_eq!(guides(&app), [(false, xf.to_doc(to).y), (true, xf.to_doc(to2).x)]);
|
||||
// Released back on the ruler: no guide.
|
||||
drag(&mut app, pos2(rect.center().x, rect.top() - RULER / 2.0), pos2(rect.center().x + 40.0, rect.top() - 4.0));
|
||||
assert_eq!(guides(&app).len(), 2);
|
||||
assert_eq!(app.session.active().unwrap().doc.art_bounds(), None, "the tool drew nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_with_another_tool_drags_with_the_selection_tool_used_last() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let id = app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 50})).unwrap()["id"].as_u64().unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||||
let cmd_frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
|
||||
let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||||
let events = std::iter::once(egui::Event::ModifiersChanged(egui::Modifiers::COMMAND)).chain(events).collect();
|
||||
let raw = egui::RawInput { screen_rect: Some(screen), events, ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
let button =
|
||||
|pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::COMMAND };
|
||||
// A Cmd drag from `a` to `b` with the Pen tool; → the tool that did it.
|
||||
let cmd_drag = |app: &mut VectorcraftApp, a: Pos2, b: Pos2| {
|
||||
app.select_tool("pen");
|
||||
cmd_frame(app, vec![egui::Event::PointerMoved(a), button(a, true)]);
|
||||
let used = app.session.tool_id().to_string();
|
||||
cmd_frame(app, vec![egui::Event::PointerMoved(b)]);
|
||||
cmd_frame(app, vec![button(b, false)]);
|
||||
assert_eq!(app.session.tool_id(), "pen", "back to the Pen tool");
|
||||
used
|
||||
};
|
||||
let bounds = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap();
|
||||
// Direct Selection used last: the drag moves the rectangle's bottom-right corner only.
|
||||
app.select_tool("directSelection");
|
||||
let corner = xf.to_screen(Point::new(200.0, 150.0));
|
||||
assert_eq!(cmd_drag(&mut app, corner, corner + vec2(20.0, 20.0)), "directSelection");
|
||||
let b = bounds(&app);
|
||||
assert_eq!((b.x0, b.y0), (100.0, 100.0));
|
||||
assert!((b.x1 - xf.to_doc(corner + vec2(20.0, 20.0)).x).abs() < 1e-6, "{b:?}");
|
||||
// Group Selection, then Selection: each is the one used.
|
||||
app.select_tool("groupSelection");
|
||||
let inside = xf.to_screen(Point::new(130.0, 120.0));
|
||||
assert_eq!(cmd_drag(&mut app, inside, inside), "groupSelection");
|
||||
app.select_tool("selection");
|
||||
assert_eq!(cmd_drag(&mut app, inside, inside), "selection");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_space_zooms_and_space_pans_whatever_the_tool() {
|
||||
use egui::{Event, Modifiers};
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.select_tool("rectangle");
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let at = app.canvas_rect.unwrap().center();
|
||||
let space = |pressed, modifiers| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers };
|
||||
let button = |pos, pressed, modifiers| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers };
|
||||
// A drag from `at` to `to` with Space and `held` down.
|
||||
let drag = |app: &mut VectorcraftApp, held: Modifiers, to: Pos2| {
|
||||
frame(app, &ctx, vec![Event::ModifiersChanged(held), space(true, held), Event::PointerMoved(at)]);
|
||||
frame(app, &ctx, vec![button(at, true, held)]);
|
||||
frame(app, &ctx, vec![Event::PointerMoved(to)]);
|
||||
frame(app, &ctx, vec![button(to, false, held)]);
|
||||
frame(app, &ctx, vec![space(false, held), Event::ModifiersChanged(Modifiers::NONE)]);
|
||||
};
|
||||
let z0 = app.view().unwrap().zoom;
|
||||
drag(&mut app, Modifiers::COMMAND, at);
|
||||
let z1 = app.view().unwrap().zoom;
|
||||
assert_eq!(z1, crate::state::next_zoom(z0, true), "Cmd+Space click zooms in");
|
||||
drag(&mut app, Modifiers::COMMAND | Modifiers::ALT, at);
|
||||
assert_eq!(app.view().unwrap().zoom, crate::state::next_zoom(z1, false), "Cmd+Alt+Space click zooms out");
|
||||
assert_eq!(app.session.active().unwrap().doc.art_bounds(), None, "the Rectangle tool drew nothing");
|
||||
// Space alone pans, with the Zoom tool too.
|
||||
app.select_tool("zoom");
|
||||
let before = *app.view().unwrap();
|
||||
drag(&mut app, Modifiers::NONE, at + vec2(30.0, 0.0));
|
||||
let after = *app.view().unwrap();
|
||||
assert_eq!(after.zoom, before.zoom);
|
||||
assert!((after.center.x - (before.center.x - 30.0 / before.zoom)).abs() < 1e-6, "{before:?} → {after:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_clicking_type_with_the_selection_tool_puts_the_caret_there() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let id = app.session.execute("text.create", &json!({"x": 100, "y": 150, "text": "Hello world", "size": 24})).unwrap()["id"].as_u64().unwrap();
|
||||
app.session.execute("select.none", &json!({})).unwrap();
|
||||
app.select_tool("selection");
|
||||
let ctx = egui::Context::default();
|
||||
let timed = |app: &mut VectorcraftApp, time: f64, events: Vec<egui::Event>| {
|
||||
let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||||
let mut out =
|
||||
ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time), events, ..Default::default() }, |ui| show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
timed(&mut app, 0.0, vec![]);
|
||||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||||
let text = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => t.plain_text(),
|
||||
_ => panic!("not text"),
|
||||
};
|
||||
// Double-click the middle of the text.
|
||||
let b = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap();
|
||||
let at = xf.to_screen(b.center());
|
||||
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
timed(&mut app, 1.0, vec![egui::Event::PointerMoved(at), button(true), button(false), button(true), button(false)]);
|
||||
timed(&mut app, 1.1, vec![]);
|
||||
// The Type tool edits the text, with the caret inside it, where it was clicked: typing goes
|
||||
// into the text, not to tool shortcuts (X would swap fill and stroke).
|
||||
assert_eq!(app.session.tool_id(), "type");
|
||||
assert!(app.session.tool_wants_text());
|
||||
let o = app.session.tool_options();
|
||||
assert_eq!(o["editing"], json!(id));
|
||||
let caret = o["caret"].as_u64().unwrap();
|
||||
assert!((1..11).contains(&caret), "caret {caret}");
|
||||
app.session.tool_text("X", app.view_info()).unwrap();
|
||||
let t = text(&app);
|
||||
assert_eq!((t.len(), t.find('X')), (12, Some(caret as usize)), "{t}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_symbol_dropped_on_the_canvas_is_placed_there() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 20})).unwrap();
|
||||
let name = app.session.execute("symbol.new", &json!({})).unwrap()["name"].as_str().unwrap().to_string();
|
||||
app.session.execute("select.none", &json!({})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||||
// Released over empty canvas: an instance centred there.
|
||||
let at = xf.to_screen(Point::new(250.0, 180.0));
|
||||
egui::DragAndDrop::set_payload(&ctx, widgets::PanelDrag::Symbol(name.clone()));
|
||||
let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() };
|
||||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), up]);
|
||||
let st = app.session.active().unwrap();
|
||||
let placed = st.selection.objects.first().copied().unwrap();
|
||||
let n = st.doc.node(placed).unwrap();
|
||||
assert!(matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if *symbol == name));
|
||||
let c = n.geometric_bounds().unwrap().center();
|
||||
assert!((c - xf.to_doc(at)).hypot() < 1e-6, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn middle_drag_pans_the_view_with_any_tool() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
@@ -1517,4 +1872,136 @@ mod tests {
|
||||
assert_eq!(*st.doc, *before);
|
||||
assert_eq!(st.history.undo.len(), undo);
|
||||
}
|
||||
|
||||
/// One frame of keyboard handling and canvas, as the app runs them; returns the IME output.
|
||||
fn typing_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> Option<egui::output::IMEOutput> {
|
||||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
crate::shortcuts::handle(app, ui.ctx());
|
||||
show(app, ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
out.platform_output.ime
|
||||
}
|
||||
|
||||
fn preedit(t: &str, chars: usize) -> egui::Event {
|
||||
egui::Event::Ime(egui::ImeEvent::Preedit { text: t.into(), active_range_chars: Some(chars..chars) })
|
||||
}
|
||||
|
||||
fn plain(app: &VectorcraftApp, id: u64) -> String {
|
||||
match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => t.plain_text(),
|
||||
_ => panic!("not text"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn japanese_ime_composes_on_the_canvas_with_its_window_at_the_caret() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
// Not editing: the IME stays off (single-key tool shortcuts keep working).
|
||||
assert!(typing_frame(&mut app, &ctx, vec![]).is_none());
|
||||
app.select_tool("type");
|
||||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||||
let at = xf.to_screen(Point::new(100.0, 100.0));
|
||||
let click =
|
||||
|p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]);
|
||||
typing_frame(&mut app, &ctx, vec![click(at, false)]);
|
||||
assert!(app.session.tool_wants_text());
|
||||
let id = app.session.active().unwrap().selection.objects[0].0;
|
||||
// Editing: the IME is allowed, its window at the caret (the text's baseline at y = 100).
|
||||
let ime = typing_frame(&mut app, &ctx, vec![]).expect("IME allowed while editing");
|
||||
assert!(ime.rect.contains(pos2(ime.rect.center().x, at.y)) && (ime.rect.center().x - at.x).abs() < 2.0, "{:?} vs {at:?}", ime.rect);
|
||||
assert!(!ime.should_interrupt_composition);
|
||||
// gagaku → がが → 雅楽, then commit (the macOS sequence).
|
||||
typing_frame(&mut app, &ctx, vec![preedit("g", 1)]);
|
||||
typing_frame(&mut app, &ctx, vec![preedit("が", 1), preedit("がg", 2)]);
|
||||
typing_frame(&mut app, &ctx, vec![preedit("ががく", 3)]);
|
||||
assert_eq!(plain(&app, id), "ががく");
|
||||
// While composing: plain text events, the clipboard and Undo (the native menu's ⌘Z
|
||||
// reaches `menus::invoke`) all wait.
|
||||
typing_frame(&mut app, &ctx, vec![egui::Event::Text("x".into()), egui::Event::Paste("y".into())]);
|
||||
crate::menus::invoke(&mut app, "edit.undo", json!({}));
|
||||
assert!(!crate::menus::enabled(&app, "edit.undo"));
|
||||
assert_eq!(plain(&app, id), "ががく");
|
||||
let ime2 =
|
||||
typing_frame(&mut app, &ctx, vec![egui::Event::Ime(egui::ImeEvent::Preedit { text: "雅楽".into(), active_range_chars: Some(0..2) })])
|
||||
.unwrap();
|
||||
assert!(ime2.rect.center().x <= ime.rect.center().x + 1.0, "the window stays at the clause being converted");
|
||||
typing_frame(&mut app, &ctx, vec![preedit("", 0), egui::Event::Ime(egui::ImeEvent::Commit("雅楽".into()))]);
|
||||
assert_eq!(plain(&app, id), "雅楽");
|
||||
assert!(!app.session.tool_composing());
|
||||
// After the commit, text and Undo work again.
|
||||
typing_frame(&mut app, &ctx, vec![egui::Event::Text("!".into())]);
|
||||
assert_eq!(plain(&app, id), "雅楽!");
|
||||
crate::menus::invoke(&mut app, "edit.undo", json!({}));
|
||||
assert_eq!(plain(&app, id), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_away_mid_composition_keeps_the_text_and_interrupts_the_ime() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
typing_frame(&mut app, &ctx, vec![]);
|
||||
app.select_tool("type");
|
||||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||||
let (a, b) = (xf.to_screen(Point::new(100.0, 100.0)), xf.to_screen(Point::new(300.0, 250.0)));
|
||||
let click =
|
||||
|p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(a), click(a, true)]);
|
||||
typing_frame(&mut app, &ctx, vec![click(a, false)]);
|
||||
let id = app.session.active().unwrap().selection.objects[0].0;
|
||||
typing_frame(&mut app, &ctx, vec![preedit("しょうこ", 4)]);
|
||||
// macOS sends nothing for a click away; the tool keeps the marked text and the IME is
|
||||
// told to drop its composition.
|
||||
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(b), click(b, true)]);
|
||||
let ime = typing_frame(&mut app, &ctx, vec![click(b, false)]);
|
||||
assert_eq!(plain(&app, id), "しょうこ");
|
||||
assert!(!app.session.tool_composing());
|
||||
assert!(ime.expect("editing the new text").should_interrupt_composition);
|
||||
assert!(app.ime_marked.is_none());
|
||||
assert!(app.take_ime_discard(), "the host tells the system IME to drop its marked text");
|
||||
// Interrupted once, not every frame.
|
||||
assert!(!typing_frame(&mut app, &ctx, vec![]).unwrap().should_interrupt_composition);
|
||||
assert!(!app.take_ime_discard());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ime_edge_orders_never_eat_committed_text_or_lose_the_composition() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
typing_frame(&mut app, &ctx, vec![]);
|
||||
app.select_tool("type");
|
||||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||||
let at = xf.to_screen(Point::new(100.0, 100.0));
|
||||
let click =
|
||||
|p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]);
|
||||
typing_frame(&mut app, &ctx, vec![click(at, false)]);
|
||||
let id = app.session.active().unwrap().selection.objects[0].0;
|
||||
typing_frame(&mut app, &ctx, vec![egui::Event::Text("雅".into())]);
|
||||
let backspace =
|
||||
|pressed| egui::Event::Key { key: egui::Key::Backspace, physical_key: None, pressed, repeat: false, modifiers: Default::default() };
|
||||
// The IME empties its marked text and the Backspace comes in the same frame.
|
||||
typing_frame(&mut app, &ctx, vec![preedit("が", 1)]);
|
||||
typing_frame(&mut app, &ctx, vec![preedit("", 0), backspace(true), backspace(false)]);
|
||||
assert_eq!(plain(&app, id), "雅", "the committed character stays");
|
||||
// A bare line-break commit ends the composition with the marked text, no newline.
|
||||
typing_frame(&mut app, &ctx, vec![preedit("がく", 2)]);
|
||||
typing_frame(&mut app, &ctx, vec![egui::Event::Ime(egui::ImeEvent::Commit("\n".into()))]);
|
||||
assert!(!app.session.tool_composing());
|
||||
assert_eq!(plain(&app, id), "雅がく");
|
||||
assert!(app.take_ime_discard());
|
||||
// Switching apps mid-composition: the composition goes on when the window comes back.
|
||||
typing_frame(&mut app, &ctx, vec![preedit("らく", 2)]);
|
||||
typing_frame(&mut app, &ctx, vec![egui::Event::WindowFocused(false)]);
|
||||
typing_frame(&mut app, &ctx, vec![egui::Event::WindowFocused(true), preedit("らくか", 3)]);
|
||||
assert!(app.session.tool_composing());
|
||||
assert!(!app.take_ime_discard());
|
||||
assert_eq!(plain(&app, id), "雅がくらくか");
|
||||
}
|
||||
}
|
||||
+141
-31
@@ -30,7 +30,7 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
crate::brand::paint_mark(ui, r);
|
||||
ui.add_space(4.0);
|
||||
let on_home = app.ui.home.is_some() || app.session.active().is_none();
|
||||
if widgets::icon_button(ui, "house", "Home", on_home, 24.0).clicked() {
|
||||
if widgets::icon_button(ui, "house", tl!("Home"), on_home, 24.0).clicked() {
|
||||
app.run("app.home", json!({})).ok();
|
||||
}
|
||||
ui.add_space(2.0);
|
||||
@@ -48,7 +48,10 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let right_edge = if custom { full.right() - titlebar::WIDTH - 10.0 } else { full.right() };
|
||||
let right = egui::Rect::from_min_max(egui::pos2(menus_end + 8.0, full.top()), egui::pos2(right_edge, full.bottom()));
|
||||
let room = right.width();
|
||||
let ws = ui.painter().layout_no_wrap(app.ui.workspace.clone(), egui::FontId::proportional(12.0), t.text);
|
||||
// Built-in workspace names translate; the user's own stay as typed.
|
||||
let ws_name =
|
||||
if crate::workspaces::is_builtin(&app.ui.workspace) { tl!(&app.ui.workspace).to_string() } else { app.ui.workspace.clone() };
|
||||
let ws = ui.painter().layout_no_wrap(ws_name, egui::FontId::proportional(12.0), t.text);
|
||||
let ws_w = (ws.size().x + 36.0).clamp(112.0, 190.0);
|
||||
let gap = ui.spacing().item_spacing.x;
|
||||
let with_search = ws_w + 8.0 + gap + 200.0;
|
||||
@@ -62,7 +65,7 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
ui.painter().rect_filled(wr, CornerRadius::same(4), if wresp.hovered() { t.hover } else { t.panel });
|
||||
ui.painter().with_clip_rect(wr.shrink2(vec2(4.0, 0.0))).galley(wr.left_center() + vec2(10.0, -ws.size().y / 2.0), ws, t.text);
|
||||
icons::paint(ui, "chevron-down", egui::Rect::from_center_size(wr.right_center() - vec2(12.0, 0.0), vec2(12.0, 12.0)), t.text_dim);
|
||||
let wresp = wresp.on_hover_text("Switch workspace");
|
||||
let wresp = wresp.on_hover_text(tl!("Switch workspace"));
|
||||
egui::Popup::menu(&wresp).show(|ui| crate::workspaces::popup(app, ui));
|
||||
ui.add_space(8.0);
|
||||
// Search box → command palette.
|
||||
@@ -79,13 +82,13 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
ui.painter().text(
|
||||
r.left_center() + vec2(26.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
"Search commands and tools",
|
||||
tl!("Search commands and tools"),
|
||||
egui::FontId::proportional(11.5),
|
||||
t.text_dim,
|
||||
);
|
||||
resp.clicked()
|
||||
} else {
|
||||
widgets::icon_button(ui, "search", "Search commands and tools", false, 24.0).clicked()
|
||||
widgets::icon_button(ui, "search", tl!("Search commands and tools"), false, 24.0).clicked()
|
||||
};
|
||||
if open_palette {
|
||||
app.ui.palette_open = true;
|
||||
@@ -116,7 +119,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
ui.painter().circle_filled(g.center_top() + vec2(0.0, 2.0 + i as f32 * 4.0), 0.9, t.text_disabled);
|
||||
}
|
||||
let Some(st) = app.session.active() else {
|
||||
ui.label(egui::RichText::new("No Document").color(t.text_dim));
|
||||
ui.label(egui::RichText::new(tl!("No Document")).color(t.text_dim));
|
||||
return;
|
||||
};
|
||||
let sel = st.selection.objects.clone();
|
||||
@@ -124,15 +127,15 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let first = sel.first().and_then(|id| st.doc.node(*id)).cloned();
|
||||
let anchor_mode = !st.selection.anchors.is_empty();
|
||||
let label = match &first {
|
||||
Some(_) if sel.len() == 1 && anchor_mode => "Anchor Point",
|
||||
Some(_) if sel.len() == 1 && anchor_mode => tl!("Anchor Point"),
|
||||
Some(vectorcraft_doc::Node { kind: NodeKind::Image(im), .. }) if sel.len() == 1 => {
|
||||
if im.link.is_some() {
|
||||
"Linked File"
|
||||
tl!("Linked File")
|
||||
} else {
|
||||
"Embedded"
|
||||
tl!("Embedded")
|
||||
}
|
||||
}
|
||||
_ => crate::panels::appearance::object_label(app),
|
||||
_ => tl!(crate::panels::appearance::object_label(app)),
|
||||
};
|
||||
ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text));
|
||||
ui.add_space(6.0);
|
||||
@@ -147,7 +150,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
crate::panels::paint_chip(app, ui, true, 22.0, true);
|
||||
// The link opens the Stroke panel as a popover under it; then the weight spinner
|
||||
// (with presets) and the width profile.
|
||||
let link = ui.link(egui::RichText::new("Stroke:").size(12.0).color(t.text).underline()).on_hover_text("Stroke options");
|
||||
let link = ui.link(egui::RichText::new(tl!("Stroke:")).size(12.0).color(t.text).underline()).on_hover_text(tl!("Stroke options"));
|
||||
stroke_panel::popover(app, &link);
|
||||
stroke_panel::weight_field(app, ui, "cb-stroke", weight, 100.0);
|
||||
if let Some(id) = stroke_panel::profile_dropdown(app, ui, shown_stroke.as_ref().and_then(|s| s.profile.as_ref())) {
|
||||
@@ -155,7 +158,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
ui.separator();
|
||||
if ui.link(egui::RichText::new("Opacity:").size(12.0).color(t.text).underline()).clicked() {
|
||||
if ui.link(egui::RichText::new(tl!("Opacity:")).size(12.0).color(t.text).underline()).clicked() {
|
||||
app.ui.open_panel = Some("transparency".into());
|
||||
}
|
||||
if let Some(o) = widgets::plain_field(ui, "cb-opacity", opacity as f64 * 100.0, "%", 0, 56.0)
|
||||
@@ -166,19 +169,19 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if !sel.is_empty() {
|
||||
// Style picker: the selection's graphic style; its menu applies another.
|
||||
ui.add_space(4.0);
|
||||
if ui.link(egui::RichText::new("Style:").size(12.0).color(t.text).underline()).clicked() {
|
||||
if ui.link(egui::RichText::new(tl!("Style:")).size(12.0).color(t.text).underline()).clicked() {
|
||||
app.ui.open_panel = Some("graphicStyles".into());
|
||||
}
|
||||
let resp = widgets::chip_button(ui, 22.0, true, |ui, r| crate::panels::graphic_styles::paint_linked(app, ui, r))
|
||||
.on_hover_text("Graphic Style");
|
||||
.on_hover_text(tl!("Graphic Style"));
|
||||
egui::Popup::menu(&resp).show(|ui| crate::panels::graphic_styles::picker(app, ui));
|
||||
}
|
||||
ui.separator();
|
||||
if sel.is_empty() {
|
||||
if widgets::flat_button(ui, "Document Setup", 112.0).clicked() {
|
||||
if widgets::flat_button(ui, tl!("Document Setup"), 112.0).clicked() {
|
||||
app.run("file.documentSetup", json!({})).ok();
|
||||
}
|
||||
if widgets::flat_button(ui, "Preferences", 90.0).clicked() {
|
||||
if widgets::flat_button(ui, tl!("Preferences"), 90.0).clicked() {
|
||||
app.run("edit.preferences", json!({})).ok();
|
||||
}
|
||||
return;
|
||||
@@ -192,7 +195,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
("align-center-horizontal", "Vertical Align Center", json!({"vertical": "center"})),
|
||||
("align-end-horizontal", "Vertical Align Bottom", json!({"vertical": "bottom"})),
|
||||
] {
|
||||
if widgets::icon_button(ui, icon, tip, false, 24.0).clicked() {
|
||||
if widgets::icon_button(ui, icon, tl!(tip), false, 24.0).clicked() {
|
||||
let mut p = p;
|
||||
if sel.len() == 1 {
|
||||
p["to"] = json!("artboard");
|
||||
@@ -211,7 +214,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if k == "height" {
|
||||
crate::panels::transform::constrain_link(app, ui);
|
||||
}
|
||||
ui.label(egui::RichText::new(lbl).size(12.0).color(t.text_dim));
|
||||
ui.label(egui::RichText::new(tl!(lbl)).size(12.0).color(t.text_dim));
|
||||
if let Some(nv) = widgets::num_field(ui, ("cb", k), Some(v), units, 80.0) {
|
||||
app.run("object.setBounds", json!({k: nv, "reference": 4, "proportional": link})).ok();
|
||||
}
|
||||
@@ -225,10 +228,10 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
/// "Name* @ 66.67 % (<Opacity Mask>/Opacity Mask)".
|
||||
fn tab_title(d: &vectorcraft_engine::DocState, zoom: f64, outline: bool) -> String {
|
||||
let mode = if d.doc.mask_edit.is_some() {
|
||||
"<Opacity Mask>/Opacity Mask".to_string()
|
||||
format!("<{0}>/{0}", tl!("Opacity Mask"))
|
||||
} else {
|
||||
let color = if d.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { "CMYK" } else { "RGB" };
|
||||
format!("{color}/{}", if outline { "Outline" } else { "Preview" })
|
||||
format!("{color}/{}", if outline { tl!("Outline") } else { tl!("Preview") })
|
||||
};
|
||||
format!("{}{} @ {} ({mode})", d.title(), if d.is_dirty() { "*" } else { "" }, zoom_label(zoom).replace('%', " %"))
|
||||
}
|
||||
@@ -305,6 +308,7 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if pattern_edit.is_some() {
|
||||
let mut x = bar.right() - 6.0;
|
||||
for (label, cmd) in [("Cancel", "object.pattern.cancel"), ("Done", "object.pattern.done"), ("Save a Copy", "object.pattern.saveCopy")] {
|
||||
let label = tl!(label);
|
||||
let w = 12.0 + 7.0 * label.len() as f32;
|
||||
let r = egui::Rect::from_min_max(egui::pos2(x - w, bar.top() + 3.0), egui::pos2(x, bar.bottom() - 3.0));
|
||||
let resp = ui.interact(r, ui.id().with(("pat-bar", cmd)), Sense::click());
|
||||
@@ -346,10 +350,10 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if ui.selectable_label(false, "Fit on Screen").clicked() {
|
||||
if ui.selectable_label(false, tl!("Fit on Screen")).clicked() {
|
||||
app.run("view.fitArtboard", json!({})).ok();
|
||||
}
|
||||
if ui.selectable_label(false, "Fit All").clicked() {
|
||||
if ui.selectable_label(false, tl!("Fit All")).clicked() {
|
||||
app.run("view.fitAll", json!({})).ok();
|
||||
}
|
||||
},
|
||||
@@ -368,16 +372,34 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
},
|
||||
);
|
||||
ui.separator();
|
||||
// The artboard navigator: first, previous, the current artboard's number, next, last.
|
||||
let nab = app.session.active().map(|d| d.doc.artboards.len()).unwrap_or(0);
|
||||
for icon in ["chevrons-left", "chevron-left"] {
|
||||
widgets::icon_button(ui, icon, "", false, 18.0);
|
||||
let cur = app.view().map_or(0, |v| v.artboard).min(nab.saturating_sub(1));
|
||||
let mut go = None;
|
||||
for (icon, to) in [("chevrons-left", "first"), ("chevron-left", "previous")] {
|
||||
if widgets::icon_button_enabled(ui, icon, "", false, cur > 0, 18.0).clicked() {
|
||||
go = Some(to);
|
||||
}
|
||||
}
|
||||
ui.label(egui::RichText::new(if nab > 0 { "1" } else { "–" }).size(11.5));
|
||||
for icon in ["chevron-right", "chevrons-right"] {
|
||||
widgets::icon_button(ui, icon, "", false, 18.0);
|
||||
ui.label(egui::RichText::new(if nab > 0 { (cur + 1).to_string() } else { "–".into() }).size(11.5));
|
||||
for (icon, to) in [("chevron-right", "next"), ("chevrons-right", "last")] {
|
||||
if widgets::icon_button_enabled(ui, icon, "", false, cur + 1 < nab, 18.0).clicked() {
|
||||
go = Some(to);
|
||||
}
|
||||
}
|
||||
if let Some(to) = go
|
||||
&& let Err(e) = app.run("view.goToArtboard", json!({ "index": to }))
|
||||
{
|
||||
app.status(e);
|
||||
}
|
||||
ui.separator();
|
||||
let tool = vectorcraft_tools::tool_info(app.session.tool_id()).map(|t| t.label.trim_end_matches(" Tool")).unwrap_or("");
|
||||
let tool = vectorcraft_tools::tool_info(app.session.tool_id())
|
||||
.map(|t| {
|
||||
// The catalog keys carry the full label ("Selection Tool"); only English drops the suffix.
|
||||
let shown = tl!(t.label);
|
||||
if crate::i18n::current() == crate::i18n::Lang::EN { shown.trim_end_matches(" Tool") } else { shown }
|
||||
})
|
||||
.unwrap_or("");
|
||||
ui.label(egui::RichText::new(tool).size(11.5).color(t.text_dim));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label(
|
||||
@@ -563,19 +585,51 @@ fn hint_for(tool: &str) -> Option<&'static [(&'static str, bool)]> {
|
||||
|
||||
/// The hint bar's segments for `tool`, with keys named as the menus name them (Alt and Ctrl on
|
||||
/// Windows and Linux, ⌥ and ⌘ on macOS). Tools without a hint point to Search Commands.
|
||||
/// Every hint-bar fragment (the action after the last `+` of a chord included), so the catalog tests
|
||||
/// can insist a complete language covers them.
|
||||
pub fn hint_strings() -> Vec<&'static str> {
|
||||
let mut v = Vec::new();
|
||||
for tool in vectorcraft_tools::catalog::all_tools() {
|
||||
for &(text, bold) in hint_for(tool.id).unwrap_or(&[]) {
|
||||
let text = if bold { text.rsplit_once('+').map_or(text, |(_, k)| k) } else { text };
|
||||
if !text.is_empty() && !text.chars().all(|c| c.is_ascii_digit()) && !v.contains(&text) {
|
||||
v.push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn hint_segments(tool: &str) -> Vec<(Cow<'static, str>, bool)> {
|
||||
let search;
|
||||
let segments = match hint_for(tool) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
search = match menus::shortcut_of("help.commandPalette") {
|
||||
Some(sc) => [("Press ", false), (sc, true), (" to search every command", false)],
|
||||
None => [("Use ", false), ("Help › Search Commands…", true), (" to search every command", false)],
|
||||
Some(sc) => [(tl!("Press "), false), (sc, true), (tl!(" to search every command"), false)],
|
||||
None => [(tl!("Use "), false), (tl!("Help › Search Commands…"), true), (tl!(" to search every command"), false)],
|
||||
};
|
||||
&search
|
||||
}
|
||||
};
|
||||
segments.iter().map(|&(text, bold)| (if bold { Cow::Owned(menus::pretty_shortcut(text)) } else { Cow::Borrowed(text) }, bold)).collect()
|
||||
segments
|
||||
.iter()
|
||||
.map(|&(text, bold)| {
|
||||
// Key names ("Click", "Esc") translate; in a chord ("Alt+Drag") the modifiers are named for
|
||||
// the platform and only the action after the last `+` translates.
|
||||
if !bold {
|
||||
return (Cow::Borrowed(tl!(text)), bold);
|
||||
}
|
||||
match text.rsplit_once('+') {
|
||||
Some((_, key)) if !key.is_empty() => {
|
||||
let pretty = menus::pretty_shortcut(text);
|
||||
let shown = pretty.strip_suffix(key).map_or(pretty.clone(), |m| format!("{m}{}", tl!(key)));
|
||||
(Cow::Owned(shown), bold)
|
||||
}
|
||||
_ => (Cow::Owned(menus::pretty_shortcut(tl!(text))), bold),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn hint_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
@@ -625,6 +679,62 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// One headless frame of the status bar; returns the artboard navigator's buttons, left to
|
||||
/// right: first, previous, next, last.
|
||||
fn navigator(app: &mut crate::VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> Vec<egui::Rect> {
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 300.0));
|
||||
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| super::status_bar(app, ui));
|
||||
out.textures_delta.clear();
|
||||
let size = egui::vec2(18.0, 18.0);
|
||||
let mut r: Vec<egui::Rect> =
|
||||
ctx.viewport(|vp| vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == size).map(|w| w.rect).collect());
|
||||
r.sort_by(|a, b| a.left().total_cmp(&b.left()));
|
||||
r
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_artboard_navigator_goes_to_artboards_and_fitting_shows_the_current_one() {
|
||||
use vectorcraft_geom::Point;
|
||||
let mut app = crate::VectorcraftApp::new(Session::new(), Default::default());
|
||||
// Three 200 × 100 artboards in a row, centred at x = 100, 320 and 540.
|
||||
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
|
||||
app.run("artboard.new", json!({})).unwrap();
|
||||
app.run("artboard.new", json!({})).unwrap();
|
||||
app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(460.0, 260.0)));
|
||||
let ctx = egui::Context::default();
|
||||
let buttons = navigator(&mut app, &ctx, vec![]);
|
||||
assert_eq!(buttons.len(), 4);
|
||||
let click = |app: &mut crate::VectorcraftApp, r: egui::Rect| {
|
||||
let b = |pressed| egui::Event::PointerButton {
|
||||
pos: r.center(),
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed,
|
||||
modifiers: Default::default(),
|
||||
};
|
||||
navigator(app, &ctx, vec![egui::Event::PointerMoved(r.center())]);
|
||||
navigator(app, &ctx, vec![b(true)]);
|
||||
navigator(app, &ctx, vec![b(false)]);
|
||||
};
|
||||
let at = |app: &crate::VectorcraftApp| (app.view().unwrap().artboard, app.view().unwrap().center);
|
||||
click(&mut app, buttons[2]);
|
||||
assert_eq!(at(&app), (1, Point::new(320.0, 50.0)), "next");
|
||||
click(&mut app, buttons[3]);
|
||||
assert_eq!(at(&app), (2, Point::new(540.0, 50.0)), "last");
|
||||
click(&mut app, buttons[1]);
|
||||
assert_eq!(at(&app), (1, Point::new(320.0, 50.0)), "previous");
|
||||
// Fit Artboard in Window and Actual Size show the navigator's artboard.
|
||||
app.run("view.setZoom", json!({"zoom": 300, "center": [0, 0]})).unwrap();
|
||||
app.run("view.fitArtboard", json!({})).unwrap();
|
||||
assert_eq!(at(&app), (1, Point::new(320.0, 50.0)));
|
||||
app.run("view.actualSize", json!({})).unwrap();
|
||||
assert_eq!((at(&app).1, app.view().unwrap().zoom), (Point::new(320.0, 50.0), 1.0));
|
||||
click(&mut app, buttons[0]);
|
||||
assert_eq!(at(&app), (0, Point::new(100.0, 50.0)), "first");
|
||||
// The command also takes an index, clamped to the last artboard.
|
||||
assert_eq!(app.run("view.goToArtboard", json!({"index": 9})), Ok(json!({"index": 2})));
|
||||
assert!(app.run("view.goToArtboard", json!({"index": "sideways"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tab_title_names_the_opacity_mask_while_it_is_edited() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -12,7 +12,7 @@ const DISCORD: Color32 = Color32::from_rgb(0x58, 0x65, 0xF2);
|
||||
|
||||
/// The Discord button's label, height and width.
|
||||
fn discord_layout(ui: &Ui, large: bool) -> (std::sync::Arc<egui::Galley>, f32, f32) {
|
||||
let (h, font, label) = if large { (36.0, theme::semibold(14.0), "Join our Discord") } else { (24.0, theme::semibold(12.0), "Discord") };
|
||||
let (h, font, label) = if large { (36.0, theme::semibold(14.0), tl!("Join our Discord")) } else { (24.0, theme::semibold(12.0), "Discord") };
|
||||
let galley = ui.painter().layout_no_wrap(label.to_string(), font, Color32::WHITE);
|
||||
let w = galley.size().x + h * 0.55 + h * 0.9;
|
||||
(galley, h, w)
|
||||
@@ -33,7 +33,7 @@ pub fn discord_button(app: &mut VectorcraftApp, ui: &mut Ui, large: bool) -> egu
|
||||
let ir = egui::Rect::from_center_size(r.left_center() + vec2(h * 0.35 + icon / 2.0, 0.0), vec2(icon, icon));
|
||||
icons::paint(ui, "message-circle", ir, Color32::WHITE);
|
||||
ui.painter().galley(egui::pos2(ir.right() + h * 0.2, r.center().y - galley.size().y / 2.0), galley, Color32::WHITE);
|
||||
let resp = resp.on_hover_text("Join the ArtCraft community on Discord (discord.gg/artcraft)");
|
||||
let resp = resp.on_hover_text(tl!("Join the ArtCraft community on Discord (discord.gg/artcraft)"));
|
||||
if resp.clicked() {
|
||||
app.open_link("help.discord");
|
||||
}
|
||||
@@ -58,9 +58,9 @@ pub fn links(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
ui.add_space(8.0);
|
||||
let l = app.session.execute("help.links", &serde_json::json!({})).unwrap_or_default();
|
||||
let s = |k: &str| l[k].as_str().unwrap_or("").to_string();
|
||||
link(app, ui, "globe", "ArtCraft website", "help.website", &s("website"));
|
||||
link(app, ui, "external-link", "VectorCraft on getartcraft.com", "help.appPage", &s("appPage"));
|
||||
link(app, ui, "git-branch", "Source code on GitHub", "help.github", &s("github"));
|
||||
link(app, ui, "globe", tl!("ArtCraft website"), "help.website", &s("website"));
|
||||
link(app, ui, "external-link", tl!("VectorCraft on getartcraft.com"), "help.appPage", &s("appPage"));
|
||||
link(app, ui, "git-branch", tl!("Source code on GitHub"), "help.github", &s("github"));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! - `engine.commands`: engine + UI commands with enablement
|
||||
//! - `document.inspect`: document summary; `ui.inspect`: UI state
|
||||
//! - `ui.menu.list`: flattened menu tree
|
||||
//! - `ui.contextMenu.list`: the canvas context menu for the current selection, flattened
|
||||
//! - `ui.tool.select {tool}`, `ui.tool.list`
|
||||
//! - `ui.pointer {events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen"}], mods?}`:
|
||||
//! drive the active tool through the same path as the mouse
|
||||
@@ -117,6 +118,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
|
||||
"document.inspect" => wrap(app.run("document.inspect", json!({}))),
|
||||
"ui.inspect" => ok(inspect(app, ctx)),
|
||||
"ui.menu.list" => ok(serde_json::to_value(crate::menus::menu_entries(app)).unwrap_or_default()),
|
||||
"ui.contextMenu.list" => ok(serde_json::to_value(crate::menus::context_entries(app)).unwrap_or_default()),
|
||||
"ui.tool.select" => wrap(app.run("tool.select", json!({"tool": s("tool").unwrap_or("")}))),
|
||||
"ui.tool.list" => ok(serde_json::to_value(vectorcraft_tools::TOOL_GROUPS).unwrap_or_default()),
|
||||
"ui.pointer" => {
|
||||
|
||||
@@ -9,7 +9,7 @@ pub(super) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
return;
|
||||
}
|
||||
let mut open = true;
|
||||
egui::Window::new("About VectorCraft")
|
||||
egui::Window::new(tl!("About VectorCraft"))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.open(&mut open)
|
||||
@@ -22,16 +22,19 @@ pub(super) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
crate::brand::paint_mark(ui, r);
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new("VectorCraft").font(theme::semibold(22.0)));
|
||||
ui.label(format!("Version {} — open-source vector illustration in pure Rust.", env!("CARGO_PKG_VERSION")));
|
||||
ui.label(crate::i18n::fmt(
|
||||
tl!("Version {version} — open-source vector illustration in pure Rust."),
|
||||
&[("version", env!("CARGO_PKG_VERSION"))],
|
||||
));
|
||||
});
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
crate::community::links(app, ui);
|
||||
ui.add_space(12.0);
|
||||
ui.label(
|
||||
egui::RichText::new(
|
||||
"Part of ArtCraft. MIT OR Apache-2.0. Fonts: Source Sans 3, Inter, JetBrains Mono (OFL). Icons: Lucide (ISC) + VectorCraft.",
|
||||
)
|
||||
egui::RichText::new(tl!(
|
||||
"Part of ArtCraft. MIT OR Apache-2.0. Fonts: Source Sans 3, Inter, JetBrains Mono (OFL). Icons: Lucide (ISC) + VectorCraft."
|
||||
))
|
||||
.size(11.0),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -4,14 +4,16 @@ use super::DialogSpec;
|
||||
use crate::VectorcraftApp;
|
||||
use crate::state::Dialog;
|
||||
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "All Tools".into(), body, ok: None, ..DialogSpec::FORM };
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("All Tools").into(), body, ok: None, ..DialogSpec::FORM };
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, _: &mut Dialog) -> bool {
|
||||
let mut picked = false;
|
||||
for g in vectorcraft_tools::TOOL_GROUPS {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for tool in g.iter() {
|
||||
if ui.button(tool.label.trim_end_matches(" Tool")).clicked() {
|
||||
let shown = tl!(tool.label);
|
||||
let shown = if crate::i18n::current() == crate::i18n::Lang::EN { shown.trim_end_matches(" Tool") } else { shown };
|
||||
if ui.button(shown).clicked() {
|
||||
app.select_tool(tool.id);
|
||||
picked = true;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use super::{DialogSpec, run_and_close};
|
||||
use crate::VectorcraftApp;
|
||||
use crate::state::Dialog;
|
||||
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Artboard Options".into(), confirm, ..DialogSpec::FORM };
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Artboard Options").into(), confirm, ..DialogSpec::FORM };
|
||||
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let mut p = Value::Object(d.fields.clone());
|
||||
|
||||
@@ -28,7 +28,7 @@ const LABEL_W: f32 = 82.0;
|
||||
const SPACING: [(&str, &str); 3] = [("smooth", "Smooth Color"), ("steps", "Specified Steps"), ("distance", "Specified Distance")];
|
||||
|
||||
pub(super) const SPEC: DialogSpec =
|
||||
DialogSpec { heading: |_| "Blend Options".into(), body, confirm, preview: true, min_width: 300.0, ..DialogSpec::FORM };
|
||||
DialogSpec { heading: |_| tl!("Blend Options").into(), body, confirm, preview: true, min_width: 300.0, ..DialogSpec::FORM };
|
||||
|
||||
/// Open Blend Options on the selected blend's options (else those new blends start with).
|
||||
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||||
@@ -63,7 +63,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let mode = d.str("spacing");
|
||||
let shown = SPACING.iter().find(|(v, _)| *v == mode).map_or(SPACING[0].1, |(_, l)| l);
|
||||
let labels = SPACING.map(|(_, l)| l);
|
||||
widgets::label_row(ui, "Spacing:", LABEL_W, |ui| {
|
||||
widgets::label_row(ui, tl!("Spacing:"), LABEL_W, |ui| {
|
||||
if let Some((value, _)) = widgets::dropdown(ui, "blend-spacing", shown, &labels, 150.0).and_then(|i| SPACING.get(i)) {
|
||||
d.fields.insert("spacing".into(), json!(value));
|
||||
}
|
||||
@@ -84,9 +84,9 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
widgets::label_row(ui, "Orientation:", LABEL_W, |ui| {
|
||||
widgets::label_row(ui, tl!("Orientation:"), LABEL_W, |ui| {
|
||||
let path = d.str("orientation") == "path";
|
||||
for (value, along, tip) in [("page", false, "Align to Page"), ("path", true, "Align to Path")] {
|
||||
for (value, along, tip) in [("page", false, tl!("Align to Page")), ("path", true, tl!("Align to Path"))] {
|
||||
if orientation_button(ui, along, path == along, tip) {
|
||||
d.fields.insert("orientation".into(), json!(value));
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ pub const KIND: &str = "colorBalance";
|
||||
const CMD: &str = "edit.colors.adjustBalance";
|
||||
|
||||
pub(super) const SPEC: DialogSpec =
|
||||
DialogSpec { heading: |_| "Adjust Colors".into(), body, confirm, preview: true, min_width: 340.0, ..DialogSpec::FORM };
|
||||
DialogSpec { heading: |_| tl!("Adjust Colors").into(), body, confirm, preview: true, min_width: 340.0, ..DialogSpec::FORM };
|
||||
|
||||
/// A colour mode: its `mode` id, label and channels as (field, label).
|
||||
struct Mode {
|
||||
@@ -83,28 +83,28 @@ fn track(key: &str, x: f32) -> egui::Color32 {
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let m = mode(d);
|
||||
ui.horizontal(|ui| {
|
||||
widgets::dim_label(ui, "Color Mode:");
|
||||
widgets::dim_label(ui, tl!("Color Mode:"));
|
||||
let labels: Vec<&str> = MODES.iter().map(|m| m.label).collect();
|
||||
if let Some(i) = widgets::dropdown(ui, "balance-mode", m.label, &labels, 120.0) {
|
||||
d.fields.insert("mode".into(), json!(MODES[i].id));
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
if widgets::check(ui, "Convert", d.bool("convert"), m.id != GLOBAL) {
|
||||
if widgets::check(ui, tl!("Convert"), d.bool("convert"), m.id != GLOBAL) {
|
||||
d.fields.insert("convert".into(), json!(!d.bool("convert")));
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
let m = mode(d);
|
||||
if m.id == GLOBAL {
|
||||
widgets::dim_label(ui, "Shifts the tints of global and spot colors; other colors stay.");
|
||||
widgets::dim_label(ui, tl!("Shifts the tints of global and spot colors; other colors stay."));
|
||||
}
|
||||
for (k, label) in m.channels {
|
||||
form::slider(ui, d, k, label, -100.0..=100.0, "%", &|x| track(k, x));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
widgets::dim_label(ui, "Adjust Options:");
|
||||
for (k, label) in [("fill", "Fill"), ("stroke", "Stroke")] {
|
||||
widgets::dim_label(ui, tl!("Adjust Options:"));
|
||||
for (k, label) in [("fill", tl!("Fill")), ("stroke", tl!("Stroke"))] {
|
||||
if widgets::check(ui, label, d.bool(k), true) {
|
||||
d.fields.insert(k.into(), json!(!d.bool(k)));
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ use crate::theme::Tokens;
|
||||
/// The dialog kind of Color Guide Options.
|
||||
pub const KIND: &str = "colorGuideOptions";
|
||||
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Color Guide Options".into(), body, confirm, min_width: 320.0, ..DialogSpec::FORM };
|
||||
pub(super) const SPEC: DialogSpec =
|
||||
DialogSpec { heading: |_| tl!("Color Guide Options").into(), body, confirm, min_width: 320.0, ..DialogSpec::FORM };
|
||||
|
||||
/// Open the dialog with the panel's current options.
|
||||
pub fn open(app: &mut VectorcraftApp) {
|
||||
@@ -38,14 +39,14 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let v = (60.0 + 160.0 * x) as u8;
|
||||
egui::Color32::from_rgb(v, v, v)
|
||||
};
|
||||
form::slider(ui, d, "steps", "Steps:", 1.0..=max, "", &ramp);
|
||||
form::slider(ui, d, "amount", "Variation:", 0.0..=100.0, "%", &ramp);
|
||||
form::slider(ui, d, "steps", tl!("Steps:"), 1.0..=max, "", &ramp);
|
||||
form::slider(ui, d, "amount", tl!("Variation:"), 0.0..=100.0, "%", &ramp);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(form::SLIDER_LABEL + ui.spacing().item_spacing.x);
|
||||
let (r, _) = ui.allocate_exact_size(vec2(form::SLIDER_WIDTH, 14.0), Sense::hover());
|
||||
let font = egui::FontId::proportional(11.0);
|
||||
ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, "Less", font.clone(), t.text_dim);
|
||||
ui.painter().text(r.right_center(), egui::Align2::RIGHT_CENTER, "More", font, t.text_dim);
|
||||
ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, tl!("Less"), font.clone(), t.text_dim);
|
||||
ui.painter().text(r.right_center(), egui::Align2::RIGHT_CENTER, tl!("More"), font, t.text_dim);
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
// Preview: the base colour's row of variations.
|
||||
|
||||
@@ -24,7 +24,7 @@ use crate::{VectorcraftApp, widgets};
|
||||
pub(super) const KIND: &str = "colorPicker";
|
||||
|
||||
pub(super) const SPEC: DialogSpec =
|
||||
DialogSpec { heading: |_| "Color Picker".into(), body, confirm, min_width: 600.0, max_width: Some(640.0), ..DialogSpec::FORM };
|
||||
DialogSpec { heading: |_| tl!("Color Picker").into(), body, confirm, min_width: 600.0, max_width: Some(640.0), ..DialogSpec::FORM };
|
||||
|
||||
/// Side of the colour field (and height of the channel slider).
|
||||
const FIELD: f32 = 240.0;
|
||||
@@ -222,7 +222,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
});
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
|
||||
toggle_swatches = widgets::flat_button(ui, if swatches { "Color Models" } else { "Color Swatches" }, 110.0).clicked();
|
||||
toggle_swatches = widgets::flat_button(ui, if swatches { tl!("Color Models") } else { tl!("Color Swatches") }, 110.0).clicked();
|
||||
});
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
@@ -232,7 +232,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
});
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
if widgets::check(ui, "Only Web Colors", web, true) {
|
||||
if widgets::check(ui, tl!("Only Web Colors"), web, true) {
|
||||
d.fields.insert("webOnly".into(), json!(!web));
|
||||
}
|
||||
if let Some(ch) = new_channel {
|
||||
@@ -254,8 +254,8 @@ fn new_original_chips(ui: &mut Ui, new: &Color, original: &Color) -> bool {
|
||||
widgets::paint_chip(ui, top, &Paint::solid(*new));
|
||||
widgets::paint_chip(ui, bottom, &Paint::solid(*original));
|
||||
ui.painter().rect_stroke(r, 0.0, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Outside);
|
||||
ui.interact(top, ui.id().with("cp-new"), Sense::hover()).on_hover_text("New");
|
||||
ui.interact(bottom, ui.id().with("cp-original"), Sense::click()).on_hover_text("Original: click to restore").clicked()
|
||||
ui.interact(top, ui.id().with("cp-new"), Sense::hover()).on_hover_text(tl!("New"));
|
||||
ui.interact(bottom, ui.id().with("cp-original"), Sense::click()).on_hover_text(tl!("Original: click to restore")).clicked()
|
||||
}
|
||||
|
||||
/// The HSB and RGB fields with the channel radios, hex, Lab and CMYK. Returns the edited colour
|
||||
@@ -334,7 +334,7 @@ fn swatch_list(app: &VectorcraftApp, ui: &mut Ui, current: &Color) -> Option<Col
|
||||
egui::ScrollArea::vertical().id_salt("cp-swatches").max_height(FIELD).show(ui, |ui| {
|
||||
ui.set_width(FIELD + 24.0);
|
||||
let Some(st) = app.session.active() else {
|
||||
widgets::dim_label(ui, "Open a document to see its swatches.");
|
||||
widgets::dim_label(ui, tl!("Open a document to see its swatches."));
|
||||
return;
|
||||
};
|
||||
let doc = &st.doc;
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::VectorcraftApp;
|
||||
use crate::state::Dialog;
|
||||
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec {
|
||||
heading: |d| d.str("__label"),
|
||||
heading: |d| tl!(&d.str("__label")).to_string(),
|
||||
body: |app, ui, d| {
|
||||
let lengths = lengths(&d.str("__command"));
|
||||
form::param_fields(ui, d, &|k| lengths.contains(&k), app.session.general_unit());
|
||||
@@ -26,7 +26,7 @@ fn lengths(command: &str) -> &'static [&'static str] {
|
||||
"artboard.rearrange" => &["spacing"],
|
||||
"perspective.grid.set" => &["cell", "distance"],
|
||||
"object.repeat.options" => &["radius", "hSpacing", "vSpacing"],
|
||||
"text.areaOptions" => &["gutter", "inset", "firstBaselineMin"],
|
||||
"text.areaOptions" => &["width", "height", "gutter", "inset", "firstBaselineMin"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//! as deleting swatches.
|
||||
//!
|
||||
//! Fields: `message` (the question, shown as the heading), `detail` (an optional line under it),
|
||||
//! `__command` and `__params` (the command OK runs).
|
||||
//! `__command` and `__params` (the command OK runs). Both texts are shown as given: callers
|
||||
//! translate their template before filling in a name, so the name is never translated.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -28,7 +29,8 @@ pub(super) const SPEC: DialogSpec = DialogSpec {
|
||||
..DialogSpec::FORM
|
||||
};
|
||||
|
||||
/// Ask `message` (with an optional `detail` line); OK runs `command` with `params`.
|
||||
/// Ask `message` (with an optional `detail` line), both in the UI language; OK runs `command`
|
||||
/// with `params`.
|
||||
pub fn ask(app: &mut VectorcraftApp, message: &str, detail: &str, command: &str, params: Value) {
|
||||
let fields = json!({"message": message, "detail": detail, "__command": command, "__params": params});
|
||||
app.ui.dialog = Some(Dialog::new(KIND, fields));
|
||||
|
||||
@@ -21,7 +21,7 @@ const UI_ONLY: [&str; 2] = ["tab", "bleedLinked"];
|
||||
pub(super) const LABEL: f32 = 130.0;
|
||||
|
||||
pub(super) const SPEC: DialogSpec =
|
||||
DialogSpec { heading: |_| "Document Setup".into(), body, confirm, min_width: 540.0, max_width: Some(540.0), ..DialogSpec::FORM };
|
||||
DialogSpec { heading: |_| tl!("Document Setup").into(), body, confirm, min_width: 540.0, max_width: Some(540.0), ..DialogSpec::FORM };
|
||||
|
||||
/// Open Document Setup on the active document's settings.
|
||||
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||||
@@ -79,28 +79,28 @@ fn unit(d: &Dialog) -> Unit {
|
||||
|
||||
fn general(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let units: Vec<(&str, &str)> = Unit::ALL.iter().map(|u| (u.label(), u.label())).collect();
|
||||
choice(ui, d, "units", "Units:", &units);
|
||||
choice(ui, d, "units", tl!("Units:"), &units);
|
||||
ui.add_space(6.0);
|
||||
let unit = unit(d);
|
||||
widgets::label_row(ui, "Bleed:", LABEL, |ui| form::bleed(ui, d, unit, 64.0));
|
||||
widgets::label_row(ui, tl!("Bleed:"), LABEL, |ui| form::bleed(ui, d, unit, 64.0));
|
||||
ui.add_space(6.0);
|
||||
widgets::label_row(ui, "", LABEL, |ui| {
|
||||
// Applies the settings, then edits the artboards with the Artboard tool.
|
||||
if widgets::flat_button(ui, "Edit Artboards", 120.0).clicked() && confirm(app, d).is_ok() {
|
||||
if widgets::flat_button(ui, tl!("Edit Artboards"), 120.0).clicked() && confirm(app, d).is_ok() {
|
||||
app.select_tool("artboard");
|
||||
}
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
check(ui, d, "outlineImages", "Show Images in Outline Mode");
|
||||
check(ui, d, "highlightSubstitutedFonts", "Highlight Substituted Fonts");
|
||||
check(ui, d, "highlightSubstitutedGlyphs", "Highlight Substituted Glyphs");
|
||||
check(ui, d, "outlineImages", tl!("Show Images in Outline Mode"));
|
||||
check(ui, d, "highlightSubstitutedFonts", tl!("Highlight Substituted Fonts"));
|
||||
check(ui, d, "highlightSubstitutedGlyphs", tl!("Highlight Substituted Glyphs"));
|
||||
}
|
||||
|
||||
fn transparency(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
widgets::subheader(ui, "Transparency Grid");
|
||||
widgets::subheader(ui, tl!("Transparency Grid"));
|
||||
let sizes: Vec<(&str, &str)> = GridSize::ALL.iter().map(|g| (g.id(), g.label())).collect();
|
||||
choice(ui, d, "gridSize", "Grid Size:", &sizes);
|
||||
choice(ui, d, "gridSize", tl!("Grid Size:"), &sizes);
|
||||
let colors: Vec<String> = d
|
||||
.fields
|
||||
.get("gridColors")
|
||||
@@ -112,7 +112,7 @@ fn transparency(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
.find(|(_, pair)| colors.len() == 2 && colors.iter().zip(pair).all(|(h, c)| h.eq_ignore_ascii_case(&c.to_hex())))
|
||||
.map_or("Custom", |(n, _)| n);
|
||||
let names: Vec<&str> = GRID_COLOR_PRESETS.iter().map(|(n, _)| *n).chain(["Custom"]).collect();
|
||||
widgets::label_row(ui, "Grid Colors:", LABEL, |ui| {
|
||||
widgets::label_row(ui, tl!("Grid Colors:"), LABEL, |ui| {
|
||||
if let Some((_, pair)) = widgets::dropdown(ui, "gridColors", preset, &names, 220.0).and_then(|i| GRID_COLOR_PRESETS.get(i)) {
|
||||
d.fields.insert("gridColors".into(), json!(pair.map(|c| c.to_hex())));
|
||||
}
|
||||
@@ -136,27 +136,29 @@ fn transparency(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
check(ui, d, "simulatePaper", "Simulate Colored Paper");
|
||||
check(ui, d, "simulatePaper", tl!("Simulate Colored Paper"));
|
||||
ui.add_space(14.0);
|
||||
widgets::subheader(ui, "Export and Clipboard Transparency Flattener Settings");
|
||||
widgets::subheader(ui, tl!("Export and Clipboard Transparency Flattener Settings"));
|
||||
let presets: Vec<String> = app.session.flattener_presets().into_iter().map(|p| p.name).collect();
|
||||
let options: Vec<(&str, &str)> = presets.iter().map(|p| (p.as_str(), p.as_str())).collect();
|
||||
choice(ui, d, "flattenerPreset", "Preset:", &options);
|
||||
check(ui, d, "discardWhiteOverprint", "Discard White Overprint in Output");
|
||||
choice(ui, d, "flattenerPreset", tl!("Preset:"), &options);
|
||||
check(ui, d, "discardWhiteOverprint", tl!("Discard White Overprint in Output"));
|
||||
}
|
||||
|
||||
fn typography(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let langs: Vec<(&str, &str)> = LANGUAGES.iter().map(|(l, _)| (*l, *l)).collect();
|
||||
let before = d.str("language");
|
||||
choice(ui, d, "language", "Language:", &langs);
|
||||
choice(ui, d, "language", tl!("Language:"), &langs);
|
||||
// A language brings its quotes.
|
||||
if d.str("language") != before
|
||||
&& let Some(q) = vectorcraft_doc::setup::language_quotes(&d.str("language"))
|
||||
{
|
||||
d.fields.insert("quotes".into(), json!({"double": q.double.iter().collect::<String>(), "single": q.single.iter().collect::<String>()}));
|
||||
}
|
||||
for (key, label, choices) in [("double", "Double Quotes:", Quotes::DOUBLE_CHOICES), ("single", "Single Quotes:", Quotes::SINGLE_CHOICES)] {
|
||||
for (key, label, choices) in
|
||||
[("double", tl!("Double Quotes:"), Quotes::DOUBLE_CHOICES), ("single", tl!("Single Quotes:"), Quotes::SINGLE_CHOICES)]
|
||||
{
|
||||
let pairs: Vec<String> = choices.iter().map(|p| p.iter().collect()).collect();
|
||||
let labels: Vec<&str> = pairs.iter().map(String::as_str).collect();
|
||||
let cur = d.fields.get("quotes").and_then(|q| q.get(key)).and_then(Value::as_str).unwrap_or("").to_string();
|
||||
@@ -168,16 +170,16 @@ fn typography(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
}
|
||||
});
|
||||
}
|
||||
check(ui, d, "typographersQuotes", "Use Typographer's Quotes");
|
||||
check(ui, d, "typographersQuotes", tl!("Use Typographer's Quotes"));
|
||||
ui.add_space(14.0);
|
||||
widgets::subheader(ui, "Options");
|
||||
widgets::subheader(ui, tl!("Options"));
|
||||
egui::Grid::new("docsetup-scripts").num_columns(3).spacing([16.0, 6.0]).show(ui, |ui| {
|
||||
ui.label("");
|
||||
for h in ["Size", "Position"] {
|
||||
for h in [tl!("Size"), tl!("Position")] {
|
||||
ui.label(egui::RichText::new(h).size(11.0).color(t.text_dim));
|
||||
}
|
||||
ui.end_row();
|
||||
for (key, label) in [("superscript", "Superscript:"), ("subscript", "Subscript:")] {
|
||||
for (key, label) in [("superscript", tl!("Superscript:")), ("subscript", tl!("Subscript:"))] {
|
||||
ui.label(egui::RichText::new(label).color(t.text));
|
||||
for part in ["size", "position"] {
|
||||
let v = d.fields.get(key).and_then(|m| m.get(part)).and_then(Value::as_f64).unwrap_or(0.0);
|
||||
@@ -189,14 +191,14 @@ fn typography(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
ui.label(egui::RichText::new("Small Caps:").color(t.text));
|
||||
ui.label(egui::RichText::new(tl!("Small Caps:")).color(t.text));
|
||||
if let Some(n) = widgets::plain_field(ui, "smallCaps", d.f64("smallCapsSize", 70.0), "%", 1, 70.0) {
|
||||
d.fields.insert("smallCapsSize".into(), json!(n));
|
||||
}
|
||||
ui.end_row();
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
choice(ui, d, "exportText", "Export:", &[("editable", "Preserve Text Editability"), ("appearance", "Preserve Text Appearance")]);
|
||||
choice(ui, d, "exportText", tl!("Export:"), &[("editable", tl!("Preserve Text Editability")), ("appearance", tl!("Preserve Text Appearance"))]);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -22,7 +22,7 @@ use crate::{VectorcraftApp, io, widgets};
|
||||
pub const KIND: &str = "dxfImport";
|
||||
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec {
|
||||
heading: |d| if d.str("mode") == "place" { "Place DXF" } else { "DXF Import Options" }.into(),
|
||||
heading: |d| if d.str("mode") == "place" { tl!("Place DXF") } else { tl!("DXF Import Options") }.into(),
|
||||
body,
|
||||
confirm,
|
||||
min_width: 560.0,
|
||||
@@ -141,19 +141,19 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let layouts: Vec<String> =
|
||||
d.fields.get("layouts").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect();
|
||||
let rows: Vec<(&str, &str)> = layouts.iter().map(|l| (l.as_str(), l.as_str())).collect();
|
||||
choice(ui, d, "layout", "Layout:", &rows);
|
||||
choice(ui, d, "layout", tl!("Layout:"), &rows);
|
||||
ui.add_space(10.0);
|
||||
widgets::subheader(ui, "Scaling");
|
||||
check(ui, d, "fit", "Scale to Fit Artboard");
|
||||
widgets::subheader(ui, tl!("Scaling"));
|
||||
check(ui, d, "fit", tl!("Scale to Fit Artboard"));
|
||||
ui.add_enabled_ui(!d.bool("fit"), |ui| super::dxf_options::scale_row(ui, d));
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(LABEL + 8.0);
|
||||
check(ui, d, "scaleLineweights", "Scale Lineweights");
|
||||
check(ui, d, "scaleLineweights", tl!("Scale Lineweights"));
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
widgets::subheader(ui, "Options");
|
||||
check(ui, d, "center", "Center Artwork");
|
||||
check(ui, d, "mergeLayers", "Merge Layers");
|
||||
widgets::subheader(ui, tl!("Options"));
|
||||
check(ui, d, "center", tl!("Center Artwork"));
|
||||
check(ui, d, "mergeLayers", tl!("Merge Layers"));
|
||||
});
|
||||
});
|
||||
false
|
||||
|
||||
@@ -18,8 +18,15 @@ use crate::{VectorcraftApp, io, widgets};
|
||||
|
||||
pub(super) const KIND: &str = "dxfOptions";
|
||||
|
||||
pub(super) const SPEC: DialogSpec =
|
||||
DialogSpec { heading: |_| "DXF Options".into(), body, confirm, ok: Some("Export"), min_width: 420.0, max_width: Some(460.0), ..DialogSpec::FORM };
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec {
|
||||
heading: |_| tl!("DXF Options").into(),
|
||||
body,
|
||||
confirm,
|
||||
ok: Some("Export"),
|
||||
min_width: 420.0,
|
||||
max_width: Some(460.0),
|
||||
..DialogSpec::FORM
|
||||
};
|
||||
|
||||
/// Fields that are not remembered between exports (they belong to one export).
|
||||
const PER_EXPORT: [&str; 7] = ["format", "path", "useArtboards", "range", "artboard", "artboards", "selectedOnly"];
|
||||
@@ -59,24 +66,24 @@ pub fn open(app: &mut VectorcraftApp, params: &Value) {
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let versions: Vec<(&str, &str)> = DxfVersion::ALL.iter().map(|v| (v.id(), v.label())).collect();
|
||||
choice(ui, d, "version", "Version:", &versions);
|
||||
choice(ui, d, "version", tl!("Version:"), &versions);
|
||||
ui.add_space(4.0);
|
||||
scale_row(ui, d);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(LABEL + 8.0);
|
||||
check(ui, d, "scaleLineweights", "Scale Lineweights");
|
||||
check(ui, d, "scaleLineweights", tl!("Scale Lineweights"));
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
let depths: Vec<(&str, &str)> = ColorDepth::ALL.iter().map(|c| (c.id(), c.label())).collect();
|
||||
choice(ui, d, "colors", "Number of Colors:", &depths);
|
||||
choice(ui, d, "colors", tl!("Number of Colors:"), &depths);
|
||||
let version = DxfVersion::from_id(&d.str("version")).unwrap_or_default();
|
||||
if ColorDepth::from_id(&d.str("colors")) == Some(ColorDepth::True) && version < DxfVersion::R2004 {
|
||||
note(ui, "True colour needs version 2004 or later: the nearest of 256 colours is written.");
|
||||
note(ui, tl!("True colour needs version 2004 or later: the nearest of 256 colours is written."));
|
||||
}
|
||||
let rasters: Vec<(&str, &str)> = RasterFormat::ALL.iter().map(|r| (r.id(), r.label())).collect();
|
||||
choice(ui, d, "rasterFormat", "Raster File Format:", &rasters);
|
||||
choice(ui, d, "rasterFormat", tl!("Raster File Format:"), &rasters);
|
||||
ui.add_space(10.0);
|
||||
widgets::subheader(ui, "Export Option");
|
||||
widgets::subheader(ui, tl!("Export Option"));
|
||||
let preserve = Preserve::from_id(&d.str("preserve")).unwrap_or_default();
|
||||
ui.horizontal(|ui| {
|
||||
for p in Preserve::ALL {
|
||||
@@ -88,21 +95,21 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
let selection = app.session.active().is_some_and(|st| !st.selection.is_empty());
|
||||
if widgets::check(ui, "Export Selected Art Only", d.bool("selectedOnly") && selection, selection) {
|
||||
if widgets::check(ui, tl!("Export Selected Art Only"), d.bool("selectedOnly") && selection, selection) {
|
||||
d.fields.insert("selectedOnly".into(), json!(!d.bool("selectedOnly")));
|
||||
}
|
||||
check(ui, d, "alterPaths", "Alter Paths for Appearance");
|
||||
check(ui, d, "alterPaths", tl!("Alter Paths for Appearance"));
|
||||
// Preserve Appearance always outlines type.
|
||||
let appearance = preserve == Preserve::Appearance;
|
||||
if widgets::check(ui, "Outline Text", appearance || d.bool("outlineText"), !appearance) {
|
||||
if widgets::check(ui, tl!("Outline Text"), appearance || d.bool("outlineText"), !appearance) {
|
||||
d.fields.insert("outlineText".into(), json!(!d.bool("outlineText")));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
super::form::caption(
|
||||
ui,
|
||||
match preserve {
|
||||
Preserve::Appearance => "Type becomes outlines, and strokes a CAD line can't draw become filled shapes.",
|
||||
Preserve::Editability => "Type stays text, and every stroke is a line with its lineweight and dashes.",
|
||||
Preserve::Appearance => tl!("Type becomes outlines, and strokes a CAD line can't draw become filled shapes."),
|
||||
Preserve::Editability => tl!("Type stays text, and every stroke is a line with its lineweight and dashes."),
|
||||
},
|
||||
);
|
||||
false
|
||||
@@ -113,7 +120,7 @@ pub(super) fn scale_row(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
let units: Vec<&str> = Unit::ALL.iter().filter(|u| **u != Unit::FeetInches).map(|u| u.label()).collect();
|
||||
let unit = Unit::named(&d.str("unit")).unwrap_or(Unit::Millimeters);
|
||||
widgets::label_row(ui, "Scale:", LABEL, |ui| {
|
||||
widgets::label_row(ui, tl!("Scale:"), LABEL, |ui| {
|
||||
ui.label(egui::RichText::new("1").color(t.text));
|
||||
if let Some(u) = widgets::dropdown(ui, "dxf-unit", unit.label(), &units, 120.0).and_then(|i| units.get(i)) {
|
||||
d.fields.insert("unit".into(), json!(u));
|
||||
@@ -124,7 +131,7 @@ pub(super) fn scale_row(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let (lo, hi) = (*vectorcraft_cad::SCALE_RANGE.start(), *vectorcraft_cad::SCALE_RANGE.end());
|
||||
d.fields.insert("scale".into(), json!(v.clamp(lo, hi)));
|
||||
}
|
||||
ui.label(egui::RichText::new("Units").color(t.text));
|
||||
ui.label(egui::RichText::new(tl!("Units")).color(t.text));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,19 @@ use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{VectorcraftApp, widgets};
|
||||
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| d.str("__label"), body, confirm, preview: true, ..DialogSpec::FORM };
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| heading_label(d), body, confirm, preview: true, ..DialogSpec::FORM };
|
||||
|
||||
/// The effect's name (`__label`, English) in the UI language: the catalog may carry it with or
|
||||
/// without its trailing ellipsis. A plug-in effect's name is shown as it is.
|
||||
fn heading_label(d: &Dialog) -> String {
|
||||
let label = d.str("__label");
|
||||
if vectorcraft_plugins::effect::plugin_id(&d.str("__effect")).is_some() {
|
||||
return label;
|
||||
}
|
||||
let dotted = format!("{label}…");
|
||||
let shown = tl!(&dotted);
|
||||
if shown != dotted { shown.trim_end_matches('…').to_string() } else { tl!(&label).to_string() }
|
||||
}
|
||||
|
||||
/// The dialog kind asking whether to edit an effect that is already applied or add another.
|
||||
/// Fields: `__effect`, `__label`, `__item?` (as given to `effect.dialog`), `__target` and
|
||||
@@ -26,9 +38,9 @@ pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| d.str("__label"),
|
||||
pub const EXISTS: &str = "effectExists";
|
||||
|
||||
pub(super) const EXISTS_SPEC: DialogSpec = DialogSpec {
|
||||
heading: |d| format!("{} is already applied", d.str("__label")),
|
||||
heading: |d| crate::i18n::fmt(tl!("{effect} is already applied"), &[("effect", &heading_label(d))]),
|
||||
body: |_, ui, _| {
|
||||
ui.label(egui::RichText::new("Edit the applied effect, or add another one?").color(Tokens::get(ui.ctx()).text_dim));
|
||||
ui.label(egui::RichText::new(tl!("Edit the applied effect, or add another one?")).color(Tokens::get(ui.ctx()).text_dim));
|
||||
false
|
||||
},
|
||||
confirm: confirm_exists,
|
||||
@@ -123,7 +135,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
};
|
||||
ui.add_space(6.0);
|
||||
let mut pv = d.bool("preview");
|
||||
let pv_changed = ui.checkbox(&mut pv, "Preview").changed();
|
||||
let pv_changed = ui.checkbox(&mut pv, tl!("Preview")).changed();
|
||||
d.fields.insert("preview".into(), json!(pv));
|
||||
if pv && (changed || pv_changed || !app.session.in_interaction()) {
|
||||
let label = d.str("__label");
|
||||
@@ -189,7 +201,14 @@ fn adjust_fields(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
form::slider_w(ui, d, (key, label, LABEL_W), *min..=*max, "", rail);
|
||||
}
|
||||
if d.fields.contains_key("channel") {
|
||||
form::choice(ui, d, "channel", "Channel", (LABEL_W, 120.0), &[("rgb", "RGB"), ("red", "Red"), ("green", "Green"), ("blue", "Blue")]);
|
||||
form::choice(
|
||||
ui,
|
||||
d,
|
||||
"channel",
|
||||
tl!("Channel"),
|
||||
(LABEL_W, 120.0),
|
||||
&[("rgb", "RGB"), ("red", tl!("Red")), ("green", tl!("Green")), ("blue", tl!("Blue"))],
|
||||
);
|
||||
}
|
||||
if d.fields.contains_key("points") {
|
||||
ui.horizontal(|ui| {
|
||||
@@ -197,7 +216,7 @@ fn adjust_fields(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
curve_graph(ui, d);
|
||||
});
|
||||
}
|
||||
for (key, label, width) in [("points", "Points", 220.0), ("color", "Color", 120.0)] {
|
||||
for (key, label, width) in [("points", tl!("Points"), 220.0), ("color", tl!("Color"), 120.0)] {
|
||||
if d.fields.contains_key(key) {
|
||||
widgets::label_row(ui, label, LABEL_W, |ui| {
|
||||
form::text(ui, d, key, width);
|
||||
@@ -205,14 +224,14 @@ fn adjust_fields(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
}
|
||||
if d.fields.contains_key("gamma") {
|
||||
widgets::label_row(ui, "Gamma", LABEL_W, |ui| {
|
||||
widgets::label_row(ui, tl!("Gamma"), LABEL_W, |ui| {
|
||||
let mut g = d.f64("gamma", 1.0);
|
||||
if ui.add(egui::DragValue::new(&mut g).speed(0.01).range(0.1..=10.0).max_decimals(2)).changed() {
|
||||
d.fields.insert("gamma".into(), json!(g));
|
||||
}
|
||||
});
|
||||
}
|
||||
for (key, label) in [("colorize", "Colorize"), ("preserveLightness", "Preserve Lightness")] {
|
||||
for (key, label) in [("colorize", tl!("Colorize")), ("preserveLightness", tl!("Preserve Lightness"))] {
|
||||
if d.fields.contains_key(key) {
|
||||
form::check(ui, d, key, label);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, preview
|
||||
|
||||
fn heading(d: &Dialog) -> String {
|
||||
match d.kind.as_str() {
|
||||
WARP => "Warp Options",
|
||||
MESH if d.bool("reset") => "Reset Envelope Mesh",
|
||||
MESH => "Envelope Mesh",
|
||||
_ => "Envelope Options",
|
||||
WARP => tl!("Warp Options"),
|
||||
MESH if d.bool("reset") => tl!("Reset Envelope Mesh"),
|
||||
MESH => tl!("Envelope Mesh"),
|
||||
_ => tl!("Envelope Options"),
|
||||
}
|
||||
.into()
|
||||
}
|
||||
@@ -143,44 +143,44 @@ fn count(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
|
||||
|
||||
fn warp_body(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let styles: Vec<(&str, &str)> = WARP_STYLES.iter().map(|(id, label)| (*id, label.trim_end_matches('…'))).collect();
|
||||
form::choice(ui, d, "style", "Style:", (LABEL_W, 160.0), &styles);
|
||||
form::choice(ui, d, "style", tl!("Style:"), (LABEL_W, 160.0), &styles);
|
||||
let horizontal = d.bool("horizontal");
|
||||
widgets::label_row(ui, "", LABEL_W, |ui| {
|
||||
for (h, label) in [(true, "Horizontal"), (false, "Vertical")] {
|
||||
for (h, label) in [(true, tl!("Horizontal")), (false, tl!("Vertical"))] {
|
||||
if widgets::radio(ui, label, horizontal == h, true) {
|
||||
set(d, "horizontal", json!(h));
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
}
|
||||
});
|
||||
percent(ui, d, "bend", "Bend:");
|
||||
percent(ui, d, "bend", tl!("Bend:"));
|
||||
ui.add_space(6.0);
|
||||
widgets::subheader(ui, "Distortion");
|
||||
percent(ui, d, "h", "Horizontal:");
|
||||
percent(ui, d, "v", "Vertical:");
|
||||
widgets::subheader(ui, tl!("Distortion"));
|
||||
percent(ui, d, "h", tl!("Horizontal:"));
|
||||
percent(ui, d, "v", tl!("Vertical:"));
|
||||
}
|
||||
|
||||
fn mesh_body(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
count(ui, d, "rows", "Rows:");
|
||||
count(ui, d, "cols", "Columns:");
|
||||
count(ui, d, "rows", tl!("Rows:"));
|
||||
count(ui, d, "cols", tl!("Columns:"));
|
||||
if d.bool("reset") {
|
||||
ui.add_space(4.0);
|
||||
form::check(ui, d, "maintainShape", "Maintain Envelope Shape");
|
||||
form::check(ui, d, "maintainShape", tl!("Maintain Envelope Shape"));
|
||||
}
|
||||
}
|
||||
|
||||
fn options_body(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
widgets::subheader(ui, "Rasters");
|
||||
widgets::subheader(ui, tl!("Rasters"));
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(12.0);
|
||||
form::check(ui, d, "antiAlias", "Anti-Alias");
|
||||
form::check(ui, d, "antiAlias", tl!("Anti-Alias"));
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
widgets::subheader(ui, "Preserve Shape Using:");
|
||||
widgets::subheader(ui, tl!("Preserve Shape Using:"));
|
||||
let clip = d.str("preserveShape") != "transparency";
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(12.0);
|
||||
for (value, label) in [("clippingMask", "Clipping Mask"), ("transparency", "Transparency")] {
|
||||
for (value, label) in [("clippingMask", tl!("Clipping Mask")), ("transparency", tl!("Transparency"))] {
|
||||
if widgets::radio(ui, label, clip == (value == "clippingMask"), true) {
|
||||
set(d, "preserveShape", json!(value));
|
||||
}
|
||||
@@ -189,12 +189,12 @@ fn options_body(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
let rail = Tokens::get(ui.ctx()).input_border;
|
||||
form::slider_w(ui, d, ("fidelity", "Fidelity:", LABEL_W), 0.0..=100.0, "", &move |_| rail);
|
||||
form::slider_w(ui, d, ("fidelity", tl!("Fidelity:"), LABEL_W), 0.0..=100.0, "", &move |_| rail);
|
||||
ui.add_space(4.0);
|
||||
form::check(ui, d, "distortAppearance", "Distort Appearance");
|
||||
form::check(ui, d, "distortAppearance", tl!("Distort Appearance"));
|
||||
// Gradients and patterns bend only with the appearance.
|
||||
let appearance = d.bool("distortAppearance");
|
||||
for (key, label) in [("distortLinearGradients", "Distort Linear Gradients"), ("distortPatternFills", "Distort Pattern Fills")] {
|
||||
for (key, label) in [("distortLinearGradients", tl!("Distort Linear Gradients")), ("distortPatternFills", tl!("Distort Pattern Fills"))] {
|
||||
let on = d.bool(key);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(20.0);
|
||||
@@ -256,7 +256,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
|
||||
let Some((editing, bar)) = bar_of(app) else { return };
|
||||
let mut run: Option<(&str, Value)> = None;
|
||||
ui.separator();
|
||||
for (contents, icon, tip) in [(false, "dc-mesh", "Edit Envelope"), (true, "shapes", "Edit Contents")] {
|
||||
for (contents, icon, tip) in [(false, "dc-mesh", tl!("Edit Envelope")), (true, "shapes", tl!("Edit Contents"))] {
|
||||
if widgets::icon_button(ui, icon, tip, editing == contents, 24.0).clicked() && editing != contents {
|
||||
run = Some(("object.envelope.editContents", json!({ "editing": contents })));
|
||||
}
|
||||
@@ -270,23 +270,23 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
|
||||
Bar::Warp { style, bend, h, v, horizontal } => {
|
||||
let styles: Vec<&str> = WARP_STYLES.iter().map(|(_, l)| l.trim_end_matches('…')).collect();
|
||||
let current = WARP_STYLES.iter().find(|(id, _)| id == style).map_or(style.as_str(), |(_, l)| l.trim_end_matches('…'));
|
||||
widgets::dim_label(ui, "Style:");
|
||||
widgets::dim_label(ui, tl!("Style:"));
|
||||
if let Some((id, _)) = widgets::dropdown(ui, "cb-envelope-style", current, &styles, 110.0).and_then(|i| WARP_STYLES.get(i)) {
|
||||
run = Some((OPTIONS_CMD, json!({ "style": id })));
|
||||
}
|
||||
for (h, label) in [(true, "Horizontal"), (false, "Vertical")] {
|
||||
for (h, label) in [(true, tl!("Horizontal")), (false, tl!("Vertical"))] {
|
||||
if widgets::radio(ui, label, *horizontal == h, true) {
|
||||
run = Some((OPTIONS_CMD, json!({ "horizontal": h })));
|
||||
}
|
||||
}
|
||||
for (key, label, value) in [("bend", "Bend:", *bend), ("h", "H:", *h), ("v", "V:", *v)] {
|
||||
for (key, label, value) in [("bend", tl!("Bend:"), *bend), ("h", tl!("H:"), *h), ("v", tl!("V:"), *v)] {
|
||||
if let Some(n) = field(ui, key, label, value, "%") {
|
||||
run = Some((OPTIONS_CMD, json!({ key: n.round().clamp(-100.0, 100.0) })));
|
||||
}
|
||||
}
|
||||
}
|
||||
Bar::Mesh { rows, cols } => {
|
||||
for (key, label, value) in [("rows", "Rows:", *rows), ("cols", "Columns:", *cols)] {
|
||||
for (key, label, value) in [("rows", tl!("Rows:"), *rows), ("cols", tl!("Columns:"), *cols)] {
|
||||
if let Some(n) = field(ui, key, label, f64::from(value), "") {
|
||||
run = Some((RESET_MESH, json!({ key: n.round().clamp(1.0, 50.0), "maintainShape": true })));
|
||||
}
|
||||
@@ -301,11 +301,11 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
|
||||
Bar::TopObject => None,
|
||||
};
|
||||
if let Some(r) = reset
|
||||
&& widgets::flat_button(ui, "Reset", 50.0).on_hover_text("Reset Envelope Shape").clicked()
|
||||
&& widgets::flat_button(ui, tl!("Reset"), 50.0).on_hover_text(tl!("Reset Envelope Shape")).clicked()
|
||||
{
|
||||
run = Some(r);
|
||||
}
|
||||
if widgets::icon_button(ui, "dc-options", "Envelope Options", false, 24.0).clicked() {
|
||||
if widgets::icon_button(ui, "dc-options", tl!("Envelope Options"), false, 24.0).clicked() {
|
||||
crate::menus::invoke(app, OPTIONS_CMD, json!({}));
|
||||
}
|
||||
if let Some((cmd, p)) = run
|
||||
|
||||
@@ -18,8 +18,15 @@ use crate::{VectorcraftApp, io, widgets};
|
||||
|
||||
pub(super) const KIND: &str = "epsOptions";
|
||||
|
||||
pub(super) const SPEC: DialogSpec =
|
||||
DialogSpec { heading: |_| "EPS Options".into(), body, confirm, ok: Some("Export"), min_width: 420.0, max_width: Some(460.0), ..DialogSpec::FORM };
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec {
|
||||
heading: |_| tl!("EPS Options").into(),
|
||||
body,
|
||||
confirm,
|
||||
ok: Some("Export"),
|
||||
min_width: 420.0,
|
||||
max_width: Some(460.0),
|
||||
..DialogSpec::FORM
|
||||
};
|
||||
|
||||
/// Fields that are not remembered between exports (they belong to one export).
|
||||
const PER_EXPORT: [&str; 7] = ["format", "path", "useArtboards", "range", "artboard", "artboards", "selectedOnly"];
|
||||
@@ -62,15 +69,15 @@ pub fn open(app: &mut VectorcraftApp, params: &Value) {
|
||||
}
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
widgets::subheader(ui, "Preview");
|
||||
widgets::subheader(ui, tl!("Preview"));
|
||||
let previews: Vec<(&str, &str)> = Preview::ALL.iter().map(|p| (p.id(), p.label())).collect();
|
||||
choice(ui, d, "previewFormat", "Format:", &previews);
|
||||
choice(ui, d, "previewFormat", tl!("Format:"), &previews);
|
||||
// Only the colour preview can keep transparency.
|
||||
let color = Preview::from_id(&d.str("previewFormat")) == Some(Preview::TiffColor);
|
||||
let transparent = d.bool("transparentPreview");
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(LABEL + 8.0);
|
||||
for (label, on) in [("Transparent", true), ("Opaque", false)] {
|
||||
for (label, on) in [(tl!("Transparent"), true), (tl!("Opaque"), false)] {
|
||||
if widgets::radio(ui, label, color && transparent == on, color) {
|
||||
d.fields.insert("transparentPreview".into(), json!(on));
|
||||
}
|
||||
@@ -78,22 +85,22 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
widgets::subheader(ui, "Transparency");
|
||||
widgets::subheader(ui, tl!("Transparency"));
|
||||
let overprints: Vec<(&str, &str)> = Overprint::ALL.iter().map(|o| (o.id(), o.label())).collect();
|
||||
choice(ui, d, "overprints", "Overprints:", &overprints);
|
||||
choice(ui, d, "overprints", tl!("Overprints:"), &overprints);
|
||||
let presets = app.session.flattener_presets();
|
||||
let names: Vec<(&str, &str)> = presets.iter().map(|p| (p.name.as_str(), p.name.as_str())).collect();
|
||||
choice(ui, d, "flattenerPreset", "Preset:", &names);
|
||||
choice(ui, d, "flattenerPreset", tl!("Preset:"), &names);
|
||||
ui.add_space(8.0);
|
||||
widgets::subheader(ui, "Fonts");
|
||||
widgets::subheader(ui, tl!("Fonts"));
|
||||
// Type is written as glyph outlines, which need no fonts.
|
||||
widgets::check(ui, "Embed Fonts (for other applications)", true, false);
|
||||
widgets::check(ui, tl!("Embed Fonts (for other applications)"), true, false);
|
||||
ui.add_space(8.0);
|
||||
widgets::subheader(ui, "Options");
|
||||
check(ui, d, "includeLinkedFiles", "Include Linked Files");
|
||||
check(ui, d, "thumbnails", "Include Document Thumbnails");
|
||||
check(ui, d, "cmykPostScript", "Include CMYK PostScript in RGB Files");
|
||||
check(ui, d, "compatibleGradients", "Compatible Gradient and Gradient Mesh Printing");
|
||||
widgets::subheader(ui, tl!("Options"));
|
||||
check(ui, d, "includeLinkedFiles", tl!("Include Linked Files"));
|
||||
check(ui, d, "thumbnails", tl!("Include Document Thumbnails"));
|
||||
check(ui, d, "cmykPostScript", tl!("Include CMYK PostScript in RGB Files"));
|
||||
check(ui, d, "compatibleGradients", tl!("Compatible Gradient and Gradient Mesh Printing"));
|
||||
ui.add_space(4.0);
|
||||
let levels: Vec<(&str, &str)> = Level::ALL.iter().map(|l| (l.id(), l.label())).collect();
|
||||
// A level set as a number (`level: 2`) reads as its id.
|
||||
@@ -101,14 +108,14 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let id = n.to_string();
|
||||
d.fields.insert("level".into(), json!(id));
|
||||
}
|
||||
choice(ui, d, "level", "PostScript:", &levels);
|
||||
choice(ui, d, "level", tl!("PostScript:"), &levels);
|
||||
ui.add_space(8.0);
|
||||
super::form::caption(
|
||||
ui,
|
||||
if Level::from_id(&d.str("level")) == Some(Level::Two) || d.bool("compatibleGradients") {
|
||||
"Transparency is flattened with the preset. Type is written as outlines; gradients as bands of colour."
|
||||
tl!("Transparency is flattened with the preset. Type is written as outlines; gradients as bands of colour.")
|
||||
} else {
|
||||
"Transparency is flattened with the preset. Type is written as outlines; gradients stay smooth."
|
||||
tl!("Transparency is flattened with the preset. Type is written as outlines; gradients stay smooth.")
|
||||
},
|
||||
);
|
||||
false
|
||||
|
||||
Loaded 100 of 224 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user