mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-16 12:16:54 -04:00
Compare commits
118
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bc6532928 | ||
|
|
4791c88809 | ||
|
|
06b33b0d2d | ||
|
|
4664907fdc | ||
|
|
35b9d5a645 | ||
|
|
18b472092e | ||
|
|
32a3362e7b | ||
|
|
4bb469563b | ||
|
|
d59733937a | ||
|
|
1799fa533e | ||
|
|
b32017d44f | ||
|
|
0c9b65c931 | ||
|
|
2d230dc31a | ||
|
|
c06c794e5a | ||
|
|
143e4d78e7 | ||
|
|
cb3915d230 | ||
|
|
93913c7906 | ||
|
|
c459285f42 | ||
|
|
e31a39ec40 | ||
|
|
36deb67f20 | ||
|
|
fec2ac402b | ||
|
|
8593c15b90 | ||
|
|
6d4ad17954 | ||
|
|
c401df19d0 | ||
|
|
743801b255 | ||
|
|
f39b01174c | ||
|
|
0f786df95e | ||
|
|
4ee4bff9b6 | ||
|
|
233ad71bd6 | ||
|
|
0e97cf1d4b | ||
|
|
830dea80e0 | ||
|
|
6fc79c33b7 | ||
|
|
24583effe0 | ||
|
|
d21040d746 | ||
|
|
9e44139641 | ||
|
|
314bfe802f | ||
|
|
b795d15d69 | ||
|
|
05b160b32e | ||
|
|
84bd59de36 | ||
|
|
065e237f7f | ||
|
|
adea2d307d | ||
|
|
dfe215ca30 | ||
|
|
bc8e42d8c7 | ||
|
|
d973805269 | ||
|
|
56acb8d3b6 | ||
|
|
0ca6503f09 | ||
|
|
ca0673e0bc | ||
|
|
917dcdeeef | ||
|
|
25d348f391 | ||
|
|
403b335039 | ||
|
|
ddfd08f321 | ||
|
|
f0d3f81f3b | ||
|
|
3b683bbeec | ||
|
|
5527418234 | ||
|
|
38dd835af6 | ||
|
|
2269a58887 | ||
|
|
7bbb63e4bc | ||
|
|
6b06cba32d | ||
|
|
6af7769f08 | ||
|
|
8c21a7710d | ||
|
|
84e2da4bdd | ||
|
|
878d1f231a | ||
|
|
012395d49d | ||
|
|
ef32703362 | ||
|
|
156b492119 | ||
|
|
236e908dc9 | ||
|
|
6dc14c30a9 | ||
|
|
eb50f50599 | ||
|
|
700df5dbe4 | ||
|
|
7fee2c762f | ||
|
|
6cdd63638f | ||
|
|
2df4fede2a | ||
|
|
ee1203d463 | ||
|
|
98a988eaf4 | ||
|
|
e69b25faa6 | ||
|
|
c69463782a | ||
|
|
07e7d14f43 | ||
|
|
17736b6fe3 | ||
|
|
d12f75afcd | ||
|
|
5e01868020 | ||
|
|
6f129beb86 | ||
|
|
a282e4d2bf | ||
|
|
cafe3d658c | ||
|
|
9493e6f91d | ||
|
|
5cb8b1ba07 | ||
|
|
21b4e70150 | ||
|
|
6dfdf643d9 | ||
|
|
ad1e8d1112 | ||
|
|
f46b0f7dc4 | ||
|
|
cc3299dc0f | ||
|
|
467c47b719 | ||
|
|
03ffe790be | ||
|
|
ebc2857549 | ||
|
|
fb13a1e1f1 | ||
|
|
d049e69b95 | ||
|
|
288037543e | ||
|
|
f554c699a1 | ||
|
|
9336111655 | ||
|
|
bb66070841 | ||
|
|
f88242f576 | ||
|
|
b9069e761e | ||
|
|
88088e99bb | ||
|
|
54a8d60dae | ||
|
|
a3adaca19e | ||
|
|
837d2b5667 | ||
|
|
04806a98bf | ||
|
|
c91405dbea | ||
|
|
80b7aee756 | ||
|
|
d838af024b | ||
|
|
e510190867 | ||
|
|
ee6cd6de55 | ||
|
|
f14f8d8eb7 | ||
|
|
3b00334c41 | ||
|
|
e9edc49977 | ||
|
|
f9092edfe3 | ||
|
|
54e43d0a97 | ||
|
|
c5e38a309c | ||
|
|
842656f240 |
No files matched your search
@@ -36,47 +36,4 @@ jobs:
|
||||
with:
|
||||
require-craft-fonts: true
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
|
||||
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Japanese, Chinese, Arabic and Thai 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
path: craft-fonts
|
||||
- name: Package in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
|
||||
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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: freebsd-package
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
run-package-check: true
|
||||
@@ -18,16 +18,6 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
eval:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
|
||||
- name: nix flake check --no-build
|
||||
run: |
|
||||
if [ -f flake.nix ]; then
|
||||
nix flake check --no-build --all-systems
|
||||
else
|
||||
echo "No flake.nix present in this repository; skipping check."
|
||||
fi
|
||||
nix:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
|
||||
Generated
+28
-28
@@ -4507,7 +4507,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"block2 0.6.2",
|
||||
@@ -4542,7 +4542,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-affinity"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4553,7 +4553,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-brush"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
@@ -4565,7 +4565,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cad"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4581,7 +4581,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cli"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"objc2-core-foundation",
|
||||
@@ -4603,7 +4603,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-color"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"moxcms",
|
||||
@@ -4615,7 +4615,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-doc"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"jpeg-encoder",
|
||||
@@ -4632,7 +4632,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-effects"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4648,7 +4648,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-engine"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4684,7 +4684,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4705,7 +4705,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-format"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
@@ -4720,7 +4720,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
@@ -4732,7 +4732,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-mcp"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4751,7 +4751,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-metafile"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4765,7 +4765,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"linesweeper",
|
||||
@@ -4777,7 +4777,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pdf"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"craft-pdf",
|
||||
@@ -4807,7 +4807,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-plugins"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4821,7 +4821,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-render"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"gif",
|
||||
@@ -4848,14 +4848,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-scriptcheck"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-svg"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4879,7 +4879,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-testkit"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4896,7 +4896,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-text"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"icu_segmenter",
|
||||
@@ -4916,7 +4916,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-three-d"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
@@ -4924,7 +4924,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-tools"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4940,7 +4940,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-trace"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4952,7 +4952,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-ui-egui"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
@@ -4986,7 +4986,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-web"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -6096,7 +6096,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.11.0"
|
||||
version = "0.12.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.11.0"
|
||||
version = "0.12.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -245,7 +245,7 @@ The scores are self-assessed from our code against Illustrator's public document
|
||||
- reliable exchange of Illustrator's own `.ai` files (layer structure in real files; Illustrator's editing data isn't written);
|
||||
- an interaction-fidelity pass covering every tool's modifiers and small behaviours;
|
||||
- 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials);
|
||||
- about half of the Photoshop-style raster effects (28 of 57 still missing; Film Grain is available under Artistic) and the Effect Gallery;
|
||||
- about half of the Photoshop-style raster effects (27 of 57 still missing; Film Grain is available under Artistic, Halftone Pattern under Sketch) and the Effect Gallery;
|
||||
- CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface);
|
||||
- scripting, and the rest of Variables (image and graph kinds, dataset import);
|
||||
- generative AI features.
|
||||
|
||||
+3
-2
@@ -29,7 +29,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
|
||||
|
||||
| | Value | Kind |
|
||||
|---|---|---|
|
||||
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 44/50, Photoshop effects 29/57, 3D 4/4 (initial types, no materials) |
|
||||
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 44/50, Photoshop effects 30/57, 3D 4/4 (initial types, no materials) |
|
||||
| **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated |
|
||||
| **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated |
|
||||
| **To full parity** | **~360–590 h** one agent · ~95–165 h with 4–6 agents | estimated |
|
||||
@@ -80,7 +80,7 @@ The dimension hours overlap (feature rows hold some UI, format and AI work), so
|
||||
| 3D and Materials | ~25% | 30–55 |
|
||||
| Type core — [type-parity.md](docs/type-parity.md) | 80% | 14–19 |
|
||||
| Type advanced (CJK, spelling, Touch Type) | 50% | 20–28 |
|
||||
| Symbols, blends, envelopes, Repeat, perspective | 85% | 6–10 |
|
||||
| Symbols, blends, envelopes, Repeat, perspective | 87% | 5–9 |
|
||||
| Image Trace, graphs, image tools | 75% | 5–8 |
|
||||
| Layers, artboards, document setup | 78% | 5–8 |
|
||||
| View & navigation | 70% | 9–14 |
|
||||
@@ -134,6 +134,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-16 | Symbol Options › Export Type (Graphic or Movie Clip, default Movie Clip) with Registration; `symbol.options` / `symbol.list` (G5).
|
||||
| 2026-10-16 | About 160 community PRs landed, each through the full gate. Crashes and hostile files: damaged PDF dictionaries and arrays no longer crash import (#2133, #2240, #2244, #2271), area type with huge row or column counts opens (#2109), hostile mesh edges are bounded (#2051). New: Dimension tool (#1788), legacy version 7 `.ai` save (#1794), Object › Intertwine (#2095), Apple Pencil and touch input on the web (#1740), Sample Color from Screen (#2257), Drop Shadow Darkness (#2252), Smart Blur edge modes (#2154), Scribble variations (#2180), Free Distort corners in its dialog (#2134), Rotate View presets (#2106), tritanopia proofing (#2234), Layers panel filters (#2087), Hide Template (#2117), File › Automate Batch through the Actions panel (#1938), dataset and graph CSV (#1929, #1941), Reset Tracking and Kerning (#2274), Alt+Enter copies in Transform fields (#2236), Shift+Tab panels (#2254), Control bar isolation, Recolor and image Replace (#2258, #2079, #2081). Revolve renders smooth with Cap on by default (#1193); Image Trace keeps tiny parts, thin outlines and shared corners, and Grayscale gradients are grey (#2012, #2025, #2263, #2264, #2265, #1879). Import: many `.ai`, EPS and PDF fidelity fixes (#2014–#2016, #2029, #2030, #2044, #2046, #2050–#2052, #2058, #2129, #2199, #2202, #2218), images up to 1 GB decoded (#2060), `.ai`/`.ait`/`.svgz` files paste from a file manager (#2148, #2201). Fixes across fields (arithmetic #2069, #2209, #2022; caret #1403), New Document (#2158, #2176), menus (#2232), shortcuts (#2235, #2146), Transform Each axes (#2227), Paste in Place (#1926), Color Picker tab order (#1937, #1939) and more. Languages: Bulgarian, Greek, Swedish and Finnish complete under the tests, pt-br completed. Details per PR in the PRs; headline numbers unchanged (estimated). |
|
||||
| 2026-10-14 | Brazilian Portuguese (`pt-br`) covers the whole interface and status/error messages under the completeness tests (#1914); Localization 71% → 74% (measured), hours 55–90 → 50–85. |
|
||||
| 2026-10-14 | Direct Selection: deleting an interior anchor of an open path leaves separate surviving runs. Delete/Backspace and history regressions cover the gap; explicit Remove Anchors remains connected. Headline numbers unchanged. |
|
||||
|
||||
+338
-14
@@ -1,29 +1,184 @@
|
||||
//! Native profile paths. Isolated profiles never migrate preferences from a user's old profile.
|
||||
//!
|
||||
//! In order:
|
||||
//! 1. `VECTORCRAFT_CONFIG_DIR`, when set (tests, agents, custom setups).
|
||||
//! 2. **Portable mode** (#1366), as in PhotoCraft: a marker file ([`PORTABLE_MARKERS`]) in the
|
||||
//! install folder puts the settings in `VectorCraftData` beside it, so a portable copy leaves
|
||||
//! nothing in `%APPDATA%`. The install folder is the executable's; on macOS the one holding
|
||||
//! `VectorCraft.app` (nothing is written inside a signed bundle), for an AppImage the one holding
|
||||
//! the `.AppImage` file (`$APPIMAGE`; the executable runs from a read-only mount). The Windows
|
||||
//! portable zip ships with `portable.txt`; the MSI doesn't. The first time `VectorCraftData` is
|
||||
//! created, the settings in the user's profile are copied into it, so a portable zip that gains
|
||||
//! the marker keeps its users' settings. If the folder can't be written (read-only medium,
|
||||
//! Program Files), a warning is logged and the platform folder is used. A portable copy never
|
||||
//! reads the old DrawCraft profile, and a run without preferences (`VECTORCRAFT_NO_PREFS`) never
|
||||
//! looks for the marker.
|
||||
//! 3. The platform convention.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Files in the install folder that switch on portable mode (either one; contents are ignored).
|
||||
pub const PORTABLE_MARKERS: [&str; 2] = ["portable.txt", "VectorCraft.portable"];
|
||||
/// The settings folder created in the install folder in portable mode.
|
||||
pub const PORTABLE_DATA_DIR: &str = "VectorCraftData";
|
||||
|
||||
pub fn prefs_path(legacy: bool) -> Option<PathBuf> {
|
||||
prefs_path_with(std::env::consts::OS, legacy, |key| std::env::var_os(key))
|
||||
prefs_path_with(std::env::consts::OS, legacy, |key| std::env::var_os(key), portable_dir)
|
||||
}
|
||||
|
||||
fn prefs_path_with(os: &str, legacy: bool, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||
/// `portable` is the portable settings folder, if any: only asked for when no override is set and
|
||||
/// preferences are in use.
|
||||
fn prefs_path_with(os: &str, legacy: bool, env: impl Fn(&str) -> Option<OsString>, portable: impl FnOnce() -> Option<PathBuf>) -> Option<PathBuf> {
|
||||
if let Some(root) = env("VECTORCRAFT_CONFIG_DIR") {
|
||||
// Never fall back to DrawCraft's real profile when the isolated file does not exist.
|
||||
return (!legacy && !root.is_empty()).then(|| PathBuf::from(root).join("ui.json"));
|
||||
}
|
||||
// A run without preferences reads and writes nothing, so it must not create the portable folder.
|
||||
if env("VECTORCRAFT_NO_PREFS").is_none()
|
||||
&& let Some(dir) = portable()
|
||||
{
|
||||
// A portable copy is self-contained: DrawCraft's old profile is not read into it.
|
||||
return (!legacy).then(|| dir.join("ui.json"));
|
||||
}
|
||||
platform_dir(os, legacy, &env).map(|p| p.join("ui.json"))
|
||||
}
|
||||
|
||||
/// The platform's settings folder of VectorCraft, or of DrawCraft (`legacy`).
|
||||
fn platform_dir(os: &str, legacy: bool, env: &impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||
let (name, lower) = if legacy { ("DrawCraft", "drawcraft") } else { ("VectorCraft", "vectorcraft") };
|
||||
let base = match os {
|
||||
match os {
|
||||
"macos" => env("HOME").map(|p| PathBuf::from(p).join("Library/Application Support").join(name)),
|
||||
"windows" => env("APPDATA").map(|p| PathBuf::from(p).join(name)),
|
||||
_ => env("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| env("HOME").map(|p| PathBuf::from(p).join(".config"))).map(|p| p.join(lower)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The portable settings folder of this copy, looked for once per process (it probes the disk, and
|
||||
/// the settings path is asked for many times).
|
||||
fn portable_dir() -> Option<PathBuf> {
|
||||
static DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
|
||||
DIR.get_or_init(|| {
|
||||
let os = std::env::consts::OS;
|
||||
let env = |key: &str| std::env::var_os(key);
|
||||
let install = install_dir(os, &std::env::current_exe().ok()?, &env)?;
|
||||
portable_dir_in(&install, platform_dir(os, false, &env).as_deref()).unwrap_or_else(|warning| {
|
||||
// Only the log: `eprintln!` panics when standard error is a closed pipe, and the logger
|
||||
// keeps early records for the log file.
|
||||
log::warn!("{warning}");
|
||||
None
|
||||
})
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// The folder the user sees the app in, where the portable marker is looked for: the executable's;
|
||||
/// on macOS the one holding `VectorCraft.app` when the executable sits in its `Contents/MacOS` (a
|
||||
/// signed bundle is never written into); on Linux the one holding the `.AppImage` file when
|
||||
/// `$APPIMAGE` names it (the executable itself runs from a read-only mount).
|
||||
fn install_dir(os: &str, exe: &Path, env: &impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||
let exe_dir = exe.parent()?;
|
||||
let dir = match os {
|
||||
"macos" => app_bundle_parent(exe_dir).unwrap_or(exe_dir).to_path_buf(),
|
||||
"windows" => exe_dir.to_path_buf(),
|
||||
_ => {
|
||||
let appimage = env("APPIMAGE").map(PathBuf::from);
|
||||
let beside = appimage.as_deref().and_then(Path::parent).filter(|p| !p.as_os_str().is_empty());
|
||||
beside.unwrap_or(exe_dir).to_path_buf()
|
||||
}
|
||||
};
|
||||
base.map(|p| p.join("ui.json"))
|
||||
Some(dir)
|
||||
}
|
||||
|
||||
/// The folder holding `X.app` when `exe_dir` is `X.app/Contents/MacOS`.
|
||||
fn app_bundle_parent(exe_dir: &Path) -> Option<&Path> {
|
||||
let contents = exe_dir.parent().filter(|_| exe_dir.file_name() == Some("MacOS".as_ref()))?;
|
||||
let bundle = contents.parent().filter(|_| contents.file_name() == Some("Contents".as_ref()))?;
|
||||
bundle.extension().filter(|ext| *ext == "app").and(bundle.parent())
|
||||
}
|
||||
|
||||
/// `<install_dir>/VectorCraftData` when a portable marker sits in `install_dir`, created if missing;
|
||||
/// `None` without a marker, and the warning to log when there is one but that folder can't be
|
||||
/// written. The first time the folder is created, the settings in `previous` (the user's profile
|
||||
/// folder, when it exists) are copied into it, so a portable zip that gains the marker keeps its
|
||||
/// users' settings. `previous` is never changed; a copy that fails halfway is logged and left as it
|
||||
/// is, and a later start finds the folder and copies nothing.
|
||||
fn portable_dir_in(install_dir: &Path, previous: Option<&Path>) -> Result<Option<PathBuf>, String> {
|
||||
let Some(marker) = PORTABLE_MARKERS.iter().map(|m| install_dir.join(m)).find(|p| p.is_file()) else { return Ok(None) };
|
||||
let data = install_dir.join(PORTABLE_DATA_DIR);
|
||||
let first_start = !data.exists();
|
||||
if let Err(e) = ensure_writable(&data) {
|
||||
return Err(format!(
|
||||
"portable mode ({} found) but {} isn't writable ({e}); settings are kept in the user's settings folder instead",
|
||||
marker.display(),
|
||||
data.display()
|
||||
));
|
||||
}
|
||||
if first_start && let Some(previous) = previous.filter(|p| p.is_dir()) {
|
||||
match copy_dir(previous, &data) {
|
||||
Ok(()) => log::info!("portable mode: copied the settings from {} to {}", previous.display(), data.display()),
|
||||
Err(e) => log::warn!("portable mode: the settings in {} were only partly copied to {}: {e}", previous.display(), data.display()),
|
||||
}
|
||||
}
|
||||
Ok(Some(data))
|
||||
}
|
||||
|
||||
/// Create `dir` and prove a file can be written in it (a folder can exist yet be read-only).
|
||||
fn ensure_writable(dir: &Path) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let probe = dir.join(format!(".write-test-{}", std::process::id()));
|
||||
std::fs::write(&probe, b"")?;
|
||||
// A leftover probe is harmless; don't give up portable mode over it.
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy the contents of `from` into `to` (which exists), going on past entries that can't be
|
||||
/// copied; the error says how many were skipped and why the first one was.
|
||||
fn copy_dir(from: &Path, to: &Path) -> Result<(), String> {
|
||||
let mut skipped = 0usize;
|
||||
let mut first: Option<String> = None;
|
||||
copy_dir_into(from, to, &mut |why| {
|
||||
skipped += 1;
|
||||
first.get_or_insert(why);
|
||||
});
|
||||
match first {
|
||||
None => Ok(()),
|
||||
Some(why) => Err(format!("{skipped} skipped, the first because {why}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_dir_into(from: &Path, to: &Path, skip: &mut impl FnMut(String)) {
|
||||
let entries = match std::fs::read_dir(from) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => return skip(format!("{}: {e}", from.display())),
|
||||
};
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(e) => {
|
||||
skip(format!("{}: {e}", from.display()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let (src, dst) = (entry.path(), to.join(entry.file_name()));
|
||||
// A symbolic link is copied as what it points to, or skipped when that is a folder.
|
||||
let copied = match entry.file_type() {
|
||||
Ok(kind) if kind.is_dir() => std::fs::create_dir_all(&dst).map(|()| copy_dir_into(&src, &dst, skip)),
|
||||
Ok(_) => std::fs::copy(&src, &dst).map(|_| ()),
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
if let Err(e) = copied {
|
||||
skip(format!("{}: {e}", src.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
fn environment(key: &str) -> Option<OsString> {
|
||||
match key {
|
||||
@@ -34,33 +189,202 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// No portable marker in the install folder.
|
||||
fn installed() -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolated_profile_has_no_legacy_fallback_on_any_platform() {
|
||||
for os in ["macos", "windows", "linux"] {
|
||||
let env = |key: &str| {
|
||||
if key == "VECTORCRAFT_CONFIG_DIR" { Some("/isolated".into()) } else { environment(key) }
|
||||
};
|
||||
assert_eq!(prefs_path_with(os, false, env), Some("/isolated/ui.json".into()));
|
||||
assert_eq!(prefs_path_with(os, true, env), None);
|
||||
assert_eq!(prefs_path_with(os, false, env, installed), Some("/isolated/ui.json".into()));
|
||||
assert_eq!(prefs_path_with(os, true, env, installed), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_override_does_not_fall_back_to_user_files() {
|
||||
let env = |key: &str| if key == "VECTORCRAFT_CONFIG_DIR" { Some(OsString::new()) } else { environment(key) };
|
||||
assert_eq!(prefs_path_with("macos", false, env), None);
|
||||
assert_eq!(prefs_path_with("macos", true, env), None);
|
||||
assert_eq!(prefs_path_with("macos", false, env, installed), None);
|
||||
assert_eq!(prefs_path_with("macos", true, env, installed), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unset_override_keeps_platform_paths_and_legacy_migration() {
|
||||
assert_eq!(prefs_path_with("macos", false, environment), Some("/user/Library/Application Support/VectorCraft/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("macos", true, environment), Some("/user/Library/Application Support/DrawCraft/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("windows", false, environment), Some("/roaming/VectorCraft/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("linux", false, environment), Some("/xdg-config/vectorcraft/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("macos", false, environment, installed), Some("/user/Library/Application Support/VectorCraft/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("macos", true, environment, installed), Some("/user/Library/Application Support/DrawCraft/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("windows", false, environment, installed), Some("/roaming/VectorCraft/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("linux", false, environment, installed), Some("/xdg-config/vectorcraft/ui.json".into()));
|
||||
assert_eq!(
|
||||
prefs_path_with("linux", true, |key| if key == "HOME" { environment(key) } else { None }),
|
||||
prefs_path_with("linux", true, |key| if key == "HOME" { environment(key) } else { None }, installed),
|
||||
Some("/user/.config/drawcraft/ui.json".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_mode_keeps_the_settings_beside_the_executable_and_skips_the_drawcraft_profile() {
|
||||
let portable = || Some(PathBuf::from("/usb/VectorCraft/VectorCraftData"));
|
||||
for os in ["macos", "windows", "linux"] {
|
||||
assert_eq!(prefs_path_with(os, false, environment, portable), Some("/usb/VectorCraft/VectorCraftData/ui.json".into()), "{os}");
|
||||
// A portable copy is self-contained: the one-time read of DrawCraft's old settings is skipped.
|
||||
assert_eq!(prefs_path_with(os, true, environment, portable), None, "{os}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_config_dir_override_wins_over_portable_mode_without_probing_it() {
|
||||
let env = |key: &str| if key == "VECTORCRAFT_CONFIG_DIR" { Some("/isolated".into()) } else { environment(key) };
|
||||
let portable = || -> Option<PathBuf> { panic!("the portable folder is not looked for under VECTORCRAFT_CONFIG_DIR") };
|
||||
assert_eq!(prefs_path_with("windows", false, env, portable), Some("/isolated/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("windows", true, env, portable), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_without_preferences_never_looks_for_the_portable_folder() {
|
||||
let env = |key: &str| if key == "VECTORCRAFT_NO_PREFS" { Some("1".into()) } else { environment(key) };
|
||||
let portable = || -> Option<PathBuf> { panic!("the portable folder is not looked for under VECTORCRAFT_NO_PREFS") };
|
||||
assert_eq!(prefs_path_with("windows", false, env, portable), Some("/roaming/VectorCraft/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("windows", true, env, portable), Some("/roaming/DrawCraft/ui.json".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_macos_the_marker_is_looked_for_beside_the_app_bundle() {
|
||||
let bundled = Path::new("/Apps/VectorCraft.app/Contents/MacOS/vectorcraft");
|
||||
assert_eq!(install_dir("macos", bundled, &environment), Some("/Apps".into()));
|
||||
// A bare executable (the CLI zip) and other platforms: the executable's own folder.
|
||||
assert_eq!(install_dir("macos", Path::new("/Apps/vectorcraft-cli"), &environment), Some("/Apps".into()));
|
||||
assert_eq!(install_dir("windows", bundled, &environment), Some("/Apps/VectorCraft.app/Contents/MacOS".into()));
|
||||
assert_eq!(install_dir("linux", bundled, &environment), Some("/Apps/VectorCraft.app/Contents/MacOS".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_appimage_looks_for_the_marker_beside_the_appimage_file() {
|
||||
let mounted = Path::new("/tmp/.mount_vc/usr/bin/vectorcraft");
|
||||
let appimage = |key: &str| if key == "APPIMAGE" { Some("/home/user/Apps/VectorCraft.AppImage".into()) } else { environment(key) };
|
||||
assert_eq!(install_dir("linux", mounted, &appimage), Some("/home/user/Apps".into()));
|
||||
assert_eq!(install_dir("linux", mounted, &environment), Some("/tmp/.mount_vc/usr/bin".into()));
|
||||
let empty = |key: &str| if key == "APPIMAGE" { Some(OsString::new()) } else { environment(key) };
|
||||
assert_eq!(install_dir("linux", mounted, &empty), Some("/tmp/.mount_vc/usr/bin".into()));
|
||||
// The variable means nothing on other platforms.
|
||||
assert_eq!(install_dir("windows", mounted, &appimage), Some("/tmp/.mount_vc/usr/bin".into()));
|
||||
assert_eq!(install_dir("macos", mounted, &appimage), Some("/tmp/.mount_vc/usr/bin".into()));
|
||||
}
|
||||
|
||||
fn temp(tag: &str) -> PathBuf {
|
||||
static N: AtomicUsize = AtomicUsize::new(0);
|
||||
let d = std::env::temp_dir().join(format!("vectorcraft-profile-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn either_marker_beside_the_executable_makes_a_writable_data_folder() {
|
||||
for marker in PORTABLE_MARKERS {
|
||||
let exe = temp("portable");
|
||||
std::fs::write(exe.join(marker), b"").unwrap();
|
||||
let dir = portable_dir_in(&exe, None).unwrap().unwrap();
|
||||
assert_eq!(dir, exe.join(PORTABLE_DATA_DIR), "{marker}");
|
||||
assert!(dir.is_dir(), "the data folder is created");
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "the write probe is removed");
|
||||
let _ = std::fs::remove_dir_all(&exe);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fake_app_bundle_keeps_the_marker_and_the_data_outside_the_bundle() {
|
||||
let folder = temp("bundle");
|
||||
let exe = folder.join("VectorCraft.app/Contents/MacOS/vectorcraft");
|
||||
std::fs::create_dir_all(exe.parent().unwrap()).unwrap();
|
||||
std::fs::write(folder.join("portable.txt"), b"").unwrap();
|
||||
let install = install_dir("macos", &exe, &environment).unwrap();
|
||||
assert_eq!(portable_dir_in(&install, None), Ok(Some(folder.join(PORTABLE_DATA_DIR))));
|
||||
assert!(!exe.parent().unwrap().join(PORTABLE_DATA_DIR).exists(), "nothing is written inside the bundle");
|
||||
// A marker dropped inside the bundle is not looked at.
|
||||
std::fs::remove_file(folder.join("portable.txt")).unwrap();
|
||||
std::fs::write(exe.parent().unwrap().join("portable.txt"), b"").unwrap();
|
||||
let _ = std::fs::remove_dir_all(folder.join(PORTABLE_DATA_DIR));
|
||||
assert_eq!(portable_dir_in(&install, None), Ok(None));
|
||||
let _ = std::fs::remove_dir_all(&folder);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_marker_file_there_is_no_portable_folder() {
|
||||
let exe = temp("installed");
|
||||
assert_eq!(portable_dir_in(&exe, None), Ok(None));
|
||||
// A folder named like the marker is not a marker.
|
||||
std::fs::create_dir_all(exe.join("portable.txt")).unwrap();
|
||||
assert_eq!(portable_dir_in(&exe, None), Ok(None));
|
||||
assert!(!exe.join(PORTABLE_DATA_DIR).exists(), "no data folder without a marker");
|
||||
let _ = std::fs::remove_dir_all(&exe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unwritable_portable_folder_is_reported_instead_of_used() {
|
||||
let exe = temp("unwritable");
|
||||
std::fs::write(exe.join("portable.txt"), b"").unwrap();
|
||||
// A file where the data folder should go: it can't be created, on every platform.
|
||||
std::fs::write(exe.join(PORTABLE_DATA_DIR), b"in the way").unwrap();
|
||||
let warning = portable_dir_in(&exe, None).unwrap_err();
|
||||
assert!(warning.contains("portable") && warning.contains(PORTABLE_DATA_DIR), "{warning}");
|
||||
let _ = std::fs::remove_dir_all(&exe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_start_copies_the_user_profile_into_the_data_folder_once() {
|
||||
let exe = temp("upgrade");
|
||||
std::fs::write(exe.join("portable.txt"), b"").unwrap();
|
||||
let previous = temp("previous-profile");
|
||||
std::fs::write(previous.join("ui.json"), b"{\"theme\":\"dark\"}").unwrap();
|
||||
std::fs::create_dir_all(previous.join("Swatches/Mine")).unwrap();
|
||||
std::fs::write(previous.join("Swatches/Mine/warm.ase"), b"ase").unwrap();
|
||||
|
||||
let data = portable_dir_in(&exe, Some(&previous)).unwrap().unwrap();
|
||||
assert_eq!(data, exe.join(PORTABLE_DATA_DIR));
|
||||
assert_eq!(std::fs::read(data.join("ui.json")).unwrap(), b"{\"theme\":\"dark\"}", "the settings file is copied");
|
||||
assert_eq!(std::fs::read(data.join("Swatches/Mine/warm.ase")).unwrap(), b"ase", "nested folders are copied");
|
||||
assert_eq!(std::fs::read(previous.join("ui.json")).unwrap(), b"{\"theme\":\"dark\"}", "the source is left as it is");
|
||||
assert!(previous.join("Swatches/Mine/warm.ase").is_file(), "the source is left as it is");
|
||||
|
||||
// A second start finds the folder and copies nothing, whatever changed on either side.
|
||||
std::fs::write(data.join("ui.json"), b"{\"theme\":\"light\"}").unwrap();
|
||||
std::fs::write(previous.join("ui.json"), b"{\"theme\":\"newer\"}").unwrap();
|
||||
std::fs::write(previous.join("later.txt"), b"").unwrap();
|
||||
assert_eq!(portable_dir_in(&exe, Some(&previous)), Ok(Some(data.clone())));
|
||||
assert_eq!(std::fs::read(data.join("ui.json")).unwrap(), b"{\"theme\":\"light\"}", "the portable settings are not overwritten");
|
||||
assert!(!data.join("later.txt").exists(), "nothing is copied after the first start");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&exe);
|
||||
let _ = std::fs::remove_dir_all(&previous);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_user_profile_the_data_folder_starts_empty() {
|
||||
let exe = temp("fresh");
|
||||
std::fs::write(exe.join("portable.txt"), b"").unwrap();
|
||||
let missing = exe.join("no-such-profile");
|
||||
let data = portable_dir_in(&exe, Some(&missing)).unwrap().unwrap();
|
||||
assert_eq!(std::fs::read_dir(&data).unwrap().count(), 0);
|
||||
let _ = std::fs::remove_dir_all(&exe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_that_fails_halfway_reports_what_it_skipped_and_keeps_the_rest() {
|
||||
let from = temp("copy-from");
|
||||
let to = temp("copy-to");
|
||||
std::fs::write(from.join("kept.txt"), b"kept").unwrap();
|
||||
std::fs::create_dir_all(from.join("blocked")).unwrap();
|
||||
std::fs::write(from.join("blocked/inner.txt"), b"").unwrap();
|
||||
// A file where the sub-folder should go: that entry can't be copied, the others can.
|
||||
std::fs::write(to.join("blocked"), b"in the way").unwrap();
|
||||
let error = copy_dir(&from, &to).unwrap_err();
|
||||
assert!(error.starts_with("1 skipped"), "{error}");
|
||||
assert!(error.contains("blocked"), "{error}");
|
||||
assert_eq!(std::fs::read(to.join("kept.txt")).unwrap(), b"kept");
|
||||
let _ = std::fs::remove_dir_all(&from);
|
||||
let _ = std::fs::remove_dir_all(&to);
|
||||
}
|
||||
}
|
||||
@@ -115,3 +115,8 @@ sync_github_name = false
|
||||
real_name = "Kamil Kwiatkowski"
|
||||
display_name = "Kamil Kwiatkowski"
|
||||
sync_github_name = false
|
||||
|
||||
[people.marcoarnulfo]
|
||||
real_name = "Marco Arnulfo"
|
||||
display_name = "Marco Arnulfo"
|
||||
sync_github_name = false
|
||||
@@ -965,6 +965,41 @@ fn drawings_the_export_writes_read_back() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_control_point_splines_stay_closed_through_export_and_import() {
|
||||
// (explicitly closed, ends where it starts): coincident endpoints alone do not close a path.
|
||||
let cases = [(true, true), (true, false), (false, true), (false, false)];
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
for (i, &(closed, returns)) in cases.iter().enumerate() {
|
||||
let mut b = kurbo::BezPath::new();
|
||||
b.move_to((20.0, 20.0));
|
||||
b.curve_to((80.0, 0.0), (80.0, 80.0), (20.0, 80.0));
|
||||
if returns {
|
||||
b.curve_to((0.0, 80.0), (0.0, 20.0), (20.0, 20.0));
|
||||
}
|
||||
if closed {
|
||||
b.close_path();
|
||||
}
|
||||
let id = doc.alloc_id();
|
||||
let black = Appearance::basic(Paint::None, Paint::solid(Color::rgb8(0, 0, 0)), 1.0);
|
||||
doc.insert(Some(layer), i, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&b), black)).unwrap();
|
||||
}
|
||||
for version in crate::DxfVersion::ALL.into_iter().filter(|v| *v >= crate::DxfVersion::R13) {
|
||||
let opts = crate::DxfOptions { version, unit: Unit::Points, region: doc.artboards[0].rect, ..crate::DxfOptions::default() };
|
||||
let out = crate::export(&doc, &opts).unwrap();
|
||||
let text = std::str::from_utf8(&out.bytes).unwrap();
|
||||
assert_eq!(text.matches("\nSPLINE\n").count(), cases.len(), "{version:?}: curves are written as SPLINE entities");
|
||||
let back = leaves(&open(&out.bytes, &points()).document);
|
||||
assert_eq!(back.len(), cases.len(), "{version:?}");
|
||||
for (n, &(closed, returns)) in back.iter().zip(&cases) {
|
||||
let path = n.path_data().unwrap();
|
||||
assert_eq!(path.subpaths.len(), 1, "{version:?}");
|
||||
assert_eq!(path.subpaths[0].closed, closed, "{version:?}: closed {closed}, returns {returns}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_lists_layers_layouts_and_version() {
|
||||
let tables = [(0, "TABLE"), (2, "LAYER"), (0, "LAYER"), (2, "0"), (62, "7"), (0, "LAYER"), (2, "Walls"), (62, "1"), (0, "ENDTAB")];
|
||||
|
||||
@@ -169,6 +169,80 @@ fn objects_at_repeated(doc: &Document, p: Point, opt: HitOptions, scope: Option<
|
||||
tops
|
||||
}
|
||||
|
||||
/// The most objects [`locked_objects_at`] lists.
|
||||
pub const MAX_LOCKED_AT: usize = 20;
|
||||
|
||||
/// The locked objects under `p`, topmost first, at most [`MAX_LOCKED_AT`]: each object whose own
|
||||
/// lock is on and that a click at `p` would hit if it were not locked. A locked group is listed,
|
||||
/// not what it holds. Hidden objects and everything on hidden, locked and template layers are left
|
||||
/// out. While a pattern is edited only its tile layer is searched, and in isolation mode
|
||||
/// (`opt.scope`) only what is inside the isolated group or object.
|
||||
pub fn locked_objects_at(doc: &Document, p: Point, opt: HitOptions) -> Vec<NodeId> {
|
||||
let symbols = doc.symbol_boxes();
|
||||
let mut out = Vec::new();
|
||||
if let Some(scope) = opt.scope
|
||||
&& let Some(isolated) = doc.node(scope)
|
||||
{
|
||||
if isolated.is_container() {
|
||||
locked_children(isolated, p, opt, &symbols, &mut out);
|
||||
} else if isolated.visible && isolated.locked && locked_hit(isolated, p, opt, &symbols) {
|
||||
out.push(isolated.id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
for layer in doc.layers.iter().rev() {
|
||||
if !layer.visible || layer.locked || layer.is_template() || doc.pattern_edit.as_ref().is_some_and(|e| e.layer != layer.id) {
|
||||
continue;
|
||||
}
|
||||
let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt };
|
||||
locked_children(layer, p, opt, &symbols, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// [`locked_objects_at`] through the children of `parent`, front first: a locked child is tested
|
||||
/// whole, and an unlocked layer, group, blend or envelope in Edit Contents is searched inside.
|
||||
fn locked_children(parent: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, out: &mut Vec<NodeId>) {
|
||||
let Some(children) = parent.children() else { return };
|
||||
if parent.clips() && children.first().is_some_and(|clip| !clip_contains(clip, p)) {
|
||||
return;
|
||||
}
|
||||
for i in hit_order(parent, children, p) {
|
||||
if out.len() >= MAX_LOCKED_AT {
|
||||
return;
|
||||
}
|
||||
let Some(c) = children.get(i) else { continue };
|
||||
if !may_hit(parent, i, c, p, opt, symbols, false) || c.is_layer() && (c.locked || c.is_template()) {
|
||||
continue;
|
||||
}
|
||||
if c.locked {
|
||||
if locked_hit(c, p, opt, symbols) {
|
||||
out.push(c.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match &c.kind {
|
||||
NodeKind::Layer { preview: false, .. } => locked_children(c, p, HitOptions { outline: true, ..opt }, symbols, out),
|
||||
NodeKind::Layer { .. }
|
||||
| NodeKind::Group { .. }
|
||||
| NodeKind::Intertwine { .. }
|
||||
| NodeKind::Blend { .. }
|
||||
| NodeKind::Envelope { editing: true, .. } => locked_children(c, p, opt, symbols, out),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Would a click at `p` hit the locked object `n` if it were not locked?
|
||||
fn locked_hit(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> bool {
|
||||
let inside = || hit_children(n, p, opt, symbols, &|_| false, &mut Vec::new(), false).is_some();
|
||||
match &n.kind {
|
||||
NodeKind::Group { .. } | NodeKind::Intertwine { .. } | NodeKind::Envelope { editing: true, .. } => inside(),
|
||||
NodeKind::Blend { .. } => inside() || hit_leaf(n, p, opt, symbols).is_some(),
|
||||
_ => hit_leaf(n, p, opt, symbols).is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`hit_test`] passing over the nodes `skip` names (and their contents). Symbol instances are
|
||||
/// measured by their art ([`Node::geometric_bounds_with`]).
|
||||
fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId) -> bool, editable_only: bool) -> Option<Hit> {
|
||||
@@ -901,6 +975,69 @@ world",
|
||||
assert!(hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).is_none());
|
||||
}
|
||||
|
||||
/// Insert `n` locked at the top of `parent`.
|
||||
fn insert_locked(d: &mut Document, parent: NodeId, mut n: Node) -> NodeId {
|
||||
let id = n.id;
|
||||
n.locked = true;
|
||||
d.insert(Some(parent), usize::MAX, n).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
/// `locked_objects_at`: the locked objects under the point, topmost first. A locked group is
|
||||
/// listed rather than its contents, and a locked object in an unlocked group is listed itself.
|
||||
/// Hidden objects, locked layers and the art outside an isolated group are left out.
|
||||
#[test]
|
||||
fn locked_objects_at_lists_the_locked_stack() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let rect =
|
||||
|d: &mut Document, x: f64| Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 0.0, x + 50.0, 50.0)), Appearance::default_art());
|
||||
let (a, b) = (rect(&mut d, 0.0), rect(&mut d, 10.0));
|
||||
let (a, b) = (insert_locked(&mut d, l, a), insert_locked(&mut d, l, b));
|
||||
let (mut inner, locked_group) = (rect(&mut d, 20.0), d.alloc_id());
|
||||
inner.locked = true;
|
||||
let inner_id = inner.id;
|
||||
let mut g = Node::group(locked_group, vec![Arc::new(inner)]);
|
||||
g.locked = true;
|
||||
d.insert(Some(l), usize::MAX, g).unwrap();
|
||||
let open_group = d.alloc_id();
|
||||
let mut child = rect(&mut d, 25.0);
|
||||
child.locked = true;
|
||||
let child_id = child.id;
|
||||
let free = rect(&mut d, 25.0);
|
||||
d.insert(Some(l), usize::MAX, Node::group(open_group, vec![Arc::new(child), Arc::new(free)])).unwrap();
|
||||
let at = |d: &Document, x: f64, scope| locked_objects_at(d, Point::new(x, 25.0), HitOptions { scope, ..HitOptions::default() });
|
||||
assert_eq!(at(&d, 30.0, None), [child_id, locked_group, b, a]);
|
||||
assert!(!at(&d, 30.0, None).contains(&inner_id), "the locked group stands for what it holds");
|
||||
assert_eq!(at(&d, 5.0, None), [a]);
|
||||
assert!(at(&d, 150.0, None).is_empty());
|
||||
// Isolated, only the group's own objects are looked at.
|
||||
assert_eq!(at(&d, 30.0, Some(open_group)), [child_id]);
|
||||
// A hidden object is left out, and nothing on a locked or hidden layer is listed.
|
||||
d.node_mut(b).unwrap().visible = false;
|
||||
assert_eq!(at(&d, 30.0, None), [child_id, locked_group, a]);
|
||||
d.node_mut(l).unwrap().locked = true;
|
||||
assert!(at(&d, 30.0, None).is_empty());
|
||||
d.node_mut(l).unwrap().locked = false;
|
||||
d.node_mut(l).unwrap().visible = false;
|
||||
assert!(at(&d, 30.0, None).is_empty());
|
||||
}
|
||||
|
||||
/// `locked_objects_at` lists at most `MAX_LOCKED_AT` objects, the topmost ones.
|
||||
#[test]
|
||||
fn locked_objects_at_stops_at_its_limit() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let l = d.layers[0].id;
|
||||
let ids: Vec<NodeId> = (0..MAX_LOCKED_AT + 5)
|
||||
.map(|_| {
|
||||
let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art());
|
||||
insert_locked(&mut d, l, n)
|
||||
})
|
||||
.collect();
|
||||
let found = locked_objects_at(&d, Point::new(25.0, 25.0), HitOptions::default());
|
||||
assert_eq!(found, ids.iter().rev().take(MAX_LOCKED_AT).copied().collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_sampling_reads_locked_art_and_layers_but_excludes_hidden_art() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
|
||||
+51
-3
@@ -20,9 +20,11 @@ pub mod inks;
|
||||
mod inline;
|
||||
pub mod intertwine;
|
||||
pub mod shared;
|
||||
mod symbol_export_type;
|
||||
mod symbol_registration;
|
||||
pub use inline::{SYMBOL_HALF, SYMBOL_SIZES};
|
||||
pub use shared::Shared;
|
||||
pub use symbol_export_type::{SYMBOL_EXPORT_TYPE, SymbolExportType};
|
||||
pub use symbol_registration::{SYMBOL_REGISTRATION, SymbolRegistration};
|
||||
pub mod links;
|
||||
pub mod live;
|
||||
@@ -110,9 +112,9 @@ pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes};
|
||||
pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource};
|
||||
pub use style_libs::StyleLibrary;
|
||||
pub use text::{
|
||||
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, InlineArt, Justify, Kinsoku, LeadingModel,
|
||||
Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef,
|
||||
TextWrap, VerticalAlign, WrapShape,
|
||||
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt, Justify,
|
||||
Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject,
|
||||
TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape,
|
||||
};
|
||||
pub use trace::TraceView;
|
||||
pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables};
|
||||
@@ -1578,6 +1580,52 @@ impl Document {
|
||||
o.insert(name.into(), serde_json::json!(reg.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the stored registration when a symbol is renamed.
|
||||
pub fn rename_symbol_registration(&mut self, from: &str, to: &str) {
|
||||
if from == to {
|
||||
return;
|
||||
}
|
||||
let reg = self.symbol_registration(from);
|
||||
self.set_symbol_registration(from, SymbolRegistration::CENTER);
|
||||
self.set_symbol_registration(to, reg);
|
||||
}
|
||||
|
||||
/// Symbol Options › Export Type for `name` (default: Movie Clip).
|
||||
pub fn symbol_export_type(&self, name: &str) -> SymbolExportType {
|
||||
self.unknown.get(SYMBOL_EXPORT_TYPE).and_then(|m| m.get(name)).and_then(SymbolExportType::from_value).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Set or clear (when Movie Clip, the default) the export type stored for `name`.
|
||||
pub fn set_symbol_export_type(&mut self, name: &str, kind: SymbolExportType) {
|
||||
if kind.is_default() {
|
||||
self.clear_symbol_export_type(name);
|
||||
return;
|
||||
}
|
||||
let m = self.unknown.entry(SYMBOL_EXPORT_TYPE.to_string()).or_insert_with(|| serde_json::json!({}));
|
||||
let Some(o) = m.as_object_mut() else { return };
|
||||
o.insert(name.into(), serde_json::json!(kind.id()));
|
||||
}
|
||||
|
||||
/// Drop any stored export type for `name` (Movie Clip default).
|
||||
pub fn clear_symbol_export_type(&mut self, name: &str) {
|
||||
let Some(m) = self.unknown.get_mut(SYMBOL_EXPORT_TYPE) else { return };
|
||||
let Some(o) = m.as_object_mut() else { return };
|
||||
o.remove(name);
|
||||
if o.is_empty() {
|
||||
self.unknown.remove(SYMBOL_EXPORT_TYPE);
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the stored export type when a symbol is renamed.
|
||||
pub fn rename_symbol_export_type(&mut self, from: &str, to: &str) {
|
||||
if from == to {
|
||||
return;
|
||||
}
|
||||
let kind = self.symbol_export_type(from);
|
||||
self.clear_symbol_export_type(from);
|
||||
self.set_symbol_export_type(to, kind);
|
||||
}
|
||||
}
|
||||
|
||||
fn default_graphic_styles() -> Vec<GraphicStyle> {
|
||||
|
||||
@@ -1217,6 +1217,13 @@ impl Node {
|
||||
let o = self.appearance.outset() + symbols.map_or(0.0, |s| s.brush_pad(symbol));
|
||||
self.projected(Some(b)).map(|b| b.inflate(o, o))
|
||||
}
|
||||
NodeKind::Text(t) => {
|
||||
// The characters' strokes paint around the glyphs, scaled with the type.
|
||||
let k = Scaling::factor(t.xf).unwrap_or(1.0);
|
||||
let chars = t.runs.iter().map(|r| r.style.stroke_outset()).fold(0.0, f64::max) * k;
|
||||
let o = self.appearance.outset().max(chars);
|
||||
self.geometric(symbols).map(|b| b.inflate(o, o))
|
||||
}
|
||||
_ => {
|
||||
let o = self.appearance.outset();
|
||||
self.geometric(symbols).map(|b| b.inflate(o, o))
|
||||
@@ -2336,6 +2343,25 @@ mod tests {
|
||||
assert_eq!(p.visual_bounds(), Some(Rect::new(-0.5, -0.5, 10.5, 10.5)));
|
||||
}
|
||||
|
||||
/// Type's visual bounds grow by how far its character strokes paint past the glyphs: half the
|
||||
/// weight centred, the whole weight outside, nothing inside; scaled with the type.
|
||||
#[test]
|
||||
fn type_visual_bounds_grow_with_the_character_stroke() {
|
||||
use crate::appearance::StrokeAlign;
|
||||
use crate::text::{CharStyle, TextObject};
|
||||
let style = CharStyle { stroke: vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), stroke_width: 4.0, ..Default::default() };
|
||||
let mut t = TextObject::point(vectorcraft_geom::Point::new(0.0, 0.0), "Ab", style);
|
||||
t.xf = Affine::scale(2.0) * t.xf;
|
||||
let node = |t: &TextObject| Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone())));
|
||||
let g = node(&t).geometric_bounds().unwrap();
|
||||
for (align, grow) in [(StrokeAlign::Center, 4.0), (StrokeAlign::Outside, 8.0), (StrokeAlign::Inside, 0.0)] {
|
||||
t.runs[0].style.stroke_align = align;
|
||||
assert_eq!(node(&t).visual_bounds(), Some(g.inflate(grow, grow)), "{align:?}");
|
||||
}
|
||||
t.runs[0].style.stroke = vectorcraft_color::Paint::None;
|
||||
assert_eq!(node(&t).visual_bounds(), Some(g), "no stroke, no growth");
|
||||
}
|
||||
|
||||
/// A group holding a path, a compound, an image and a nested group, as a file writes it.
|
||||
fn nested_group() -> Node {
|
||||
let path = |id| Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
//! Symbol Options › Export Type: Graphic or Movie Clip
|
||||
//! ([helpx Symbol Options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html)).
|
||||
//!
|
||||
//! For Illustrator-only work the choice has no canvas effect; it is kept for Animate / Flash /
|
||||
//! HTML5 handoff. Illustrator's default is Movie Clip. Stored under [`SYMBOL_EXPORT_TYPE`] keyed
|
||||
//! by symbol name; the default (Movie Clip) is omitted from the map.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// Document unknown key: `{ symbolName: "graphic"|"movieClip" }`.
|
||||
pub const SYMBOL_EXPORT_TYPE: &str = "symbolExportType";
|
||||
|
||||
/// Symbol Options › Export Type.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum SymbolExportType {
|
||||
/// Static / graphic symbol instance (Animate Graphic).
|
||||
Graphic,
|
||||
/// Default: Movie Clip for Animate / game handoff; instances still place the same on the canvas.
|
||||
#[default]
|
||||
MovieClip,
|
||||
}
|
||||
|
||||
impl SymbolExportType {
|
||||
pub const IDS: [&'static str; 2] = ["movieClip", "graphic"];
|
||||
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Graphic => "graphic",
|
||||
Self::MovieClip => "movieClip",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_id(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"graphic" => Some(Self::Graphic),
|
||||
"movieClip" => Some(Self::MovieClip),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// True when this is the document default (nothing stored for the symbol).
|
||||
pub fn is_default(self) -> bool {
|
||||
matches!(self, Self::MovieClip)
|
||||
}
|
||||
|
||||
pub fn from_value(v: &Value) -> Option<Self> {
|
||||
v.as_str().and_then(Self::from_id)
|
||||
}
|
||||
}
|
||||
+65
-5
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Rect, Vec2};
|
||||
|
||||
use crate::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, LineJoin, StrokeLayer};
|
||||
use crate::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, LineJoin, StrokeAlign, StrokeLayer};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Justify {
|
||||
@@ -84,6 +84,10 @@ pub struct CharStyle {
|
||||
pub stroke_miter_limit: f64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stroke_dash: Option<Dash>,
|
||||
/// Align Stroke of the character stroke (Stroke panel, with type selected): centred on the
|
||||
/// glyph outlines, inside or outside them, as for object strokes; the type stays live.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub stroke_align: StrokeAlign,
|
||||
/// Superscript or subscript (Character panel).
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub position: CharPosition,
|
||||
@@ -249,6 +253,7 @@ impl Default for CharStyle {
|
||||
stroke_join: LineJoin::Miter,
|
||||
stroke_miter_limit: 10.0,
|
||||
stroke_dash: None,
|
||||
stroke_align: StrokeAlign::Center,
|
||||
position: CharPosition::Normal,
|
||||
char_align: CharAlign::RomanBaseline,
|
||||
proportional_metrics: false,
|
||||
@@ -266,8 +271,9 @@ impl CharStyle {
|
||||
pub fn has_stroke(&self) -> bool {
|
||||
!self.stroke.is_none() && self.stroke_width > 0.0
|
||||
}
|
||||
/// The character stroke as a stroke layer (paint, weight, cap, join, miter limit, dashes and
|
||||
/// overprint), so type strokes share the object strokes' geometry, rendering and export.
|
||||
/// The character stroke as a stroke layer (paint, weight, cap, join, miter limit, dashes,
|
||||
/// alignment and overprint), so type strokes share the object strokes' geometry, rendering
|
||||
/// and export.
|
||||
pub fn stroke_layer(&self) -> StrokeLayer {
|
||||
StrokeLayer {
|
||||
overprint: self.overprint_stroke,
|
||||
@@ -275,11 +281,13 @@ impl CharStyle {
|
||||
join: self.stroke_join,
|
||||
miter_limit: self.stroke_miter_limit,
|
||||
dash: self.stroke_dash.clone(),
|
||||
align: self.stroke_align,
|
||||
..StrokeLayer::new(self.stroke.clone(), self.stroke_width)
|
||||
}
|
||||
}
|
||||
/// Take the paint, weight, cap, join, miter limit and dashes of `st` as the character stroke
|
||||
/// (the options characters have no use for, such as alignment and arrowheads, are dropped).
|
||||
/// Take the paint, weight, cap, join, miter limit, dashes and alignment of `st` as the
|
||||
/// character stroke (the options characters have no use for, such as arrowheads and width
|
||||
/// profiles, are dropped).
|
||||
pub fn set_stroke_layer(&mut self, st: &StrokeLayer) {
|
||||
self.stroke = st.paint.clone();
|
||||
self.stroke_width = st.width;
|
||||
@@ -287,6 +295,19 @@ impl CharStyle {
|
||||
self.stroke_join = st.join;
|
||||
self.stroke_miter_limit = st.miter_limit;
|
||||
self.stroke_dash = st.dash.clone();
|
||||
self.stroke_align = st.align;
|
||||
}
|
||||
/// How far the character stroke paints past the glyph outlines (text space): half the weight
|
||||
/// centred, the whole weight outside, nothing inside (miter spikes aside).
|
||||
pub fn stroke_outset(&self) -> f64 {
|
||||
if !self.has_stroke() {
|
||||
return 0.0;
|
||||
}
|
||||
match self.stroke_align {
|
||||
StrokeAlign::Center => self.stroke_width / 2.0,
|
||||
StrokeAlign::Outside => self.stroke_width,
|
||||
StrokeAlign::Inside => 0.0,
|
||||
}
|
||||
}
|
||||
/// The character fill as a fill layer, overprinting as the characters do.
|
||||
fn fill_layer(&self) -> FillLayer {
|
||||
@@ -360,6 +381,37 @@ impl Composer {
|
||||
}
|
||||
}
|
||||
|
||||
/// A paragraph's hyphenation options (Paragraph panel menu › Hyphenation…): which words automatic
|
||||
/// hyphenation may break, where, and on how many lines in a row. Saved only when they differ from
|
||||
/// the defaults: words longer than 5 letters, 3 letters on each side of the hyphen, no hyphen
|
||||
/// limit, no hyphenation zone, and capitalized words hyphenated.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct HyphenationOptions {
|
||||
/// Words Longer Than: only words with more letters than this are hyphenated.
|
||||
pub words_longer_than: u32,
|
||||
/// After First: the fewest letters a word keeps before the hyphen.
|
||||
pub after_first: u32,
|
||||
/// Before Last: the fewest letters a word keeps after the hyphen.
|
||||
pub before_last: u32,
|
||||
/// Hyphen Limit: the most consecutive lines that automatic hyphenation may end with a hyphen;
|
||||
/// 0 sets no limit.
|
||||
pub hyphen_limit: u32,
|
||||
/// Hyphenation Zone (pt): with the Single-line Composer, a line whose text before the next
|
||||
/// word already ends within this distance of its right edge breaks before that word instead
|
||||
/// of hyphenating it. 0 allows every hyphenation.
|
||||
pub zone: f64,
|
||||
/// Hyphenate Capitalized Words: when it is off, words that start with a capital letter are not
|
||||
/// hyphenated. Words in capitals only are never hyphenated.
|
||||
pub capitalized: bool,
|
||||
}
|
||||
|
||||
impl Default for HyphenationOptions {
|
||||
fn default() -> Self {
|
||||
Self { words_longer_than: 5, after_first: 3, before_last: 3, hyphen_limit: 0, zone: 0.0, capitalized: true }
|
||||
}
|
||||
}
|
||||
|
||||
/// Paragraph attributes (the Paragraph panel).
|
||||
///
|
||||
/// Saved through [`ParaStyleFile`], which keeps files with text openable by builds from before
|
||||
@@ -374,6 +426,8 @@ pub struct ParaStyle {
|
||||
pub space_before: f64,
|
||||
pub space_after: f64,
|
||||
pub hyphenate: bool,
|
||||
/// The limits automatic hyphenation keeps to (Paragraph panel menu › Hyphenation…).
|
||||
pub hyphenation: HyphenationOptions,
|
||||
/// Line breaking: Single-line or Every-line Composer.
|
||||
pub composer: Composer,
|
||||
/// Tab stops (Tabs panel), sorted by position.
|
||||
@@ -421,6 +475,8 @@ struct ParaStyleFile {
|
||||
space_after: f64,
|
||||
#[serde(default)]
|
||||
hyphenate: bool,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
hyphenation: HyphenationOptions,
|
||||
#[serde(default, skip_serializing_if = "Composer::is_default")]
|
||||
composer: Composer,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
@@ -454,6 +510,7 @@ impl From<ParaStyle> for ParaStyleFile {
|
||||
space_before,
|
||||
space_after,
|
||||
hyphenate,
|
||||
hyphenation,
|
||||
composer,
|
||||
tabs,
|
||||
style_name,
|
||||
@@ -473,6 +530,7 @@ impl From<ParaStyle> for ParaStyleFile {
|
||||
space_before,
|
||||
space_after,
|
||||
hyphenate,
|
||||
hyphenation,
|
||||
composer,
|
||||
tabs,
|
||||
style_name,
|
||||
@@ -495,6 +553,7 @@ impl From<ParaStyleFile> for ParaStyle {
|
||||
space_before,
|
||||
space_after,
|
||||
hyphenate,
|
||||
hyphenation,
|
||||
composer,
|
||||
tabs,
|
||||
style_name,
|
||||
@@ -513,6 +572,7 @@ impl From<ParaStyleFile> for ParaStyle {
|
||||
space_before,
|
||||
space_after,
|
||||
hyphenate,
|
||||
hyphenation,
|
||||
composer,
|
||||
tabs,
|
||||
style_name,
|
||||
|
||||
@@ -28,7 +28,7 @@ use crate::group::{has_own_paint, paints};
|
||||
use crate::reshape::{as_art, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves};
|
||||
use crate::{
|
||||
GeomContext, adjust_in_document, apply_geometry_with, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry,
|
||||
has_pathfinder, is_geometry, is_pathfinder, merged_params, needs_outline, outline_text, reshape,
|
||||
has_item_geometry, has_pathfinder, is_geometry, is_pathfinder, merged_params, needs_outline, outline_text, reshape,
|
||||
};
|
||||
|
||||
fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] {
|
||||
@@ -106,9 +106,10 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
let m = evaluate_container(&m).unwrap_or(m);
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
// Type, images, symbol instances and live objects: reshaped through their outlines. Type that
|
||||
// Type, images, symbol instances and live objects: reshaped through their outlines, and so is
|
||||
// type whose own fills or strokes have geometry effects ([`has_item_geometry`]). Type that
|
||||
// Transform effects only move and copy stays type, moved to each place.
|
||||
if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some()) {
|
||||
if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some() || has_item_geometry(n)) {
|
||||
if let Some(m) = moved_type_group(n) {
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! - **Geometry effects** (Distort & Transform, Path, Convert to Shape, Round Corners, Scribble,
|
||||
//! Warp) rewrite a path: [`apply_geometry`] evaluates them in stack order.
|
||||
//! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur, and the
|
||||
//! Photoshop-style filters of [`pixel`]: Radial Blur, Smart Blur, Color Halftone, Crystallize, Mezzotint, Pointillize,
|
||||
//! Photoshop-style filters of [`pixel`]: Radial Blur, Smart Blur, Color Halftone, Crystallize, Mezzotint, Pointillize, Halftone Pattern,
|
||||
//! Unsharp Mask and Glowing Edges) are described by
|
||||
//! [`raster_effects`] and painted by the renderer; [`outset`] says how far they reach beyond
|
||||
//! the geometry.
|
||||
@@ -69,7 +69,7 @@ pub use live::{expand_live, expand_live_deep, expanded_live_group, text_outliner
|
||||
pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
|
||||
pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
|
||||
pub use raster::{RasterFx, outset, raster_effects};
|
||||
pub use reshape::{expand_outlined, needs_outline, outline_art, outline_text, reshape};
|
||||
pub use reshape::{expand_outlined, has_item_geometry, needs_outline, outline_art, outline_text, reshape};
|
||||
pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve};
|
||||
pub use solids::{
|
||||
EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, extrude_options, has_solid, inflate_options, is_three_d, solid_art, validate_solid,
|
||||
@@ -149,6 +149,7 @@ fn lengths_of(id: &str) -> Lengths {
|
||||
"texture.mosaicTiles" => always(&["tileSize"]),
|
||||
"texture.patchwork" => always(&["squareSize"]),
|
||||
"texture.stainedGlass" => always(&["cellSize"]),
|
||||
"sketch.halftonePattern" => always(&["size"]),
|
||||
_ => Lengths::default(),
|
||||
}
|
||||
}
|
||||
@@ -165,6 +166,7 @@ const ARTISTIC: &[&str] = &["Effect", "Artistic"];
|
||||
const BRUSH_STROKES: &[&str] = &["Effect", "Brush Strokes"];
|
||||
const DISTORT: &[&str] = &["Effect", "Distort"];
|
||||
const SHARPEN: &[&str] = &["Effect", "Sharpen"];
|
||||
const SKETCH: &[&str] = &["Effect", "Sketch"];
|
||||
const PIXELATE: &[&str] = &["Effect", "Pixelate"];
|
||||
const TEXTURE: &[&str] = &["Effect", "Texture"];
|
||||
const VIDEO: &[&str] = &["Effect", "Video"];
|
||||
@@ -289,6 +291,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"{offset: pt (10; negative insets), joins: \"miter\"|\"round\"|\"bevel\", miterLimit: (4)}",
|
||||
json!({"offset": 10.0, "joins": "miter", "miterLimit": 4.0}),
|
||||
),
|
||||
g(
|
||||
"path.outlineObject",
|
||||
"Outline Object",
|
||||
PATH,
|
||||
"{} type is drawn, exported and expanded as its glyph outlines and stays editable, and the effects after it reshape those outlines; paths and images do not change",
|
||||
json!({}),
|
||||
),
|
||||
g(
|
||||
"path.outlineStroke",
|
||||
"Outline Stroke",
|
||||
@@ -432,6 +441,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"{amount: % 1..500 (50), radius: pt 0.1..250 (1; σ of the blur edges are found against), threshold: levels 0..255 (0; smaller differences are left alone)}",
|
||||
json!({"amount": 50.0, "radius": 1.0, "threshold": 0.0}),
|
||||
),
|
||||
r(
|
||||
"sketch.halftonePattern",
|
||||
"Halftone Pattern…",
|
||||
SKETCH,
|
||||
"{size: pt 1..12 (1; how far apart the screen's dots, rings or lines are), contrast: 0..50 (5; 50 draws pure black and white), patternType: \"circle\"|\"dot\"|\"line\" (\"dot\"; rings round the object's centre, dots on a square grid, horizontal lines)} redraws the object in black ink on white paper through a halftone screen whose ink covers as much as the colour is dark, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"size": 1.0, "contrast": 5.0, "patternType": "dot"}),
|
||||
),
|
||||
r(
|
||||
"stylize.glowingEdges",
|
||||
"Glowing Edges…",
|
||||
@@ -865,6 +881,9 @@ pub(crate) fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &Geo
|
||||
}
|
||||
vectorcraft_pathops::offset_path(path, off, join(p, "joins"), num(p, "miterLimit", 4.0).clamp(1.0, 500.0))
|
||||
}
|
||||
// Outline Object returns the geometry unchanged. Type reaches every geometry effect as its
|
||||
// glyph outlines (`reshape::reshape`), so type that carries it is drawn as those outlines.
|
||||
"path.outlineObject" => path.clone(),
|
||||
"path.outlineStroke" => {
|
||||
let fallback;
|
||||
let base = match ctx.stroke {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Photoshop-style raster effects (Effect › Artistic › Film Grain, Blur › Radial Blur and Smart Blur, Brush Strokes ›
|
||||
//! Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes
|
||||
//! and Sumi-e, Distort › Diffuse Glow, Glass and Ocean Ripple, Pixelate › Color Halftone,
|
||||
//! Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Stylize › Glowing Edges, Texture
|
||||
//! Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Sketch › Halftone Pattern, Stylize ›
|
||||
//! Glowing Edges, Texture
|
||||
//! › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, and Video ›
|
||||
//! De-Interlace and NTSC Colors): filters over premultiplied RGBA8 pixels.
|
||||
//!
|
||||
@@ -20,6 +21,7 @@ mod edges;
|
||||
mod loaded;
|
||||
mod pixelate;
|
||||
mod sharpen;
|
||||
mod sketch;
|
||||
mod texture;
|
||||
mod video;
|
||||
|
||||
@@ -33,10 +35,11 @@ pub use brushstrokes::{STROKE_DIRECTIONS, StrokeDirection};
|
||||
pub use distort::{GLASS_TEXTURES, GLASS_TEXTURES_LOADED, GlassTexture};
|
||||
pub use loaded::{LOADED, LoadedTexture, TEXTURE_IMAGE, texture};
|
||||
pub use pixelate::{MEZZOTINT_TYPES, Mezzotint};
|
||||
pub use sketch::{HALFTONE_TYPES, HalftonePattern};
|
||||
pub use texture::{GRAIN_TYPES, Grain, LIGHT_DIRECTIONS, Light, TEXTURES, Texture};
|
||||
|
||||
/// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]).
|
||||
pub const PIXEL_EFFECTS: [&str; 28] = [
|
||||
pub const PIXEL_EFFECTS: [&str; 29] = [
|
||||
"artistic.filmGrain",
|
||||
"blur.radial",
|
||||
"blur.smart",
|
||||
@@ -56,6 +59,7 @@ pub const PIXEL_EFFECTS: [&str; 28] = [
|
||||
"pixelate.mezzotint",
|
||||
"pixelate.pointillize",
|
||||
"sharpen.unsharpMask",
|
||||
"sketch.halftonePattern",
|
||||
"stylize.glowingEdges",
|
||||
"texture.craquelure",
|
||||
"texture.grain",
|
||||
@@ -123,6 +127,9 @@ pub enum PixelFx {
|
||||
/// Sharpen › Unsharp Mask: colours pushed away from a Gaussian blur of σ = `radius` by `amount`
|
||||
/// (1 = 100 %), where they differ from it by at least `threshold` levels.
|
||||
UnsharpMask { amount: f64, radius: f64, threshold: f64 },
|
||||
/// Sketch › Halftone Pattern: black ink on white paper through a `kind` screen of cells `size`
|
||||
/// (1..12) apart, its edges as hard as `contrast` (0..50) says.
|
||||
HalftonePattern { size: f64, contrast: f64, kind: HalftonePattern },
|
||||
/// Stylize › Glowing Edges: bright coloured Sobel outlines on black.
|
||||
GlowingEdges { width: f64, brightness: f64, smoothness: f64 },
|
||||
/// Pixelate › Color Halftone: each colour channel screened at its angle (`angles`° of channels
|
||||
@@ -277,6 +284,11 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
|
||||
radius: num(p, "radius", 1.0).clamp(0.1, 250.0),
|
||||
threshold: num(p, "threshold", 0.0).clamp(0.0, 255.0),
|
||||
},
|
||||
"sketch.halftonePattern" => PixelFx::HalftonePattern {
|
||||
size: num(p, "size", 1.0).clamp(1.0, 12.0),
|
||||
contrast: num(p, "contrast", 5.0).clamp(0.0, 50.0),
|
||||
kind: HalftonePattern::parse(text(p, "patternType", "dot")),
|
||||
},
|
||||
"stylize.glowingEdges" => PixelFx::GlowingEdges {
|
||||
width: num(p, "edgeWidth", 2.0).clamp(1.0, 14.0),
|
||||
brightness: num(p, "edgeBrightness", 6.0).clamp(0.0, 20.0),
|
||||
@@ -353,7 +365,7 @@ impl PixelFx {
|
||||
// Content shifted outwards reaches past the bounds by as much as the shift.
|
||||
PixelFx::Glass { distortion, .. } => distort::glass_shift(distortion),
|
||||
PixelFx::OceanRipple { size, magnitude } => distort::ripple_shift(size, magnitude),
|
||||
PixelFx::UnsharpMask { .. } => 0.0,
|
||||
PixelFx::UnsharpMask { .. } | PixelFx::HalftonePattern { .. } => 0.0,
|
||||
PixelFx::GlowingEdges { width, smoothness, .. } => width.max(smoothness * 3.0),
|
||||
PixelFx::FilmGrain { .. }
|
||||
| PixelFx::ColorHalftone { .. }
|
||||
@@ -396,7 +408,7 @@ impl PixelFx {
|
||||
// The cells around the pixel's, and the cells around theirs.
|
||||
PixelFx::ColorHalftone { max_radius, .. } => Some(3.0 * std::f64::consts::SQRT_2 * max_radius),
|
||||
PixelFx::Crystallize { cell } => Some(1.5 * cell),
|
||||
PixelFx::FilmGrain { .. } | PixelFx::Mezzotint { .. } => Some(0.0),
|
||||
PixelFx::FilmGrain { .. } | PixelFx::Mezzotint { .. } | PixelFx::HalftonePattern { .. } => Some(0.0),
|
||||
// The dots' points, and the softened colour around them.
|
||||
PixelFx::Pointillize { cell } => Some(2.5 * cell),
|
||||
// The softened tones around the pixel.
|
||||
@@ -439,6 +451,7 @@ impl PixelFx {
|
||||
}
|
||||
PixelFx::OceanRipple { size, magnitude } => distort::ocean_ripple(px, w, h, space, size, magnitude),
|
||||
PixelFx::UnsharpMask { amount, radius, threshold } => sharpen::unsharp(px, w, h, amount, to_px(radius), threshold),
|
||||
PixelFx::HalftonePattern { size, contrast, kind } => sketch::halftone_pattern(px, w, h, space, size, contrast, kind),
|
||||
PixelFx::GlowingEdges { width, brightness, smoothness } => edges::glow(px, w, h, to_px(width), brightness, to_px(smoothness)),
|
||||
PixelFx::ColorHalftone { max_radius, angles } => pixelate::color_halftone(px, w, h, space, max_radius, angles),
|
||||
PixelFx::Crystallize { cell } => pixelate::crystallize(px, w, h, space, cell),
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Effect › Sketch › Halftone Pattern. Original filter built from public behaviour documentation:
|
||||
//! <https://helpx.adobe.com/illustrator/using/summary-effects.html> (Sketch effects redraw in
|
||||
//! black and white; Halftone Pattern simulates a halftone screen while keeping the range of tones)
|
||||
//! and <https://helpx.adobe.com/illustrator/using/effects.html> (only Size depends on the raster
|
||||
//! resolution). A visual approximation, not a reproduction of a proprietary algorithm.
|
||||
|
||||
use super::pixelate::Place;
|
||||
use super::texture::{luma, premultiply, straight};
|
||||
use super::{PixelSpace, pick};
|
||||
|
||||
/// Halftone Pattern's screens, in its Pattern Type menu's order: (label, parameter value).
|
||||
pub const HALFTONE_TYPES: [(&str, &str); 3] = [("Circle", "circle"), ("Dot", "dot"), ("Line", "line")];
|
||||
|
||||
/// A Halftone Pattern screen ([`HALFTONE_TYPES`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum HalftonePattern {
|
||||
/// Rings round the object's centre.
|
||||
Circle,
|
||||
/// Dots on a square grid.
|
||||
Dot,
|
||||
/// Horizontal lines.
|
||||
Line,
|
||||
}
|
||||
|
||||
impl HalftonePattern {
|
||||
const ALL: [HalftonePattern; 3] = [HalftonePattern::Circle, HalftonePattern::Dot, HalftonePattern::Line];
|
||||
|
||||
/// The screen of Pattern Type `value` (a [`HALFTONE_TYPES`] value, any case); Dot otherwise.
|
||||
pub fn parse(value: &str) -> Self {
|
||||
pick(&HALFTONE_TYPES, &Self::ALL, value, HalftonePattern::Dot)
|
||||
}
|
||||
}
|
||||
|
||||
/// Halftone Pattern: the object redrawn in black ink on white paper through a `kind` screen whose
|
||||
/// cells are `size` points apart, anchored at the object's centre. The ink covers as much of a
|
||||
/// cell as the colour is dark; `contrast` (0..50) hardens the screen's soft edges, to pure black
|
||||
/// and white at 50. Coverage is kept.
|
||||
pub(super) fn halftone_pattern(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, size: f64, contrast: f64, kind: HalftonePattern) {
|
||||
if !(size.is_finite() && size > 0.0 && contrast.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
// The width of the screen's soft edge, in tones: 0 is a hard threshold.
|
||||
let soft = (0.5 * (1.0 - contrast / 50.0)).clamp(0.0, 0.5);
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let q = place.at(x, y);
|
||||
let band = |v: f64| (2.0 * ((v / size).rem_euclid(1.0) - 0.5)).abs();
|
||||
// Where in its cell the pixel lies, 0 where the ink starts to 1 where it ends.
|
||||
let screen = match kind {
|
||||
HalftonePattern::Line => band(q.y),
|
||||
HalftonePattern::Circle => band(q.x.hypot(q.y)),
|
||||
HalftonePattern::Dot => 0.5 * (band(q.x).powi(2) + band(q.y).powi(2)),
|
||||
};
|
||||
let dark = f64::from(1.0 - luma(straight(*p))).clamp(0.0, 1.0);
|
||||
let ink = if dark >= 1.0 {
|
||||
1.0
|
||||
} else if soft > 0.0 {
|
||||
// The threshold kept inside [soft / 2, 1 - soft / 2], so white stays white and black black.
|
||||
let threshold = 0.5 * soft + screen * (1.0 - soft);
|
||||
((dark - threshold) / soft + 0.5).clamp(0.0, 1.0)
|
||||
} else if dark > screen {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
*p = premultiply([(1.0 - ink) as f32; 3], f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
@@ -1893,3 +1893,119 @@ fn brush_strokes_follow_the_object_at_any_resolution() {
|
||||
assert_eq!(shifted, one, "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Halftone Pattern of `p` over `w` × `h` pixels of `src`, laid out by `s`.
|
||||
fn halftone(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec<u8> {
|
||||
let mut out = src.to_vec();
|
||||
fx("sketch.halftonePattern", p).apply(&mut out, w, h, s);
|
||||
out
|
||||
}
|
||||
|
||||
/// The smallest period of `values` (their length when they don't repeat).
|
||||
fn period(values: &[u8]) -> usize {
|
||||
(1..values.len()).find(|p| values.iter().zip(&values[*p..]).all(|(a, b)| a == b)).unwrap_or(values.len())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn halftone_pattern_params_take_defaults_and_stay_in_range() {
|
||||
assert_eq!(fx("sketch.halftonePattern", json!({})), PixelFx::HalftonePattern { size: 1.0, contrast: 5.0, kind: HalftonePattern::Dot });
|
||||
assert_eq!(
|
||||
fx("sketch.halftonePattern", json!({"size": 1e9, "contrast": -3, "patternType": "LINE"})),
|
||||
PixelFx::HalftonePattern { size: 12.0, contrast: 0.0, kind: HalftonePattern::Line }
|
||||
);
|
||||
assert_eq!(
|
||||
fx("sketch.halftonePattern", json!({"size": 0, "contrast": 1e9, "patternType": "circle"})),
|
||||
PixelFx::HalftonePattern { size: 1.0, contrast: 50.0, kind: HalftonePattern::Circle }
|
||||
);
|
||||
for junk in [json!({"patternType": 2}), json!({"patternType": "nonsense"})] {
|
||||
assert_eq!(fx("sketch.halftonePattern", junk), fx("sketch.halftonePattern", json!({})));
|
||||
}
|
||||
let doc = crate::effect_info("sketch.halftonePattern").unwrap().params;
|
||||
let kinds: Vec<HalftonePattern> = HALFTONE_TYPES.iter().map(|(_, v)| HalftonePattern::parse(v)).collect();
|
||||
assert_eq!(kinds, [HalftonePattern::Circle, HalftonePattern::Dot, HalftonePattern::Line]);
|
||||
assert!(HALFTONE_TYPES.iter().all(|(_, v)| doc.contains(&format!("\"{v}\""))), "{doc}");
|
||||
let f = fx("sketch.halftonePattern", json!({"size": 12}));
|
||||
assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), 0.0);
|
||||
assert_eq!(f.reach(), Some(0.0));
|
||||
}
|
||||
|
||||
/// Line's rows repeat every Size points: Size pixels at 72 ppi, twice as many at 144 ppi.
|
||||
#[test]
|
||||
fn halftone_line_period_is_the_size_at_any_resolution() {
|
||||
let p = json!({"size": 4, "contrast": 50, "patternType": "line"});
|
||||
let (w, h) = (48, 48);
|
||||
let out = halftone(p.clone(), &image(w, h, |_, _| grey(128)), w, h, &space(w, h));
|
||||
let column: Vec<u8> = (0..h).map(|y| at(&out, w, 0, y)[0]).collect();
|
||||
assert!(column.contains(&0) && column.contains(&255), "{column:?}");
|
||||
assert_eq!(period(&column), 4, "{column:?}");
|
||||
let fine = PixelSpace { to_doc: Affine::scale(0.5), px: 0.5, ..space(w, h) };
|
||||
let out = halftone(p, &image(2 * w, 2 * h, |_, _| grey(128)), 2 * w, 2 * h, &fine);
|
||||
let column: Vec<u8> = (0..2 * h).map(|y| at(&out, 2 * w, 0, y)[0]).collect();
|
||||
assert_eq!(period(&column), 8, "{column:?}");
|
||||
}
|
||||
|
||||
/// Circle draws rings round the object's centre, Line rows that don't change along them, and
|
||||
/// Dot neither.
|
||||
#[test]
|
||||
fn halftone_pattern_type_changes_the_pattern() {
|
||||
let (w, h) = (64, 64);
|
||||
let src = image(w, h, |_, _| grey(100));
|
||||
// The centre on a pixel's centre: pixel (32 + dx, 32 + dy) lies at (dx, dy) from it.
|
||||
let s = PixelSpace { center: Point::new(32.5, 32.5), ..space(w, h) };
|
||||
let draw = |kind: &str| halftone(json!({"size": 8, "contrast": 50, "patternType": kind}), &src, w, h, &s);
|
||||
let (circle, dot, line) = (draw("circle"), draw("dot"), draw("line"));
|
||||
assert!(circle != dot && dot != line && line != circle);
|
||||
// Pixels at the same distance from the centre in different directions.
|
||||
let same_radius: [&[(i64, i64)]; 6] = [
|
||||
&[(5, 0), (3, 4), (-4, 3), (0, -5), (-3, -4)],
|
||||
&[(10, 0), (6, 8), (-8, -6), (0, 10)],
|
||||
&[(13, 0), (5, 12), (-12, 5), (0, -13)],
|
||||
&[(15, 0), (9, 12), (-12, 9)],
|
||||
&[(17, 0), (8, 15), (-15, -8)],
|
||||
&[(25, 0), (7, 24), (-15, 20), (-20, -15), (24, -7)],
|
||||
];
|
||||
let value = |d: &[u8], (dx, dy): (i64, i64)| at(d, w, (32 + dx) as usize, (32 + dy) as usize)[0];
|
||||
let radial = |d: &[u8]| same_radius.iter().all(|ring| ring.iter().all(|q| value(d, *q) == value(d, ring[0])));
|
||||
assert!(radial(&circle), "Circle is the same all round");
|
||||
assert!(!radial(&dot) && !radial(&line));
|
||||
for y in 0..h {
|
||||
assert!((0..w).all(|x| at(&line, w, x, y) == at(&line, w, 0, y)), "row {y} of Line is uniform");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn halftone_contrast_sharpens_the_screen() {
|
||||
let (w, h) = (64, 32);
|
||||
let src = image(w, h, |x, _| grey((x * 4) as u8));
|
||||
let mid = |d: &[u8]| share(d, |p| p[0] != 0 && p[0] != 255);
|
||||
let soft = halftone(json!({"size": 6, "contrast": 0}), &src, w, h, &space(w, h));
|
||||
let hard = halftone(json!({"size": 6, "contrast": 50}), &src, w, h, &space(w, h));
|
||||
assert!(mid(&soft) > 0.3, "{}", mid(&soft));
|
||||
assert_eq!(mid(&hard), 0.0);
|
||||
}
|
||||
|
||||
/// Halftone Pattern redraws in black ink on white paper, whatever the object's colours, and
|
||||
/// keeps its coverage.
|
||||
#[test]
|
||||
fn halftone_draws_in_black_and_white_only() {
|
||||
let (w, h) = (48, 48);
|
||||
let src = image(w, h, |x, y| {
|
||||
let c = [[230u8, 20, 20], [20, 200, 40], [30, 60, 220], [240, 220, 30]][(x / 12) % 4];
|
||||
let a: u16 = if y < 24 { 255 } else { 128 };
|
||||
[c[0], c[1], c[2]].map(|v| (u16::from(v) * a / 255) as u8).into_iter().chain([a as u8]).collect::<Vec<_>>().try_into().unwrap()
|
||||
});
|
||||
for kind in ["circle", "dot", "line"] {
|
||||
let out = halftone(json!({"size": 6, "contrast": 50, "patternType": kind}), &src, w, h, &space(w, h));
|
||||
for (p, q) in out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) {
|
||||
assert_eq!(p[3], q[3], "{kind}: coverage kept");
|
||||
assert!(p[0] == p[1] && p[1] == p[2] && (p[0] == 0 || p[0] == p[3]), "{kind}: {p:?} from {q:?}");
|
||||
}
|
||||
assert!(share(&out, |p| p[0] == 0) > 0.1 && share(&out, |p| p[0] == p[3]) > 0.1, "{kind}: ink and paper");
|
||||
let soft = halftone(json!({"size": 6, "contrast": 0, "patternType": kind}), &src, w, h, &space(w, h));
|
||||
assert!(soft.as_chunks::<4>().0.iter().all(|p| p[0] == p[1] && p[1] == p[2]), "{kind}: greys only");
|
||||
for (tone, expect) in [(0, 0), (255, 255)] {
|
||||
let out = halftone(json!({"size": 6, "contrast": 0, "patternType": kind}), &image(w, h, |_, _| grey(tone)), w, h, &space(w, h));
|
||||
assert!(out.as_chunks::<4>().0.iter().all(|p| p[0] == expect), "{kind}: {tone} stays {expect}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,9 @@
|
||||
//! (glyph outlines, the evaluated live object, the symbol's art) and [`reshape`] runs each effect
|
||||
//! over all of them as one piece, with the whole art's bounds as the effect's reference box, so a
|
||||
//! warp bends a line of type as a whole. An image is clipped by its reshaped frame. Raster effects
|
||||
//! (shadows, glows, blur, feather) stay on the result for the renderer and exporters.
|
||||
//! (shadows, glows, blur, feather) stay on the result for the renderer and exporters. Type without
|
||||
//! inline graphics whose own fills or strokes have geometry effects is outlined too: each of those
|
||||
//! effects reshapes the whole outline that its fill or stroke paints.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -30,6 +32,14 @@ pub fn needs_outline(n: &Node) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// Is `n` type without inline graphics that has a fill or stroke of its own that paints and has a
|
||||
/// visible geometry effect? Such an effect reshapes the whole outline that its fill or stroke
|
||||
/// paints ([`outline_text`], which leaves inline graphics out).
|
||||
pub fn has_item_geometry(n: &Node) -> bool {
|
||||
let NodeKind::Text(t) = &n.kind else { return false };
|
||||
t.runs.iter().all(|r| r.inline.is_none()) && n.appearance.items.iter().any(|i| crate::paints(i) && crate::has_geometry(i.effects()))
|
||||
}
|
||||
|
||||
/// The glyph outlines of type `t` in text space with its underline and strikethrough bars: one
|
||||
/// path per run, and all of them as one.
|
||||
pub(crate) fn glyph_outlines(t: &vectorcraft_doc::TextObject) -> (Vec<kurbo::BezPath>, kurbo::BezPath) {
|
||||
@@ -155,11 +165,13 @@ pub(crate) fn outline_nested_text(n: &mut Node, clipping: bool) {
|
||||
|
||||
/// `n` (see [`needs_outline`]) with its object-level geometry effects and its perspective applied:
|
||||
/// its [`outline_art`] reshaped as one piece, or for an image the image clipped by its reshaped
|
||||
/// frame. The result is a group with `n`'s id, name, visibility, transparency, opacity mask and
|
||||
/// remaining (raster) effects; `None` when `n` has neither a visible geometry effect nor a
|
||||
/// perspective, or has no art (a symbol instance without `symbol_art`).
|
||||
/// frame. Type's own fills and strokes keep their effects on its whole outline. The result is a
|
||||
/// group with `n`'s id, name, visibility, transparency, opacity mask and remaining (raster)
|
||||
/// effects; `None` when `n` has no visible geometry effect, no perspective and is not type with
|
||||
/// a geometry effect on a fill or stroke of its own ([`has_item_geometry`]), or has no art (a
|
||||
/// symbol instance without `symbol_art`).
|
||||
pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
if (geometry_effects(n).is_empty() && n.projection().is_none()) || !needs_outline(n) {
|
||||
if (geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !needs_outline(n) {
|
||||
return None;
|
||||
}
|
||||
as_art(n, symbol_art)
|
||||
|
||||
@@ -572,6 +572,18 @@ fn outline_stroke_uses_context_stroke() {
|
||||
assert!(close(out.bounds().unwrap().width(), 101.0, 0.1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_object_is_a_geometry_effect_that_keeps_the_path() {
|
||||
let info = effect_info("path.outlineObject").unwrap();
|
||||
assert_eq!((info.label, info.menu, &info.defaults, info.raster), ("Outline Object", &["Effect", "Path"][..], &json!({}), false));
|
||||
assert!(is_geometry("path.outlineObject"));
|
||||
assert_eq!(run("path.outlineObject", json!({}), &square()), square());
|
||||
// The effects after it reshape the same geometry.
|
||||
let b = square().bounds().unwrap();
|
||||
let offset = fx("path.offsetPath", json!({"offset": 5}));
|
||||
assert_eq!(apply_geometry(&[fx("path.outlineObject", json!({})), offset.clone()], &square(), b), apply_geometry(&[offset], &square(), b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_to_shape_sizes() {
|
||||
let r = run("convertToShape.rectangle", json!({"extraW": 20, "extraH": 10}), &square()).bounds().unwrap();
|
||||
|
||||
@@ -80,7 +80,7 @@ fn clean_name(name: &str) -> Option<String> {
|
||||
|
||||
/// Ids of art under `id`: the id itself when it is not a layer, else every non-layer under it
|
||||
/// (sublayers unfolded). Layers alone have nothing to collect.
|
||||
fn art_under(doc: &vectorcraft_doc::Document, id: NodeId, out: &mut Vec<NodeId>) {
|
||||
pub(crate) fn art_under(doc: &vectorcraft_doc::Document, id: NodeId, out: &mut Vec<NodeId>) {
|
||||
let Some(n) = doc.node(id) else { return };
|
||||
if n.is_layer() {
|
||||
if let Some(kids) = n.children() {
|
||||
|
||||
@@ -11,7 +11,7 @@ use std::sync::Arc;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_brush::{self as brush, Brush};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Symbol, SymbolRegistration};
|
||||
use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Symbol, SymbolExportType, SymbolRegistration};
|
||||
use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
|
||||
use super::edit::{outermost, selected_roots};
|
||||
@@ -95,16 +95,25 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
brush_freehand
|
||||
),
|
||||
// ----- symbols -----
|
||||
cmd!(query "symbol.list", "Symbols", [], None, "{} → {symbols: [{name, instances, size: [w,h], registration: 0..8}], current}", has_doc, symbol_list),
|
||||
cmd!(query "symbol.list", "Symbols", [], None, "{} → {symbols: [{name, instances, size: [w,h], registration: 0..8, exportType: movieClip|graphic}], current}", has_doc, symbol_list),
|
||||
cmd!(
|
||||
"symbol.new",
|
||||
"New Symbol…",
|
||||
[],
|
||||
None,
|
||||
"{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration)} make a symbol from the selection and replace it with an instance → {name, id, registration}",
|
||||
"{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration), exportType?: movieClip (default)|graphic} make a symbol from the selection and replace it with an instance → {name, id, registration, exportType}",
|
||||
has_selection,
|
||||
symbol_new
|
||||
),
|
||||
cmd!(
|
||||
"symbol.options",
|
||||
"Symbol Options…",
|
||||
[],
|
||||
None,
|
||||
"{name?, newName?, registration?: 0..8, exportType?: movieClip|graphic} rename a symbol and/or set Symbol Options › Registration and Export Type (default name: current) → {name, registration, exportType}",
|
||||
has_doc,
|
||||
symbol_options
|
||||
),
|
||||
cmd!(
|
||||
"symbol.place",
|
||||
"Place Symbol Instance",
|
||||
@@ -517,10 +526,30 @@ fn place_xf(at: Point, (w, h): (f64, f64), reg: SymbolRegistration) -> Affine {
|
||||
Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(w / (2.0 * HALF), h / (2.0 * HALF))
|
||||
}
|
||||
|
||||
fn registration_param(p: &Value, cmd: &str) -> Result<SymbolRegistration> {
|
||||
fn registration_param(p: &Value, cmd: &str) -> Result<Option<SymbolRegistration>> {
|
||||
match p.get("registration") {
|
||||
None => Ok(SymbolRegistration::CENTER),
|
||||
Some(v) => v.as_u64().and_then(SymbolRegistration::from_u64).ok_or_else(|| bad(cmd, "registration must be 0..8")),
|
||||
None => Ok(None),
|
||||
Some(v) => v.as_u64().and_then(SymbolRegistration::from_u64).map(Some).ok_or_else(|| bad(cmd, "registration must be 0..8")),
|
||||
}
|
||||
}
|
||||
|
||||
fn export_type_param(p: &Value, cmd: &str) -> Result<Option<SymbolExportType>> {
|
||||
match str_param(p, "exportType") {
|
||||
None => Ok(None),
|
||||
Some(s) => SymbolExportType::from_id(s).map(Some).ok_or_else(|| bad(cmd, format!("exportType must be movieClip or graphic, not `{s}`"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn rename_natural_size(d: &mut Document, from: &str, to: &str) {
|
||||
if from == to {
|
||||
return;
|
||||
}
|
||||
let size = d.unknown.get(SIZES_KEY).and_then(|m| m.get(from)).cloned();
|
||||
set_natural_size(d, from, None);
|
||||
if let Some(v) = size
|
||||
&& let (Some(w), Some(h)) = (v.get(0).and_then(Value::as_f64), v.get(1).and_then(Value::as_f64))
|
||||
{
|
||||
set_natural_size(d, to, Some((w, h)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -566,6 +595,7 @@ fn symbol_list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
"instances": instances_of(d, Some(&sym.name)).len(),
|
||||
"size": [w, h],
|
||||
"registration": d.symbol_registration(&sym.name).0,
|
||||
"exportType": d.symbol_export_type(&sym.name).id(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -589,11 +619,13 @@ fn replace_with_instance(d: &mut Document, ids: &[NodeId], name: &str, xf: Affin
|
||||
}
|
||||
|
||||
fn symbol_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "symbol.new";
|
||||
let ids = roots(s, p)?;
|
||||
let art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?;
|
||||
let (art, size, c) = normalize(art)?;
|
||||
let name = s.doc()?.doc.unique_symbol_name(str_param(p, "name").filter(|n| !n.trim().is_empty()).unwrap_or("New Symbol"));
|
||||
let reg = registration_param(p, "symbol.new")?;
|
||||
let reg = registration_param(p, C)?.unwrap_or(SymbolRegistration::CENTER);
|
||||
let export = export_type_param(p, C)?.unwrap_or_default();
|
||||
// Keep the art where it was: the geometric centre stays at `c`, whatever the registration is.
|
||||
let place = place_xf(c + reg.offset_from_center(size.0, size.1), size, reg);
|
||||
let id = s.edit("New Symbol", |d, sel| {
|
||||
@@ -601,12 +633,64 @@ fn symbol_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
d.symbols.push(Symbol { name: name.clone(), art: Arc::new(art) });
|
||||
set_natural_size(d, &name, Some(size));
|
||||
d.set_symbol_registration(&name, reg);
|
||||
d.set_symbol_export_type(&name, export);
|
||||
d.unknown.insert(CURRENT_SYMBOL.into(), json!(name));
|
||||
let id = replace_with_instance(d, &ids, &name, place)?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({ "name": name, "id": id.0, "registration": reg.0 }))
|
||||
Ok(json!({ "name": name, "id": id.0, "registration": reg.0, "exportType": export.id() }))
|
||||
}
|
||||
|
||||
fn symbol_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "symbol.options";
|
||||
let name = resolve_symbol(&s.doc()?.doc, p, C)?;
|
||||
let export = export_type_param(p, C)?;
|
||||
let reg = registration_param(p, C)?;
|
||||
let new_name = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
if new_name.is_none() && export.is_none() && reg.is_none() {
|
||||
return Err(bad(C, "pass newName, registration and/or exportType"));
|
||||
}
|
||||
if let Some(ref n) = new_name
|
||||
&& n != &name
|
||||
&& s.doc()?.doc.symbols.iter().any(|x| x.name == *n)
|
||||
{
|
||||
return Err(bad(C, format!("a symbol named `{n}` already exists")));
|
||||
}
|
||||
let final_name = new_name.clone().unwrap_or_else(|| name.clone());
|
||||
s.edit("Symbol Options", |d, _| {
|
||||
if let Some(ref n) = new_name
|
||||
&& n != &name
|
||||
{
|
||||
if let Some(sym) = d.symbols.iter_mut().find(|x| x.name == name) {
|
||||
sym.name = n.clone();
|
||||
}
|
||||
for id in instances_of(d, Some(&name)) {
|
||||
if let Some(NodeKind::SymbolInstance { symbol, .. }) = d.node_mut(id).map(|node| &mut node.kind) {
|
||||
*symbol = n.clone();
|
||||
}
|
||||
}
|
||||
rename_natural_size(d, &name, n);
|
||||
d.rename_symbol_registration(&name, n);
|
||||
d.rename_symbol_export_type(&name, n);
|
||||
if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) {
|
||||
d.unknown.insert(CURRENT_SYMBOL.into(), json!(n));
|
||||
}
|
||||
}
|
||||
if let Some(r) = reg {
|
||||
d.set_symbol_registration(&final_name, r);
|
||||
}
|
||||
if let Some(kind) = export {
|
||||
d.set_symbol_export_type(&final_name, kind);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
let d = &s.doc()?.doc;
|
||||
Ok(json!({
|
||||
"name": final_name,
|
||||
"registration": d.symbol_registration(&final_name).0,
|
||||
"exportType": d.symbol_export_type(&final_name).id(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn resolve_symbol(d: &Document, p: &Value, cmd: &str) -> Result<String> {
|
||||
@@ -763,6 +847,7 @@ fn symbol_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
d.symbols.retain(|x| x.name != name);
|
||||
set_natural_size(d, &name, None);
|
||||
d.set_symbol_registration(&name, SymbolRegistration::CENTER);
|
||||
d.clear_symbol_export_type(&name);
|
||||
if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) {
|
||||
d.unknown.remove(CURRENT_SYMBOL);
|
||||
}
|
||||
@@ -779,14 +864,16 @@ fn symbol_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let new = d.unique_symbol_name(str_param(p, "newName").unwrap_or(&format!("{name} copy")));
|
||||
let size = natural_size(d, &name);
|
||||
let reg = d.symbol_registration(&name);
|
||||
let export = d.symbol_export_type(&name);
|
||||
s.edit("Duplicate Symbol", |d, _| {
|
||||
let art = d.reid(&sym.art);
|
||||
d.symbols.push(Symbol { name: new.clone(), art: Arc::new(art) });
|
||||
set_natural_size(d, &new, Some(size));
|
||||
d.set_symbol_registration(&new, reg);
|
||||
d.set_symbol_export_type(&new, export);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "name": new }))
|
||||
Ok(json!({ "name": new, "registration": reg.0, "exportType": export.id() }))
|
||||
}
|
||||
|
||||
fn symbol_replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -100,6 +100,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
save_selection
|
||||
),
|
||||
cmd!(
|
||||
"select.update",
|
||||
"Update Selection",
|
||||
["Select"],
|
||||
None,
|
||||
"{} make the saved selection last recalled (`select.recall`) hold the selected objects instead of its own, in one undo step → {name}",
|
||||
has_recalled_selection,
|
||||
update_selection
|
||||
),
|
||||
cmd!(
|
||||
"select.recall",
|
||||
"Recall Selection",
|
||||
@@ -135,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Release Guides",
|
||||
["View", "Guides"],
|
||||
Some("Cmd+Alt+5"),
|
||||
"{} turn the selected guides (or, with none selected, all guide paths) back into paths → {count}",
|
||||
"{index?: a ruler guide's index, ids?: [guide path id…]} turn guides into paths: index releases that ruler guide and ids release those guide paths (and the guide paths inside them); otherwise the selected guide paths and ruler guides are released, and with no guide selected every guide path is released; a ruler guide becomes a straight path with no fill or stroke, across its artboard or, for a canvas guide, across the bounds of all the artboards; index and ids need guides unlocked; one undo step, the released paths selected → {count}",
|
||||
has_doc,
|
||||
release_guides
|
||||
),
|
||||
@@ -342,6 +351,7 @@ fn save_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if existing.is_none() && saved.len() >= SavedSelection::MAX {
|
||||
return Err(bad(C, format!("a document keeps at most {} saved selections", SavedSelection::MAX)));
|
||||
}
|
||||
let mut slots = saved_ids(s)?;
|
||||
s.edit("Save Selection", |d, _| {
|
||||
let entry = SavedSelection { name: name.clone(), objects: ids };
|
||||
match existing.and_then(|i| d.saved_selections.get_mut(i)) {
|
||||
@@ -350,18 +360,26 @@ fn save_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
let st = s.doc_mut()?;
|
||||
if existing.is_none() {
|
||||
slots.push(st.recalled_selection.new_id());
|
||||
}
|
||||
st.recalled_selection.remember(&st.doc.saved_selections, slots);
|
||||
Ok(json!({ "name": name }))
|
||||
}
|
||||
|
||||
fn recall_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "select.recall";
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing name"))?;
|
||||
let ids = s.doc()?.doc.saved_selections.iter().find(|x| x.name == name).map(|x| x.objects.clone());
|
||||
let ids = ids.ok_or_else(|| bad(C, format!("no saved selection `{name}`")))?;
|
||||
let saved = &s.doc()?.doc.saved_selections;
|
||||
let i = saved.iter().position(|x| x.name == name).ok_or_else(|| bad(C, format!("no saved selection `{name}`")))?;
|
||||
let ids = saved.get(i).map(|x| x.objects.clone()).unwrap_or_default();
|
||||
let st = s.doc()?;
|
||||
let can = selectable_ids(&st.doc, st.isolation);
|
||||
s.select(|_, sel| sel.set(ids.into_iter().filter(|id| can.contains(id))))?;
|
||||
Ok(json!({ "count": s.doc()?.selection.len() }))
|
||||
let st = s.doc_mut()?;
|
||||
st.recalled_selection.recall(&st.doc.saved_selections, i);
|
||||
Ok(json!({ "count": st.selection.len() }))
|
||||
}
|
||||
|
||||
/// Recall and Edit Selection… need a document with at least one saved selection.
|
||||
@@ -370,6 +388,42 @@ fn has_saved_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
if st.doc.saved_selections.is_empty() { Err("no saved selections".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// The session ids of the active document's saved selections ([`crate::RecalledSelection`]).
|
||||
fn saved_ids(s: &mut Session) -> Result<Vec<u64>> {
|
||||
let st = s.doc_mut()?;
|
||||
Ok(st.recalled_selection.ids(&st.doc.saved_selections))
|
||||
}
|
||||
|
||||
/// Where the saved selection last recalled is, while the document has it.
|
||||
fn recalled_index(st: &crate::DocState) -> Option<usize> {
|
||||
st.recalled_selection.index_in(&st.doc.saved_selections)
|
||||
}
|
||||
|
||||
/// Update Selection needs selected objects and a recalled saved selection the document still has.
|
||||
fn has_recalled_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
has_selection(s)?;
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if recalled_index(st).is_some() { Ok(()) } else { Err("no saved selection recalled".into()) }
|
||||
}
|
||||
|
||||
fn update_selection(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
const C: &str = "select.update";
|
||||
let st = s.doc()?;
|
||||
let i = recalled_index(st).ok_or_else(|| bad(C, "no saved selection recalled"))?;
|
||||
let ids = st.selection.objects.clone();
|
||||
let name = st.doc.saved_selections.get(i).map(|x| x.name.clone()).unwrap_or_default();
|
||||
let slots = saved_ids(s)?;
|
||||
s.edit("Update Selection", |d, _| {
|
||||
if let Some(x) = d.saved_selections.get_mut(i) {
|
||||
x.objects = ids;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
let st = s.doc_mut()?;
|
||||
st.recalled_selection.remember(&st.doc.saved_selections, slots);
|
||||
Ok(json!({ "name": name }))
|
||||
}
|
||||
|
||||
fn edit_saved(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "select.editSaved";
|
||||
let edits = match p.get("edits").and_then(Value::as_array) {
|
||||
@@ -396,6 +450,7 @@ fn edit_saved(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
x.name = n;
|
||||
}
|
||||
}
|
||||
let slots: Vec<u64> = saved_ids(s)?.into_iter().zip(&after).filter(|(_, x)| x.is_some()).map(|(id, _)| id).collect();
|
||||
let after: Vec<SavedSelection> = after.into_iter().flatten().collect();
|
||||
let mut names = std::collections::HashSet::with_capacity(after.len());
|
||||
for x in &after {
|
||||
@@ -408,6 +463,8 @@ fn edit_saved(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
d.saved_selections = after.into();
|
||||
Ok(())
|
||||
})?;
|
||||
let st = s.doc_mut()?;
|
||||
st.recalled_selection.remember(&st.doc.saved_selections, slots);
|
||||
}
|
||||
ok()
|
||||
}
|
||||
@@ -465,22 +522,77 @@ fn guide_paths(d: &vectorcraft_doc::Document) -> Vec<NodeId> {
|
||||
v
|
||||
}
|
||||
|
||||
fn release_guides(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
/// Turn guides into paths: ruler guide `index` and the guide paths in `ids`, else the selected
|
||||
/// guide paths and ruler guides, else every guide path, in one undo step.
|
||||
fn release_guides(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "view.guides.release";
|
||||
let (index, ids) = (p.get("index"), p.get("ids"));
|
||||
if (index.is_some() || ids.is_some()) && s.menu.guides_locked {
|
||||
return Err(EngineError::Other("guides are locked".into()));
|
||||
}
|
||||
let (rulers, paths): (Vec<usize>, Vec<NodeId>) = if index.is_some() || ids.is_some() {
|
||||
let rulers = match index {
|
||||
Some(v) => vec![editable_guide_index(s, Some(v), C)?],
|
||||
None => vec![],
|
||||
};
|
||||
let roots = if ids.is_some() { checked_ids_param(s, p, "ids", C)? } else { vec![] };
|
||||
let d = &s.doc()?.doc;
|
||||
let mut paths = vec![];
|
||||
for r in roots {
|
||||
if let Some(n) = d.node(r) {
|
||||
n.walk(&mut |c| {
|
||||
if matches!(c.kind, NodeKind::Path { guide: true, .. }) && d.is_editable(c.id) && !paths.contains(&c.id) {
|
||||
paths.push(c.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
(rulers, paths)
|
||||
} else {
|
||||
let st = s.doc()?;
|
||||
let all = guide_paths(&st.doc);
|
||||
let sel: Vec<NodeId> = all.iter().copied().filter(|id| st.selection.contains(*id)).collect();
|
||||
let rulers: Vec<usize> = if s.menu.guides_locked {
|
||||
vec![]
|
||||
} else {
|
||||
st.selection.guides.iter().copied().filter(|i| st.doc.guides.get(*i).is_some_and(|g| st.doc.guide_editable(g))).collect()
|
||||
};
|
||||
if sel.is_empty() && rulers.is_empty() { (vec![], all.into_iter().filter(|id| st.doc.is_editable(*id)).collect()) } else { (rulers, sel) }
|
||||
};
|
||||
// Find each ruler guide's line and its parent: the guide's layer, else where new art goes.
|
||||
let st = s.doc()?;
|
||||
let all = guide_paths(&st.doc);
|
||||
let sel: Vec<NodeId> = all.iter().copied().filter(|id| st.selection.contains(*id)).collect();
|
||||
let targets: Vec<NodeId> = if sel.is_empty() { all.into_iter().filter(|id| st.doc.is_editable(*id)).collect() } else { sel };
|
||||
if targets.is_empty() {
|
||||
let artboards = st.doc.artboards.iter().map(|a| a.rect).reduce(|a, b| a.union(b));
|
||||
let mut lines = Vec::with_capacity(rulers.len());
|
||||
for &i in &rulers {
|
||||
let Some(g) = st.doc.guides.get(i) else { continue };
|
||||
let span = st.doc.guide_span(g).or_else(|| artboards.map(|r| if g.vertical { (r.y0, r.y1) } else { (r.x0, r.x1) }));
|
||||
let Some((a, b)) = span else { continue };
|
||||
let (a, b) = if g.vertical { (Point::new(g.pos, a), Point::new(g.pos, b)) } else { (Point::new(a, g.pos), Point::new(b, g.pos)) };
|
||||
let parent = match g.layer.filter(|l| st.doc.node(*l).is_some_and(Node::is_layer)) {
|
||||
Some(l) => Some(l),
|
||||
None => st.target_parent()?,
|
||||
};
|
||||
lines.push((i, parent, a, b));
|
||||
}
|
||||
let n = paths.len() + lines.len();
|
||||
if n == 0 {
|
||||
return Err(EngineError::Other("Release Guides: no guides".into()));
|
||||
}
|
||||
let n = targets.len();
|
||||
s.edit("Release Guides", |d, sel| {
|
||||
for id in &targets {
|
||||
for id in &paths {
|
||||
if let Some(NodeKind::Path { guide, .. }) = d.node_mut(*id).map(|n| &mut n.kind) {
|
||||
*guide = false;
|
||||
}
|
||||
}
|
||||
sel.set(targets.iter().copied());
|
||||
let mut made = Vec::with_capacity(lines.len());
|
||||
for &(_, parent, a, b) in &lines {
|
||||
let id = d.alloc_id();
|
||||
let look = vectorcraft_doc::Appearance::basic(vectorcraft_color::Paint::None, vectorcraft_color::Paint::None, 0.0);
|
||||
d.insert(parent, usize::MAX, Node::path(id, vectorcraft_geom::shapes::line(a, b), look))?;
|
||||
made.push(id);
|
||||
}
|
||||
d.retain_guides(sel, |i, _| !lines.iter().any(|l| l.0 == i));
|
||||
sel.set(paths.iter().copied().chain(made));
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "count": n }))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Commands backing the drawing tools (pencil, curvature, anchor tools, scissors, knife, eraser,
|
||||
//! blob brush, smooth, path eraser, join).
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use kurbo::{CubicBez, ParamCurve, ParamCurveNearest};
|
||||
@@ -12,7 +13,8 @@ use vectorcraft_geom::{Anchor, AnchorKind, BezPath, FillRule, PathData, Point, R
|
||||
use vectorcraft_pathops as po;
|
||||
use vectorcraft_pathops::{BoolOp, Cap, Join, SimplifyOptions};
|
||||
|
||||
use super::create::{add_look, add_node, insert_look};
|
||||
use super::create::{add_look, insert_look};
|
||||
use super::newart::NewArt;
|
||||
use super::pathops::{Beside, remove_each};
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
@@ -60,7 +62,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Remove Anchor Points",
|
||||
["Object", "Path"],
|
||||
None,
|
||||
"{} remove the direct-selected anchors without opening their paths, refitting the curve round each → {removedObjects}",
|
||||
"{} remove the direct-selected anchors without opening their paths, refitting the curve round each; the anchor after each removed one is selected, so running it again goes on along the path → {removedObjects}",
|
||||
has_anchors,
|
||||
remove_anchors
|
||||
),
|
||||
@@ -141,7 +143,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Blob Brush",
|
||||
[],
|
||||
None,
|
||||
"{points: [[x,y]…], size?: pt (10), merge?: true} filled brush shape, merged with touching same-colour blobs → {id}",
|
||||
"{points: [[x,y]…], size?: pt (10), merge?: true, mergeOnlySelection?: bool (false: merge with any touching same-color blob; true: only with selected ones), keepSelected?: bool (false: the drawn or merged path is not selected, and the rest of the selection stays; true: it is selected)} filled brush shape, merged with touching same-colour blobs → {id}",
|
||||
has_doc,
|
||||
blob
|
||||
),
|
||||
@@ -772,7 +774,16 @@ fn remove_anchors(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let mut removed = 0;
|
||||
let mut emptied = Beside::replacing(d, &jobs.iter().map(|(id, _)| *id).collect::<Vec<_>>());
|
||||
for (id, anchors) in jobs {
|
||||
removed += usize::from(remove_anchors_of(d, sel, &mut emptied, id, anchors)?);
|
||||
let shape: Vec<(usize, bool)> =
|
||||
d.node(id).and_then(Node::path_data).map(|p| p.subpaths.iter().map(|sp| (sp.anchors.len(), sp.closed)).collect()).unwrap_or_default();
|
||||
let next = following_anchors(&shape, &anchors);
|
||||
if remove_anchors_of(d, sel, &mut emptied, id, anchors)? {
|
||||
removed += 1;
|
||||
} else if !next.is_empty() {
|
||||
// The next anchor along is selected, so the next Remove Anchor Points goes on from
|
||||
// there without picking it first (#2241).
|
||||
sel.anchors.insert(id, next);
|
||||
}
|
||||
}
|
||||
emptied.finish(d)?;
|
||||
Ok(removed)
|
||||
@@ -780,6 +791,30 @@ fn remove_anchors(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "removedObjects": removed }))
|
||||
}
|
||||
|
||||
/// Where the selection goes once Remove Anchor Points takes `removed` (subpath, anchor) out of a
|
||||
/// path whose subpaths have `shape` (anchor count, closed): in each subpath, the anchor after its
|
||||
/// last removed one, round the end of a closed subpath, or the one before it at an open subpath's
|
||||
/// end. Indexes are those after the removal, without the subpaths left with fewer than two anchors.
|
||||
fn following_anchors(shape: &[(usize, bool)], removed: &[(usize, usize)]) -> BTreeSet<(usize, usize)> {
|
||||
let mut next = BTreeSet::new();
|
||||
let mut kept = 0;
|
||||
for (si, &(n, closed)) in shape.iter().enumerate() {
|
||||
let gone: BTreeSet<usize> = removed.iter().filter(|&&(s, a)| s == si && a < n).map(|&(_, a)| a).collect();
|
||||
if n - gone.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
if let Some(&last) = gone.last() {
|
||||
let wrap = if closed { 0..last } else { 0..0 };
|
||||
let after = (last + 1..n).chain(wrap).find(|a| !gone.contains(a));
|
||||
if let Some(a) = after.or_else(|| (0..last).rev().find(|a| !gone.contains(a))) {
|
||||
next.insert((kept, a - gone.range(..a).count()));
|
||||
}
|
||||
}
|
||||
kept += 1;
|
||||
}
|
||||
next
|
||||
}
|
||||
|
||||
/// Remove `anchors` (subpath, anchor) of path `id` without opening it, refitting the curve round
|
||||
/// each one. A subpath left with one anchor is removed, and a path left with no subpath is removed
|
||||
/// through `emptied`. True when the path went.
|
||||
@@ -1312,11 +1347,14 @@ fn blob(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
Paint::solid(Color::BLACK)
|
||||
};
|
||||
let keep_selected = bool_or(p, "keepSelected", false);
|
||||
let only_selection = bool_or(p, "mergeOnlySelection", false);
|
||||
let st = s.doc()?;
|
||||
let mut merge_ids = vec![];
|
||||
if bool_or(p, "merge", true) {
|
||||
if bool_or(p, "merge", true) && !(only_selection && st.selection.is_empty()) {
|
||||
let all = Selection::default();
|
||||
for id in target_paths(&st.doc, &all, poly_bounds(&pts, size)) {
|
||||
let from = if only_selection { &st.selection } else { &all };
|
||||
for id in target_paths(&st.doc, from, poly_bounds(&pts, size)) {
|
||||
let Some(n) = st.doc.node(id) else { continue };
|
||||
if n.appearance.fill_paint() != paint || !n.appearance.stroke_paint().is_none() {
|
||||
continue;
|
||||
@@ -1327,20 +1365,26 @@ fn blob(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let ap = Appearance::basic(paint, Paint::None, 1.0);
|
||||
let ap = NewArt::plain(Appearance::basic(paint, Paint::None, 1.0));
|
||||
if merge_ids.is_empty() {
|
||||
return add_node(s, "Blob Brush", path_kind(region), ap, None);
|
||||
return insert_look(s, "Blob Brush", path_kind(region), ap, None, keep_selected);
|
||||
}
|
||||
let mut inputs: Vec<(&PathData, FillRule)> = vec![(®ion, FillRule::NonZero)];
|
||||
inputs.extend(merge_ids.iter().map(|(_, pd, r)| (pd, *r)));
|
||||
let merged = po::unite_all(&inputs);
|
||||
let Some(((keep, _, _), rest)) = merge_ids.split_last() else { return add_node(s, "Blob Brush", path_kind(region), ap, None) };
|
||||
let Some(((keep, _, _), rest)) = merge_ids.split_last() else {
|
||||
return insert_look(s, "Blob Brush", path_kind(region), ap, None, keep_selected);
|
||||
};
|
||||
let keep = *keep;
|
||||
let others: Vec<NodeId> = rest.iter().map(|m| m.0).collect();
|
||||
s.edit("Blob Brush", |d, sel| {
|
||||
*path_mut(d, keep)? = merged;
|
||||
remove_each(d, &others)?;
|
||||
sel.set([keep]);
|
||||
if keep_selected {
|
||||
sel.set([keep]);
|
||||
} else {
|
||||
sel.remove(keep);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "id": keep.0 }))
|
||||
|
||||
@@ -324,9 +324,22 @@ pub(crate) fn selected_roots(s: &Session) -> Result<Vec<NodeId>> {
|
||||
Ok(roots_of(&st.doc, st.selection.in_paint_order(&st.doc)))
|
||||
}
|
||||
|
||||
/// [`selected_roots`] for the commands that move or reshape the art (Move, Transform, Rotate…): in
|
||||
/// an isolated compound path its paths are objects of their own, so the hole of a ring moves
|
||||
/// without the ring. Paint and the rest still go to the compound path, which holds the paint.
|
||||
pub(crate) fn selected_roots_to_transform(s: &Session) -> Result<Vec<NodeId>> {
|
||||
let st = s.doc()?;
|
||||
Ok(roots_within(&st.doc, st.selection.in_paint_order(&st.doc), st.isolation))
|
||||
}
|
||||
|
||||
/// The top-level objects among `ids` (in paint order): compound-path members stand for their
|
||||
/// compound, and ids whose ancestor is also listed, and layers, are dropped.
|
||||
pub(crate) fn roots_of(doc: &Document, ids: Vec<NodeId>) -> Vec<NodeId> {
|
||||
roots_within(doc, ids, None)
|
||||
}
|
||||
|
||||
/// [`roots_of`], less the swap for the members of `isolation` when it is a compound path.
|
||||
fn roots_within(doc: &Document, ids: Vec<NodeId>, isolation: Option<NodeId>) -> Vec<NodeId> {
|
||||
// Every id's place in the tree, found in one walk (a lookup per id is a walk per id).
|
||||
let paths = doc.index_paths(ids.iter().copied());
|
||||
// A compound-path member acts as its compound (compounds are one object).
|
||||
@@ -336,7 +349,7 @@ pub(crate) fn roots_of(doc: &Document, ids: Vec<NodeId>) -> Vec<NodeId> {
|
||||
let path = paths.get(&id);
|
||||
let parent = path.and_then(|p| p.split_last()).map(|(_, up)| up).filter(|up| !up.is_empty());
|
||||
match parent.and_then(|up| doc.node_at(up).map(|n| (up, n))) {
|
||||
Some((up, n)) if matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }) => (n.id, Some(up.to_vec())),
|
||||
Some((up, n)) if matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }) && Some(n.id) != isolation => (n.id, Some(up.to_vec())),
|
||||
_ => (id, path.cloned()),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -94,11 +94,12 @@ fn selected_part(n: &Node, keep: &HashSet<NodeId>) -> Option<Node> {
|
||||
}
|
||||
|
||||
/// `doc` with only the art over `rect` (an artboard): objects whose visual bounds miss it are left
|
||||
/// out, at any depth of layers, sublayers and groups that don't clip, and objects that reach it
|
||||
/// stay whole (the file's bounds clip them), as DXF's crop leaves them. Symbol instances are
|
||||
/// measured by their art, and the box of one whose art holds brushed strokes grows by as far as
|
||||
/// their brush art can paint ([`SymbolBoxes::with_brush_art`]). An object that holds a symbol
|
||||
/// instance also stays when what the canvas paints for it reaches `rect`
|
||||
/// out, at any depth of layers, sublayers and groups that don't clip, unless what the renderer
|
||||
/// draws for them, their effects included ([`vectorcraft_render::effect_bounds`]), reaches it.
|
||||
/// Objects that reach it stay whole (the file's bounds clip them), as DXF's crop leaves them.
|
||||
/// Symbol instances are measured by their art, and the box of one whose art holds brushed strokes
|
||||
/// grows by as far as their brush art can paint ([`SymbolBoxes::with_brush_art`]). An object that
|
||||
/// holds a symbol instance also stays when what the canvas paints for it reaches `rect`
|
||||
/// ([`vectorcraft_render::painted_bounds`]: the effects in the symbols' art and its own effects). A
|
||||
/// clipping layer keeps every object, its clipping path among them. An artboard's file then holds
|
||||
/// its own art, not a whole document of artboards.
|
||||
@@ -122,8 +123,9 @@ pub(super) fn art_over(doc: &Document, rect: vectorcraft_geom::Rect) -> Document
|
||||
let over = |b: vectorcraft_geom::Rect| b.x0 <= rect.x1 && rect.x0 <= b.x1 && b.y0 <= rect.y1 && rect.y0 <= b.y1;
|
||||
let mut holds_instance = false;
|
||||
n.walk(&mut |c| holds_instance |= matches!(c.kind, NodeKind::SymbolInstance { .. }));
|
||||
let drawn = || vectorcraft_render::effect_bounds(symbols, n).is_some_and(over);
|
||||
let painted = || holds_instance && vectorcraft_render::painted_bounds(doc, n).is_some_and(over);
|
||||
(n.visual_bounds_with(symbols).is_none_or(over) || painted()).then(|| n.clone())
|
||||
(n.visual_bounds_with(symbols).is_none_or(over) || drawn() || painted()).then(|| n.clone())
|
||||
}
|
||||
let symbols = doc.symbol_boxes().with_brush_art();
|
||||
let mut d = doc.clone();
|
||||
@@ -165,13 +167,19 @@ fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<Co
|
||||
/// The selected objects (not on template layers) alone on one layer, with one artboard: their
|
||||
/// visual bounds.
|
||||
fn selection(s: &mut Session, cmd: &str) -> Result<(Document, vectorcraft_geom::Rect)> {
|
||||
let ids = selected_artwork(s)?;
|
||||
isolated(&s.doc()?.doc, &ids, "Selection").ok_or_else(|| bad(cmd, "select something to export"))
|
||||
}
|
||||
|
||||
/// The selected objects (their top-most selected ancestors) that are artwork: those on template
|
||||
/// layers are guides and are left out.
|
||||
pub(crate) fn selected_artwork(s: &mut Session) -> Result<Vec<NodeId>> {
|
||||
let ids = edit::selected_roots(s)?;
|
||||
let st = s.doc()?;
|
||||
let is_template = |id| st.doc.node(id).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. }));
|
||||
// On a template layer or sublayer, at any depth.
|
||||
let on_template = |id| st.doc.ancestry(id).is_some_and(|a| a.into_iter().any(is_template));
|
||||
let ids: Vec<NodeId> = ids.into_iter().filter(|id| !on_template(*id)).collect();
|
||||
isolated(&st.doc, &ids, "Selection").ok_or_else(|| bad(cmd, "select something to export"))
|
||||
Ok(ids.into_iter().filter(|id| !on_template(*id)).collect())
|
||||
}
|
||||
|
||||
/// The crop of an export of objects `ids` of `doc` alone: the bounds the renderer measures for what
|
||||
|
||||
@@ -34,6 +34,7 @@ mod legacy_ai;
|
||||
mod load;
|
||||
pub(crate) use load::{MAX_RASTER_ALLOC, too_large_note};
|
||||
mod metafile;
|
||||
mod naming;
|
||||
mod native;
|
||||
pub mod pdf;
|
||||
mod pdfimport;
|
||||
@@ -63,6 +64,7 @@ pub use load::{
|
||||
read_for_open, texture_image,
|
||||
};
|
||||
pub use load::{losses_summary, overwrite_losses};
|
||||
pub use naming::{NameParts as FileNameParts, parse_date as parse_file_date, render as render_file_name};
|
||||
pub(crate) use native::preview_png;
|
||||
pub use native::with_compression_pref;
|
||||
pub use pdfimport::{LoadOptions, page_document};
|
||||
@@ -71,7 +73,10 @@ pub use save::{
|
||||
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, export_folder, save_filters, save_format, save_job, save_plan,
|
||||
save_with, stamp_save_dates, template_filters, templates_dialog_folder, templates_folder,
|
||||
};
|
||||
pub use screens::{PRESETS as SCREEN_PRESETS, ScreenSize, preset_rows as screen_preset_rows};
|
||||
pub use screens::{
|
||||
PRESET_KEYS as SCREEN_EXPORT_PRESET_KEYS, PRESETS as SCREEN_PRESETS, ScreenExportPreset, ScreenSize,
|
||||
builtin_presets as screen_export_builtin_presets, preset_rows as screen_preset_rows,
|
||||
};
|
||||
pub(crate) use screens::{
|
||||
SHARED_KEYS as SCREEN_SETTINGS_KEYS, check_settings as check_screen_settings, export as export_screens, store_settings as store_screen_settings,
|
||||
};
|
||||
@@ -136,10 +141,37 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export for Screens",
|
||||
["File", "Export"],
|
||||
None,
|
||||
"{folder?, zip?: false (one store-only .zip of every file: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/[email protected]). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step)",
|
||||
"{folder?, zip?: false (one store-only .zip of every file: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/[email protected]). eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step)",
|
||||
has_doc,
|
||||
screens::export_for_screens
|
||||
),
|
||||
cmd!(
|
||||
query "document.exportPresets.list",
|
||||
"Export Presets",
|
||||
[],
|
||||
None,
|
||||
"{} → {presets: [{name, builtIn, settings}]} the built-in batch export presets (Logo Package, Print-Ready PDF), then the saved ones; any name works as document.exportForScreens' exportPreset",
|
||||
always,
|
||||
screens::presets_list
|
||||
),
|
||||
cmd!(
|
||||
query "document.exportPresets.save",
|
||||
"Save Export Preset",
|
||||
[],
|
||||
None,
|
||||
"{name, settings: {formats, settings, preset, name, prefix, counterStart, counterPer, folders, subfolders, includeBleed, antiAlias, folder, fullDocument} (as document.exportForScreens; other keys are left out)} save (or replace) a batch export preset, kept with the preferences; built-in names are refused → {name, created, settings}",
|
||||
always,
|
||||
screens::presets_save
|
||||
),
|
||||
cmd!(
|
||||
query "document.exportPresets.delete",
|
||||
"Delete Export Preset",
|
||||
[],
|
||||
None,
|
||||
"{name} delete a saved batch export preset (built-in ones stay) → {deleted}",
|
||||
always,
|
||||
screens::presets_delete
|
||||
),
|
||||
cmd!(
|
||||
query "command.batch",
|
||||
"Batch",
|
||||
@@ -1143,6 +1175,8 @@ mod tests_jpeg;
|
||||
#[cfg(test)]
|
||||
mod tests_palette;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_batch;
|
||||
#[cfg(test)]
|
||||
mod tests_screens;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! File-name templates for batch exports (`document.exportForScreens {name}`): the same pattern
|
||||
//! gives every exported file a consistent name, such as `COMPANY-Logo-<filename>-####-YYYYMMDD`.
|
||||
//!
|
||||
//! Tokens:
|
||||
//! - `<filename>` (or `<document>`): the document's name without its extension;
|
||||
//! - `<name>` (or `<artboard>`, `<item>`): the artboard's, asset's, layer's or selection's name;
|
||||
//! - `<index>`: the item's place in the export, from 1;
|
||||
//! - a run of `#`: the counter, zero-padded to the run's length (`####` → `0001`);
|
||||
//! - `<format>` (or `<ext>`): the file's extension; `<scale>`: the row's size (`2x`, `100w`);
|
||||
//! `<suffix>`: the row's suffix (otherwise added at the end);
|
||||
//! - the date and time: `YYYYMMDD`, `YYYY-MM-DD` and `YYYY` as written, and `<date>`
|
||||
//! (YYYYMMDD), `<time>` (hhmmss), `<YYYY>`, `<MM>`, `<DD>`, `<hh>`, `<mm>`, `<ss>`.
|
||||
//!
|
||||
//! A trailing `.ext` (or `.<ext>`, `.<format>`) is dropped: the extension comes from the format.
|
||||
//! Anything that could start a folder (`/`, `\`, `:`) becomes `-`.
|
||||
|
||||
/// What a template's tokens stand for, for one file.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct NameParts<'a> {
|
||||
pub filename: &'a str,
|
||||
pub name: &'a str,
|
||||
pub index: usize,
|
||||
pub counter: u64,
|
||||
pub format: &'a str,
|
||||
pub scale: &'a str,
|
||||
pub suffix: &'a str,
|
||||
/// `[year, month, day, hour, minute, second]`.
|
||||
pub when: [i64; 6],
|
||||
}
|
||||
|
||||
/// The bracketed tokens and what each stands for.
|
||||
fn token(name: &str, p: &NameParts<'_>) -> Option<String> {
|
||||
let [y, mo, d, h, mi, s] = p.when;
|
||||
Some(match name {
|
||||
"filename" | "document" => p.filename.to_string(),
|
||||
"name" | "artboard" | "item" => p.name.to_string(),
|
||||
"index" => p.index.to_string(),
|
||||
"format" | "ext" => p.format.to_string(),
|
||||
"scale" => p.scale.to_string(),
|
||||
"suffix" => p.suffix.to_string(),
|
||||
"date" => format!("{y:04}{mo:02}{d:02}"),
|
||||
"time" => format!("{h:02}{mi:02}{s:02}"),
|
||||
"YYYY" => format!("{y:04}"),
|
||||
"MM" => format!("{mo:02}"),
|
||||
"DD" => format!("{d:02}"),
|
||||
"hh" => format!("{h:02}"),
|
||||
"mm" => format!("{mi:02}"),
|
||||
"ss" => format!("{s:02}"),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Text with `/`, `\` and `:` turned into `-`, so it can't start a folder.
|
||||
fn clean(s: &str) -> String {
|
||||
s.chars().map(|c| if matches!(c, '/' | '\\' | ':') { '-' } else { c }).collect()
|
||||
}
|
||||
|
||||
/// Does `template` place the row's suffix itself (so it isn't added at the end)?
|
||||
pub fn has_suffix_token(template: &str) -> bool {
|
||||
template.contains("<suffix>")
|
||||
}
|
||||
|
||||
/// The file name (without extension) `template` gives for `p`.
|
||||
pub fn render(template: &str, p: &NameParts<'_>) -> String {
|
||||
let mut t = template.trim();
|
||||
for ext in [".ext", ".<ext>", ".<format>"] {
|
||||
if let Some(stripped) = t.strip_suffix(ext) {
|
||||
t = stripped;
|
||||
}
|
||||
}
|
||||
let [y, mo, d, ..] = p.when;
|
||||
let mut out = String::with_capacity(t.len() + 16);
|
||||
let mut rest = t;
|
||||
while let Some(c) = rest.chars().next() {
|
||||
// Bracketed tokens; an unknown one stays as written.
|
||||
if c == '<'
|
||||
&& let Some(end) = rest.find('>')
|
||||
&& let Some(value) = rest.get(1..end).and_then(|name| token(name, p))
|
||||
{
|
||||
out.push_str(&clean(&value));
|
||||
rest = rest.get(end + 1..).unwrap_or("");
|
||||
continue;
|
||||
}
|
||||
if c == '#' {
|
||||
let run = rest.chars().take_while(|c| *c == '#').count();
|
||||
out.push_str(&format!("{:0run$}", p.counter));
|
||||
rest = rest.get(run..).unwrap_or("");
|
||||
continue;
|
||||
}
|
||||
// The date spelled out, longest first.
|
||||
let date = [("YYYYMMDD", format!("{y:04}{mo:02}{d:02}")), ("YYYY-MM-DD", format!("{y:04}-{mo:02}-{d:02}")), ("YYYY", format!("{y:04}"))];
|
||||
if let Some((pat, value)) = date.iter().find(|(pat, _)| rest.starts_with(pat)) {
|
||||
out.push_str(value);
|
||||
rest = rest.get(pat.len()..).unwrap_or("");
|
||||
continue;
|
||||
}
|
||||
out.push(if matches!(c, '/' | '\\' | ':') { '-' } else { c });
|
||||
rest = rest.get(c.len_utf8()..).unwrap_or("");
|
||||
}
|
||||
out.trim().to_string()
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DD` (or `YYYYMMDD`) as `[year, month, day, 0, 0, 0]`; `None` when it isn't a date.
|
||||
pub fn parse_date(s: &str) -> Option<[i64; 6]> {
|
||||
let digits: String = s.trim().chars().filter(|c| *c != '-').collect();
|
||||
if digits.len() != 8 || !digits.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
let num = |a: usize, b: usize| digits.get(a..b).and_then(|t| t.parse::<i64>().ok());
|
||||
let (y, m, d) = (num(0, 4)?, num(4, 6)?, num(6, 8)?);
|
||||
((1..=12).contains(&m) && (1..=31).contains(&d)).then_some([y, m, d, 0, 0, 0])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parts() -> NameParts<'static> {
|
||||
NameParts { filename: "Brand", name: "Dark", index: 3, counter: 7, format: "png", scale: "2x", suffix: "@2x", when: [2026, 10, 16, 9, 5, 0] }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_example_from_the_request() {
|
||||
assert_eq!(render("COMPANY-Logo-<filename>-####-YYYYMMDD.ext", &parts()), "COMPANY-Logo-Brand-0007-20261016");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_token() {
|
||||
let p = parts();
|
||||
assert_eq!(render("<document>_<name>_<artboard>_<item>", &p), "Brand_Dark_Dark_Dark");
|
||||
assert_eq!(render("<index>-#-##-<format>-<ext>-<scale><suffix>", &p), "3-7-07-png-png-2x@2x");
|
||||
assert_eq!(render("<date>T<time> <YYYY>/<MM>/<DD> <hh>:<mm>:<ss>", &p), "20261016T090500 2026-10-16 09-05-00");
|
||||
assert_eq!(render("YYYY-MM-DD YYYY", &p), "2026-10-16 2026");
|
||||
// Words are left alone: only the date patterns and bracketed tokens are replaced.
|
||||
assert_eq!(render("SUMMER-DD-MM", &p), "SUMMER-DD-MM");
|
||||
assert_eq!(render("<unknown>-x", &p), "<unknown>-x");
|
||||
assert_eq!(render("logo.<format>", &p), "logo", "the extension comes from the format");
|
||||
assert!(has_suffix_token("a<suffix>") && !has_suffix_token("a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_name_climbs_out_of_its_folder() {
|
||||
let p = NameParts { name: "../../etc", ..parts() };
|
||||
let n = render("a/b\\c:<name>", &p);
|
||||
assert!(!n.contains('/') && !n.contains('\\') && !n.contains(':'), "{n}");
|
||||
// A long counter is padded, a short run is not cut.
|
||||
assert_eq!(render("########", &NameParts { counter: 123, ..parts() }), "00000123");
|
||||
assert_eq!(render("#", &NameParts { counter: 12345, ..parts() }), "12345");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dates_parse() {
|
||||
assert_eq!(parse_date("2026-10-16"), Some([2026, 10, 16, 0, 0, 0]));
|
||||
assert_eq!(parse_date("20261016"), Some([2026, 10, 16, 0, 0, 0]));
|
||||
for bad in ["", "2026-13-01", "2026-1-1", "today", "2026-10-1x"] {
|
||||
assert_eq!(parse_date(bad), None, "{bad}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,25 @@
|
||||
//! File → Export → Export for Screens (`document.exportForScreens`): the chosen artboards (or the
|
||||
//! whole document, or the chosen assets of the Asset Export panel) in every format row at each
|
||||
//! row's size, into a folder and its sub-folders, or returned for download (each file, or one ZIP);
|
||||
//! the presets the dialog offers; and the settings the document remembers
|
||||
//! whole document, the chosen assets of the Asset Export panel, layers, or the selection) in every
|
||||
//! format row at each row's size, into a folder and its sub-folders, or returned for download (each
|
||||
//! file, or one ZIP); the presets the dialog offers; and the settings the document remembers
|
||||
//! (`document.exportSettings`), which the Asset Export panel shares ([`SHARED_KEYS`]).
|
||||
//!
|
||||
//! A batch export names every file from one pattern (`name`, see [`super::naming`]: the document's
|
||||
//! and the item's names, a counter, the date), and sorts the files into folders by row, by format,
|
||||
//! by item, or by item and format (`folders`).
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::Document;
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use super::super::*;
|
||||
use super::encode::single_artboard;
|
||||
use super::export::{isolated, without_artboards};
|
||||
use super::export::{isolated, selected_artwork, without_artboards};
|
||||
use super::naming::{self, NameParts};
|
||||
use super::{
|
||||
ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, encode, unique_file_names, with_black_output, writable, write_file,
|
||||
};
|
||||
@@ -161,6 +166,8 @@ struct Row {
|
||||
folder: String,
|
||||
/// The encoder options: the format's settings, then the row's own.
|
||||
options: Value,
|
||||
/// What makes two rows the same file: format, size, suffix and options.
|
||||
key: String,
|
||||
}
|
||||
|
||||
impl Row {
|
||||
@@ -169,8 +176,8 @@ impl Row {
|
||||
return Err(bad(C, "each format is an object {format, scale?, suffix?}"));
|
||||
}
|
||||
let format = writable(C, Some(str_param(row, "format").unwrap_or("png")), None)?;
|
||||
if matches!(format.id, "vectorcraft" | "template" | "ai" | "txt" | "dxf" | "eps" | "emf" | "wmf") {
|
||||
return Err(bad(C, "Export for Screens writes png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz or pdf"));
|
||||
if matches!(format.id, "template" | "txt" | "dxf" | "emf" | "wmf") {
|
||||
return Err(bad(C, "Export for Screens writes png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai or vectorcraft"));
|
||||
}
|
||||
// Vector formats have no pixel size: it doesn't apply and adds no @2x suffix (not even one
|
||||
// left over from a raster row switched to SVG or PDF).
|
||||
@@ -203,13 +210,21 @@ impl Row {
|
||||
let options = Value::Object(options);
|
||||
// A PDF preset (built-in or saved) becomes its settings.
|
||||
let options = if format.id == "pdf" { super::pdf::expand_preset(s, C, &options)?.into_owned() } else { options };
|
||||
Ok(Self { format, size, suffix, folder, options })
|
||||
// Two rows that would write the same bytes under the same name are one file.
|
||||
let key = format!("{}|{}|{suffix}|{options}", format.id, size.label());
|
||||
Ok(Self { format, size, suffix, folder, options, key })
|
||||
}
|
||||
|
||||
/// Formats that hold a whole document to edit (native, Illustrator, EPS): written from a
|
||||
/// document of the item alone, one artboard, rather than with an `artboard` option.
|
||||
fn whole_document(&self) -> bool {
|
||||
matches!(self.format.id, "vectorcraft" | "ai" | "eps")
|
||||
}
|
||||
|
||||
/// The encoder options for `region`, `artboard` of the document written.
|
||||
fn options_for(&self, region: Rect, artboard: Option<usize>) -> Result<Value> {
|
||||
let mut o = self.options.clone();
|
||||
if let Some(b) = artboard {
|
||||
if let Some(b) = artboard.filter(|_| !self.whole_document()) {
|
||||
o["artboard"] = json!(b);
|
||||
}
|
||||
if self.format.raster {
|
||||
@@ -247,6 +262,60 @@ struct ScreenParams {
|
||||
prefix: Option<String>,
|
||||
/// Asset ids (Asset Export) to write instead of artboards.
|
||||
assets: Option<Vec<u64>>,
|
||||
/// Layer ids to write instead of artboards: each layer's art alone.
|
||||
layers: Option<Vec<u64>>,
|
||||
/// The selection instead of artboards: `one` file of it all, or one for `each` object.
|
||||
selection: Option<String>,
|
||||
/// The file name pattern ([`naming`]); without it, `prefix` + name + suffix.
|
||||
name: Option<String>,
|
||||
/// The counter's first value (`####` in `name`).
|
||||
counter_start: Option<u64>,
|
||||
/// The counter counts `item`s (every file of an artboard has its number) or `file`s.
|
||||
counter_per: Option<String>,
|
||||
/// The date the pattern's date tokens use (`YYYY-MM-DD`); default today (UTC).
|
||||
date: Option<String>,
|
||||
/// Where files go: `none` (all in the folder), `rows` (the rows' sub-folders, as
|
||||
/// `subfolders`), `format` (a folder per extension), `item`, or `itemFormat`.
|
||||
folders: Option<String>,
|
||||
}
|
||||
|
||||
/// How exported files are sorted into folders.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Folders {
|
||||
None,
|
||||
Rows,
|
||||
Format,
|
||||
Item,
|
||||
ItemFormat,
|
||||
}
|
||||
|
||||
impl Folders {
|
||||
fn of(o: &ScreenParams) -> Result<Self> {
|
||||
Ok(match o.folders.as_deref() {
|
||||
None if o.subfolders || o.preset.is_some() => Self::Rows,
|
||||
None | Some("none" | "") => Self::None,
|
||||
Some("rows") => Self::Rows,
|
||||
Some("format") => Self::Format,
|
||||
Some("item") => Self::Item,
|
||||
Some("itemFormat") => Self::ItemFormat,
|
||||
Some(f) => return Err(bad(C, format!("folders `{f}`: none, rows, format, item or itemFormat"))),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// One thing a batch export writes a file of per row: an artboard, an asset, a layer, the
|
||||
/// selection or one selected object, or the whole document.
|
||||
struct Item {
|
||||
/// Its name as the user gave it (`<name>`).
|
||||
name: String,
|
||||
/// Its name made safe and unique among the items (the default file name, the item folder).
|
||||
stem: String,
|
||||
/// The document written: the whole document with `artboard`, or the item alone.
|
||||
doc: Arc<Document>,
|
||||
artboard: Option<usize>,
|
||||
region: Rect,
|
||||
/// Full Document: what a PDF row writes (every artboard of the document).
|
||||
every_artboard: Option<Arc<Document>>,
|
||||
}
|
||||
|
||||
impl ScreenParams {
|
||||
@@ -289,8 +358,9 @@ pub(crate) fn check_settings(s: &Session, p: &Value) -> Result<()> {
|
||||
/// The file names (and bytes) an export wrote, or the files it returns.
|
||||
#[derive(Default)]
|
||||
struct Output {
|
||||
/// Lower-case relative names already used (two identical rows write one file).
|
||||
taken: HashSet<String>,
|
||||
/// Lower-case relative names already used, with what wrote each (two identical rows write
|
||||
/// one file; different files that would share a name get `-2`, `-3`…).
|
||||
taken: std::collections::HashMap<String, String>,
|
||||
/// Sub-folders already made.
|
||||
made: HashSet<String>,
|
||||
/// `(relative name, bytes)` kept to return or zip (empty when written to a folder).
|
||||
@@ -300,9 +370,30 @@ struct Output {
|
||||
}
|
||||
|
||||
impl Output {
|
||||
/// Is `name` new (not written by an identical row already)?
|
||||
fn claim(&mut self, name: &str) -> bool {
|
||||
self.taken.insert(name.to_lowercase())
|
||||
/// The name the file `key` writes as `name` takes: `name`, else (with `number`) `name-2`,
|
||||
/// `name-3`… when another file has it; `None` when the same file (`key`) was written as `name`
|
||||
/// already, or, without `number`, when any file was (the first row wins, as Export for Screens
|
||||
/// always did when no name pattern is given).
|
||||
fn claim(&mut self, name: &str, key: &str, number: bool) -> Option<String> {
|
||||
let (base, ext) = name.rsplit_once('.').map_or((name, ""), |(b, e)| (b, e));
|
||||
for n in 1.. {
|
||||
let candidate = if n == 1 {
|
||||
name.to_string()
|
||||
} else if ext.is_empty() {
|
||||
format!("{base}-{n}")
|
||||
} else {
|
||||
format!("{base}-{n}.{ext}")
|
||||
};
|
||||
match self.taken.get(&candidate.to_lowercase()) {
|
||||
Some(k) if k == key || !number => return None,
|
||||
Some(_) => continue,
|
||||
None => {
|
||||
self.taken.insert(candidate.to_lowercase(), key.to_string());
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Write `bytes` as `name` (relative) under `folder`, or keep them.
|
||||
@@ -324,32 +415,166 @@ impl Output {
|
||||
}
|
||||
}
|
||||
|
||||
/// File → Export for Screens: [`export`], with the params remembered in the document
|
||||
/// (`document.exportSettings`).
|
||||
/// File → Export for Screens: [`export`] (over the settings of `exportPreset`, when given), with
|
||||
/// the params remembered in the document (`document.exportSettings`).
|
||||
pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = export(s, p)?;
|
||||
remember(s, p);
|
||||
let p = with_preset(s, p)?;
|
||||
let r = export(s, &p)?;
|
||||
remember(s, &p);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Every chosen artboard in every format row, one file each (a PDF holds its artboard alone), or
|
||||
/// with `fullDocument` one file per row (a PDF of every artboard, else the bounds of all art), or
|
||||
/// with `assets` each asset's art alone, cropped to it. Artboards (assets) that share a name get
|
||||
/// `-2`, `-3`… instead of overwriting.
|
||||
// ---------- export presets ----------
|
||||
|
||||
/// A saved batch export: what to write and how to name and sort it, to use again
|
||||
/// (`document.exportPresets.*`, `exportPreset`). Kept with the preferences.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ScreenExportPreset {
|
||||
pub name: String,
|
||||
/// The [`PRESET_KEYS`] of `document.exportForScreens` params.
|
||||
pub settings: Map<String, Value>,
|
||||
}
|
||||
|
||||
/// What a preset keeps: the formats, their settings, the naming and the folders; not which art
|
||||
/// (it changes with the document) nor where to (unless given).
|
||||
pub const PRESET_KEYS: [&str; 13] = [
|
||||
"formats",
|
||||
"settings",
|
||||
"preset",
|
||||
"name",
|
||||
"prefix",
|
||||
"counterStart",
|
||||
"counterPer",
|
||||
"folders",
|
||||
"subfolders",
|
||||
"includeBleed",
|
||||
"antiAlias",
|
||||
"folder",
|
||||
"fullDocument",
|
||||
];
|
||||
|
||||
/// The built-in export presets: (name, settings).
|
||||
pub fn builtin_presets() -> Vec<ScreenExportPreset> {
|
||||
let marks = json!({"trim": true, "registration": true, "colorBars": true, "pageInfo": true});
|
||||
let preset = |name: &str, settings: Value| ScreenExportPreset { name: name.into(), settings: settings.as_object().cloned().unwrap_or_default() };
|
||||
vec![
|
||||
preset(
|
||||
"Logo Package",
|
||||
json!({
|
||||
"name": "<filename>-<name>",
|
||||
"folders": "format",
|
||||
"formats": [
|
||||
{"format": "svg"},
|
||||
{"format": "png", "scale": "1x", "suffix": ""},
|
||||
{"format": "png", "scale": "2x", "suffix": "@2x"},
|
||||
{"format": "pdf"},
|
||||
{"format": "pdf", "suffix": "-print", "marks": marks},
|
||||
],
|
||||
}),
|
||||
),
|
||||
preset("Print-Ready PDF", json!({"formats": [{"format": "pdf", "suffix": "-print", "marks": marks, "bleed": {"useDocument": true}}]})),
|
||||
]
|
||||
}
|
||||
|
||||
/// The preset called `name`: a saved one, else a built-in one.
|
||||
fn find_preset(s: &Session, name: &str) -> Option<ScreenExportPreset> {
|
||||
s.prefs.screen_export_presets.iter().find(|p| p.name == name).cloned().or_else(|| builtin_presets().into_iter().find(|p| p.name == name))
|
||||
}
|
||||
|
||||
/// `p` over the settings of its `exportPreset`, when it names one (its own keys win).
|
||||
fn with_preset(s: &Session, p: &Value) -> Result<Value> {
|
||||
let Some(name) = str_param(p, "exportPreset") else { return Ok(p.clone()) };
|
||||
let preset = find_preset(s, name).ok_or_else(|| bad(C, format!("no export preset `{name}` (see document.exportPresets.list)")))?;
|
||||
let mut merged = preset.settings;
|
||||
if let Some(o) = p.as_object() {
|
||||
merged.extend(o.iter().filter(|(k, _)| k.as_str() != "exportPreset").map(|(k, v)| (k.clone(), v.clone())));
|
||||
}
|
||||
Ok(Value::Object(merged))
|
||||
}
|
||||
|
||||
pub(super) fn presets_list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let row = |p: &ScreenExportPreset, built_in: bool| json!({"name": p.name, "builtIn": built_in, "settings": p.settings});
|
||||
let rows: Vec<Value> =
|
||||
builtin_presets().iter().map(|p| row(p, true)).chain(s.prefs.screen_export_presets.iter().map(|p| row(p, false))).collect();
|
||||
Ok(json!({ "presets": rows }))
|
||||
}
|
||||
|
||||
pub(super) fn presets_save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const SAVE: &str = "document.exportPresets.save";
|
||||
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(SAVE, "give the preset a name"))?.to_string();
|
||||
if builtin_presets().iter().any(|b| b.name == name) {
|
||||
return Err(bad(SAVE, format!("`{name}` is a built-in preset: save under another name")));
|
||||
}
|
||||
let given = p.get("settings").and_then(Value::as_object).ok_or_else(|| bad(SAVE, "settings is an object of document.exportForScreens params"))?;
|
||||
let settings: Map<String, Value> = given.iter().filter(|(k, _)| PRESET_KEYS.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect();
|
||||
// Refuse what an export would refuse, now rather than when the preset is used.
|
||||
check_settings(s, &Value::Object(settings.clone())).map_err(|e| bad(SAVE, e.to_string()))?;
|
||||
Folders::of(&ScreenParams::of(&Value::Object(settings.clone()))?).map_err(|e| bad(SAVE, e.to_string()))?;
|
||||
let preset = ScreenExportPreset { name: name.clone(), settings };
|
||||
let created = match s.prefs.screen_export_presets.iter_mut().find(|q| q.name == name) {
|
||||
Some(slot) => {
|
||||
*slot = preset.clone();
|
||||
false
|
||||
}
|
||||
None => {
|
||||
s.prefs.screen_export_presets.push(preset.clone());
|
||||
true
|
||||
}
|
||||
};
|
||||
Ok(json!({ "name": name, "created": created, "settings": preset.settings }))
|
||||
}
|
||||
|
||||
pub(super) fn presets_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const DELETE: &str = "document.exportPresets.delete";
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(DELETE, "missing `name`"))?;
|
||||
if builtin_presets().iter().any(|b| b.name == name) {
|
||||
return Err(bad(DELETE, format!("`{name}` is built in and stays")));
|
||||
}
|
||||
let before = s.prefs.screen_export_presets.len();
|
||||
s.prefs.screen_export_presets.retain(|q| q.name != name);
|
||||
if s.prefs.screen_export_presets.len() == before {
|
||||
return Err(bad(DELETE, format!("no saved export preset `{name}`")));
|
||||
}
|
||||
Ok(json!({ "deleted": name }))
|
||||
}
|
||||
|
||||
/// Every chosen artboard (asset, layer, selected object) in every format row, one file each (a PDF
|
||||
/// holds its artboard alone), or with `fullDocument` one file per row (a PDF of every artboard,
|
||||
/// else the bounds of all art). Items that share a name get `-2`, `-3`… instead of overwriting, and
|
||||
/// so do different files the `name` pattern gives one name.
|
||||
pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let o = ScreenParams::of(p)?;
|
||||
let rows = rows_of(s, p, o.preset.as_deref())?;
|
||||
if o.full_document && o.assets.is_some() {
|
||||
return Err(bad(C, "give assets or fullDocument, not both"));
|
||||
let sources = [o.full_document, o.assets.is_some(), o.layers.is_some(), o.selection.is_some()].iter().filter(|b| **b).count();
|
||||
if sources > 1 {
|
||||
return Err(bad(C, "give one of assets, layers, selection or fullDocument"));
|
||||
}
|
||||
let subfolders = o.subfolders || o.preset.is_some();
|
||||
let folders = Folders::of(&o)?;
|
||||
// A prefix names files, not folders.
|
||||
let prefix = file_part(o.prefix.as_deref().unwrap_or(""));
|
||||
let folder = o.folder.as_deref().filter(|f| !f.is_empty());
|
||||
let when = match o.date.as_deref() {
|
||||
Some(d) => naming::parse_date(d).ok_or_else(|| bad(C, format!("date `{d}`: YYYY-MM-DD")))?,
|
||||
None => vectorcraft_doc::metadata::now_unix().map_or([1970, 1, 1, 0, 0, 0], vectorcraft_doc::metadata::civil),
|
||||
};
|
||||
let per_file = match o.counter_per.as_deref() {
|
||||
None | Some("item") => false,
|
||||
Some("file") => true,
|
||||
Some(c) => return Err(bad(C, format!("counterPer `{c}`: item or file"))),
|
||||
};
|
||||
let counter_start = o.counter_start.unwrap_or(1);
|
||||
let selection = match o.selection.as_deref() {
|
||||
None => None,
|
||||
Some("one") => Some(false),
|
||||
Some("each") => Some(true),
|
||||
Some(m) => return Err(bad(C, format!("selection `{m}`: one or each"))),
|
||||
};
|
||||
let selected = if selection.is_some() { selected_artwork(s)? } else { vec![] };
|
||||
if let Some(dir) = folder {
|
||||
create_dir(dir)?;
|
||||
}
|
||||
let doc = s.doc()?.doc.clone();
|
||||
let filename = safe_name(&super::file_stem(&doc.title)).unwrap_or_else(|| "Untitled".into());
|
||||
// Include Bleed: each artboard grown by the document's bleed.
|
||||
let doc = if o.include_bleed && doc.setup.has_bleed() {
|
||||
let mut d = (*doc).clone();
|
||||
@@ -360,79 +585,66 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
doc
|
||||
};
|
||||
let name_of = |r: &Row, stem: &str| {
|
||||
let file = format!("{prefix}{stem}{}.{}", r.suffix, r.format.extensions[0]);
|
||||
if subfolders { format!("{}/{file}", r.folder) } else { file }
|
||||
};
|
||||
let crop = rows.iter().any(|r| r.format.id != "pdf");
|
||||
let items = items(&doc, &o, selection, &selected, &filename, crop)?;
|
||||
let mut out = Output::default();
|
||||
// Appearance of Black › Printing / Exporting for RGB raster rows.
|
||||
let write = |doc: &Document, format: &str, options: &Value| encode(doc, format, &with_black_output(s, options));
|
||||
if o.full_document {
|
||||
let stem = safe_name(&super::file_stem(&doc.title)).unwrap_or_else(|| "Untitled".into());
|
||||
// The bounds of all visible art, as the one artboard of the files that aren't PDFs.
|
||||
let mut whole: Option<Document> = None;
|
||||
// A whole-document copy of an artboard, for the formats that write documents to edit.
|
||||
let mut board_docs: std::collections::HashMap<usize, Arc<Document>> = std::collections::HashMap::new();
|
||||
let mut files = 0u64;
|
||||
for (index, item) in items.iter().enumerate() {
|
||||
for r in &rows {
|
||||
let name = name_of(r, &stem);
|
||||
if !out.claim(&name) {
|
||||
continue;
|
||||
}
|
||||
let bytes = if r.format.id == "pdf" {
|
||||
let counter = counter_start.saturating_add(if per_file { files } else { index as u64 });
|
||||
let ext = r.format.extensions.first().copied().unwrap_or(r.format.id);
|
||||
let stem = match o.name.as_deref().filter(|n| !n.trim().is_empty()) {
|
||||
Some(pattern) => {
|
||||
let parts = NameParts {
|
||||
filename: &filename,
|
||||
name: &item.name,
|
||||
index: index + 1,
|
||||
counter,
|
||||
format: ext,
|
||||
scale: &if r.format.raster { r.size.label() } else { String::new() },
|
||||
suffix: &r.suffix,
|
||||
when,
|
||||
};
|
||||
let named = naming::render(pattern, &parts);
|
||||
let named = if named.is_empty() { item.stem.clone() } else { named };
|
||||
if naming::has_suffix_token(pattern) { format!("{prefix}{named}") } else { format!("{prefix}{named}{}", r.suffix) }
|
||||
}
|
||||
None => format!("{prefix}{}{}", item.stem, r.suffix),
|
||||
};
|
||||
let file = format!("{stem}.{ext}");
|
||||
let sub = match folders {
|
||||
Folders::None => None,
|
||||
Folders::Rows => Some(r.folder.clone()),
|
||||
Folders::Format => Some(ext.to_string()),
|
||||
Folders::Item => Some(item.stem.clone()),
|
||||
Folders::ItemFormat => Some(format!("{}/{ext}", item.stem)),
|
||||
};
|
||||
let name = sub.map_or_else(|| file.clone(), |d| format!("{d}/{file}"));
|
||||
let Some(name) = out.claim(&name, &format!("{}|{}", item.stem, r.key), o.name.is_some()) else { continue };
|
||||
let bytes = if let (Some(all), "pdf") = (&item.every_artboard, r.format.id) {
|
||||
// One page per artboard.
|
||||
let mut options = r.options.clone();
|
||||
options["range"] = json!("all");
|
||||
write(&doc, "pdf", &options)?
|
||||
write(all, "pdf", &options)?
|
||||
} else if r.whole_document() && item.artboard.is_some() {
|
||||
let b = item.artboard.unwrap_or(0);
|
||||
let d = board_docs.entry(b).or_insert_with(|| Arc::new(single_artboard(&item.doc, item.region, &item.name))).clone();
|
||||
write(&d, r.format.id, &r.options_for(item.region, None)?)?
|
||||
} else {
|
||||
if whole.is_none() {
|
||||
let bounds =
|
||||
vectorcraft_render::encode::art_bounds(&doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?;
|
||||
whole = Some(single_artboard(&doc, bounds, "Document"));
|
||||
}
|
||||
let Some(d) = whole.as_ref() else { continue };
|
||||
let region = d.artboards.first().map_or(Rect::ZERO, |a| a.rect);
|
||||
write(d, r.format.id, &r.options_for(region, Some(0))?)?
|
||||
write(&item.doc, r.format.id, &r.options_for(item.region, item.artboard)?)?
|
||||
};
|
||||
out.put(folder, o.zip, name, bytes)?;
|
||||
}
|
||||
} else if let Some(ids) = &o.assets {
|
||||
let assets =
|
||||
ids.iter().map(|id| doc.asset(*id).ok_or_else(|| bad(C, format!("no asset {id} (see assets.list)")))).collect::<Result<Vec<_>>>()?;
|
||||
if assets.is_empty() {
|
||||
return Err(bad(C, "no assets to export"));
|
||||
}
|
||||
for (a, stem) in assets.iter().zip(unique_file_names(assets.iter().map(|a| (a.name.as_str(), "Asset".to_string())))) {
|
||||
let (d, bounds) = isolated(&doc, &doc.paint_order(a.nodes.iter().copied()), &a.name)
|
||||
.ok_or_else(|| bad(C, format!("asset `{}` has no art to export", a.name)))?;
|
||||
for r in &rows {
|
||||
let name = name_of(r, &stem);
|
||||
if !out.claim(&name) {
|
||||
continue;
|
||||
}
|
||||
let bytes = write(&d, r.format.id, &r.options_for(bounds, Some(0))?)?;
|
||||
out.put(folder, o.zip, name, bytes)?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let n = doc.artboards.len();
|
||||
let boards = o.boards.resolve(n).map_err(|e| bad(C, e))?.unwrap_or_else(|| (0..n).collect());
|
||||
if boards.is_empty() {
|
||||
return Err(bad(C, "the document has no artboard"));
|
||||
}
|
||||
for (b, stem) in boards.iter().copied().zip(artboard_file_names(&doc, &boards)) {
|
||||
let region = doc.artboards.get(b).map_or(Rect::ZERO, |a| a.rect);
|
||||
for r in &rows {
|
||||
let name = name_of(r, &stem);
|
||||
if !out.claim(&name) {
|
||||
continue; // the same file from two identical rows
|
||||
}
|
||||
let bytes = write(&doc, r.format.id, &r.options_for(region, Some(b))?)?;
|
||||
out.put(folder, o.zip, name, bytes)?;
|
||||
}
|
||||
files += 1;
|
||||
}
|
||||
}
|
||||
let names: Vec<&str> = out.kept.iter().map(|(n, _)| n.as_str()).collect();
|
||||
if o.zip {
|
||||
let zip = super::zip::store(&out.kept).map_err(|e| bad(C, e))?;
|
||||
let zip_name = format!("{prefix}{}.zip", safe_name(&super::file_stem(&doc.title)).unwrap_or_else(|| "Untitled".into()));
|
||||
let zip_name = format!("{prefix}{filename}.zip");
|
||||
return match folder {
|
||||
Some(dir) => {
|
||||
let path = format!("{dir}/{zip_name}");
|
||||
@@ -450,6 +662,98 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// What `o` exports a file of per row ([`Item`]): the whole document, the assets, the layers, the
|
||||
/// selection (one item, or one per object), else the chosen artboards.
|
||||
/// `crop`: some row needs the Full Document's art bounds (a format other than PDF).
|
||||
fn items(doc: &Arc<Document>, o: &ScreenParams, selection: Option<bool>, selected: &[NodeId], filename: &str, crop: bool) -> Result<Vec<Item>> {
|
||||
// The art of `ids` alone, as an item named `name`.
|
||||
let alone = |ids: &[NodeId], name: &str| -> Option<(Arc<Document>, Rect)> {
|
||||
isolated(doc, &doc.paint_order(ids.iter().copied()), name).map(|(d, r)| (Arc::new(d), r))
|
||||
};
|
||||
let named = |pieces: Vec<(String, Arc<Document>, Option<usize>, Rect)>, fallback: &str| -> Vec<Item> {
|
||||
let stems = unique_file_names(pieces.iter().map(|(n, ..)| (n.as_str(), fallback.to_string())));
|
||||
pieces
|
||||
.into_iter()
|
||||
.zip(stems)
|
||||
.map(|((name, doc, artboard, region), stem)| Item { name, stem, doc, artboard, region, every_artboard: None })
|
||||
.collect()
|
||||
};
|
||||
if o.full_document {
|
||||
// The bounds of all visible art, as the one artboard of the files that aren't PDFs (a PDF
|
||||
// row writes every artboard of the document itself).
|
||||
let (whole, region) = if crop {
|
||||
let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?;
|
||||
(Arc::new(single_artboard(doc, bounds, "Document")), bounds)
|
||||
} else {
|
||||
(doc.clone(), Rect::ZERO)
|
||||
};
|
||||
let name = filename.to_string();
|
||||
return Ok(vec![Item { name: name.clone(), stem: name, doc: whole, artboard: Some(0), region, every_artboard: Some(doc.clone()) }]);
|
||||
}
|
||||
if let Some(ids) = &o.assets {
|
||||
let mut pieces = vec![];
|
||||
for id in ids {
|
||||
let a = doc.asset(*id).ok_or_else(|| bad(C, format!("no asset {id} (see assets.list)")))?;
|
||||
let (d, r) = alone(&a.nodes, &a.name).ok_or_else(|| bad(C, format!("asset `{}` has no art to export", a.name)))?;
|
||||
pieces.push((a.name.clone(), d, Some(0), r));
|
||||
}
|
||||
if pieces.is_empty() {
|
||||
return Err(bad(C, "no assets to export"));
|
||||
}
|
||||
return Ok(named(pieces, "Asset"));
|
||||
}
|
||||
if let Some(ids) = &o.layers {
|
||||
let mut pieces = vec![];
|
||||
for id in ids {
|
||||
let layer = doc.node(NodeId(*id)).filter(|n| n.is_layer()).ok_or_else(|| bad(C, format!("no layer {id} (see layer.list)")))?;
|
||||
let mut art = vec![];
|
||||
crate::cmd::assets::art_under(doc, layer.id, &mut art);
|
||||
let name = layer.display_name();
|
||||
let (d, r) = alone(&art, &name).ok_or_else(|| bad(C, format!("layer `{name}` has no art to export")))?;
|
||||
pieces.push((name, d, Some(0), r));
|
||||
}
|
||||
if pieces.is_empty() {
|
||||
return Err(bad(C, "no layers to export"));
|
||||
}
|
||||
return Ok(named(pieces, "Layer"));
|
||||
}
|
||||
if let Some(each) = selection {
|
||||
if selected.is_empty() {
|
||||
return Err(bad(C, "select something to export"));
|
||||
}
|
||||
let mut pieces = vec![];
|
||||
if each {
|
||||
for id in selected {
|
||||
let name = doc.node(*id).map(|n| n.display_name()).filter(|n| !n.is_empty()).unwrap_or_else(|| "Selection".into());
|
||||
if let Some((d, r)) = alone(&[*id], &name) {
|
||||
pieces.push((name, d, Some(0), r));
|
||||
}
|
||||
}
|
||||
} else if let Some((d, r)) = alone(selected, "Selection") {
|
||||
pieces.push(("Selection".to_string(), d, Some(0), r));
|
||||
}
|
||||
if pieces.is_empty() {
|
||||
return Err(bad(C, "the selection has no art to export"));
|
||||
}
|
||||
return Ok(named(pieces, "Selection"));
|
||||
}
|
||||
let n = doc.artboards.len();
|
||||
let boards = o.boards.resolve(n).map_err(|e| bad(C, e))?.unwrap_or_else(|| (0..n).collect());
|
||||
if boards.is_empty() {
|
||||
return Err(bad(C, "the document has no artboard"));
|
||||
}
|
||||
let stems = artboard_file_names(doc, &boards);
|
||||
Ok(boards
|
||||
.into_iter()
|
||||
.zip(stems)
|
||||
.map(|(b, stem)| {
|
||||
let a = doc.artboards.get(b);
|
||||
let name = a.map(|a| a.name.clone()).filter(|n| !n.is_empty()).unwrap_or_else(|| stem.clone());
|
||||
Item { name, stem, doc: doc.clone(), artboard: Some(b), region: a.map_or(Rect::ZERO, |a| a.rect), every_artboard: None }
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Keep the params of a successful export in the document (without `zip`, a delivery detail, and
|
||||
/// `assets`, a choice of art), so the dialog reopens on them.
|
||||
fn remember(s: &mut Session, p: &Value) {
|
||||
|
||||
@@ -378,7 +378,9 @@ fn export_for_screens_one_page_per_pdf_unique_names() {
|
||||
let r = s.execute("document.exportForScreens", &json!({"range": "3", "formats": [{"format": "jpg", "scale": 0.5}]})).unwrap();
|
||||
assert_eq!(r["files"][0]["name"], "[email protected]");
|
||||
assert_eq!(image::load_from_memory(&b64(&r["files"][0])).unwrap().width(), 50);
|
||||
assert!(s.execute("document.exportForScreens", &json!({"formats": [{"format": "vectorcraft"}]})).is_err());
|
||||
// A native row: each artboard as a document of its own to edit.
|
||||
let r = s.execute("document.exportForScreens", &json!({"range": "1", "formats": [{"format": "vectorcraft"}]})).unwrap();
|
||||
assert!(r["files"][0]["name"].as_str().unwrap().ends_with(".vectorcraft"), "{r}");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Batch exports through Export for Screens (#1935): a file name pattern (the document's and the
|
||||
//! item's names, a counter, the date), folders by format, by item or both, layers and the selection
|
||||
//! as what is exported, and document formats (VectorCraft, Illustrator, EPS) as rows.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn b64(v: &Value) -> Vec<u8> {
|
||||
vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap()
|
||||
}
|
||||
|
||||
/// A document of `artboards` 40×30 pt artboards side by side with a 20 pt square on the first.
|
||||
fn session(artboards: usize) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 40, "height": 30, "artboards": artboards})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 20})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn export(s: &mut Session, p: Value) -> Value {
|
||||
s.execute("document.exportForScreens", &p).unwrap()
|
||||
}
|
||||
|
||||
fn files(r: &Value) -> Vec<(String, Vec<u8>)> {
|
||||
r["files"].as_array().unwrap().iter().map(|f| (f["name"].as_str().unwrap().to_string(), b64(f))).collect()
|
||||
}
|
||||
|
||||
fn names(r: &Value) -> Vec<String> {
|
||||
files(r).into_iter().map(|(n, _)| n).collect()
|
||||
}
|
||||
|
||||
/// The request in #1935: SVG, PNG, PDF and PDF with printer's marks for artboards 1–3, named
|
||||
/// `COMPANY-Logo-<filename>-####-YYYYMMDD`, sorted into a folder per extension.
|
||||
#[test]
|
||||
fn the_logo_package_of_the_request() {
|
||||
let mut s = session(4);
|
||||
let r = export(
|
||||
&mut s,
|
||||
json!({
|
||||
"range": "1-3",
|
||||
"name": "COMPANY-Logo-<filename>-####-YYYYMMDD.ext",
|
||||
"date": "2026-10-16",
|
||||
"folders": "format",
|
||||
"formats": [
|
||||
{"format": "svg"},
|
||||
{"format": "png", "scale": "2x", "suffix": ""},
|
||||
{"format": "pdf"},
|
||||
{"format": "pdf", "suffix": "-print", "marks": {"trim": true, "registration": true, "colorBars": true, "pageInfo": true}}
|
||||
]
|
||||
}),
|
||||
);
|
||||
let n = names(&r);
|
||||
assert_eq!(n.len(), 12, "{n:?}");
|
||||
for (i, number) in ["0001", "0002", "0003"].iter().enumerate() {
|
||||
let stem = format!("COMPANY-Logo-Untitled-1-{number}-20261016");
|
||||
for want in [format!("svg/{stem}.svg"), format!("png/{stem}.png"), format!("pdf/{stem}.pdf"), format!("pdf/{stem}-print.pdf")] {
|
||||
assert!(n.contains(&want), "artboard {}: {want} in {n:?}", i + 1);
|
||||
}
|
||||
}
|
||||
// The printed PDF has room for its marks: a bigger page than the plain one.
|
||||
let f = files(&r);
|
||||
let page = |name: &str| {
|
||||
let bytes = &f.iter().find(|(n, _)| n == name).unwrap().1;
|
||||
let d = vectorcraft_pdf::import(bytes).unwrap();
|
||||
let a = d.artboards.first().unwrap().rect;
|
||||
(a.width(), a.height())
|
||||
};
|
||||
let plain = page("pdf/COMPANY-Logo-Untitled-1-0001-20261016.pdf");
|
||||
let print = page("pdf/COMPANY-Logo-Untitled-1-0001-20261016-print.pdf");
|
||||
assert!(print.0 > plain.0 && print.1 > plain.1, "{plain:?} vs {print:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folders_by_item_and_by_item_and_format() {
|
||||
let mut s = session(2);
|
||||
let rows = json!([{"format": "png"}, {"format": "svg"}]);
|
||||
let by_item = names(&export(&mut s, json!({"folders": "item", "formats": rows})));
|
||||
assert_eq!(by_item, ["Artboard-1/Artboard-1.png", "Artboard-1/Artboard-1.svg", "Artboard-2/Artboard-2.png", "Artboard-2/Artboard-2.svg"]);
|
||||
let both = names(&export(&mut s, json!({"folders": "itemFormat", "formats": rows})));
|
||||
assert_eq!(both[0], "Artboard-1/png/Artboard-1.png");
|
||||
let flat = names(&export(&mut s, json!({"folders": "none", "subfolders": true, "formats": rows})));
|
||||
assert_eq!(flat[0], "Artboard-1.png", "folders wins over subfolders");
|
||||
// Without folders, subfolders keeps its meaning (a folder per row).
|
||||
let rows_mode = names(&export(&mut s, json!({"subfolders": true, "formats": rows})));
|
||||
assert_eq!(rows_mode[0], "1x/Artboard-1.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_counter_counts_items_or_files_from_any_start() {
|
||||
let mut s = session(2);
|
||||
let rows = json!([{"format": "png"}, {"format": "svg"}]);
|
||||
let items = names(&export(&mut s, json!({"name": "logo-###", "counterStart": 9, "formats": rows})));
|
||||
assert_eq!(items, ["logo-009.png", "logo-009.svg", "logo-010.png", "logo-010.svg"]);
|
||||
let per_file = names(&export(&mut s, json!({"name": "logo-#", "counterPer": "file", "formats": rows})));
|
||||
assert_eq!(per_file, ["logo-1.png", "logo-2.svg", "logo-3.png", "logo-4.svg"]);
|
||||
// A pattern that names different files alike numbers them instead of dropping one.
|
||||
let same = names(&export(&mut s, json!({"name": "logo", "formats": [{"format": "png"}]})));
|
||||
assert_eq!(same, ["logo.png", "logo-2.png"]);
|
||||
// The item's name, its place and the row's size and suffix.
|
||||
let parts = names(&export(
|
||||
&mut s,
|
||||
json!({"name": "<index>_<name>_<scale><suffix>_<format>", "range": "2", "formats": [{"format": "png", "scale": "2x"}]}),
|
||||
));
|
||||
assert_eq!(parts, ["1_Artboard 2_2x@2x_png.png"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_and_the_selection_are_exported_by_name() {
|
||||
let mut s = session(1);
|
||||
let first_layer = s.doc().unwrap().doc.layers[0].id.0;
|
||||
let top = s.execute("layer.new", &json!({"name": "Dark Mode"})).unwrap()["id"].as_u64().unwrap();
|
||||
let circle = s.execute("shape.ellipse", &json!({"x": 2, "y": 2, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
|
||||
let r = export(&mut s, json!({"layers": [first_layer, top], "formats": [{"format": "svg"}]}));
|
||||
assert_eq!(names(&r), ["Layer-1.svg", "Dark-Mode.svg"]);
|
||||
// The selection: one file of it all, or a file per object.
|
||||
let square = s.doc().unwrap().doc.layers[0].children().unwrap()[0].id.0;
|
||||
s.execute("select.set", &json!({"ids": [square, circle]})).unwrap();
|
||||
assert_eq!(names(&export(&mut s, json!({"selection": "one", "formats": [{"format": "png"}]}))), ["Selection.png"]);
|
||||
let each = names(&export(&mut s, json!({"selection": "each", "formats": [{"format": "png"}]})));
|
||||
assert_eq!(each.len(), 2, "{each:?}");
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert!(s.execute("document.exportForScreens", &json!({"selection": "one"})).is_err(), "nothing selected");
|
||||
}
|
||||
|
||||
/// Document formats write each artboard as a document of its own: one artboard, its art.
|
||||
#[test]
|
||||
fn document_formats_write_one_artboard_each() {
|
||||
let mut s = session(2);
|
||||
let r = export(&mut s, json!({"formats": [{"format": "vectorcraft"}, {"format": "ai"}, {"format": "eps"}]}));
|
||||
let n = names(&r);
|
||||
assert_eq!(n, ["Artboard-1.vectorcraft", "Artboard-1.ai", "Artboard-1.eps", "Artboard-2.vectorcraft", "Artboard-2.ai", "Artboard-2.eps"]);
|
||||
let native = &files(&r)[0].1;
|
||||
let mut t = Session::new();
|
||||
t.execute("document.open", &json!({"name": "a.vectorcraft", "dataBase64": vectorcraft_format::base64_encode(native)})).unwrap();
|
||||
assert_eq!(t.doc().unwrap().doc.artboards.len(), 1, "that artboard alone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_batch_params_are_refused() {
|
||||
let mut s = session(1);
|
||||
for bad in [
|
||||
json!({"folders": "byColour"}),
|
||||
json!({"counterPer": "page"}),
|
||||
json!({"date": "16/10/2026"}),
|
||||
json!({"selection": "some"}),
|
||||
json!({"selection": "one", "fullDocument": true}),
|
||||
json!({"layers": [99999]}),
|
||||
json!({"layers": [1], "assets": [1]}),
|
||||
] {
|
||||
assert!(s.execute("document.exportForScreens", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Export presets: the built-in ones, saving (only what a preset keeps), using one under other
|
||||
/// params, replacing, deleting, and keeping them with the preferences.
|
||||
#[test]
|
||||
fn export_presets_save_use_and_delete() {
|
||||
let mut s = session(2);
|
||||
let list = s.execute("document.exportPresets.list", &json!({})).unwrap();
|
||||
let built_in: Vec<&str> =
|
||||
list["presets"].as_array().unwrap().iter().filter(|p| p["builtIn"] == true).map(|p| p["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(built_in, ["Logo Package", "Print-Ready PDF"]);
|
||||
// The Logo Package: SVG, PNG 1x and 2x, PDF and PDF with marks, a folder per format.
|
||||
let r = export(&mut s, json!({"exportPreset": "Logo Package", "range": "1"}));
|
||||
assert_eq!(
|
||||
names(&r),
|
||||
[
|
||||
"svg/Untitled-1-Artboard 1.svg",
|
||||
"png/Untitled-1-Artboard 1.png",
|
||||
"png/Untitled-1-Artboard [email protected]",
|
||||
"pdf/Untitled-1-Artboard 1.pdf",
|
||||
"pdf/Untitled-1-Artboard 1-print.pdf"
|
||||
]
|
||||
);
|
||||
|
||||
let settings = json!({"name": "ACME-<name>-##", "folders": "item", "formats": [{"format": "jpg", "quality": 80}], "range": "2", "assets": [1]});
|
||||
let saved = s.execute("document.exportPresets.save", &json!({"name": "Web JPG", "settings": settings})).unwrap();
|
||||
assert_eq!(saved["created"], true);
|
||||
assert!(saved["settings"].get("range").is_none() && saved["settings"].get("assets").is_none(), "which art is not kept: {saved}");
|
||||
let r = export(&mut s, json!({"exportPreset": "Web JPG", "range": "2"}));
|
||||
assert_eq!(names(&r), ["Artboard-2/ACME-Artboard 2-01.jpg"]);
|
||||
// The call's own params win over the preset's.
|
||||
let r = export(&mut s, json!({"exportPreset": "Web JPG", "range": "2", "folders": "none"}));
|
||||
assert_eq!(names(&r), ["ACME-Artboard 2-01.jpg"]);
|
||||
// Saving again replaces it; the preferences keep it.
|
||||
let again = s.execute("document.exportPresets.save", &json!({"name": "Web JPG", "settings": {"formats": [{"format": "png"}]}})).unwrap();
|
||||
assert_eq!(again["created"], false);
|
||||
let prefs = serde_json::to_value(&s.prefs).unwrap();
|
||||
assert_eq!(prefs["screenExportPresets"][0]["name"], "Web JPG");
|
||||
s.execute("document.exportPresets.delete", &json!({"name": "Web JPG"})).unwrap();
|
||||
assert!(s.execute("document.exportForScreens", &json!({"exportPreset": "Web JPG"})).is_err());
|
||||
// Built-in names stay; bad settings are refused when saved.
|
||||
for bad in [
|
||||
("document.exportPresets.save", json!({"name": "Logo Package", "settings": {}})),
|
||||
("document.exportPresets.save", json!({"name": "", "settings": {}})),
|
||||
("document.exportPresets.save", json!({"name": "X", "settings": {"formats": [{"format": "txt"}]}})),
|
||||
("document.exportPresets.save", json!({"name": "X", "settings": {"folders": "sideways"}})),
|
||||
("document.exportPresets.save", json!({"name": "X"})),
|
||||
("document.exportPresets.delete", json!({"name": "Logo Package"})),
|
||||
("document.exportPresets.delete", json!({"name": "Never saved"})),
|
||||
] {
|
||||
assert!(s.execute(bad.0, &bad.1).is_err(), "{bad:?}");
|
||||
}
|
||||
}
|
||||
@@ -171,7 +171,9 @@ fn bad_eps_options_are_refused_and_formats_list_eps() {
|
||||
let e = s.execute("document.exportEps", &p).unwrap_err().to_string();
|
||||
assert!(e.contains("EPS"), "{p}: {e}");
|
||||
}
|
||||
assert!(s.execute("document.exportForScreens", &json!({"formats": [{"format": "eps"}]})).is_err());
|
||||
// Export for Screens writes an EPS per artboard (a document of that artboard alone).
|
||||
let r = s.execute("document.exportForScreens", &json!({"formats": [{"format": "eps"}]})).unwrap();
|
||||
assert!(r["files"][0]["name"].as_str().unwrap().ends_with(".eps"), "{r}");
|
||||
let r = s.execute("document.formats", &json!({})).unwrap();
|
||||
let eps = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "eps").unwrap().clone();
|
||||
// Readable too since EPS import (tests_epsimport.rs).
|
||||
|
||||
@@ -139,6 +139,27 @@ fn exports_write_ai_as_save_as_does() {
|
||||
assert!(b64(&r).starts_with(b"%PDF"));
|
||||
}
|
||||
|
||||
/// A `.ai` with an artboard larger than 14,400 pt has its PDF page written with a `UserUnit`,
|
||||
/// and reopens at its size from its editing data and from its PDF part.
|
||||
#[test]
|
||||
fn an_ai_with_an_artboard_past_14400_pt_reopens_at_its_size() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 20000, "height": 10000})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 1000, "y": 1000, "width": 5000, "height": 3000})).unwrap();
|
||||
let ai = b64(&s.execute("document.export", &json!({"format": "ai", "compress": false})).unwrap());
|
||||
assert!(String::from_utf8_lossy(&ai).contains("/UserUnit 1.389"), "20,000 / 14,400 rounded up to the next thousandth");
|
||||
let size = |s: &Session| {
|
||||
let r = s.doc().unwrap().doc.artboards[0].rect;
|
||||
(r.width().round(), r.height().round())
|
||||
};
|
||||
assert_eq!(open(&mut s, "big.ai", &ai)["restored"], true);
|
||||
assert_eq!(size(&s), (20_000.0, 10_000.0));
|
||||
// Picking its page opens the PDF part.
|
||||
let r = s.execute("document.open", &json!({"name": "big.ai", "dataBase64": vectorcraft_format::base64_encode(&ai), "pages": "1"})).unwrap();
|
||||
assert_eq!(r["restored"], false, "{r}");
|
||||
assert_eq!(size(&s), (20_000.0, 10_000.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ait_templates_open_their_document_untitled() {
|
||||
let mut s = rich();
|
||||
|
||||
@@ -91,3 +91,20 @@ fn a_chosen_artboard_s_svg_leaves_out_its_neighbours_art_in_a_shared_group() {
|
||||
assert_eq!(third_only.matches("<path").count() + third_only.matches("<rect").count(), 1, "{third_only}");
|
||||
assert!(third_only.contains(" C") || third_only.contains("<ellipse"), "{third_only}");
|
||||
}
|
||||
|
||||
/// An object off a chosen artboard whose drop shadow reaches onto it is in that artboard's SVG.
|
||||
#[test]
|
||||
fn a_chosen_artboard_s_svg_keeps_an_object_whose_shadow_reaches_it() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
let x0 = s.doc().unwrap().doc.artboards[0].rect.x0;
|
||||
let id = s.execute("shape.rectangle", &json!({"x": x0 - 30.0, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
|
||||
let paths = |s: &mut Session| {
|
||||
let svg = s.execute("document.serialize", &json!({"format": "svg", "artboard": 0})).unwrap();
|
||||
let text = svg["text"].as_str().unwrap().to_string();
|
||||
text.matches("<path").count() + text.matches("<rect").count()
|
||||
};
|
||||
assert_eq!(paths(&mut s), 0, "the plain rectangle is off the artboard");
|
||||
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [id], "params": {"x": 20, "y": 0, "blur": 0}})).unwrap();
|
||||
assert_eq!(paths(&mut s), 1, "its shadow reaches 10 pt onto the artboard");
|
||||
}
|
||||
@@ -114,9 +114,12 @@ pub(super) fn used_fonts(d: &Document) -> BTreeSet<UsedFont> {
|
||||
fonts
|
||||
}
|
||||
|
||||
/// The fonts (family, style, version) of the type of `d` that prints on pages showing `areas`
|
||||
/// (document space): visible type on `layers` (the layers that print) but not on a template layer
|
||||
/// (of area type and type on a path that don't fit, the runs that their layout draws),
|
||||
/// The type of `d` that prints on pages showing `areas` (document space): the fonts (family, style,
|
||||
/// version) it is set in, and the ids of the area type and type on a path among the objects of the
|
||||
/// document whose text doesn't fit (not those in the art of symbols, opacity masks, patterns,
|
||||
/// brushes or placed documents).
|
||||
/// The type that prints is visible type on `layers` (the layers that print) but not on a template
|
||||
/// layer (of area type and type on a path that don't fit, the runs that their layout draws),
|
||||
/// where what it paints ([`vectorcraft_render::painted_bounds`], its effects applied) meets an area
|
||||
/// inside the clipping paths around it, the type in the opacity masks and patterns of the objects
|
||||
/// that print and in the pieces their brushes draw, and the type of the symbols that their
|
||||
@@ -125,27 +128,32 @@ pub(super) fn used_fonts(d: &Document) -> BTreeSet<UsedFont> {
|
||||
/// fills or strokes of its own, are taken with it when what it paints meets an area, and so is the
|
||||
/// art of a placed document (`d` as [`Document::with_placed_art`] gives it, for that art's
|
||||
/// resources).
|
||||
pub(super) fn printed_fonts(d: &Document, layers: &[Arc<Node>], areas: &[Rect]) -> BTreeSet<UsedFont> {
|
||||
let mut walk = Printed { d, fonts: BTreeSet::new(), symbols: BTreeSet::new(), patterns: BTreeSet::new() };
|
||||
pub(super) fn printed_type(d: &Document, layers: &[Arc<Node>], areas: &[Rect]) -> (BTreeSet<UsedFont>, BTreeSet<NodeId>) {
|
||||
let mut walk = Printed { d, own: true, fonts: BTreeSet::new(), overflow: BTreeSet::new(), symbols: BTreeSet::new(), patterns: BTreeSet::new() };
|
||||
for l in layers {
|
||||
walk.node(l, Some(areas));
|
||||
}
|
||||
walk.fonts
|
||||
(walk.fonts, walk.overflow)
|
||||
}
|
||||
|
||||
/// The walk of [`printed_fonts`].
|
||||
/// The walk of [`printed_type`].
|
||||
struct Printed<'a> {
|
||||
d: &'a Document,
|
||||
/// Whether the walk is among the objects of the document, not in art drawn for one.
|
||||
own: bool,
|
||||
fonts: BTreeSet<UsedFont>,
|
||||
/// The objects of the document whose text doesn't fit.
|
||||
overflow: BTreeSet<NodeId>,
|
||||
/// The symbols and patterns whose art was walked.
|
||||
symbols: BTreeSet<String>,
|
||||
patterns: BTreeSet<String>,
|
||||
}
|
||||
|
||||
impl Printed<'_> {
|
||||
/// Adds the fonts of `n` and its descendants that print: those whose bounds meet one of `areas`,
|
||||
/// or with `None` (in the art of a symbol, opacity mask, pattern or placed document, in brush
|
||||
/// pieces, or in a live object) all of them but those outside the clipping path of a clip group.
|
||||
/// Adds the fonts of `n` and its descendants that print, and the ids of those that don't fit
|
||||
/// while [`Printed::own`] is set: those whose bounds meet one of `areas`, or with `None` (in the
|
||||
/// art of a symbol, opacity mask, pattern or placed document, in brush pieces, or in a live
|
||||
/// object) all of them but those outside the clipping path of a clip group.
|
||||
fn node(&mut self, n: &Node, areas: Option<&[Rect]>) {
|
||||
if !n.visible || matches!(n.kind, NodeKind::Layer { template: true, .. }) {
|
||||
return;
|
||||
@@ -162,9 +170,9 @@ impl Printed<'_> {
|
||||
}
|
||||
// The art that its opacity mask, pattern paints and brushed strokes draw.
|
||||
let mask = n.extras().mask.as_deref().filter(|m| !m.disabled);
|
||||
let runs = match &n.kind {
|
||||
let (runs, overflows) = match &n.kind {
|
||||
NodeKind::Text(t) => drawn_runs(t),
|
||||
_ => vec![],
|
||||
_ => (vec![], false),
|
||||
};
|
||||
let items: Vec<&AppearanceItem> =
|
||||
n.appearance.items.iter().filter(|i| i.visible() && !matches!(i, AppearanceItem::Stroke(s) if s.width <= 0.0)).collect();
|
||||
@@ -180,7 +188,7 @@ impl Printed<'_> {
|
||||
let brushed = vectorcraft_brush::has_brush(n);
|
||||
if (mask.is_some() || !patterns.is_empty() || brushed) && areas.is_none_or(|a| !open || self.meets(n, a)) {
|
||||
if let Some(m) = mask {
|
||||
self.node(&m.art, None);
|
||||
self.art(&m.art);
|
||||
}
|
||||
let d = self.d;
|
||||
for name in patterns {
|
||||
@@ -188,19 +196,22 @@ impl Printed<'_> {
|
||||
&& let Some(p) = d.pattern(name)
|
||||
{
|
||||
for art in &p.art {
|
||||
self.node(art, None);
|
||||
self.art(art);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The pieces its brushes draw along the path (art and pattern brushes leave type out).
|
||||
if brushed {
|
||||
for piece in vectorcraft_brush::node_pieces(d, n).unwrap_or_default() {
|
||||
self.node(&piece, None);
|
||||
self.art(&piece);
|
||||
}
|
||||
}
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Text(_) => {
|
||||
if overflows && self.own {
|
||||
self.overflow.insert(n.id);
|
||||
}
|
||||
for r in runs {
|
||||
self.fonts.insert((r.style.font_family.clone(), r.style.font_style.clone(), r.style.font_version.clone()));
|
||||
// Inline graphics draw their symbols' art.
|
||||
@@ -212,7 +223,7 @@ impl Printed<'_> {
|
||||
NodeKind::SymbolInstance { symbol, .. } => self.symbol(symbol),
|
||||
NodeKind::PlacedDocument(p) => {
|
||||
for art in p.art(n) {
|
||||
self.node(&art, None);
|
||||
self.art(&art);
|
||||
}
|
||||
}
|
||||
NodeKind::Layer { children, clip, .. } | NodeKind::Group { children, clip } if !moved => {
|
||||
@@ -240,10 +251,19 @@ impl Printed<'_> {
|
||||
if self.symbols.insert(name.to_string())
|
||||
&& let Some(s) = d.symbols.iter().find(|s| s.name == name)
|
||||
{
|
||||
self.node(&s.art, None);
|
||||
self.art(&s.art);
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the fonts of `art`, drawn for an object of the document but not one of its objects (the
|
||||
/// art of a symbol, opacity mask, pattern or placed document, or a brush piece), with
|
||||
/// [`Printed::node`]'s `None`.
|
||||
fn art(&mut self, art: &Node) {
|
||||
let own = std::mem::replace(&mut self.own, false);
|
||||
self.node(art, None);
|
||||
self.own = own;
|
||||
}
|
||||
|
||||
/// Whether what `n` paints, its effects applied, meets one of `areas`.
|
||||
fn meets(&self, n: &Node, areas: &[Rect]) -> bool {
|
||||
vectorcraft_render::painted_bounds(self.d, n).is_some_and(|b| areas.iter().any(|r| b.intersect(*r).area() > 0.0 || r.contains_rect(b)))
|
||||
@@ -256,14 +276,15 @@ impl Printed<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The runs of `t` that print: all of them, or of area type and type on a path that don't fit, the
|
||||
/// runs that their layout draws glyphs or inline graphics of.
|
||||
fn drawn_runs(t: &vectorcraft_doc::TextObject) -> Vec<&TextRun> {
|
||||
/// The runs of `t` that print, and whether its text doesn't fit: all of them, or of area type and
|
||||
/// type on a path that don't fit, the runs that their layout draws glyphs or inline graphics of.
|
||||
fn drawn_runs(t: &vectorcraft_doc::TextObject) -> (Vec<&TextRun>, bool) {
|
||||
let drawn: Option<BTreeSet<usize>> = (!matches!(t.kind, TextKind::Point))
|
||||
.then(|| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t))
|
||||
.filter(|lay| lay.overflow)
|
||||
.map(|lay| lay.glyphs.iter().map(|g| g.run).chain(lay.inlines.iter().map(|i| i.run)).collect());
|
||||
t.runs.iter().enumerate().filter(|(i, _)| drawn.as_ref().is_none_or(|d| d.contains(i))).map(|(_, r)| r).collect()
|
||||
let runs = t.runs.iter().enumerate().filter(|(i, _)| drawn.as_ref().is_none_or(|d| d.contains(i))).map(|(_, r)| r).collect();
|
||||
(runs, drawn.is_some())
|
||||
}
|
||||
|
||||
/// What an export that draws type (as outlines, embedded fonts or pixels) says when `d` uses font
|
||||
|
||||
@@ -152,7 +152,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fit to Artwork Bounds",
|
||||
["Object", "Artboards"],
|
||||
None,
|
||||
"{index?: n (0-based; the app passes the active artboard; default: the first)} the artboard fits the art that shows (with its strokes and effects; hidden objects and layers, guides and template layers don't count)",
|
||||
"{index?: n | indices?: [n…] (0-based; the app passes the selected artboards: the Artboard tool's, else the active one; default: the first)} each artboard fits the art on it that shows (with its strokes and effects; hidden objects and layers, guides and template layers don't count), in one undo step: an object counts for every artboard its bounds overlap, and a group or clipping layer counts as one object; an artboard with no art stays as it is, and the command fails when no artboard it names has art",
|
||||
has_doc,
|
||||
artboard_fit_art
|
||||
),
|
||||
@@ -161,7 +161,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fit to Selected Art",
|
||||
["Object", "Artboards"],
|
||||
None,
|
||||
"{index?: n (0-based; the app passes the active artboard; default: the first)}",
|
||||
"{index?: n (0-based; the app passes the active artboard; default: the first)} the artboard fits the selected art (with its strokes)",
|
||||
has_selection,
|
||||
artboard_fit_sel
|
||||
),
|
||||
@@ -412,6 +412,9 @@ fn target(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !st.doc.is_editable(id) {
|
||||
return Err(bad(C, "the object is hidden or locked"));
|
||||
}
|
||||
if !in_isolation(&st.doc, st.isolation, id) {
|
||||
return Err(bad(C, "the object is outside the isolated group"));
|
||||
}
|
||||
let layer = n.is_layer();
|
||||
let rows = st.layer_rows.clone();
|
||||
if p.get("add").and_then(Value::as_bool).unwrap_or(false) {
|
||||
@@ -645,6 +648,11 @@ fn collect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "id": lid.0 }))
|
||||
}
|
||||
|
||||
/// Whether `id` lies inside the isolated group or layer (always, outside isolation mode).
|
||||
fn in_isolation(doc: &Document, isolation: Option<NodeId>, id: NodeId) -> bool {
|
||||
isolation.is_none_or(|iso| iso != id && doc.ancestry(id).is_some_and(|a| a.contains(&iso)))
|
||||
}
|
||||
|
||||
/// The selection column: select a row's art.
|
||||
fn select_art(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.selectAll";
|
||||
@@ -652,7 +660,7 @@ fn select_art(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let add = bool_or(p, "add", false);
|
||||
let st = s.doc()?;
|
||||
let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
let ids: Vec<NodeId> = if !st.doc.is_editable(id) {
|
||||
let ids: Vec<NodeId> = if !st.doc.is_editable(id) || !in_isolation(&st.doc, st.isolation, id) {
|
||||
vec![]
|
||||
} else if n.is_layer() {
|
||||
n.layer_art(true)
|
||||
@@ -890,12 +898,38 @@ fn rect_map(from: Rect, to: Rect) -> Option<Affine> {
|
||||
xf.is_finite().then_some(xf)
|
||||
}
|
||||
|
||||
/// The artboard fits the art that exports draw: hidden objects and layers, guides and template
|
||||
/// layers don't count.
|
||||
/// Each artboard of `index` or `indices` (default: the first) fits the art on it that exports
|
||||
/// draw ([`vectorcraft_render::encode::art_bounds_on`]): hidden objects and layers, guides and
|
||||
/// template layers don't count. An artboard with no art on it stays as it is, and the command
|
||||
/// fails when none has art. The fits are one undo step named Artboard Options.
|
||||
fn artboard_fit_art(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let b = vectorcraft_render::encode::art_bounds(&s.doc()?.doc).ok_or_else(|| EngineError::Other("no artwork".into()))?;
|
||||
s.execute("artboard.setProps", &json!({"index": i, "x": b.x0, "y": b.y0, "width": b.width(), "height": b.height()}))
|
||||
let d = &s.doc()?.doc;
|
||||
let indices =
|
||||
if p.get("index").is_none() && p.get("indices").is_none() { vec![0] } else { super::panelcmds::artboard_indices(d, p, "artboard.fitToArt")? };
|
||||
let mut fits = vec![];
|
||||
for i in indices {
|
||||
let was = d.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
if let Some(b) = vectorcraft_render::encode::art_bounds_on(d, was) {
|
||||
// At least 1 pt a side, as `rect_from` makes Artboard Options' sizes.
|
||||
fits.push((i, was, Rect::new(b.x0, b.y0, b.x0 + b.width().max(1.0), b.y0 + b.height().max(1.0))));
|
||||
}
|
||||
}
|
||||
if fits.is_empty() {
|
||||
return Err(EngineError::Other("no artwork".into()));
|
||||
}
|
||||
s.edit("Artboard Options", |d, _| {
|
||||
for &(i, was, now) in &fits {
|
||||
let a = d.artboards.get_mut(i).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
a.rect = now;
|
||||
let id = a.id;
|
||||
// Moved and not resized, it takes its guides along, as in Artboard Options.
|
||||
if (now.width() - was.width()).abs() <= 1e-6 && (now.height() - was.height()).abs() <= 1e-6 {
|
||||
d.move_artboard_guides(id, now.origin() - was.origin());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
fn artboard_fit_sel(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -11,7 +11,7 @@ use vectorcraft_doc::{Appearance, Document, Knockout, LiveCorners, LiveShape, No
|
||||
use vectorcraft_geom::shapes::CornerKind;
|
||||
use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect, Vec2};
|
||||
|
||||
use super::edit::{duplicate_in, outermost, selected_roots};
|
||||
use super::edit::{duplicate_in, outermost, selected_roots, selected_roots_to_transform};
|
||||
use super::opacitymask::percent;
|
||||
use super::*;
|
||||
use crate::{EngineError, LastTransform};
|
||||
@@ -252,7 +252,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Distribute",
|
||||
["Window", "Align"],
|
||||
None,
|
||||
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the selection; \"key\" distributes as \"selection\" does; not used with anchor points selected), artboard?: index (0-based; with to: \"artboard\", the artboard to distribute across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} space the objects' left edges, centers or right edges (top edges, centers or bottom edges) evenly, in their order on that line: the first and last objects stay put, and fewer than three objects don't move; with to: \"artboard\", the first one's left (top) edge goes to the artboard's left (top) edge, the last one's right (bottom) edge goes to the artboard's right (bottom) edge, and two objects are enough. With anchor points selected (Direct Selection), the points are spaced evenly between the first and the last along the axis instead (#1636)",
|
||||
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", spacing?: pt (used with a key object, select.key: the key object stays put and the lines are placed this far apart, outward from its line, in their order on that line; 0 puts them all on its line; not used without a key object or with anchor points selected), to?: \"selection\"|\"artboard\"|\"key\" (default: the selection; \"key\" distributes as \"selection\" does; not used with anchor points selected, or with spacing and a key object), artboard?: index (0-based; with to: \"artboard\", the artboard to distribute across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} space the objects' left edges, centers or right edges (top edges, centers or bottom edges) in their order on that line: without spacing and a key object they are spaced evenly, the first and last objects stay put, and fewer than three objects don't move; with spacing and a key object, see spacing (two objects are enough); with to: \"artboard\", the first one's left (top) edge goes to the artboard's left (top) edge, the last one's right (bottom) edge goes to the artboard's right (bottom) edge, and two objects are enough. With anchor points selected (Direct Selection), the points are spaced evenly between the first and the last along the axis instead (#1636)",
|
||||
can_distribute,
|
||||
distribute
|
||||
),
|
||||
@@ -493,14 +493,14 @@ 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, anchors) = match ids_param(p, "ids") {
|
||||
Some(v) => (outermost(&s.doc()?.doc, v), false),
|
||||
None => (selected_roots(s)?, bool_or(p, "anchors", false)),
|
||||
None => (selected_roots_to_transform(s)?, bool_or(p, "anchors", false)),
|
||||
};
|
||||
apply_transform(s, "Transform", ids, m, p, anchors)
|
||||
}
|
||||
|
||||
fn move_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_numbers(p, &["dx", "dy"], "object.move")?;
|
||||
let ids = selected_roots(s)?;
|
||||
let ids = selected_roots_to_transform(s)?;
|
||||
apply_transform(s, "Move", ids, Affine::translate((f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0))), p, true)
|
||||
}
|
||||
|
||||
@@ -524,7 +524,7 @@ fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
return super::docmenu::guide_move(s, &json!({ "dx": dx, "dy": dy, "copy": copy }));
|
||||
}
|
||||
let ids = selected_roots(s)?;
|
||||
let ids = selected_roots_to_transform(s)?;
|
||||
apply_transform(s, "Move", ids, Affine::translate((dx, dy)), p, true)
|
||||
}
|
||||
|
||||
@@ -534,7 +534,7 @@ fn about(o: Point, a: Affine) -> Affine {
|
||||
|
||||
fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_numbers(p, &["angle"], "object.rotate")?;
|
||||
let ids = selected_roots(s)?;
|
||||
let ids = selected_roots_to_transform(s)?;
|
||||
let o = origin_of(s, p, &ids)?;
|
||||
let mut angle = f64_or(p, "angle", 0.0);
|
||||
if bool_or(p, "absolute", false) {
|
||||
@@ -547,7 +547,7 @@ fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn scale(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_numbers(p, &["sy"], "object.scale")?;
|
||||
let ids = selected_roots(s)?;
|
||||
let ids = selected_roots_to_transform(s)?;
|
||||
let o = origin_of(s, p, &ids)?;
|
||||
let sx = f64_req(p, "sx", "object.scale")? / 100.0;
|
||||
let sy = f64_or(p, "sy", sx * 100.0) / 100.0;
|
||||
@@ -558,7 +558,7 @@ fn scale(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn reflect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
let ids = selected_roots_to_transform(s)?;
|
||||
let o = origin_of(s, p, &ids)?;
|
||||
let m = match p.get("axis") {
|
||||
Some(Value::String(a)) if a == "horizontal" => Affine::scale_non_uniform(1.0, -1.0),
|
||||
@@ -578,7 +578,7 @@ fn reflect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn shear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_numbers(p, &["angle"], "object.shear")?;
|
||||
let ids = selected_roots(s)?;
|
||||
let ids = selected_roots_to_transform(s)?;
|
||||
let o = origin_of(s, p, &ids)?;
|
||||
let t = f64_or(p, "angle", 0.0).clamp(-89.0, 89.0).to_radians().tan();
|
||||
let vertical = choice(p, "object.shear", "axis", &["horizontal", "vertical"])? == Some("vertical");
|
||||
@@ -602,7 +602,7 @@ fn transform_again(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
match last {
|
||||
LastTransform::Matrix(m, options) => {
|
||||
let ids = selected_roots(s)?;
|
||||
let ids = selected_roots_to_transform(s)?;
|
||||
apply_transform(s, "Transform Again", ids, m, &merge(options), true)
|
||||
}
|
||||
LastTransform::Each(params) => super::menucmds::transform_each_again(s, &merge(params)),
|
||||
@@ -1232,6 +1232,15 @@ fn direction<'a>(p: &'a Value, cmd: &str) -> Result<(Option<&'a str>, Option<&'a
|
||||
Ok((choice(p, cmd, "horizontal", &["left", "center", "right"])?, choice(p, cmd, "vertical", &["top", "center", "bottom"])?))
|
||||
}
|
||||
|
||||
/// `spacing` of `object.distribute` and `object.distributeSpacing`: a finite number, or `None`
|
||||
/// when it is missing or null. Any other value is an error.
|
||||
fn spacing(p: &Value, cmd: &str) -> Result<Option<f64>> {
|
||||
p.get("spacing")
|
||||
.filter(|v| !v.is_null())
|
||||
.map(|v| v.as_f64().filter(|g| g.is_finite()).ok_or_else(|| bad(cmd, "`spacing` must be a number")))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// `ids` with their bounds (`preview`: visual bounds).
|
||||
fn items_bounds(d: &Document, ids: &[NodeId], preview: bool) -> Vec<(NodeId, Rect)> {
|
||||
ids.iter().filter_map(|id| Some((*id, d.bounds_of(&[*id], preview)?))).collect()
|
||||
@@ -1294,8 +1303,8 @@ fn distribute_to(s: &Session, p: &Value, ids: &[NodeId], preview: bool, cmd: &st
|
||||
}
|
||||
|
||||
/// Returns the selected root (one of `ids`) that is the key object or contains it, such as the
|
||||
/// compound path or selected group the key is in. Align to the key object and Distribute Spacing
|
||||
/// with a spacing keep it in place.
|
||||
/// compound path or selected group the key is in. Align to the key object, and Distribute and
|
||||
/// Distribute Spacing with a spacing, keep it in place.
|
||||
fn key_root(s: &Session, ids: &[NodeId]) -> Option<NodeId> {
|
||||
let st = s.doc().ok()?;
|
||||
let ancestry = st.doc.ancestry(st.selection.key?)?;
|
||||
@@ -1439,6 +1448,7 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
let preview = preview_bounds(s, p, "object.distribute")?;
|
||||
let board = distribute_to(s, p, &ids, preview, "object.distribute")?;
|
||||
let spacing = spacing(p, "object.distribute")?;
|
||||
// Anchor points selected with Direct Selection are distributed themselves, as Align aligns them.
|
||||
if !s.doc()?.selection.anchors.is_empty() {
|
||||
return match direction(p, "object.distribute")? {
|
||||
@@ -1468,8 +1478,14 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
items.sort_by(|a, b| key(&a.1, horiz).total_cmp(&key(&b.1, horiz)));
|
||||
let n = items.len();
|
||||
// Two objects are enough on an artboard: they go to its edges.
|
||||
if n < 2 || (n < 3 && board.is_none()) {
|
||||
// With a spacing, the key object (or the compound path or selected group it is in) stays where
|
||||
// it is, and the lines are placed `spacing` apart, outward from its line, in their order.
|
||||
// Without a key object the spacing is not used. `from_key` is the key's index in `items` and
|
||||
// the spacing.
|
||||
let from_key = spacing.zip(key_root(s, &ids)).and_then(|(g, k)| Some((items.iter().position(|(id, _)| *id == k)?, g)));
|
||||
// Two objects are enough on an artboard, where they go to its edges, and when they are spaced
|
||||
// from a key object.
|
||||
if n < 2 || (n < 3 && board.is_none() && from_key.is_none()) {
|
||||
return ok();
|
||||
}
|
||||
let (first_box, last_box) = (&items[0].1, &items[n - 1].1);
|
||||
@@ -1486,7 +1502,10 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (id, r))| {
|
||||
let target = first + (last - first) * i as f64 / (n - 1) as f64;
|
||||
let target = match from_key {
|
||||
Some((k, g)) => key(&items[k].1, horiz) + g * (i as f64 - k as f64),
|
||||
None => first + (last - first) * i as f64 / (n - 1) as f64,
|
||||
};
|
||||
let delta = target - key(r, horiz);
|
||||
(*id, if horiz { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) })
|
||||
})
|
||||
@@ -1499,11 +1518,7 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let horiz = choice(p, "object.distributeSpacing", "axis", &["horizontal", "vertical"])? != Some("vertical");
|
||||
let preview = preview_bounds(s, p, "object.distributeSpacing")?;
|
||||
let board = distribute_to(s, p, &ids, preview, "object.distributeSpacing")?;
|
||||
let spacing = p
|
||||
.get("spacing")
|
||||
.filter(|v| !v.is_null())
|
||||
.map(|v| v.as_f64().filter(|g| g.is_finite()).ok_or_else(|| bad("object.distributeSpacing", "`spacing` must be a number")))
|
||||
.transpose()?;
|
||||
let spacing = spacing(p, "object.distributeSpacing")?;
|
||||
if !s.doc()?.selection.anchors.is_empty() {
|
||||
return distribute_anchors(s, "Distribute Spacing", horiz, spacing);
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character / Paragraph",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
|
||||
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
|
||||
has_doc,
|
||||
set_format
|
||||
),
|
||||
@@ -210,6 +210,12 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"spaceBefore",
|
||||
"spaceAfter",
|
||||
"hyphenate",
|
||||
"wordsLongerThan",
|
||||
"afterFirst",
|
||||
"beforeLast",
|
||||
"hyphenLimit",
|
||||
"hyphenationZone",
|
||||
"hyphenateCapitalized",
|
||||
"mojikumi",
|
||||
"kinsoku",
|
||||
"direction",
|
||||
@@ -346,6 +352,25 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = flag("hyphenate") {
|
||||
para.hyphenate = v;
|
||||
}
|
||||
let letters = |k: &str, lo: f64, hi: f64| num(k).map(|v| v.round().clamp(lo, hi) as u32);
|
||||
if let Some(v) = letters("wordsLongerThan", 1.0, 25.0) {
|
||||
para.hyphenation.words_longer_than = v;
|
||||
}
|
||||
if let Some(v) = letters("afterFirst", 1.0, 15.0) {
|
||||
para.hyphenation.after_first = v;
|
||||
}
|
||||
if let Some(v) = letters("beforeLast", 1.0, 15.0) {
|
||||
para.hyphenation.before_last = v;
|
||||
}
|
||||
if let Some(v) = letters("hyphenLimit", 0.0, 25.0) {
|
||||
para.hyphenation.hyphen_limit = v;
|
||||
}
|
||||
if let Some(v) = num("hyphenationZone") {
|
||||
para.hyphenation.zone = v.clamp(0.0, MAX_PARA);
|
||||
}
|
||||
if let Some(v) = flag("hyphenateCapitalized") {
|
||||
para.hyphenation.capitalized = v;
|
||||
}
|
||||
if let Some(v) = mojikumi {
|
||||
para.mojikumi = v;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Print Preview",
|
||||
[],
|
||||
None,
|
||||
"{settings?: {…settings of print.setup} (over the document's)} → {pages (copies included), sheets: [{artboard (0-based; null with artboards ignored), tile? (1-based), ink? (separations), width, height (pt, the page), orientation, scale: [width %, height %], transform: [a, b, c, d, e, f] (document space → the page, pt, y down), area: [x0, y0, x1, y1] (the document region the page prints), trim: [x0, y0, x1, y1] (the artboard on the page)}] (one copy, in order), tiles: [{artboard, columns, rows, printed: [1-based tiles], tiles: [[x0, y0, x1, y1]…] (document space)}], inks: [{name, spot, print, frequency, angle}] (separations), warnings (art larger than the imageable area, type that prints in the fallback font, options not applied yet…), settings} without printing",
|
||||
"{settings?: {…settings of print.setup} (over the document's)} → {pages (copies included), sheets: [{artboard (0-based; null with artboards ignored), tile? (1-based), ink? (separations), width, height (pt, the page), orientation, scale: [width %, height %], transform: [a, b, c, d, e, f] (document space → the page, pt, y down), area: [x0, y0, x1, y1] (the document region the page prints), trim: [x0, y0, x1, y1] (the artboard on the page)}] (one copy, in order), tiles: [{artboard, columns, rows, printed: [1-based tiles], tiles: [[x0, y0, x1, y1]…] (document space)}], inks: [{name, spot, print, frequency, angle}] (separations), warnings (art larger than the imageable area, type that prints in the fallback font, type that doesn't fit its frame, options not applied yet…), settings} without printing",
|
||||
has_doc,
|
||||
preview
|
||||
),
|
||||
@@ -92,7 +92,7 @@ fn preview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let set = settings(C, doc, p)?;
|
||||
super::printadvanced::flattener(C, &set, &s.prefs.flattener_presets)?;
|
||||
let mut pv = vectorcraft_pdf::preview(doc, &set).map_err(|e| pdf_error(C, e))?;
|
||||
pv.warnings.extend(fonts_warning(doc, set.print_layers, &areas(&pv)));
|
||||
pv.warnings.extend(type_warnings(doc, set.print_layers, &areas(&pv)));
|
||||
let mut v = serde_json::to_value(pv).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
v["settings"] = to_json(&set)?;
|
||||
Ok(v)
|
||||
@@ -118,14 +118,16 @@ fn on_page(area: Rect, to_page: Affine, (w, h): (f64, f64)) -> Option<Rect> {
|
||||
(r.area() > 0.0).then_some(r)
|
||||
}
|
||||
|
||||
/// The warning about type in `doc` that prints in the fallback font: on the layers `which` prints,
|
||||
/// on pages that print `areas`.
|
||||
fn fonts_warning(doc: &Document, which: PrintLayers, areas: &[Rect]) -> Option<String> {
|
||||
/// The warnings about the type in `doc` that prints (on the layers `which` prints, on pages that
|
||||
/// print `areas`): type in the fallback font, and type that doesn't fit its frame.
|
||||
fn type_warnings(doc: &Document, which: PrintLayers, areas: &[Rect]) -> Vec<String> {
|
||||
// Placed documents print their files' art, which names the resources this adds.
|
||||
let doc = doc.with_placed_art();
|
||||
let mut layers = doc.layers.clone();
|
||||
vectorcraft_pdf::keep_layers(&mut layers, which);
|
||||
super::fonts::substitution_warning_of(super::fonts::printed_fonts(&doc, &layers, areas))
|
||||
let (fonts, overflow) = super::fonts::printed_type(&doc, &layers, areas);
|
||||
let overflow: Vec<_> = overflow.into_iter().collect();
|
||||
super::fonts::substitution_warning_of(fonts).into_iter().chain(super::typecmd::overflow_warning(&overflow)).collect()
|
||||
}
|
||||
|
||||
const PRINT: &str = "file.print";
|
||||
@@ -162,13 +164,13 @@ fn print(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Print as Bitmap and the flattener preset (Advanced) render and flatten it first.
|
||||
let prepared = super::printadvanced::prepare(PRINT, doc, &set, &s.prefs.flattener_presets, s.prefs.black_output == "rich")?;
|
||||
let path = str_param(p, "path");
|
||||
// Fonts are read from the type before raster effects and Print as Bitmap turn it into images.
|
||||
// The type is read before raster effects and Print as Bitmap turn it into images.
|
||||
let which = set.print_layers;
|
||||
if !postscript {
|
||||
let printed = prepared.as_ref().unwrap_or(doc);
|
||||
let fonts = fonts_warning(&full, which, &areas(&vectorcraft_pdf::preview(printed, &set).map_err(|e| pdf_error(PRINT, e))?));
|
||||
let typed = type_warnings(&full, which, &areas(&vectorcraft_pdf::preview(printed, &set).map_err(|e| pdf_error(PRINT, e))?));
|
||||
let mut r = vectorcraft_pdf::print(printed, &PrintOptions { settings: set, ..Default::default() }).map_err(|e| pdf_error(PRINT, e))?;
|
||||
r.warnings.extend(fonts);
|
||||
r.warnings.extend(typed);
|
||||
return write_or_return(path, &r.bytes, json!({ "pages": r.pages, "format": "pdf", "warnings": r.warnings }));
|
||||
}
|
||||
let level = match p.get("level").filter(|v| !v.is_null()) {
|
||||
@@ -212,6 +214,6 @@ fn print(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
let areas: Vec<Rect> = plan.sheets.iter().filter_map(|sh| on_page(sh.area, sh.view * sh.place, sh.size)).collect();
|
||||
warnings.extend(fonts_warning(&full, which, &areas));
|
||||
warnings.extend(type_warnings(&full, which, &areas));
|
||||
write_or_return(path, &out.bytes, json!({ "pages": plan.order.len(), "format": "postscript", "warnings": warnings }))
|
||||
}
|
||||
@@ -31,7 +31,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Select Shaper Face",
|
||||
[],
|
||||
None,
|
||||
"{point: [x,y], source?: bool} click selects a group, then a face; source enters construction isolation and selects an original → {id}",
|
||||
"{point: [x,y], source?: bool} click selects a group, then a face; source enters construction isolation and selects an original; while a group's originals are isolated, it selects one of that group's originals → {id}",
|
||||
has_doc,
|
||||
select
|
||||
),
|
||||
@@ -687,10 +687,13 @@ fn scribble(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"id": id.0, "regions": touched.len(), "strokes": strokes}))
|
||||
}
|
||||
|
||||
fn group_at(d: &Document, point: Point, construction: bool) -> Option<NodeId> {
|
||||
/// The topmost editable Shaper Group at `point`, measured by its source shapes in `construction`.
|
||||
/// When `within` is given, only that group is considered.
|
||||
fn group_at(d: &Document, point: Point, construction: bool, within: Option<NodeId>) -> Option<NodeId> {
|
||||
let mut groups = vec![];
|
||||
d.walk(|n| {
|
||||
if n.extras().shaper.is_some()
|
||||
&& within.is_none_or(|g| g == n.id)
|
||||
&& d.is_editable(n.id)
|
||||
&& (if construction { source_group(n).and_then(Node::geometric_bounds) } else { n.geometric_bounds() })
|
||||
.is_some_and(|b| b.inflate(6.0, 6.0).contains(point))
|
||||
@@ -704,7 +707,10 @@ fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let point = point_param(p, "point").filter(|p| finite(*p)).ok_or_else(|| bad("shaper.select", "missing point"))?;
|
||||
let st = s.doc()?;
|
||||
let source = bool_or(p, "source", false);
|
||||
let Some(group) = group_at(&st.doc, point, source) else { return Ok(json!({"id": null})) };
|
||||
// In Construction mode a source pick stays in the group whose source shapes are isolated.
|
||||
let isolated =
|
||||
st.isolation.and_then(|i| st.doc.parent_of(i)).filter(|g| st.doc.node(*g).and_then(source_group).is_some_and(|s| st.isolation == Some(s.id)));
|
||||
let Some(group) = group_at(&st.doc, point, source, isolated.filter(|_| source)) else { return Ok(json!({"id": null})) };
|
||||
let node = st.doc.node(group).ok_or(EngineError::NoNode(group))?;
|
||||
let target = if source {
|
||||
sources(node)
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{ArrowAlign, Arrowhead, CharStyle, Dash, LineCap, LineJoin, Node, NodeKind, SavedProfile, StrokeLayer, Unit, WidthProfile};
|
||||
use vectorcraft_doc::{
|
||||
ArrowAlign, Arrowhead, CharStyle, Dash, LineCap, LineJoin, Node, NodeKind, SavedProfile, StrokeAlign, StrokeLayer, Unit, WidthProfile,
|
||||
};
|
||||
|
||||
use super::appearance::{ItemTarget, edit_items, item_target};
|
||||
use super::paint::painted;
|
||||
@@ -18,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Stroke Options",
|
||||
["Window", "Stroke"],
|
||||
None,
|
||||
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\"} Without a stroke item, type takes weight, cap, join, miterLimit and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does)",
|
||||
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\"} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does)",
|
||||
has_doc,
|
||||
stroke_set
|
||||
),
|
||||
@@ -82,6 +84,8 @@ pub(crate) struct StrokeChange {
|
||||
dash: Option<Option<Vec<f64>>>,
|
||||
dash_offset: Option<f64>,
|
||||
align_dashes: Option<bool>,
|
||||
/// Align Stroke: centred, inside or outside the shape (or the glyphs, on type).
|
||||
align: Option<StrokeAlign>,
|
||||
}
|
||||
|
||||
/// The value param `key` names in `table` (see [`crate::inspect::CAPS`]); `None` when absent.
|
||||
@@ -116,6 +120,7 @@ impl StrokeChange {
|
||||
dash,
|
||||
dash_offset: num("dashOffset"),
|
||||
align_dashes: p.get("alignDashes").and_then(Value::as_bool),
|
||||
align: named(&ALIGNS, p, "align", cmd)?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -127,12 +132,16 @@ impl StrokeChange {
|
||||
&& self.dash.is_none()
|
||||
&& self.dash_offset.is_none()
|
||||
&& self.align_dashes.is_none()
|
||||
&& self.align.is_none()
|
||||
}
|
||||
|
||||
pub(crate) fn apply(&self, st: &mut StrokeLayer) {
|
||||
if let Some(w) = self.weight {
|
||||
st.width = w;
|
||||
}
|
||||
if let Some(a) = self.align {
|
||||
st.align = a;
|
||||
}
|
||||
if let Some(c) = self.cap {
|
||||
st.cap = c;
|
||||
}
|
||||
@@ -189,7 +198,6 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
};
|
||||
let (sa, ea) = (arrow("startArrow")?, arrow("endArrow")?);
|
||||
let align = named(&ALIGNS, p, "align", C)?;
|
||||
let arrow_align = match str_param(p, "arrowAlign") {
|
||||
None => None,
|
||||
Some("extend") => Some(ArrowAlign::Extend),
|
||||
@@ -202,9 +210,6 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let set = |st: &mut StrokeLayer| {
|
||||
change.apply(st);
|
||||
if let Some(a) = align {
|
||||
st.align = a;
|
||||
}
|
||||
if let Some(a) = sa {
|
||||
st.start_arrow = a;
|
||||
}
|
||||
@@ -270,8 +275,8 @@ impl Session {
|
||||
|
||||
impl DocState {
|
||||
/// Which Stroke panel values the selected objects' strokes (type: every run's) don't share,
|
||||
/// and whether Align Stroke applies to them. One walk of the selection, so callers that ask
|
||||
/// every frame cache it by revision.
|
||||
/// and whether Align Stroke applies to them (no open path among them). One walk of the
|
||||
/// selection, so callers that ask every frame cache it by revision.
|
||||
pub fn stroke_mixed(&self) -> StrokeMixed {
|
||||
let mut nodes = vec![];
|
||||
for id in &self.selection.objects {
|
||||
@@ -284,7 +289,7 @@ impl DocState {
|
||||
for n in &nodes {
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
can_align = false;
|
||||
// The characters' stroke aligns to the glyph outlines, which are closed.
|
||||
strokes.extend(t.runs.iter().map(|r| r.style.stroke_layer()));
|
||||
}
|
||||
_ => {
|
||||
|
||||
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character",
|
||||
[],
|
||||
None,
|
||||
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}",
|
||||
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}",
|
||||
has_doc,
|
||||
set_range_style
|
||||
),
|
||||
@@ -85,6 +85,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
|s, p| type_step(s, &json!({"attribute": "size", "by": -f64_or(p, "by", 1.0)}))
|
||||
),
|
||||
cmd!(
|
||||
"type.size.increaseByFive",
|
||||
"Increase Font Size by Five Steps",
|
||||
[],
|
||||
Some("Cmd+Alt+Shift+."),
|
||||
"{} type.step {attribute: size, by: 5}: five times the Size/Leading increment (typeSizeIncrement)",
|
||||
has_selection,
|
||||
|s, _| type_step(s, &json!({"attribute": "size", "by": 5.0}))
|
||||
),
|
||||
cmd!(
|
||||
"type.size.decreaseByFive",
|
||||
"Decrease Font Size by Five Steps",
|
||||
[],
|
||||
Some("Cmd+Alt+Shift+,"),
|
||||
"{} type.step {attribute: size, by: -5}: five times the Size/Leading increment (typeSizeIncrement)",
|
||||
has_selection,
|
||||
|s, _| type_step(s, &json!({"attribute": "size", "by": -5.0}))
|
||||
),
|
||||
cmd!(
|
||||
"type.resetTracking",
|
||||
"Reset Tracking and Kerning",
|
||||
@@ -94,6 +112,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
reset_tracking
|
||||
),
|
||||
cmd!(
|
||||
"type.resetHorizontalScale",
|
||||
"Reset Horizontal Scale",
|
||||
[],
|
||||
Some("Cmd+Shift+X"),
|
||||
"{ids?: [id…]} set the horizontal scale to 100% (as the Character panel shows it, the object's own transform included) in all the text of the type objects in `ids`, else in the text the Type tool has selected (nothing at a caret), else in every selected type object, as one undo step → {ids}",
|
||||
has_selection,
|
||||
reset_horizontal_scale
|
||||
),
|
||||
cmd!(
|
||||
"text.discardEmpty",
|
||||
"Discard Empty Type",
|
||||
@@ -734,6 +761,26 @@ fn reset_tracking(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "ids": ranges.iter().map(|(id, ..)| id.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// Cmd/Ctrl+Shift+X: horizontal scale 100% (the Character panel's value, which includes the
|
||||
/// object's own transform) in the type objects in `ids`, else in the Type tool's selected text,
|
||||
/// else in every selected type object. At a caret the command leaves the text as it is.
|
||||
fn reset_horizontal_scale(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "type.resetHorizontalScale";
|
||||
let ranges = type_ranges(s, p, C, |_, _| None)?;
|
||||
if !ranges.is_empty() {
|
||||
s.edit("Reset Horizontal Scale", |d, _| {
|
||||
for &(id, a, b, _) in &ranges {
|
||||
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
|
||||
let h_scale = super::typecmd::local_type_value(t, 100.0, (1.0, 10000.0), true, C)?;
|
||||
edit::style_range(&mut t.runs, a, b, |st| st.h_scale = h_scale);
|
||||
refresh_bounds(t);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({ "ids": ranges.iter().map(|(id, ..)| id.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// Tracking (1/1000 em) that makes the first paragraph of `t` exactly `target` wide, if it fits
|
||||
/// on one line at all.
|
||||
fn headline_tracking(t: &TextObject, target: f64) -> Option<f64> {
|
||||
|
||||
@@ -269,6 +269,23 @@ pub(crate) fn with_overflow(s: &Session, ids: &[NodeId], mut result: Value) -> V
|
||||
result
|
||||
}
|
||||
|
||||
/// The warning that Print gives for `ids`, area type and type on a path whose text doesn't fit
|
||||
/// (as [`with_overflow`] finds it): the text past the end of their frames doesn't print. It names
|
||||
/// the first 20 ids and counts the rest; `None` when `ids` is empty.
|
||||
pub(crate) fn overflow_warning(ids: &[NodeId]) -> Option<String> {
|
||||
const NAMED: usize = 20;
|
||||
let (named, rest) = ids.split_at(ids.len().min(NAMED));
|
||||
let mut list = named.iter().map(|i| i.0.to_string()).collect::<Vec<_>>().join(", ");
|
||||
if !rest.is_empty() {
|
||||
list = format!("{list} and {} more", rest.len());
|
||||
}
|
||||
match ids {
|
||||
[] => None,
|
||||
[_] => Some(format!("1 text object doesn't fit its frame: the text past the end of the frame doesn't print (id {list})")),
|
||||
_ => Some(format!("{} text objects don't fit their frames: the text past the end of each frame doesn't print (ids {list})", ids.len())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Text objects among `ids` and their descendants.
|
||||
fn text_ids(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
|
||||
let mut out = vec![];
|
||||
@@ -871,6 +888,48 @@ mod area_tests {
|
||||
assert_eq!(old.burasagari, Burasagari::None);
|
||||
}
|
||||
|
||||
/// Paragraph panel menu › Hyphenation…: `text.setFormat` sets each paragraph's hyphenation
|
||||
/// options, rounded and clamped, in one undo step; they are saved only when not the defaults,
|
||||
/// and paragraph styles carry them.
|
||||
#[test]
|
||||
fn hyphenation_options_are_set_per_paragraph_and_saved_only_when_not_default() {
|
||||
use vectorcraft_doc::HyphenationOptions;
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo", "area": {"width": 200, "height": 100}})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let paras = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => (t.para_at(0).clone(), t.para_at(1).clone()),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
assert_eq!(paras(&s).0.hyphenation, HyphenationOptions::default());
|
||||
assert!(serde_json::to_value(paras(&s).0).unwrap().get("hyphenation").is_none());
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
s.execute(
|
||||
"text.setFormat",
|
||||
&json!({"wordsLongerThan": 7.6, "afterFirst": 0, "beforeLast": 40, "hyphenLimit": 2, "hyphenationZone": 18, "hyphenateCapitalized": false, "start": 5, "end": 5}),
|
||||
)
|
||||
.unwrap();
|
||||
let want = HyphenationOptions { words_longer_than: 8, after_first: 1, before_last: 15, hyphen_limit: 2, zone: 18.0, capitalized: false };
|
||||
let (first, second) = paras(&s);
|
||||
assert_eq!((first.hyphenation, &second.hyphenation), (HyphenationOptions::default(), &want), "only the paragraph the caret is in");
|
||||
assert_eq!(serde_json::to_value(&second).unwrap()["hyphenation"]["hyphen_limit"], 2);
|
||||
let back: vectorcraft_doc::ParaStyle = serde_json::from_value(serde_json::to_value(&second).unwrap()).unwrap();
|
||||
assert_eq!(back.hyphenation, want);
|
||||
s.execute("text.setFormat", &json!({"hyphenLimit": -3, "hyphenationZone": 5000})).unwrap();
|
||||
assert_eq!((paras(&s).0.hyphenation.hyphen_limit, paras(&s).1.hyphenation.zone), (0, 1296.0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(paras(&s).1.hyphenation, HyphenationOptions::default());
|
||||
// A paragraph style carries them; an older file's paragraph reads the defaults.
|
||||
s.execute("paraStyle.new", &json!({"name": "Loose", "attrs": {"hyphenation": {"words_longer_than": 9}}})).unwrap();
|
||||
s.execute("paraStyle.apply", &json!({"name": "Loose", "id": id})).unwrap();
|
||||
assert_eq!(paras(&s).0.hyphenation, HyphenationOptions { words_longer_than: 9, ..HyphenationOptions::default() });
|
||||
let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left", "hyphenate": true})).unwrap();
|
||||
assert_eq!(old.hyphenation, HyphenationOptions::default());
|
||||
}
|
||||
|
||||
/// #432: while the interface is in Japanese, new type (text.create, text.createInPath) starts
|
||||
/// with em box top-to-top leading and em box centre alignment, and new styles made from nothing
|
||||
/// carry them; params override; the values are journaled; imported text keeps the Roman
|
||||
|
||||
@@ -665,16 +665,39 @@ fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if total == 0 {
|
||||
return Ok(json!({ "count": 0 }));
|
||||
}
|
||||
edit_runs::<()>(s, "Replace", &ids, |text, _| {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut last = 0;
|
||||
for (a, b) in find_all(text, &find, mc, ww) {
|
||||
out.push_str(&text[last..a]);
|
||||
out.push_str(&rep);
|
||||
last = b;
|
||||
super::overprint::edit_counted(s, "Replace", |d| {
|
||||
let mut changed = 0;
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
// Matches as (run, start, end) with offsets inside the run. Inline graphics are art, not
|
||||
// characters to rewrite.
|
||||
let mut hits = vec![];
|
||||
let mut base = 0;
|
||||
for (ri, r) in t.runs.iter().enumerate() {
|
||||
if r.inline.is_none() {
|
||||
for (a, b) in find_all(&r.text, &find, mc, ww) {
|
||||
if r.text.get(a..b) != Some(rep.as_str()) {
|
||||
hits.push((ri, base + a, base + b, a, b));
|
||||
}
|
||||
}
|
||||
}
|
||||
base += r.text.len();
|
||||
}
|
||||
if hits.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Last match first, so the offsets of earlier ones stay valid. The paragraph styles follow
|
||||
// each edit, as `text.editRange` does.
|
||||
for (ri, ta, tb, a, b) in hits.into_iter().rev() {
|
||||
t.splice_paras(ta, tb, &rep);
|
||||
if let Some(r) = t.runs.get_mut(ri) {
|
||||
r.text.replace_range(a..b, &rep);
|
||||
}
|
||||
}
|
||||
changed += 1;
|
||||
refresh_bounds(t);
|
||||
}
|
||||
out.push_str(&text[last..]);
|
||||
out
|
||||
Ok(changed)
|
||||
})?;
|
||||
Ok(json!({ "count": total }))
|
||||
}
|
||||
|
||||
@@ -430,7 +430,7 @@ fn dataset_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
|
||||
fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
|
||||
const C: &str = "dataset.select";
|
||||
let (applied, skipped) = s.edit("Apply Data Set", |d, sel| {
|
||||
let (applied, skipped, mut typed) = s.edit("Apply Data Set", |d, sel| {
|
||||
let ds = d.variables.dataset(name).cloned().ok_or_else(|| bad(C, format!("no dataset named `{name}`")))?;
|
||||
// One object may be driven by several variables; walk them in definition order, so two
|
||||
// text variables on the same object land the later one's value and both are counted.
|
||||
@@ -462,6 +462,8 @@ fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
|
||||
});
|
||||
let mut applied = 0usize;
|
||||
let mut skipped = 0usize;
|
||||
// Collects the type objects whose text the row rewrote, for the reply's `overflow`.
|
||||
let mut typed = Vec::new();
|
||||
for (id, var) in pairs {
|
||||
// A binding whose variable is gone, whose value this row doesn't carry, or whose
|
||||
// value doesn't fit the variable's kind, is left alone and counted, never
|
||||
@@ -483,7 +485,10 @@ fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
|
||||
let done = match (kind, &value) {
|
||||
// The Type tool's own whole-range replace, so a dataset's text is styled and
|
||||
// laid out exactly as typing it would be.
|
||||
(VariableKind::Text, DataValue::Text(text)) => set_plain_text(d, id, text).is_ok(),
|
||||
(VariableKind::Text, DataValue::Text(text)) => set_plain_text(d, id, text).is_ok_and(|()| {
|
||||
typed.push(id);
|
||||
true
|
||||
}),
|
||||
(VariableKind::Visibility, DataValue::Visible(show)) => d.node_mut(id).is_some_and(|n| {
|
||||
n.visible = *show;
|
||||
true
|
||||
@@ -502,9 +507,12 @@ fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
|
||||
d.variables.active_dataset = Some(name.to_string());
|
||||
// Every selected object left hidden or locked, such as one the row hid, is deselected.
|
||||
sel.deselect_uneditable(d);
|
||||
Ok((applied, skipped))
|
||||
Ok((applied, skipped, typed))
|
||||
})?;
|
||||
Ok(json!({"name": name, "applied": applied, "skipped": skipped}))
|
||||
// Two text variables on one object rewrite it twice; it is named once.
|
||||
typed.sort_unstable();
|
||||
typed.dedup();
|
||||
Ok(super::typecmd::with_overflow(s, &typed, json!({"name": name, "applied": applied, "skipped": skipped})))
|
||||
}
|
||||
|
||||
fn dataset_select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -529,11 +537,14 @@ fn datasets_csv_bytes(p: &Value, cmd: &str) -> Result<Vec<u8>> {
|
||||
|
||||
/// Parse a datasets CSV into rows of cells (no header interpretation yet): commas between cells,
|
||||
/// `"` quotes a cell (doubled inside), which may then hold commas and line breaks, as
|
||||
/// [`csv_escape`] writes them. Blank lines are skipped.
|
||||
/// [`csv_escape`] writes them. An unquoted cell is trimmed. A quoted cell keeps what its quotes
|
||||
/// hold as written, spaces included, and drops white space after the closing quote at the end
|
||||
/// of the cell. Blank lines are skipped.
|
||||
fn parse_datasets_csv(text: &str, cmd: &str) -> Result<Vec<Vec<String>>> {
|
||||
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
|
||||
let (mut rows, mut row, mut cur) = (Vec::new(), Vec::<String>::new(), String::new());
|
||||
let (mut in_quotes, mut line) = (false, 1usize);
|
||||
// `closed`: where the closing quote of a quoted cell falls in `cur`.
|
||||
let (mut in_quotes, mut closed, mut line) = (false, None, 1usize);
|
||||
let mut chars = text.chars().peekable();
|
||||
loop {
|
||||
let c = chars.next();
|
||||
@@ -544,13 +555,14 @@ fn parse_datasets_csv(text: &str, cmd: &str) -> Result<Vec<Vec<String>>> {
|
||||
chars.next();
|
||||
} else {
|
||||
in_quotes = false;
|
||||
closed = Some(cur.len());
|
||||
}
|
||||
}
|
||||
Some('"') if cur.is_empty() => in_quotes = true,
|
||||
Some(',') if !in_quotes => row.push(std::mem::take(&mut cur)),
|
||||
Some(',') if !in_quotes => row.push(end_csv_cell(&mut cur, &mut closed)),
|
||||
Some('\r') if !in_quotes => {}
|
||||
Some('\n') | None if !in_quotes => {
|
||||
row.push(std::mem::take(&mut cur));
|
||||
row.push(end_csv_cell(&mut cur, &mut closed));
|
||||
if row.iter().any(|c| !c.trim().is_empty()) {
|
||||
rows.push(std::mem::take(&mut row));
|
||||
// +1 for the header.
|
||||
@@ -576,6 +588,20 @@ fn parse_datasets_csv(text: &str, cmd: &str) -> Result<Vec<Vec<String>>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the cell read so far out of `cur`. Without a closing quote (`closed` is `None`) the cell
|
||||
/// is trimmed; with one, the text before it is kept as written and the text after it is
|
||||
/// trimmed at the end.
|
||||
fn end_csv_cell(cur: &mut String, closed: &mut Option<usize>) -> String {
|
||||
let cell = std::mem::take(cur);
|
||||
match closed.take() {
|
||||
Some(at) => match cell.split_at_checked(at) {
|
||||
Some((quoted, after)) => format!("{quoted}{}", after.trim_end()),
|
||||
None => cell,
|
||||
},
|
||||
None => cell.trim().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn csv_escape(s: &str) -> String {
|
||||
if s.contains([',', '"', '\n', '\r']) || s != s.trim() { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }
|
||||
}
|
||||
@@ -590,7 +616,9 @@ fn parse_visible_cell(s: &str, var: &str, cmd: &str) -> Result<bool> {
|
||||
}
|
||||
|
||||
fn cell_to_value(kind: VariableKind, cell: &str, var: &str, cmd: &str) -> Result<Option<DataValue>> {
|
||||
let t = cell.trim();
|
||||
// A text cell is not trimmed here (the parser trims unquoted cells); image, graph and
|
||||
// visibility cells are trimmed.
|
||||
let t = if kind == VariableKind::Text { cell } else { cell.trim() };
|
||||
if t.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -892,7 +920,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Select Data Set",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{name} apply a dataset in one undo step, deselecting every selected object left hidden or locked (the key object too) → {applied, skipped}",
|
||||
"{name} apply a dataset in one undo step, deselecting every selected object left hidden or locked (the key object too) → {applied, skipped, overflow?: [id…] (area type and type on a path whose text it rewrote and that no longer fits; left out when all fits)}",
|
||||
has_doc,
|
||||
dataset_select
|
||||
),
|
||||
|
||||
@@ -300,6 +300,9 @@ pub struct DocState {
|
||||
/// What Select → Reselect repeats: the last selection command, its params and the objects
|
||||
/// selected when it ran (a Same command's reference objects).
|
||||
pub last_selection_cmd: Option<(String, Value, Vec<NodeId>)>,
|
||||
/// The saved selection last recalled (`select.recall`), which Select → Update Selection
|
||||
/// changes. It is not saved with the file, and a recall is not an undo step.
|
||||
pub recalled_selection: RecalledSelection,
|
||||
/// Process-unique id of this open document (tab indices shift when tabs close).
|
||||
pub uid: u64,
|
||||
/// View Opacity Mask (Alt-click the mask thumbnail): the masked object whose mask the canvas
|
||||
@@ -387,6 +390,57 @@ impl OpenRows {
|
||||
}
|
||||
}
|
||||
|
||||
/// The saved selection last recalled, which Select → Update Selection changes. The Select commands
|
||||
/// give each saved selection an id for the session and keep the ids for every version of the
|
||||
/// document's saved selections they make or read, so the recalled one is found again after Edit
|
||||
/// Selection…, Undo and Redo. A version that nothing holds any more (neither the document nor its
|
||||
/// undo history) is dropped the next time a version is added.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct RecalledSelection {
|
||||
/// The recalled saved selection's id.
|
||||
id: Option<u64>,
|
||||
/// Each version of the saved selections (identified by its allocation), with the id of each saved
|
||||
/// selection in it.
|
||||
versions: Vec<(std::sync::Weak<Vec<vectorcraft_doc::SavedSelection>>, Vec<u64>)>,
|
||||
/// The last id given.
|
||||
last: u64,
|
||||
}
|
||||
|
||||
impl RecalledSelection {
|
||||
/// The ids of `list`'s saved selections; a version not met before gets new ones.
|
||||
pub(crate) fn ids(&mut self, list: &vectorcraft_doc::Shared<Vec<vectorcraft_doc::SavedSelection>>) -> Vec<u64> {
|
||||
let version = vectorcraft_doc::Shared::downgrade(list);
|
||||
if let Some((_, ids)) = self.versions.iter().find(|(v, _)| v.ptr_eq(&version)) {
|
||||
return ids.clone();
|
||||
}
|
||||
let ids: Vec<u64> = (0..list.len()).map(|_| self.new_id()).collect();
|
||||
self.remember(list, ids.clone());
|
||||
ids
|
||||
}
|
||||
/// An id no saved selection has had.
|
||||
pub(crate) fn new_id(&mut self) -> u64 {
|
||||
self.last += 1;
|
||||
self.last
|
||||
}
|
||||
/// Keep `ids` as those of `list`'s saved selections, dropping versions nothing holds any more.
|
||||
pub(crate) fn remember(&mut self, list: &vectorcraft_doc::Shared<Vec<vectorcraft_doc::SavedSelection>>, ids: Vec<u64>) {
|
||||
let version = vectorcraft_doc::Shared::downgrade(list);
|
||||
self.versions.retain(|(v, _)| v.strong_count() > 0 && !v.ptr_eq(&version));
|
||||
self.versions.push((version, ids));
|
||||
}
|
||||
/// Recall the saved selection at `index` in `list`.
|
||||
pub(crate) fn recall(&mut self, list: &vectorcraft_doc::Shared<Vec<vectorcraft_doc::SavedSelection>>, index: usize) {
|
||||
self.id = self.ids(list).get(index).copied();
|
||||
}
|
||||
/// Where the recalled saved selection is in `list`, if `list` holds it.
|
||||
pub fn index_in(&self, list: &vectorcraft_doc::Shared<Vec<vectorcraft_doc::SavedSelection>>) -> Option<usize> {
|
||||
let id = self.id?;
|
||||
let version = vectorcraft_doc::Shared::downgrade(list);
|
||||
let (_, ids) = self.versions.iter().find(|(v, _)| v.ptr_eq(&version))?;
|
||||
ids.iter().position(|&x| x == id).filter(|&i| i < list.len())
|
||||
}
|
||||
}
|
||||
|
||||
impl DocState {
|
||||
pub fn new(mut doc: Document, path: Option<String>) -> Self {
|
||||
let active_layer = doc.default_layer();
|
||||
@@ -411,6 +465,7 @@ impl DocState {
|
||||
undo_group: None,
|
||||
last_transform: None,
|
||||
last_selection_cmd: None,
|
||||
recalled_selection: RecalledSelection::default(),
|
||||
uid: NEXT_DOC_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||
mask_view: None,
|
||||
transparency_grid: false,
|
||||
@@ -563,16 +618,17 @@ pub const ON_ACTIVE_ARTBOARD: [&str; 7] = [
|
||||
"select.allOnArtboard",
|
||||
];
|
||||
|
||||
/// The commands that resize the active artboard, which the app passes as their `index`: Fit to
|
||||
/// Artwork Bounds and Fit to Selected Art (Object › Artboards). A `command.batch` given `artboard`
|
||||
/// passes it on, as `index`, to each of their steps that names none.
|
||||
/// The commands that resize the artboards the app passes (Object › Artboards): the app passes the
|
||||
/// selected artboards to Fit to Artwork Bounds as its `indices`, and the active artboard to Fit to
|
||||
/// Selected Art as its `index`. A `command.batch` given `artboard` passes it on, as `index`, to
|
||||
/// each of their steps that names none.
|
||||
pub const FIT_ACTIVE_ARTBOARD: [&str; 2] = ["artboard.fitToArt", "artboard.fitToSelection"];
|
||||
|
||||
/// `(command, param)` pairs the journal records only so a replay reproduces the original run
|
||||
/// (dates from the clock: `cmd::clock_date`; the active artboard the app passes to the
|
||||
/// (dates from the clock: `cmd::clock_date`; the artboards the app passes to the
|
||||
/// [`ON_ACTIVE_ARTBOARD`] and [`FIT_ACTIVE_ARTBOARD`] commands and to a batch); an action leaves
|
||||
/// them out ([`Session::journal_for_action`]).
|
||||
const REPLAY_ONLY: [(&str, &str); 13] = [
|
||||
const REPLAY_ONLY: [(&str, &str); 14] = [
|
||||
("file.new", "created"),
|
||||
("document.save", "modified"),
|
||||
("file.saveAs", "modified"),
|
||||
@@ -583,6 +639,7 @@ const REPLAY_ONLY: [(&str, &str); 13] = [
|
||||
("object.distribute", "artboard"),
|
||||
("object.distributeSpacing", "artboard"),
|
||||
("artboard.fitToArt", "index"),
|
||||
("artboard.fitToArt", "indices"),
|
||||
("artboard.fitToSelection", "index"),
|
||||
("select.allOnArtboard", "artboard"),
|
||||
("command.batch", "artboard"),
|
||||
@@ -847,6 +904,10 @@ pub struct Prefs {
|
||||
/// not a Preferences dialog field: resetting the preferences keeps it; `pdf.preset.*` edit it.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub pdf_presets: Vec<vectorcraft_pdf::PdfPreset>,
|
||||
/// File → Export for Screens: the user's batch export presets (`document.exportPresets.*`;
|
||||
/// the built-in ones aren't stored). A local library, not a Preferences dialog field.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub screen_export_presets: Vec<cmd::fileio::ScreenExportPreset>,
|
||||
// File Handling (continued)
|
||||
/// Where Save as Template and New from Template start ("" = `Documents/VectorCraft Templates`).
|
||||
pub templates_folder: String,
|
||||
@@ -1032,6 +1093,7 @@ impl Default for Prefs {
|
||||
new_doc_presets: vec![],
|
||||
recent_new_docs: vec![],
|
||||
pdf_presets: vec![],
|
||||
screen_export_presets: vec![],
|
||||
templates_folder: String::new(),
|
||||
append_converted: true,
|
||||
use_compression: false,
|
||||
@@ -1959,6 +2021,8 @@ mod tests_objexpand;
|
||||
#[cfg(test)]
|
||||
mod tests_opacitymask;
|
||||
#[cfg(test)]
|
||||
mod tests_outlineobject;
|
||||
#[cfg(test)]
|
||||
mod tests_outlinestroke;
|
||||
#[cfg(test)]
|
||||
mod tests_overprint;
|
||||
|
||||
@@ -113,6 +113,25 @@ fn a_selected_group_that_contains_the_key_object_stays_in_place() {
|
||||
}
|
||||
}
|
||||
|
||||
/// With a spacing, Distribute moves a compound path or selected group that contains the key object
|
||||
/// as one object and keeps it in place: the other object's left (top) edge goes that far from its
|
||||
/// left (top) edge.
|
||||
#[test]
|
||||
fn distribute_with_a_spacing_keeps_what_contains_the_key_object_in_place() {
|
||||
for (make, add) in [(&["object.compoundPath.make"][..], false), (&["object.group"], true), (&["object.group", "object.group"], true)] {
|
||||
let (mut s, key, outer, r) = key_inside(make);
|
||||
if add {
|
||||
s.execute("select.add", &json!({"ids": [outer.0]})).unwrap();
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().selection.key, Some(key), "{make:?}");
|
||||
s.execute("object.distribute", &json!({"horizontal": "left", "spacing": 5, "bounds": "geometric"})).unwrap();
|
||||
assert_eq!(starts(&s, &[outer, r], false), [0.0, 5.0], "{make:?}: 5 pt right of it, it stays");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("object.distribute", &json!({"vertical": "top", "spacing": 5, "bounds": "geometric"})).unwrap();
|
||||
assert_eq!(starts(&s, &[outer, r], true), [90.0, 85.0], "{make:?}: 5 pt above it, it stays");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrange_order() {
|
||||
let mut s = session();
|
||||
@@ -452,6 +471,7 @@ fn align_and_distribute_reject_bad_params_without_changing_state() {
|
||||
("object.distribute", json!({"vertical": "centre"}), v),
|
||||
("object.distribute", json!({"horizontal": "left", "vertical": "middle"}), v),
|
||||
("object.distribute", json!({"horizontal": "left", "to": "page"}), to),
|
||||
("object.distribute", json!({"horizontal": "left", "spacing": "5"}), "`spacing` must be a number"),
|
||||
("object.distributeSpacing", json!({"axis": "diagonal"}), "`axis` must be one of horizontal, vertical"),
|
||||
("object.distributeSpacing", json!({"axis": "vertical", "spacing": "5"}), "`spacing` must be a number"),
|
||||
("object.distributeSpacing", json!({"axis": "vertical", "to": 0}), to),
|
||||
@@ -599,6 +619,39 @@ fn distribute_spacing_to_artboard_spans_it_with_equal_gaps() {
|
||||
}
|
||||
}
|
||||
|
||||
/// With a spacing and a key object, each Distribute button keeps the key object (20, 60 and 40 pt
|
||||
/// wide or tall, the key in the middle) in place and places the left edges, centers or right edges
|
||||
/// (top edges, centers or bottom edges) that far apart, outward from the key's, in their order on
|
||||
/// that line. A spacing of 0 puts them all on the key's line, and two objects are enough.
|
||||
/// Without a key object the spacing is not used, and with one it is used with Align to Artboard too.
|
||||
#[test]
|
||||
fn distribute_with_a_spacing_spaces_the_objects_from_the_key_object() {
|
||||
for vertical in [false, true] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let ids = selected_row(&mut s, &[(100.0, 20.0), (150.0, 60.0), (260.0, 40.0)], vertical);
|
||||
let (axis, lines) = if vertical { ("vertical", ["top", "center", "bottom"]) } else { ("horizontal", ["left", "center", "right"]) };
|
||||
s.execute("object.distribute", &json!({axis: lines[0], "spacing": 10})).unwrap();
|
||||
assert_eq!(starts(&s, &ids, vertical), [100.0, 180.0, 260.0], "{axis}: no key object, evenly between the outermost");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.select(|_, sel| sel.key = Some(ids[1])).unwrap();
|
||||
for (line, at) in lines.into_iter().zip([[140.0, 150.0, 160.0], [160.0, 150.0, 170.0], [180.0, 150.0, 180.0]]) {
|
||||
for to in [json!("key"), json!("artboard"), json!(null)] {
|
||||
s.execute("object.distribute", &json!({axis: line, "spacing": 10, "to": to})).unwrap();
|
||||
assert_eq!(starts(&s, &ids, vertical), at, "{axis} {line} to {to}: 10 pt from the key");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
s.execute("object.distribute", &json!({axis: lines[0], "spacing": 0, "to": "key"})).unwrap();
|
||||
assert_eq!(starts(&s, &ids, vertical), [150.0; 3], "{axis}: on the key's line");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [ids[0].0, ids[2].0]})).unwrap();
|
||||
s.select(|_, sel| sel.key = Some(ids[2])).unwrap();
|
||||
s.execute("object.distribute", &json!({axis: lines[0], "spacing": 30, "to": "key"})).unwrap();
|
||||
assert_eq!(starts(&s, &[ids[0], ids[2]], vertical), [230.0, 260.0], "{axis}: two objects");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_both_centers() {
|
||||
let mut s = session();
|
||||
@@ -1888,6 +1941,34 @@ fn isolating_an_object_that_doesnt_exist_says_so() {
|
||||
assert!(!e.to_string().contains("type"), "{e}");
|
||||
}
|
||||
|
||||
/// A ring (a compound path with a hole) and its isolated paths.
|
||||
fn isolated_ring(s: &mut Session) -> (NodeId, NodeId, NodeId) {
|
||||
let outer = rect(s, 0.0, 0.0, 40.0, 40.0);
|
||||
let hole = rect(s, 10.0, 10.0, 10.0, 10.0);
|
||||
s.execute("select.set", &json!({"ids": [outer.0, hole.0]})).unwrap();
|
||||
let c = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("object.isolate", &json!({"id": c.0})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [hole.0]})).unwrap();
|
||||
(c, outer, hole)
|
||||
}
|
||||
|
||||
/// Isolating a compound path makes its paths objects of their own: the hole of a ring, selected
|
||||
/// alone, moves without the ring around it. Outside isolation it still moves its compound path.
|
||||
#[test]
|
||||
fn an_isolated_compound_paths_member_moves_alone() {
|
||||
let x0 = |s: &Session, id: NodeId| s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0;
|
||||
let mut s = session();
|
||||
let (_, outer, hole) = isolated_ring(&mut s);
|
||||
s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, 30, 0]})).unwrap();
|
||||
assert_eq!((x0(&s, outer), x0(&s, hole)), (0.0, 40.0), "only the hole moved");
|
||||
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
|
||||
assert_eq!((x0(&s, outer), x0(&s, hole)), (0.0, 45.0), "Move: only the hole");
|
||||
s.execute("object.exitIsolation", &json!({})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [hole.0]})).unwrap();
|
||||
s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, 5, 0]})).unwrap();
|
||||
assert_eq!((x0(&s, outer), x0(&s, hole)), (5.0, 50.0), "not isolated: the whole compound path moved");
|
||||
}
|
||||
|
||||
/// `depth` and `childLimit` slice a summary read; a level that shows fewer children
|
||||
/// than it has reports `childCount`, and invalid values are rejected, not reinterpreted.
|
||||
#[test]
|
||||
|
||||
@@ -505,6 +505,33 @@ fn delete_duplicate_and_replace_symbols() {
|
||||
assert!(s.execute("symbol.delete", &json!({"name": "Small"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_export_type_defaults_to_movie_clip_and_round_trips() {
|
||||
let mut s = session();
|
||||
let (_, name, _) = make_symbol(&mut s);
|
||||
let list = s.execute("symbol.list", &json!({})).unwrap();
|
||||
assert_eq!(list["symbols"][0]["exportType"], "movieClip");
|
||||
assert!(s.doc().unwrap().doc.unknown.get(vectorcraft_doc::SYMBOL_EXPORT_TYPE).is_none());
|
||||
s.execute("symbol.options", &json!({"name": name, "exportType": "graphic"})).unwrap();
|
||||
assert_eq!(s.execute("symbol.list", &json!({})).unwrap()["symbols"][0]["exportType"], "graphic");
|
||||
assert_eq!(s.doc().unwrap().doc.unknown[vectorcraft_doc::SYMBOL_EXPORT_TYPE][&name], json!("graphic"));
|
||||
let copy = s.execute("symbol.duplicate", &json!({"name": name})).unwrap();
|
||||
assert_eq!(copy["exportType"], "graphic");
|
||||
s.execute("symbol.options", &json!({"name": name, "newName": "Renamed", "exportType": "movieClip"})).unwrap();
|
||||
let list = s.execute("symbol.list", &json!({})).unwrap();
|
||||
let renamed = list["symbols"].as_array().unwrap().iter().find(|x| x["name"] == "Renamed").unwrap();
|
||||
assert_eq!(renamed["exportType"], "movieClip");
|
||||
assert!(s.doc().unwrap().doc.unknown.get(vectorcraft_doc::SYMBOL_EXPORT_TYPE).and_then(|m| m.get("Renamed")).is_none());
|
||||
assert_eq!(s.doc().unwrap().doc.unknown[vectorcraft_doc::SYMBOL_EXPORT_TYPE]["Box copy"], json!("graphic"));
|
||||
s.execute("symbol.delete", &json!({"name": "Box copy"})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.unknown.get(vectorcraft_doc::SYMBOL_EXPORT_TYPE).is_none());
|
||||
rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
let r = s.execute("symbol.new", &json!({"name": "Icon", "exportType": "graphic"})).unwrap();
|
||||
assert_eq!(r["exportType"], "graphic");
|
||||
assert!(s.execute("symbol.new", &json!({"exportType": "bogus"})).is_err());
|
||||
assert!(s.execute("symbol.options", &json!({"name": "Renamed"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprayer_command_creates_a_symbol_set_and_alt_removes() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{CharStyle, Dash, LineCap, LineJoin, NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_doc::{CharStyle, Dash, LineCap, LineJoin, NodeId, NodeKind, StrokeAlign, TextObject};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -133,3 +133,64 @@ fn character_stroke_options_round_trip_natively_and_old_files_load() {
|
||||
assert_eq!((st.stroke_cap, st.stroke_join, st.stroke_miter_limit, st.stroke_dash), (LineCap::Butt, LineJoin::Miter, 10.0, None));
|
||||
assert!(serde_json::to_value(CharStyle::default()).unwrap().get("stroke_miter_limit").is_none(), "defaults aren't written");
|
||||
}
|
||||
|
||||
/// Align Stroke on live type (Reddit redd.it/1dwhos6): the characters' stroke goes inside or
|
||||
/// outside the glyphs without creating outlines, for every run or a range, and is kept by the
|
||||
/// native format and Create Outlines.
|
||||
#[test]
|
||||
fn align_stroke_reaches_the_characters_stroke() {
|
||||
let mut s = session();
|
||||
let id = stroked_text(&mut s);
|
||||
assert!(s.active().unwrap().stroke_mixed().can_align, "type can take an inside or outside stroke");
|
||||
run(&mut s, "text.setRangeStyle", json!({"id": id.0, "start": 0, "end": 2, "size": 60}));
|
||||
run(&mut s, "stroke.set", json!({"align": "outside"}));
|
||||
let t = text(&s, id);
|
||||
assert!(t.runs.iter().all(|r| r.style.stroke_align == StrokeAlign::Outside), "every run");
|
||||
assert!(s.doc().unwrap().doc.node(id).unwrap().appearance.items.is_empty(), "no object-level stroke is added");
|
||||
assert_eq!(s.shown_stroke().unwrap().align, StrokeAlign::Outside, "the Stroke panel shows it");
|
||||
let m = s.active().unwrap().stroke_mixed();
|
||||
assert!(!m.align && m.can_align, "{m:?}");
|
||||
// A range takes its own alignment through strokeOptions; the runs then differ.
|
||||
run(&mut s, "text.setRangeStyle", json!({"id": id.0, "start": 0, "end": 2, "strokeOptions": {"align": "inside"}}));
|
||||
let t = text(&s, id);
|
||||
assert_eq!(t.runs[0].style.stroke_align, StrokeAlign::Inside);
|
||||
assert_eq!(t.runs.last().unwrap().style.stroke_align, StrokeAlign::Outside);
|
||||
assert!(s.active().unwrap().stroke_mixed().align, "mixed alignments light no button");
|
||||
// document.inspect reports it, as for paths.
|
||||
let doc = run(&mut s, "document.inspect", json!({}));
|
||||
assert_eq!(doc["layers"][0]["children"][0]["strokeOptions"]["align"], "inside", "{doc}");
|
||||
// Saved and reopened.
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap();
|
||||
let NodeKind::Text(b) = &back.node(id).unwrap().kind else { panic!("not type") };
|
||||
assert_eq!(b.runs, text(&s, id).runs);
|
||||
// Create Outlines keeps it on the glyphs.
|
||||
run(&mut s, "select.set", json!({ "ids": [id.0] }));
|
||||
let g = run(&mut s, "type.createOutlines", json!({}))["ids"][0].as_u64().unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let mut aligns = vec![];
|
||||
d.node(NodeId(g)).unwrap().walk(&mut |n: &vectorcraft_doc::Node| aligns.extend(n.appearance.stroke().map(|st| st.align)));
|
||||
assert!(aligns.contains(&StrokeAlign::Inside) && aligns.contains(&StrokeAlign::Outside), "{aligns:?}");
|
||||
}
|
||||
|
||||
/// Copying type's appearance onto a path keeps its characters' stroke alignment (the Eyedropper's
|
||||
/// own stroke attributes have none, on paths as on type).
|
||||
#[test]
|
||||
fn a_path_copying_types_appearance_takes_its_stroke_alignment() {
|
||||
let mut s = session();
|
||||
let id = stroked_text(&mut s);
|
||||
run(&mut s, "stroke.set", json!({"align": "inside"}));
|
||||
let rect = run(&mut s, "shape.rectangle", json!({"x": 10, "y": 150, "width": 50, "height": 50}))["id"].as_u64().unwrap();
|
||||
run(&mut s, "appearance.copyFrom", json!({"source": id.0, "ids": [rect]}));
|
||||
assert_eq!(s.doc().unwrap().doc.node(NodeId(rect)).unwrap().appearance.stroke().unwrap().align, StrokeAlign::Inside);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_centred_character_stroke_writes_no_alignment() {
|
||||
assert!(serde_json::to_value(CharStyle::default()).unwrap().get("stroke_align").is_none(), "the default isn't written");
|
||||
let mut old = serde_json::to_value(CharStyle { stroke_align: StrokeAlign::Outside, ..Default::default() }).unwrap();
|
||||
assert_eq!(old["stroke_align"], "Outside");
|
||||
old.as_object_mut().unwrap().remove("stroke_align");
|
||||
let st: CharStyle = serde_json::from_value(old).unwrap();
|
||||
assert_eq!(st.stroke_align, StrokeAlign::Center, "files written before it existed stroke centred");
|
||||
}
|
||||
@@ -122,6 +122,45 @@ fn type_fills_go_above_or_below_the_characters() {
|
||||
assert_eq!(node(&s, t).appearance.contents_at(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_effect_on_the_types_own_stroke_exports_as_the_offset_outline() {
|
||||
let mut s = session();
|
||||
let t = id_of(&run(&mut s, "text.create", json!({"x": 20, "y": 100, "text": "Hi"})));
|
||||
run(&mut s, "select.set", json!({"ids": [t.0]}));
|
||||
run(&mut s, "appearance.addStroke", json!({}));
|
||||
run(&mut s, "paint.setStroke", json!({"color": "#ff0000", "item": 0}));
|
||||
run(&mut s, "appearance.moveItem", json!({"from": "contents", "to": 1}));
|
||||
run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 4}, "item": 0}));
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let svg = vectorcraft_svg::export(&doc, &Default::default());
|
||||
assert!(!svg.contains("<text") && svg.contains("<path"), "{svg}");
|
||||
// The baked type is its outlines, and the stroke's outline lies 4 pt outside the glyphs on every side.
|
||||
let glyphs = vectorcraft_render::effects::outline_text(&node(&s, t)).unwrap().geometric_bounds().unwrap();
|
||||
let baked = vectorcraft_render::effects::bake_document(&doc).unwrap();
|
||||
let n = baked.node(t).unwrap();
|
||||
assert!(!matches!(n.kind, NodeKind::Text(_)), "{n:?}");
|
||||
let b = n.geometric_bounds().unwrap();
|
||||
let off = [glyphs.x0 - b.x0, glyphs.y0 - b.y0, b.x1 - glyphs.x1, b.y1 - glyphs.y1];
|
||||
assert!(off.iter().all(|o| (o - 4.0).abs() < 0.5), "{off:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_with_an_inline_graphic_stays_type_when_its_own_stroke_has_an_effect() {
|
||||
let mut s = session();
|
||||
run(&mut s, "shape.ellipse", json!({"x": 150, "y": 150, "width": 10, "height": 10}));
|
||||
run(&mut s, "symbol.new", json!({"name": "Dot"}));
|
||||
run(&mut s, "edit.clear", json!({}));
|
||||
let t = id_of(&run(&mut s, "text.create", json!({"x": 20, "y": 100, "text": "Hi"})));
|
||||
run(&mut s, "text.insertInline", json!({"id": t.0, "at": 0, "symbol": "Dot"}));
|
||||
run(&mut s, "select.set", json!({"ids": [t.0]}));
|
||||
run(&mut s, "appearance.addStroke", json!({}));
|
||||
run(&mut s, "paint.setStroke", json!({"color": "#ff0000", "item": 0}));
|
||||
run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 4}, "item": 0}));
|
||||
// Exports keep it as type, with its inline graphic.
|
||||
let baked = vectorcraft_render::effects::bake_document(&s.doc().unwrap().doc).unwrap();
|
||||
assert!(matches!(baked.node(t).unwrap().kind, NodeKind::Text(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_appearance_round_trips_and_expands() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -186,6 +186,61 @@ fn paintbrush_uses_stroke_blob_brush_fills() {
|
||||
assert!(area(&ps[1].1) > 1500.0);
|
||||
}
|
||||
|
||||
/// Merge only with selection: a Blob Brush stroke merges with the selected blobs it touches and
|
||||
/// leaves the others. Keep selected, off by default: the blob a stroke draws or merges into is not
|
||||
/// selected afterwards, and the rest of the selection stays; on, it is selected.
|
||||
#[test]
|
||||
fn blob_brush_merges_only_with_the_selection_and_keep_selected_off_deselects() {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 600.0, 400.0, 50.0, 50.0);
|
||||
let selected = |s: &Session| s.doc().unwrap().selection.objects.clone();
|
||||
let blob = |s: &mut Session, p: serde_json::Value| NodeId(s.execute("path.blob", &p).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
|
||||
let a = blob(&mut s, json!({"points": [[100, 60], [100, 140]], "size": 20}));
|
||||
assert_eq!(selected(&s), vec![r], "Keep selected is off by default: the new blob is not selected");
|
||||
let b = blob(&mut s, json!({"points": [[200, 60], [200, 140]], "size": 20, "keepSelected": true}));
|
||||
assert_eq!(selected(&s), vec![b], "Keep selected on: the new blob is selected");
|
||||
let n = paths(&s).len();
|
||||
let (area_a, area_b) = (area(&path(&s, a)), area(&path(&s, b)));
|
||||
// Only `b` is selected: the stroke across both merges into `b` alone.
|
||||
let undo = undo_steps(&s);
|
||||
let across = json!({"points": [[50, 100], [250, 100]], "size": 10, "mergeOnlySelection": true, "keepSelected": false});
|
||||
assert_eq!(blob(&mut s, across.clone()), b);
|
||||
assert_eq!(paths(&s).len(), n);
|
||||
assert_eq!(area(&path(&s, a)), area_a, "a is left as it was");
|
||||
assert!(area(&path(&s, b)) > area_b + 500.0, "the stroke joined b");
|
||||
assert!(selected(&s).is_empty());
|
||||
assert_eq!(undo_steps(&s), undo + 1);
|
||||
// With nothing selected, the stroke merges with nothing, and Keep selected off leaves the new
|
||||
// blob unselected.
|
||||
let new = blob(&mut s, across);
|
||||
assert!(new != a && new != b && paths(&s).len() == n + 1, "a new blob");
|
||||
assert!(selected(&s).is_empty());
|
||||
// A selection that holds no blob: a new blob, and the selection stays.
|
||||
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
|
||||
let other = blob(&mut s, json!({"points": [[50, 300], [250, 300]], "size": 10, "mergeOnlySelection": true, "keepSelected": false}));
|
||||
assert_eq!(paths(&s).len(), n + 2);
|
||||
assert_eq!(selected(&s), vec![r]);
|
||||
// Without the options, a stroke merges with every touching blob, selected or not, and the
|
||||
// blob it merges into is not selected.
|
||||
let all = blob(&mut s, json!({"points": [[50, 100], [250, 100]], "size": 10}));
|
||||
assert!([a, b, new].contains(&all) && paths(&s).len() == n, "one blob of a, b and the new one");
|
||||
assert_eq!(selected(&s), vec![r]);
|
||||
// Keep selected on: the blob a stroke merges into is selected.
|
||||
assert_eq!(blob(&mut s, json!({"points": [[150, 80], [150, 120]], "size": 10, "keepSelected": true})), all);
|
||||
assert_eq!(paths(&s).len(), n);
|
||||
assert_eq!(selected(&s), vec![all]);
|
||||
// The tool passes both options; Keep selected is off by default.
|
||||
s.execute("select.set", &json!({"ids": [other.0]})).unwrap();
|
||||
s.select_tool("blobBrush", view()).unwrap();
|
||||
assert_eq!(s.tool_options()["keepSelected"], json!(false));
|
||||
s.set_tool_option("mergeOnlySelection", &json!(true));
|
||||
drag(&mut s, "blobBrush", &[(150.0, 50.0), (150.0, 320.0)], Mods::default());
|
||||
assert_eq!(paths(&s).len(), n, "merged into the selected blob only");
|
||||
assert!(selected(&s).is_empty());
|
||||
assert!(s.doc().unwrap().doc.node(all).is_some() && s.doc().unwrap().doc.node(other).is_some());
|
||||
}
|
||||
|
||||
/// Three points already curve in the saved path: both ends carry a handle into the curve.
|
||||
#[test]
|
||||
fn path_curvature_with_three_points_saves_end_handles() {
|
||||
@@ -430,6 +485,25 @@ fn anchor_point_tool_converts() {
|
||||
assert!(d < 0.5, "{q:?}");
|
||||
}
|
||||
|
||||
/// #2267: clicking one handle end with the Anchor Point tool retracts that handle alone, as one
|
||||
/// undo step.
|
||||
#[test]
|
||||
fn anchor_point_tool_click_on_a_handle_retracts_it() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
|
||||
drag(&mut s, "anchorPoint", &[(100.0, 100.0), (130.0, 80.0)], Mods::default());
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
let smooth = path(&s, id).subpaths[0].anchors[0];
|
||||
let undo = undo_steps(&s);
|
||||
click(&mut s, "anchorPoint", 130.0, 80.0);
|
||||
let a = path(&s, id).subpaths[0].anchors[0];
|
||||
assert!(!a.has_out());
|
||||
assert_eq!((a.p, a.h_in, a.kind), (smooth.p, smooth.h_in, vectorcraft_geom::AnchorKind::Corner));
|
||||
assert_eq!(undo_steps(&s), undo + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(path(&s, id).subpaths[0].anchors[0], smooth);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scissors_opens_closed_path() {
|
||||
let mut s = session();
|
||||
@@ -881,6 +955,99 @@ fn remove_anchor_points_keeps_the_paths_closed() {
|
||||
assert_eq!(spec.menu, &["Object", "Path"][..]);
|
||||
}
|
||||
|
||||
fn selected_anchors(s: &Session, id: NodeId) -> Vec<(usize, usize)> {
|
||||
s.doc().unwrap().selection.anchors.get(&id).map(|set| set.iter().copied().collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn anchor_points(s: &Session, id: NodeId) -> Vec<Point> {
|
||||
path(s, id).subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_anchor_points_selects_the_next_anchor_so_it_can_go_on_along_the_path() {
|
||||
// #2241: after Remove Anchor Points the next anchor along is selected, so running it again
|
||||
// removes that one without picking it first. The path stays selected the whole way.
|
||||
let mut s = session();
|
||||
let anchors: Vec<_> = (0..5).map(|i| json!({"x": 100 * i, "y": 100 * (i % 2)})).collect();
|
||||
let id = NodeId(s.execute("path.create", &json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap());
|
||||
let original = anchor_points(&s, id);
|
||||
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 1]]})).unwrap();
|
||||
for (remaining, next) in [(4, 2), (3, 3), (2, 4)] {
|
||||
assert_eq!(s.execute("path.removeAnchors", &json!({})).unwrap()["removedObjects"], 0);
|
||||
assert_eq!(path(&s, id).subpaths[0].anchors.len(), remaining);
|
||||
assert_eq!(selected_anchors(&s, id), [(0, 1)], "anchor {next} of the original is selected");
|
||||
assert_eq!(path(&s, id).subpaths[0].anchors[1].p, original[next]);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [id]);
|
||||
}
|
||||
// One anchor can't make a path: the last run removes it, and nothing is left selected.
|
||||
assert_eq!(s.execute("path.removeAnchors", &json!({})).unwrap()["removedObjects"], 1);
|
||||
assert!(s.doc().unwrap().doc.node(id).is_none());
|
||||
assert!(s.doc().unwrap().selection.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_anchor_points_selects_the_anchor_before_an_open_end_and_wraps_a_closed_path() {
|
||||
// An open path's last anchor has nothing after it: the one before is selected.
|
||||
let mut s = session();
|
||||
let anchors: Vec<_> = (0..4).map(|i| json!({"x": 100 * i, "y": 0})).collect();
|
||||
let open = NodeId(s.execute("path.create", &json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap());
|
||||
let original = anchor_points(&s, open);
|
||||
s.execute("select.anchors", &json!({"id": open.0, "anchors": [[0, 3]]})).unwrap();
|
||||
s.execute("path.removeAnchors", &json!({})).unwrap();
|
||||
assert_eq!(selected_anchors(&s, open), [(0, 2)]);
|
||||
assert_eq!(anchor_points(&s, open)[2], original[2]);
|
||||
|
||||
// A closed path goes round: removing its last anchor selects its first.
|
||||
let r = rect(&mut s, 0.0, 200.0, 100.0, 80.0);
|
||||
let corners = anchor_points(&s, r);
|
||||
s.execute("select.anchors", &json!({"id": r.0, "anchors": [[0, 3]]})).unwrap();
|
||||
s.execute("path.removeAnchors", &json!({})).unwrap();
|
||||
assert_eq!(selected_anchors(&s, r), [(0, 0)]);
|
||||
assert_eq!(anchor_points(&s, r)[0], corners[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_anchor_points_selects_one_next_anchor_per_subpath_and_per_path() {
|
||||
// Several anchors of one subpath: the anchor after the last of them. Each path and each
|
||||
// subpath gets its own, at its index once a subpath left too short to keep is gone.
|
||||
let mut s = session();
|
||||
let made = s.execute("path.create", &json!({"d": "M0 0 L50 0 M0 100 L50 100 L100 100 L150 100 L200 100 L250 100"})).unwrap();
|
||||
let id = NodeId(made["id"].as_u64().unwrap());
|
||||
let original = anchor_points(&s, id);
|
||||
let anchors: Vec<_> = (0..4).map(|i| json!({"x": 100 * i, "y": 400})).collect();
|
||||
let other = NodeId(s.execute("path.create", &json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 0], [1, 1], [1, 2]]})).unwrap();
|
||||
s.execute("select.anchors", &json!({"id": other.0, "anchors": [[0, 0]], "mode": "add"})).unwrap();
|
||||
assert_eq!(s.execute("path.removeAnchors", &json!({})).unwrap()["removedObjects"], 0);
|
||||
let sp = &path(&s, id).subpaths;
|
||||
assert_eq!(sp.len(), 1, "the first subpath, down to one anchor, is gone");
|
||||
assert_eq!(selected_anchors(&s, id), [(0, 1)]);
|
||||
assert_eq!(sp[0].anchors[1].p, original[5], "the anchor after the two removed ones");
|
||||
assert_eq!(selected_anchors(&s, other), [(0, 0)]);
|
||||
|
||||
// One undo step brings back the anchors and the selection they were removed from.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(anchor_points(&s, id), original);
|
||||
assert_eq!(selected_anchors(&s, id), [(0, 0), (1, 1), (1, 2)]);
|
||||
assert_eq!(selected_anchors(&s, other), [(0, 0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_anchor_point_tool_and_delete_still_select_the_remainder_whole() {
|
||||
// Only Remove Anchor Points moves the selection on. The Delete Anchor Point tool leaves the
|
||||
// path selected whole, and so does the Delete key, so that a second Delete removes the rest.
|
||||
for command in ["path.removeAnchor", "path.deleteAnchors"] {
|
||||
let mut s = session();
|
||||
let anchors: Vec<_> = (0..4).map(|i| json!({"x": 100 * i, "y": 0})).collect();
|
||||
let id = NodeId(s.execute("path.create", &json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 1]]})).unwrap();
|
||||
let params = if command == "path.removeAnchor" { json!({"id": id.0, "subpath": 0, "anchor": 1}) } else { json!({}) };
|
||||
s.execute(command, ¶ms).unwrap();
|
||||
assert!(selected_anchors(&s, id).is_empty(), "{command}");
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [id], "{command}");
|
||||
}
|
||||
}
|
||||
|
||||
fn undo_steps(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
@@ -260,6 +260,39 @@ fn export_selection_keeps_a_groups_shadow_in_the_crop() {
|
||||
assert!(img.get_pixel((155.0 - b[0]) as u32, img.height() / 2)[3] > 0, "the shadow is in the image");
|
||||
}
|
||||
|
||||
/// Export with Use Artboards off and Fit to Artwork Bounds take in the drop shadow of a group and
|
||||
/// of type.
|
||||
#[test]
|
||||
fn the_arts_bounds_take_in_the_shadow_of_a_group_and_of_type() {
|
||||
for kind in ["group", "type"] {
|
||||
let mut s = session();
|
||||
let id = if kind == "group" {
|
||||
s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 20, "height": 20})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 130, "y": 100, "width": 20, "height": 20})).unwrap();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()
|
||||
} else {
|
||||
s.execute("text.create", &json!({"x": 100, "y": 120, "text": "Hill"})).unwrap()["id"].as_u64().unwrap()
|
||||
};
|
||||
let plain = s.doc().unwrap().doc.node(NodeId(id)).unwrap().visual_bounds().unwrap();
|
||||
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [id], "params": {"x": 8, "y": 0, "blur": 0}})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
let png = |v: &serde_json::Value| {
|
||||
image::load_from_memory(&vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()).unwrap().to_rgba8()
|
||||
};
|
||||
let art = s.execute("document.export", &json!({"format": "png", "useArtboards": false, "background": "transparent"})).unwrap();
|
||||
let sel = s.execute("document.exportSelection", &json!({"format": "png", "background": "transparent"})).unwrap();
|
||||
let x0 = sel["bounds"][0].as_f64().unwrap();
|
||||
let img = png(&art);
|
||||
assert!(png(&sel) == img, "{kind}: the same image as Export Selection's");
|
||||
let past = (plain.x1 + 1.0 - x0).ceil() as u32;
|
||||
assert!(img.enumerate_pixels().any(|(x, _, p)| x >= past && p[3] > 0), "{kind}: the shadow is in the image, right of the object");
|
||||
s.execute("artboard.fitToArt", &json!({})).unwrap();
|
||||
let board = s.doc().unwrap().doc.artboards[0].rect;
|
||||
assert!(board.x0 <= plain.x0 && board.x1 >= plain.x1 + 8.0, "{kind}: the artboard holds the shadow 8 pt right of {plain:?}: {board:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The crop of a brushed path is its visual bounds, with no room added for brush art.
|
||||
#[test]
|
||||
fn export_selection_crops_a_brushed_path_to_its_visual_bounds() {
|
||||
|
||||
@@ -130,16 +130,17 @@ fn a_batch_passes_its_artboard_to_each_step_when_it_runs() {
|
||||
|
||||
/// A batch passes its `artboard` to each Fit to Artwork Bounds and Fit to Selected Art step that
|
||||
/// names no `index`, as their `index` (past the document's last artboard, the last one), and a
|
||||
/// step's own `index` is kept. A recorded action leaves out the `index` the app passes them, so
|
||||
/// the action resizes the artboard active when it plays.
|
||||
/// step's own `index` is kept. A recorded action leaves out the `index` or `indices` the app
|
||||
/// passes them, so the action resizes the artboard active when it plays.
|
||||
#[test]
|
||||
fn fit_steps_take_the_batchs_artboard_and_actions_leave_out_their_index() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 30})).unwrap();
|
||||
// Across the three artboards, so it is on each.
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 610, "height": 30})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
||||
let boards = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect::<Vec<_>>();
|
||||
let (was, art) = (boards(&s), vectorcraft_geom::Rect::new(10.0, 10.0, 30.0, 40.0));
|
||||
let (was, art) = (boards(&s), vectorcraft_geom::Rect::new(10.0, 10.0, 620.0, 40.0));
|
||||
let step = |command: &str, params: Value| json!({"command": command, "params": params});
|
||||
s.execute("command.batch", &json!({"artboard": 9, "commands": [step("artboard.fitToArt", json!({}))]})).unwrap();
|
||||
assert_eq!(boards(&s), [was[0], was[1], art], "artboard 3, the last one, for a batch's artboard past it");
|
||||
@@ -149,10 +150,13 @@ fn fit_steps_take_the_batchs_artboard_and_actions_leave_out_their_index() {
|
||||
assert_eq!(boards(&s)[0], art, "an index the step names is kept");
|
||||
let start = s.journal.len();
|
||||
s.execute("artboard.fitToArt", &json!({"index": 1})).unwrap();
|
||||
s.execute("artboard.fitToArt", &json!({"indices": [1, 2]})).unwrap();
|
||||
s.execute("artboard.fitToSelection", &json!({"index": 1})).unwrap();
|
||||
assert!(s.journal.since(start).iter().all(|(_, p)| p["index"] == json!(1)), "the journal keeps it: {:?}", s.journal.since(start));
|
||||
let kept: Vec<Value> = s.journal.since(start).iter().map(|(_, p)| p.clone()).collect();
|
||||
assert_eq!(kept, [json!({"index": 1}), json!({"indices": [1, 2]}), json!({"index": 1})], "the journal keeps them");
|
||||
let action = s.journal_for_action(start);
|
||||
assert_eq!(action, [("artboard.fitToArt".to_string(), json!({})), ("artboard.fitToSelection".to_string(), json!({}))]);
|
||||
let fit = |id: &str| (id.to_string(), json!({}));
|
||||
assert_eq!(action, [fit("artboard.fitToArt"), fit("artboard.fitToArt"), fit("artboard.fitToSelection")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -331,6 +331,56 @@ fn an_ellipse_takes_pie_angles_and_inverts() {
|
||||
assert!(s.execute("object.setLiveShape", &json!({"pieStart": "half"})).is_err());
|
||||
}
|
||||
|
||||
/// The Selection tool's pie widgets: dragging the End widget from 0° to straight up sets Pie End
|
||||
/// Angle to 90 in one undo step; on an inverted pie a drag moves only the dragged angle; a
|
||||
/// double-click on a widget makes the ellipse whole again in one step.
|
||||
#[test]
|
||||
fn dragging_and_double_clicking_the_pie_widgets() {
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
|
||||
let id = NodeId(s.execute("shape.ellipse", &json!({"x": 50, "y": 100, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap());
|
||||
let pie = |s: &Session| match &s.doc().unwrap().doc.node(id).unwrap().kind {
|
||||
NodeKind::Path { live: Some(l), .. } => match &**l {
|
||||
LiveShape::Ellipse { pie, .. } => *pie,
|
||||
k => panic!("{k:?}"),
|
||||
},
|
||||
k => panic!("{k:?}"),
|
||||
};
|
||||
let near = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).abs() < 1e-9 && (a.1 - b.1).abs() < 1e-9;
|
||||
let steps = |s: &Session| s.doc().unwrap().history.undo.len();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
let gesture = |s: &mut Session, events: &[(PointerKind, f64, f64)]| {
|
||||
for (kind, x, y) in events {
|
||||
s.pointer(&PointerEvent::new(*kind, *x, *y).with_mods(Mods::default()), v).unwrap();
|
||||
}
|
||||
};
|
||||
// The center is (150, 150); both widgets of the whole ellipse sit 12 pt right of (250, 150).
|
||||
let n = steps(&s);
|
||||
gesture(&mut s, &[(PointerKind::Down, 262.0, 150.0), (PointerKind::Drag, 150.0, 20.0), (PointerKind::Up, 150.0, 20.0)]);
|
||||
assert!(near(pie(&s), (0.0, 90.0)), "{:?}", pie(&s));
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
// Inverted, the pie runs from 90° to 360°: dragging the End widget (back at 0°) to 270°
|
||||
// leaves the start at 90°.
|
||||
s.execute("object.setLiveShape", &json!({"invertPie": true})).unwrap();
|
||||
assert!(near(pie(&s), (90.0, 360.0)));
|
||||
gesture(&mut s, &[(PointerKind::Down, 262.0, 150.0), (PointerKind::Drag, 150.0, 280.0), (PointerKind::Up, 150.0, 280.0)]);
|
||||
assert!(near(pie(&s), (90.0, 270.0)), "{:?}", pie(&s));
|
||||
// The Start widget sits 12 pt above the top of the outline, at 90°.
|
||||
let n = steps(&s);
|
||||
gesture(
|
||||
&mut s,
|
||||
&[(PointerKind::Down, 150.0, 88.0), (PointerKind::Up, 150.0, 88.0), (PointerKind::Down, 150.0, 88.0), (PointerKind::Up, 150.0, 88.0)],
|
||||
);
|
||||
assert_eq!(steps(&s), n, "two clicks record no undo step");
|
||||
gesture(&mut s, &[(PointerKind::DoubleClick, 150.0, 88.0)]);
|
||||
assert_eq!(pie(&s), (0.0, 360.0));
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(near(pie(&s), (90.0, 270.0)));
|
||||
}
|
||||
|
||||
/// #812: a polygon's Polygon Properties: its angle, radius and side length, each one undo step;
|
||||
/// Make Sides Equal after an uneven scale; bad values are errors.
|
||||
#[test]
|
||||
|
||||
@@ -705,6 +705,37 @@ fn convert_to_artboards_and_rearrange() {
|
||||
assert_eq!(bounds(&s, b), Rect::new(50.0, 675.0, 60.0, 685.0));
|
||||
}
|
||||
|
||||
/// Fit to Artwork Bounds fits each artboard it is given to the art on it, in one undo step: an
|
||||
/// object partly on an artboard counts for each artboard it overlaps, art flush with an edge from
|
||||
/// outside or on no artboard doesn't count, and an artboard with no art stays as it is.
|
||||
#[test]
|
||||
fn fit_to_artwork_bounds_fits_each_artboard_to_its_own_art() {
|
||||
let mut s = session();
|
||||
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 400, "height": 300})).unwrap();
|
||||
s.execute("artboard.new", &json!({"x": 0, "y": 1000, "width": 300, "height": 300})).unwrap();
|
||||
rect(&mut s, 100.0, 100.0, 50.0, 50.0);
|
||||
rect(&mut s, 1100.0, 50.0, 100.0, 100.0);
|
||||
// Across the gap between artboards 1 and 2.
|
||||
rect(&mut s, 700.0, 250.0, 350.0, 30.0);
|
||||
// Flush with artboard 1's right edge, and on no artboard.
|
||||
rect(&mut s, 800.0, 500.0, 100.0, 50.0);
|
||||
rect(&mut s, 3000.0, 3000.0, 10.0, 10.0);
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
||||
let boards = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect::<Vec<_>>();
|
||||
let was = boards(&s);
|
||||
let n = undo_len(&s);
|
||||
s.execute("artboard.fitToArt", &json!({"indices": [0, 1, 2]})).unwrap();
|
||||
assert_eq!(boards(&s), [Rect::new(100.0, 100.0, 1050.0, 280.0), Rect::new(700.0, 50.0, 1200.0, 280.0), was[2]]);
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(boards(&s), was);
|
||||
assert!(s.execute("artboard.fitToArt", &json!({"index": 2})).is_err(), "no art on artboard 3");
|
||||
assert_eq!(boards(&s), was);
|
||||
s.execute("artboard.fitToArt", &json!({})).unwrap();
|
||||
assert_eq!(boards(&s), [Rect::new(100.0, 100.0, 1050.0, 280.0), was[1], was[2]], "the first artboard by default");
|
||||
}
|
||||
|
||||
// ---------- Edit Colors ----------
|
||||
|
||||
#[test]
|
||||
@@ -1014,6 +1045,35 @@ fn select_menu_commands_stay_inside_the_isolated_group() {
|
||||
assert_eq!(selected(&s), vec![a, b, text_inside]);
|
||||
}
|
||||
|
||||
/// In isolation mode the Layers panel's target circle and selection column can't select an object
|
||||
/// outside the isolated group, so it can't be moved either (#2286).
|
||||
#[test]
|
||||
fn layers_panel_selects_nothing_outside_the_isolated_group() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 50.0, 50.0);
|
||||
let b = rect(&mut s, 100.0, 0.0, 50.0, 50.0);
|
||||
let outside = rect(&mut s, 300.0, 0.0, 50.0, 50.0);
|
||||
sel(&mut s, &[a, b]);
|
||||
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||||
s.execute("object.isolate", &json!({"id": g.0})).unwrap();
|
||||
let before = bounds(&s, outside);
|
||||
let _ = s.execute("layer.target", &json!({"id": outside.0}));
|
||||
assert!(!selected(&s).contains(&outside));
|
||||
let _ = s.execute("layer.target", &json!({"id": outside.0, "add": true}));
|
||||
assert!(!selected(&s).contains(&outside));
|
||||
let _ = s.execute("layer.selectAll", &json!({"id": outside.0}));
|
||||
assert!(!selected(&s).contains(&outside));
|
||||
let _ = s.execute("layer.selectAll", &json!({"id": outside.0, "add": true}));
|
||||
assert!(!selected(&s).contains(&outside));
|
||||
let _ = s.execute("object.move", &json!({"dx": 10, "dy": 0}));
|
||||
assert_eq!(bounds(&s, outside), before);
|
||||
// Objects inside the group can still be targeted and selected.
|
||||
s.execute("layer.target", &json!({"id": a.0})).unwrap();
|
||||
assert_eq!(selected(&s), vec![a]);
|
||||
s.execute("layer.selectAll", &json!({"id": b.0})).unwrap();
|
||||
assert_eq!(selected(&s), vec![b]);
|
||||
}
|
||||
|
||||
/// In isolation mode a recalled selection leaves out the objects outside the isolated group, and
|
||||
/// Next Object Above selects nothing outside it.
|
||||
#[test]
|
||||
@@ -1144,6 +1204,141 @@ fn save_and_recall_selection() {
|
||||
assert!(s.execute("select.recall", &json!({"name": "Pair"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_selection_replaces_the_recalled_saved_selection() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
|
||||
let c = rect(&mut s, 40.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[a, b]);
|
||||
s.execute("select.save", &json!({"name": "A"})).unwrap();
|
||||
s.execute("select.save", &json!({"name": "Other"})).unwrap();
|
||||
// Nothing recalled yet: Update Selection is off.
|
||||
assert!(s.execute("select.update", &json!({})).is_err());
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.execute("select.recall", &json!({"name": "A"})).unwrap();
|
||||
s.execute("select.add", &json!({"ids": [c.0]})).unwrap();
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
assert_eq!(s.execute("select.update", &json!({})).unwrap()["name"], "A");
|
||||
assert!(s.doc().unwrap().is_dirty());
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert_eq!(s.execute("select.recall", &json!({"name": "A"})).unwrap()["count"], 3);
|
||||
assert_eq!(selected(&s), vec![a, b, c]);
|
||||
// Only the recalled one changes.
|
||||
s.execute("select.recall", &json!({"name": "Other"})).unwrap();
|
||||
assert_eq!(selected(&s), vec![a, b]);
|
||||
// It travels with the file.
|
||||
let loaded = vectorcraft_format::load(&vectorcraft_format::save_file(&s.doc().unwrap().doc)).unwrap();
|
||||
assert_eq!(loaded.saved_selections.iter().map(|x| x.objects.len()).collect::<Vec<_>>(), [3, 2]);
|
||||
// It is one undo step.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("select.recall", &json!({"name": "A"})).unwrap();
|
||||
assert_eq!(selected(&s), vec![a, b]);
|
||||
// Nothing selected: Update Selection is off.
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert!(s.execute("select.update", &json!({})).is_err());
|
||||
// It can run again without another recall.
|
||||
sel(&mut s, &[c]);
|
||||
s.execute("select.update", &json!({})).unwrap();
|
||||
sel(&mut s, &[b]);
|
||||
s.execute("select.update", &json!({})).unwrap();
|
||||
s.execute("select.recall", &json!({"name": "A"})).unwrap();
|
||||
assert_eq!(selected(&s), vec![b]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_selection_follows_a_rename_and_stops_after_a_delete() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("select.save", &json!({"name": "One"})).unwrap();
|
||||
s.execute("select.recall", &json!({"name": "One"})).unwrap();
|
||||
s.execute("select.editSaved", &json!({"name": "One", "newName": "Uno"})).unwrap();
|
||||
sel(&mut s, &[a, b]);
|
||||
assert_eq!(s.execute("select.update", &json!({})).unwrap()["name"], "Uno");
|
||||
s.execute("select.recall", &json!({"name": "Uno"})).unwrap();
|
||||
assert_eq!(selected(&s), vec![a, b]);
|
||||
// A saved selection of the same name saved after the delete is not the recalled one.
|
||||
s.execute("select.editSaved", &json!({"name": "Uno", "delete": true})).unwrap();
|
||||
s.execute("select.save", &json!({"name": "Uno"})).unwrap();
|
||||
assert!(s.execute("select.update", &json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_selection_changes_the_recalled_saved_selection_after_undo_and_redo() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
|
||||
let c = rect(&mut s, 40.0, 0.0, 10.0, 10.0);
|
||||
let recall = |s: &mut Session, name: &str| {
|
||||
s.execute("select.recall", &json!({"name": name})).unwrap();
|
||||
selected(s)
|
||||
};
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("select.save", &json!({"name": "A"})).unwrap();
|
||||
sel(&mut s, &[b]);
|
||||
s.execute("select.save", &json!({"name": "B"})).unwrap();
|
||||
recall(&mut s, "A");
|
||||
// Swap the two names, then undo the swap: Update Selection still changes the one recalled.
|
||||
let swap = json!({"edits": [{"name": "A", "newName": "B"}, {"name": "B", "newName": "A"}]});
|
||||
s.execute("select.editSaved", &swap).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
sel(&mut s, &[a, c]);
|
||||
s.execute("select.update", &json!({})).unwrap();
|
||||
assert_eq!((recall(&mut s, "A"), recall(&mut s, "B")), (vec![a, c], vec![b]));
|
||||
// Undo and redo a rename of the recalled one.
|
||||
recall(&mut s, "A");
|
||||
s.execute("select.editSaved", &json!({"name": "A", "newName": "First"})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
sel(&mut s, &[c]);
|
||||
assert_eq!(s.execute("select.update", &json!({})).unwrap()["name"], "First");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
sel(&mut s, &[c]);
|
||||
assert_eq!(s.execute("select.update", &json!({})).unwrap()["name"], "A");
|
||||
assert_eq!(recall(&mut s, "A"), vec![c]);
|
||||
// Undo and redo an Edit Selection… that renamed it and deleted the other one.
|
||||
recall(&mut s, "A");
|
||||
let edits = json!({"edits": [{"name": "A", "newName": "First"}, {"name": "B", "delete": true}]});
|
||||
s.execute("select.editSaved", &edits).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
sel(&mut s, &[a]);
|
||||
assert_eq!(s.execute("select.update", &json!({})).unwrap()["name"], "First");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
sel(&mut s, &[a]);
|
||||
assert_eq!(s.execute("select.update", &json!({})).unwrap()["name"], "A");
|
||||
assert_eq!((recall(&mut s, "A"), recall(&mut s, "B")), (vec![a], vec![b]));
|
||||
// A cancelled undo group that renamed it.
|
||||
recall(&mut s, "B");
|
||||
s.execute("edit.undoGroup.begin", &json!({})).unwrap();
|
||||
s.execute("select.editSaved", &json!({"name": "B", "newName": "Second"})).unwrap();
|
||||
s.execute("edit.undoGroup.end", &json!({"cancel": true})).unwrap();
|
||||
sel(&mut s, &[a, b]);
|
||||
assert_eq!(s.execute("select.update", &json!({})).unwrap()["name"], "B");
|
||||
assert_eq!(recall(&mut s, "B"), vec![a, b]);
|
||||
// Undo an Update Selection that gave it the other one's objects.
|
||||
recall(&mut s, "A");
|
||||
sel(&mut s, &[a, b]);
|
||||
s.execute("select.update", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
sel(&mut s, &[c]);
|
||||
assert_eq!(s.execute("select.update", &json!({})).unwrap()["name"], "A");
|
||||
assert_eq!((recall(&mut s, "A"), recall(&mut s, "B")), (vec![c], vec![a, b]));
|
||||
// Save Selection made after the recall, and undone.
|
||||
recall(&mut s, "A");
|
||||
s.execute("select.save", &json!({"name": "C"})).unwrap();
|
||||
sel(&mut s, &[b]);
|
||||
assert_eq!(s.execute("select.update", &json!({})).unwrap()["name"], "A");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
sel(&mut s, &[a]);
|
||||
assert_eq!(s.execute("select.update", &json!({})).unwrap()["name"], "A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_selection_needs_a_saved_selection_and_a_free_name() {
|
||||
let mut s = session();
|
||||
@@ -1447,6 +1642,61 @@ fn make_release_clear_guides() {
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
}
|
||||
|
||||
/// The anchor positions of `id`, which must be a path of one open subpath, and whether it is still a guide.
|
||||
fn line_ends(s: &Session, id: NodeId) -> (Vec<(f64, f64)>, bool) {
|
||||
let NodeKind::Path { path, guide, .. } = &node(s, id).kind else { panic!("not a path") };
|
||||
let [sub] = &path.subpaths[..] else { panic!("not one subpath") };
|
||||
assert!(!sub.closed);
|
||||
(sub.anchors.iter().map(|a| (a.p.x, a.p.y)).collect(), *guide)
|
||||
}
|
||||
|
||||
/// Release Guides turns ruler guides into paths with no fill or stroke, each in one undo step. An
|
||||
/// artboard guide becomes a line across its artboard, and a canvas guide becomes a line across the
|
||||
/// bounds of all the artboards.
|
||||
#[test]
|
||||
fn release_guides_turns_ruler_guides_into_paths() {
|
||||
let mut s = session();
|
||||
s.execute("artboard.new", &json!({})).unwrap();
|
||||
let g = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[g]);
|
||||
s.execute("view.guides.make", &json!({})).unwrap();
|
||||
s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap();
|
||||
s.execute("guide.add", &json!({"vertical": false, "pos": 50, "artboard": 0})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let (ab, all) = (d.artboards[0].rect, d.artboards[0].rect.union(d.artboards[1].rect));
|
||||
assert!(all.x1 > ab.x1);
|
||||
let layer = s.doc().unwrap().current_layer();
|
||||
// The selected artboard guide goes, and a selected path across its artboard takes its place.
|
||||
s.execute("guide.select", &json!({"indexes": [1]})).unwrap();
|
||||
let n = undo_len(&s);
|
||||
assert_eq!(s.execute("view.guides.release", &json!({})).unwrap()["count"], 1);
|
||||
assert_eq!((guides(&s), undo_len(&s)), (vec![(false, 300.0)], n + 1));
|
||||
let [line] = selected(&s)[..] else { panic!("one path selected") };
|
||||
assert_eq!(line_ends(&s, line), (vec![(ab.x0, 50.0), (ab.x1, 50.0)], false));
|
||||
assert_eq!(node(&s, line).appearance, Appearance::basic(Paint::None, Paint::None, 0.0));
|
||||
assert_eq!(s.doc().unwrap().doc.parent_of(line), layer);
|
||||
assert!(matches!(node(&s, g).kind, NodeKind::Path { guide: true, .. }), "the guide path stays a guide");
|
||||
// Undo brings the guide back.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(guides(&s), vec![(false, 300.0), (false, 50.0)]);
|
||||
assert!(s.doc().unwrap().doc.node(line).is_none());
|
||||
// index releases the canvas guide as a line across the artboards.
|
||||
assert_eq!(s.execute("view.guides.release", &json!({"index": 0})).unwrap()["count"], 1);
|
||||
assert_eq!(guides(&s), vec![(false, 50.0)]);
|
||||
let [line] = selected(&s)[..] else { panic!("one path selected") };
|
||||
assert_eq!(line_ends(&s, line), (vec![(all.x0, 300.0), (all.x1, 300.0)], false));
|
||||
// ids releases those guide paths.
|
||||
assert_eq!(s.execute("view.guides.release", &json!({"ids": [g.0]})).unwrap()["count"], 1);
|
||||
assert!(matches!(node(&s, g).kind, NodeKind::Path { guide: false, .. }));
|
||||
assert_eq!(selected(&s), vec![g]);
|
||||
// Locked guides are not released by index or ids.
|
||||
s.execute("view.guides.lock", &json!({"locked": true})).unwrap();
|
||||
for p in [json!({"index": 0}), json!({"ids": [g.0]})] {
|
||||
assert!(matches!(s.execute("view.guides.release", &p), Err(EngineError::Other(m)) if m == "guides are locked"), "{p}");
|
||||
}
|
||||
assert_eq!(guides(&s).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruler_guides_and_lock() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
//! Outline Object (`path.outlineObject`, Effect › Path › Outline Object): type that carries it is
|
||||
//! drawn, exported and expanded as its glyph outlines while it stays editable type.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// Selected point type "OUTLINE" (48 pt) at (40, 100).
|
||||
fn text(s: &mut Session) -> NodeId {
|
||||
let r = s.execute("text.create", &json!({"x": 40, "y": 100, "text": "OUTLINE", "size": 48})).unwrap();
|
||||
NodeId(r["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn apply(s: &mut Session, id: NodeId) {
|
||||
s.execute("effect.apply", &json!({"effect": "path.outlineObject", "ids": [id.0], "item": null})).unwrap();
|
||||
}
|
||||
|
||||
fn node(s: &Session, id: NodeId) -> Node {
|
||||
s.doc().unwrap().doc.node(id).unwrap().clone()
|
||||
}
|
||||
|
||||
fn count_text(n: &Node) -> usize {
|
||||
let mut k = 0;
|
||||
n.walk(&mut |m| k += usize::from(matches!(m.kind, NodeKind::Text(_))));
|
||||
k
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_object_is_applied_in_one_undo_step_and_takes_no_parameters() {
|
||||
let mut s = session();
|
||||
let id = text(&mut s);
|
||||
apply(&mut s, id);
|
||||
let ids: Vec<String> = node(&s, id).appearance.effects.iter().map(|e| e.id.clone()).collect();
|
||||
assert_eq!(ids, ["path.outlineObject"]);
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Outline Object"));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(node(&s, id).appearance.effects.is_empty());
|
||||
let err = s.execute("effect.apply", &json!({"effect": "path.outlineObject", "ids": [id.0], "params": {"width": 1}})).unwrap_err().to_string();
|
||||
assert!(err.contains("`width`") && err.contains("takes no parameters"), "{err}");
|
||||
assert!(node(&s, id).appearance.effects.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_with_outline_object_stays_editable_type_and_keeps_its_bounds() {
|
||||
let mut s = session();
|
||||
let id = text(&mut s);
|
||||
let before = [false, true].map(|preview| {
|
||||
s.prefs.use_preview_bounds = preview;
|
||||
s.transform_bounds(&[id])
|
||||
});
|
||||
apply(&mut s, id);
|
||||
let after = [false, true].map(|preview| {
|
||||
s.prefs.use_preview_bounds = preview;
|
||||
s.transform_bounds(&[id])
|
||||
});
|
||||
assert_eq!(after, before);
|
||||
// A native save keeps the effect.
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
let ids: Vec<String> = back.node(id).unwrap().appearance.effects.iter().map(|e| e.id.clone()).collect();
|
||||
assert_eq!(ids, ["path.outlineObject"]);
|
||||
// The type is still type, drawn as its outlines, and a text edit changes them.
|
||||
let n = node(&s, id);
|
||||
assert!(matches!(n.kind, NodeKind::Text(_)));
|
||||
let drawn = vectorcraft_render::effects::reshape(&n, None).unwrap();
|
||||
assert_eq!(count_text(&drawn), 0);
|
||||
s.execute("text.setText", &json!({"id": id.0, "text": "OUTLINED TYPE"})).unwrap();
|
||||
let longer = vectorcraft_render::effects::reshape(&node(&s, id), None).unwrap();
|
||||
assert!(longer.geometric_bounds().unwrap().width() > drawn.geometric_bounds().unwrap().width() + 50.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_with_outline_object_exports_and_expands_as_paths() {
|
||||
let mut s = session();
|
||||
let id = text(&mut s);
|
||||
apply(&mut s, id);
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let svg = vectorcraft_svg::export(&doc, &Default::default());
|
||||
assert!(!svg.contains("<text") && !svg.contains("OUTLINE"), "{svg}");
|
||||
// Expand Appearance leaves paths and no type, in one undo step.
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let layer = &doc.layers[0];
|
||||
assert_eq!(count_text(layer), 0);
|
||||
let mut paths = 0;
|
||||
layer.walk(&mut |m| paths += usize::from(matches!(m.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })));
|
||||
assert!(paths > 0);
|
||||
}
|
||||
@@ -1024,6 +1024,33 @@ fn type_increments_step_the_type() {
|
||||
assert_eq!((style_at(&s, id, 0).size, style_at(&s, id, 6).size), (22.0, 25.0));
|
||||
}
|
||||
|
||||
/// Cmd/Ctrl+Alt+Shift+. and , step the font size by five times the Size/Leading increment, on
|
||||
/// the selected type objects and on the Type tool's selected text.
|
||||
#[test]
|
||||
fn font_size_steps_by_five_increments() {
|
||||
use vectorcraft_tools::PointerKind::{Down, Up};
|
||||
let mut s = new_doc();
|
||||
let id = NodeId(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Hello world", "size": 12})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
assert_eq!(find_command("type.size.increaseByFive").unwrap().shortcut, Some("Cmd+Alt+Shift+."));
|
||||
assert_eq!(find_command("type.size.decreaseByFive").unwrap().shortcut, Some("Cmd+Alt+Shift+,"));
|
||||
s.execute("type.size.increaseByFive", &json!({})).unwrap();
|
||||
assert_eq!(style_at(&s, id, 0).size, 22.0, "five steps of the 2 pt Size/Leading increment");
|
||||
s.execute("type.size.decreaseByFive", &json!({})).unwrap();
|
||||
assert_eq!(style_at(&s, id, 0).size, 12.0);
|
||||
set_pref(&mut s, "typeSizeIncrement", json!(3));
|
||||
s.execute("type.size.increaseByFive", &json!({})).unwrap();
|
||||
assert_eq!(style_at(&s, id, 10).size, 27.0, "the step follows the preference");
|
||||
// While the Type tool edits, only its selected text steps.
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
gesture(&mut s, v, &[(Down, 102.0, 95.0), (Up, 102.0, 95.0)]);
|
||||
assert_eq!(s.tool_options()["editing"], json!(id.0), "editing the text");
|
||||
s.set_tool_option("select", &json!({"start": 6, "end": 11}));
|
||||
s.execute("type.size.decreaseByFive", &json!({})).unwrap();
|
||||
assert_eq!((style_at(&s, id, 0).size, style_at(&s, id, 6).size), (27.0, 12.0));
|
||||
}
|
||||
|
||||
/// Cmd/Ctrl+Alt+Q resets tracking to 0 and kerning to Auto (0 in the Character panel) in every
|
||||
/// selected type object and in the Type tool's selected text. At a caret it resets only the
|
||||
/// kerning of the character before it. It is one undo step. Type objects given in `ids` are reset
|
||||
@@ -1069,6 +1096,50 @@ fn reset_tracking_sets_tracking_and_kerning_to_zero() {
|
||||
assert_eq!(style_at(&s, other, 0).tracking, 0.0, "the reset stays");
|
||||
}
|
||||
|
||||
/// Cmd/Ctrl+Shift+X resets the horizontal scale to 100% in every selected type object and in the
|
||||
/// Type tool's selected text, as one undo step, and leaves the vertical scale as it is. At a caret
|
||||
/// it leaves the text as it is. On a stretched object the scale the Character panel shows, which includes the
|
||||
/// object's own stretch, goes to 100%. Type objects given in `ids` are reset in full while the
|
||||
/// Type tool edits.
|
||||
#[test]
|
||||
fn reset_horizontal_scale_sets_it_to_100_percent() {
|
||||
use vectorcraft_tools::PointerKind::{Down, Up};
|
||||
let mut s = new_doc();
|
||||
let id = NodeId(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Hello world", "size": 20})).unwrap()["id"].as_u64().unwrap());
|
||||
let scales = |s: &Session, byte| {
|
||||
let st = style_at(s, id, byte);
|
||||
(st.h_scale, st.v_scale)
|
||||
};
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "hScale": 150, "vScale": 80})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
assert_eq!(find_command("type.resetHorizontalScale").unwrap().shortcut, Some("Cmd+Shift+X"));
|
||||
assert_eq!(s.execute("type.resetHorizontalScale", &json!({})).unwrap()["ids"], json!([id.0]));
|
||||
assert_eq!((scales(&s, 0), scales(&s, 10)), ((100.0, 80.0), (100.0, 80.0)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(scales(&s, 0), (150.0, 80.0), "one undo step");
|
||||
// A type object stretched to twice its width shows 200% and keeps half of it stored.
|
||||
let wide = NodeId(s.execute("text.create", &json!({"x": 100, "y": 300, "text": "Wide", "size": 20})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [wide.0]})).unwrap();
|
||||
s.execute("object.scale", &json!({"sx": 200, "sy": 100})).unwrap();
|
||||
s.execute("type.resetHorizontalScale", &json!({})).unwrap();
|
||||
assert_eq!(style_at(&s, wide, 0).h_scale, 50.0);
|
||||
// With the Type tool, it resets only the selected text.
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
gesture(&mut s, v, &[(Down, 102.0, 95.0), (Up, 102.0, 95.0)]);
|
||||
assert_eq!(s.tool_options()["editing"], json!(id.0), "editing the text");
|
||||
s.set_tool_option("select", &json!({"start": 0, "end": 5}));
|
||||
s.execute("type.resetHorizontalScale", &json!({})).unwrap();
|
||||
assert_eq!((scales(&s, 4), scales(&s, 6)), ((100.0, 80.0), (150.0, 80.0)));
|
||||
// At a caret it leaves the text as it is.
|
||||
s.set_tool_option("select", &json!({"start": 8, "end": 8}));
|
||||
assert_eq!(s.execute("type.resetHorizontalScale", &json!({})).unwrap()["ids"], json!([]));
|
||||
assert_eq!((scales(&s, 7), scales(&s, 8)), ((150.0, 80.0), (150.0, 80.0)));
|
||||
// While the Type tool edits, the type objects in `ids` are reset in full.
|
||||
s.execute("type.resetHorizontalScale", &json!({"ids": [id.0]})).unwrap();
|
||||
assert_eq!((scales(&s, 6), scales(&s, 10)), ((100.0, 80.0), (100.0, 80.0)));
|
||||
}
|
||||
|
||||
/// Type › Fill New Type Objects With Placeholder Text (#394, on by default): type the Type tool
|
||||
/// places starts with placeholder text, selected, so typing replaces it; off, it starts empty.
|
||||
#[test]
|
||||
|
||||
@@ -130,3 +130,25 @@ fn type_in_a_missing_family_warns_as_pdf_export_does() {
|
||||
assert_eq!(fallback(&v), export, "{settings}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_that_does_not_fit_its_frame_is_named() {
|
||||
let (mut s, _) = session();
|
||||
let p = json!({"x": 10, "y": 100, "text": "Ab\nCd\nEf\nGh", "size": 12, "area": {"width": 100, "height": 20}});
|
||||
let id = s.execute("text.create", &p).unwrap()["id"].clone();
|
||||
let over = |v: &Value| -> Vec<String> {
|
||||
v["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).filter(|w| w.contains("fit its frame")).map(str::to_string).collect()
|
||||
};
|
||||
let (_, v) = print(&mut s, json!({}));
|
||||
assert_eq!(over(&v), [format!("1 text object doesn't fit its frame: the text past the end of the frame doesn't print (id {id})")]);
|
||||
assert_eq!(over(&v), over(&s.execute("print.preview", &json!({})).unwrap()));
|
||||
// Type printed as an image is named too.
|
||||
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [id]})).unwrap();
|
||||
for settings in [json!({}), json!({"advanced": {"printAsBitmap": true}})] {
|
||||
let (_, v) = print(&mut s, json!({ "settings": settings }));
|
||||
assert_eq!(over(&v).len(), 1, "{settings}");
|
||||
}
|
||||
s.execute("text.areaOptions", &json!({"ids": [id], "height": 150})).unwrap();
|
||||
let (_, v) = print(&mut s, json!({}));
|
||||
assert_eq!(over(&v), Vec::<String>::new());
|
||||
}
|
||||
@@ -140,6 +140,97 @@ fn type_that_does_not_fit_its_frame_counts_only_where_it_shows() {
|
||||
assert_eq!(preview_fallback(&mut s, json!({})), Vec::<String>::new());
|
||||
}
|
||||
|
||||
/// Area type 100 × 20 pt at (`x`, `y`) holding four lines, of which the first shows → its id.
|
||||
fn overset(s: &mut Session, x: f64, y: f64) -> u64 {
|
||||
let v = s.execute("text.create", &json!({"x": x, "y": y, "text": "Ab\nCd\nEf\nGh", "size": 12, "area": {"width": 100, "height": 20}})).unwrap();
|
||||
assert_eq!(v["overflow"], json!([v["id"]]), "{v}");
|
||||
v["id"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
/// `print.preview {settings}`'s warnings about type that doesn't fit its frame.
|
||||
fn preview_overflow(s: &mut Session, settings: Value) -> Vec<String> {
|
||||
let v = s.execute("print.preview", &json!({ "settings": settings })).unwrap();
|
||||
let over = |w: &&str| w.contains("fit its frame") || w.contains("fit their frames");
|
||||
v["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).filter(over).map(str::to_string).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_that_does_not_fit_its_frame_is_named() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
|
||||
let id = overset(&mut s, 10.0, 10.0);
|
||||
let one = format!("1 text object doesn't fit its frame: the text past the end of the frame doesn't print (id {id})");
|
||||
assert_eq!(preview_overflow(&mut s, json!({})), [one]);
|
||||
// A frame that holds its text, or a hidden object, leaves nothing out.
|
||||
s.execute("text.areaOptions", &json!({"ids": [id], "height": 150})).unwrap();
|
||||
assert_eq!(preview_overflow(&mut s, json!({})), Vec::<String>::new());
|
||||
s.execute("text.areaOptions", &json!({"ids": [id], "height": 20})).unwrap();
|
||||
s.execute("object.setProps", &json!({"ids": [id], "visible": false})).unwrap();
|
||||
assert_eq!(preview_overflow(&mut s, json!({})), Vec::<String>::new());
|
||||
s.execute("object.setProps", &json!({"ids": [id], "visible": true})).unwrap();
|
||||
// Type on a path that runs past the path's end.
|
||||
let path = s.execute("path.create", &json!({"d": "M10 150 L60 150"})).unwrap()["id"].clone();
|
||||
let on = s.execute("text.createInPath", &json!({"path": path, "mode": "onPath", "text": "Longer than its path"})).unwrap();
|
||||
assert_eq!(on["overflow"], json!([on["id"]]), "{on}");
|
||||
let both = format!("2 text objects don't fit their frames: the text past the end of each frame doesn't print (ids {id}, {})", on["id"]);
|
||||
assert_eq!(preview_overflow(&mut s, json!({})), [both]);
|
||||
// Past 20, the first 20 ids and how many more.
|
||||
let mut ids = vec![id, on["id"].as_u64().unwrap()];
|
||||
ids.extend((0..20).map(|i| overset(&mut s, 150.0, f64::from(i) * 5.0)));
|
||||
let named: Vec<String> = ids[..20].iter().map(u64::to_string).collect();
|
||||
let many =
|
||||
format!("22 text objects don't fit their frames: the text past the end of each frame doesn't print (ids {} and 2 more)", named.join(", "));
|
||||
assert_eq!(preview_overflow(&mut s, json!({})), [many]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_that_does_not_fit_counts_only_layers_and_artboards_that_print() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 200, "artboards": 2})).unwrap();
|
||||
let r = s.doc().unwrap().doc.artboards[1].rect;
|
||||
overset(&mut s, r.x0 + 10.0, r.y0 + 10.0);
|
||||
assert_eq!(preview_overflow(&mut s, json!({"artboards": "range", "range": "1"})), Vec::<String>::new());
|
||||
assert_eq!(preview_overflow(&mut s, json!({"artboards": "range", "range": "2"})).len(), 1);
|
||||
// On a hidden layer, the type prints only with Print Layers: All.
|
||||
let layer = s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
overset(&mut s, 10.0, 10.0);
|
||||
s.execute("layer.setProps", &json!({"id": layer, "visible": false})).unwrap();
|
||||
for (layers, warns) in [("visiblePrintable", false), ("visible", false), ("all", true)] {
|
||||
let warned = preview_overflow(&mut s, json!({"artboards": "range", "range": "1", "printLayers": layers}));
|
||||
assert_eq!(!warned.is_empty(), warns, "{layers}");
|
||||
}
|
||||
// A layer that doesn't print.
|
||||
s.execute("layer.setProps", &json!({"id": layer, "visible": true, "printable": false})).unwrap();
|
||||
assert_eq!(preview_overflow(&mut s, json!({"artboards": "range", "range": "1"})), Vec::<String>::new());
|
||||
assert_eq!(preview_overflow(&mut s, json!({"artboards": "range", "range": "1", "printLayers": "visible"})).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_that_does_not_fit_is_named_only_among_the_objects_of_the_document() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
|
||||
let id = overset(&mut s, 10.0, 10.0);
|
||||
assert_eq!(preview_overflow(&mut s, json!({})).len(), 1);
|
||||
s.execute("symbol.new", &json!({"name": "Label", "ids": [id]})).unwrap();
|
||||
assert_eq!(preview_overflow(&mut s, json!({})), Vec::<String>::new(), "the art of a symbol");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_threaded_story_is_named_only_when_its_last_frame_does_not_hold_it() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
|
||||
let story = "The quick brown fox jumps over the lazy dog. ".repeat(8);
|
||||
let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 60}})).unwrap();
|
||||
assert_eq!(a["overflow"], json!([a["id"]]), "{a}");
|
||||
let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].clone();
|
||||
s.execute("select.set", &json!({"ids": [a["id"], b]})).unwrap();
|
||||
s.execute("text.thread.create", &json!({})).unwrap();
|
||||
assert_eq!(preview_overflow(&mut s, json!({})), Vec::<String>::new(), "the second frame holds the rest");
|
||||
s.execute("text.editRange", &json!({"id": a["id"], "start": 0, "end": 0, "insert": story.repeat(4)})).unwrap();
|
||||
let warned = preview_overflow(&mut s, json!({}));
|
||||
assert!(warned.len() == 1 && warned[0].ends_with(&format!("(id {b})")), "{warned:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_of_an_artboard_larger_than_the_paper_counts_only_on_the_paper() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -111,3 +111,17 @@ fn type_in_a_missing_family_is_named_when_its_artboard_prints() {
|
||||
let (_, v) = print_ps(&mut s, json!({ "settings": paper }));
|
||||
assert_eq!(fallback(&v), 0, "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_that_does_not_fit_its_frame_is_named_when_its_artboard_prints() {
|
||||
let mut s = session(2);
|
||||
let r = s.doc().unwrap().doc.artboards[1].rect;
|
||||
let p = json!({"x": r.x0 + 10.0, "y": r.y0 + 10.0, "text": "Ab\nCd\nEf\nGh", "size": 12, "area": {"width": 100, "height": 20}});
|
||||
let id = s.execute("text.create", &p).unwrap()["id"].clone();
|
||||
let over = |v: &Value| v["warnings"].as_array().unwrap().iter().filter(|w| w.as_str().unwrap().contains("fit its frame")).count();
|
||||
let (_, v) = print_ps(&mut s, json!({}));
|
||||
assert_eq!(over(&v), 1, "{v}");
|
||||
assert!(v["warnings"].to_string().contains(&format!("(id {id})")), "{v}");
|
||||
let (_, v) = print_ps(&mut s, json!({"settings": {"artboards": "range", "range": "1"}}));
|
||||
assert_eq!(over(&v), 0, "{v}");
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
//! The Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Brush Strokes,
|
||||
//! Distort › Diffuse Glow, Glass and Ocean Ripple, Pixelate › Color Halftone, Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Texture ›
|
||||
//! Distort › Diffuse Glow, Glass and Ocean Ripple, Pixelate › Color Halftone, Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Sketch › Halftone Pattern, Texture ›
|
||||
//! Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, Video): applied and
|
||||
//! edited as commands, listed in the catalogue, drawn on the canvas, and written to PDF and SVG
|
||||
//! as images of the effected object.
|
||||
@@ -42,6 +42,9 @@ const DISTORT: [(&str, &str); 3] =
|
||||
const PIXELATE: [(&str, &str); 4] =
|
||||
[("pixelate.colorHalftone", "{}"), ("pixelate.crystallize", "{}"), ("pixelate.mezzotint", "{}"), ("pixelate.pointillize", "{}")];
|
||||
|
||||
/// Effect › Sketch, at their defaults.
|
||||
const SKETCH: [(&str, &str); 1] = [("sketch.halftonePattern", "{}")];
|
||||
|
||||
/// Effect › Texture, at their defaults.
|
||||
const TEXTURE: [(&str, &str); 6] = [
|
||||
("texture.craquelure", "{}"),
|
||||
@@ -97,6 +100,7 @@ fn listed_applied_edited_and_undone() {
|
||||
(BRUSH_STROKES.as_slice(), "Brush Strokes"),
|
||||
(DISTORT.as_slice(), "Distort"),
|
||||
(PIXELATE.as_slice(), "Pixelate"),
|
||||
(SKETCH.as_slice(), "Sketch"),
|
||||
(TEXTURE.as_slice(), "Texture"),
|
||||
(VIDEO.as_slice(), "Video"),
|
||||
([("video.ntscColors", "{}")].as_slice(), "Video"),
|
||||
@@ -134,7 +138,9 @@ fn listed_applied_edited_and_undone() {
|
||||
|
||||
#[test]
|
||||
fn each_effect_changes_the_canvas_and_draws_the_same_twice() {
|
||||
for (id, params) in EFFECTS.into_iter().chain(ARTISTIC).chain(BRUSH_STROKES).chain(DISTORT).chain(PIXELATE).chain(TEXTURE).chain(VIDEO) {
|
||||
for (id, params) in
|
||||
EFFECTS.into_iter().chain(ARTISTIC).chain(BRUSH_STROKES).chain(DISTORT).chain(PIXELATE).chain(SKETCH).chain(TEXTURE).chain(VIDEO)
|
||||
{
|
||||
let (mut s, g) = striped();
|
||||
let plain = render(&s);
|
||||
let params: Value = serde_json::from_str(params).unwrap();
|
||||
@@ -150,6 +156,25 @@ fn each_effect_changes_the_canvas_and_draws_the_same_twice() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Halftone Pattern's Size, Contrast and Pattern Type are saved with the document and draw the same
|
||||
/// when it opens again.
|
||||
#[test]
|
||||
fn halftone_pattern_settings_survive_a_save_and_open() {
|
||||
let (mut s, g) = striped();
|
||||
let set = json!({"size": 7.0, "contrast": 20.0, "patternType": "line"});
|
||||
s.execute("effect.apply", &json!({"effect": "sketch.halftonePattern", "ids": [g], "params": set})).unwrap();
|
||||
let drawn = render(&s);
|
||||
let doc = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
assert_eq!(doc.node(NodeId(g)).unwrap().appearance.effects[0].params, set);
|
||||
assert_eq!(Renderer::new().render_region(&doc, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true).pixels, drawn.pixels);
|
||||
// Each setting shows: changing any one draws differently.
|
||||
for change in [json!({"size": 3.0}), json!({"contrast": 50.0}), json!({"patternType": "dot"})] {
|
||||
s.execute("effect.setParams", &json!({"index": 0, "ids": [g], "params": change})).unwrap();
|
||||
assert!(mean_diff(&drawn, &render(&s)) > 0.001, "{change}");
|
||||
s.execute("effect.setParams", &json!({"index": 0, "ids": [g], "params": set})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A PNG of rings around its center, 12 × 10 pixels.
|
||||
fn rings_png() -> Vec<u8> {
|
||||
let img = image::RgbaImage::from_fn(12, 10, |x, y| {
|
||||
@@ -251,7 +276,9 @@ fn spin_blur_reaches_past_the_corners() {
|
||||
|
||||
#[test]
|
||||
fn pdf_and_svg_write_the_effected_object_as_an_image() {
|
||||
for (id, params) in EFFECTS.into_iter().chain(ARTISTIC).chain(BRUSH_STROKES).chain(DISTORT).chain(PIXELATE).chain(TEXTURE).chain(VIDEO) {
|
||||
for (id, params) in
|
||||
EFFECTS.into_iter().chain(ARTISTIC).chain(BRUSH_STROKES).chain(DISTORT).chain(PIXELATE).chain(SKETCH).chain(TEXTURE).chain(VIDEO)
|
||||
{
|
||||
let (mut s, g) = striped();
|
||||
let params: Value = serde_json::from_str(params).unwrap();
|
||||
s.execute("effect.apply", &json!({"effect": id, "ids": [g], "params": params})).unwrap();
|
||||
|
||||
@@ -157,6 +157,20 @@ fn shaper_export_selection_crops_to_the_shown_art() {
|
||||
assert_eq!(v["bounds"], json!([b.x0, b.y0, b.width(), b.height()]), "{v}");
|
||||
}
|
||||
|
||||
/// Fit to Artwork Bounds fits the art a Shaper group shows, not its transparent source art.
|
||||
#[test]
|
||||
fn shaper_fit_to_artwork_bounds_fits_the_shown_art() {
|
||||
let mut s = session();
|
||||
s.execute("paint.setFill", &json!({"color":"#ffff00"})).unwrap();
|
||||
s.execute("shape.ellipse", &json!({"x":100,"y":100,"width":100,"height":100})).unwrap();
|
||||
s.execute("shape.line", &json!({"x1":50,"y1":150,"x2":250,"y2":150})).unwrap();
|
||||
scribble(&mut s, &[[60.0, 140.0], [70.0, 160.0], [80.0, 140.0], [90.0, 160.0]]);
|
||||
let g = scribble(&mut s, &[[210.0, 140.0], [220.0, 160.0], [230.0, 140.0], [240.0, 160.0]]);
|
||||
s.execute("artboard.fitToArt", &json!({})).unwrap();
|
||||
let b = node(&s, g).visual_bounds().unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.artboards[0].rect, b, "the circle and the trimmed line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shaper_source_move_resize_native_roundtrip_and_release_preserve_live_art() {
|
||||
let mut s = session();
|
||||
@@ -217,6 +231,86 @@ fn shaper_double_click_selects_an_original_for_construction_editing() {
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![g]);
|
||||
}
|
||||
|
||||
/// The arrow widget beside a selected Shaper Group switches it to Construction mode and back, with
|
||||
/// the Shaper tool and with the Selection tool. In Construction mode a Shaper tap selects a source
|
||||
/// shape, and the mode lasts through undo and redo of an edit made in it.
|
||||
#[test]
|
||||
fn shaper_widget_switches_between_face_selection_and_construction_mode() {
|
||||
let mut s = session();
|
||||
let a = rectangle(&mut s, 0.0, "#ff0000");
|
||||
rectangle(&mut s, 50.0, "#0000ff");
|
||||
let g = scribble(&mut s, &[[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0]]);
|
||||
let sources = node(&s, g).children().unwrap()[0].id;
|
||||
let v = ViewInfo { smart_guides: false, ..Default::default() };
|
||||
let widget = |s: &Session| {
|
||||
let st = s.doc().unwrap();
|
||||
vectorcraft_tools::shaperwidget::ShaperWidget::of(&st.doc, &st.selection, st.isolation, v.zoom).unwrap()
|
||||
};
|
||||
let click = |s: &mut Session, p: Point| {
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(kind, p.x, p.y), v).unwrap();
|
||||
}
|
||||
};
|
||||
for tool in ["shaper", "selection"] {
|
||||
s.select_tool(tool, v).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
|
||||
let w = widget(&s);
|
||||
assert!(!w.construction);
|
||||
click(&mut s, w.at);
|
||||
assert_eq!(s.doc().unwrap().isolation, Some(sources), "{tool}");
|
||||
assert!(widget(&s).construction);
|
||||
if tool == "shaper" {
|
||||
click(&mut s, Point::new(25.0, 50.0));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
|
||||
} else {
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
}
|
||||
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().isolation, Some(sources), "{tool}: undo");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().isolation, Some(sources), "{tool}: redo");
|
||||
assert_eq!(fill(&s, g, 15.0, 50.0).as_deref(), Some("#ff0000"));
|
||||
let w = widget(&s);
|
||||
assert!(w.construction);
|
||||
click(&mut s, w.at);
|
||||
assert_eq!(s.doc().unwrap().isolation, None, "{tool}");
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![g]);
|
||||
s.execute("object.move", &json!({"dx": -10, "dy": 0})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// In Construction mode a Shaper tap picks a source shape of the isolated group only, even where
|
||||
/// another Shaper Group's source shapes lie above it.
|
||||
#[test]
|
||||
fn shaper_construction_taps_stay_in_the_isolated_group() {
|
||||
let mut s = session();
|
||||
let a = rectangle(&mut s, 0.0, "#ff0000");
|
||||
rectangle(&mut s, 50.0, "#0000ff");
|
||||
let lower = scribble(&mut s, &[[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0]]);
|
||||
let c = rectangle(&mut s, 20.0, "#00ff00");
|
||||
let d = rectangle(&mut s, 70.0, "#ffff00");
|
||||
let upper = NodeId(
|
||||
s.execute("shaper.scribble", &json!({"points": [[45, 20], [95, 35], [50, 50], [145, 65]], "ids": [c.0, d.0]})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap(),
|
||||
);
|
||||
assert_ne!(lower, upper);
|
||||
let sources = node(&s, lower).children().unwrap()[0].id;
|
||||
s.execute("object.isolate", &json!({"id": lower.0})).unwrap();
|
||||
let v = ViewInfo { smart_guides: false, ..Default::default() };
|
||||
s.select_tool("shaper", v).unwrap();
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(kind, 25.0, 50.0), v).unwrap();
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
|
||||
assert_eq!(s.doc().unwrap().isolation, Some(sources));
|
||||
// Outside the isolated group's source shapes, a tap changes nothing.
|
||||
s.execute("shaper.select", &json!({"point": [160, 50], "source": true})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
|
||||
assert_eq!(s.doc().unwrap().isolation, Some(sources));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shaper_lines_keep_free_angles_with_or_without_shift() {
|
||||
for shift in [false, true] {
|
||||
|
||||
@@ -52,12 +52,15 @@ fn mixed_values_and_align_stroke_follow_the_selection() {
|
||||
select(&mut s, &[a, b]);
|
||||
let m = s.active().unwrap().stroke_mixed();
|
||||
assert!(m.weight && m.join && !m.cap && !m.miter_limit && !m.align && !m.dash && m.can_align, "{m:?}");
|
||||
// An open path or type in the selection can't take an inside or outside stroke.
|
||||
// An open path in the selection can't take an inside or outside stroke; type can (its
|
||||
// characters' stroke goes around the glyphs).
|
||||
let line = NodeId(run(&mut s, "shape.line", json!({"x1": 10, "y1": 100, "x2": 90, "y2": 100}))["id"].as_u64().unwrap());
|
||||
select(&mut s, &[a, line]);
|
||||
assert!(!s.active().unwrap().stroke_mixed().can_align);
|
||||
let text = NodeId(run(&mut s, "text.create", json!({"x": 10, "y": 200, "text": "Hi"}))["id"].as_u64().unwrap());
|
||||
select(&mut s, &[text]);
|
||||
assert!(s.active().unwrap().stroke_mixed().can_align);
|
||||
select(&mut s, &[text, line]);
|
||||
assert!(!s.active().unwrap().stroke_mixed().can_align);
|
||||
// Grouped closed paths can.
|
||||
select(&mut s, &[a, b]);
|
||||
|
||||
@@ -82,6 +82,38 @@ fn dataset_next_and_prev_wrap_around() {
|
||||
assert_eq!(v["active"], "B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applying_a_dataset_reports_the_area_type_it_overflows() {
|
||||
let mut s = session();
|
||||
let area = |s: &mut Session, y: u32, content: &str| {
|
||||
let r = s.execute("text.create", &json!({"x": 10, "y": y, "text": content, "size": 12, "area": {"width": 120, "height": 40}})).unwrap();
|
||||
NodeId(r["id"].as_u64().unwrap())
|
||||
};
|
||||
let long = "word ".repeat(60);
|
||||
let a = area(&mut s, 10, "x");
|
||||
let p = text(&mut s, "x");
|
||||
// Area type that already overflows, bound to no variable, isn't named.
|
||||
area(&mut s, 200, &long);
|
||||
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Name", "ids": [a.0, p.0]})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Title", "ids": [a.0]})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "Short", "values": {"Name": "Ann", "Title": "Ann"}})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "Long", "values": {"Name": long, "Title": long}})).unwrap();
|
||||
|
||||
// The area type is named once though two variables rewrote it; the point type is never named.
|
||||
let v = s.execute("dataset.select", &json!({"name": "Long"})).unwrap();
|
||||
assert_eq!((v["applied"].as_u64(), &v["overflow"]), (Some(3), &json!([a.0])), "{v}");
|
||||
let v = s.execute("dataset.select", &json!({"name": "Short"})).unwrap();
|
||||
assert!(v.get("overflow").is_none(), "{v}");
|
||||
let v = s.execute("dataset.next", &json!({})).unwrap();
|
||||
assert_eq!((&v["name"], &v["overflow"]), (&json!("Long"), &json!([a.0])), "{v}");
|
||||
let v = s.execute("dataset.prev", &json!({})).unwrap();
|
||||
assert!(v.get("overflow").is_none(), "{v}");
|
||||
let v = s.execute("dataset.prev", &json!({})).unwrap();
|
||||
assert_eq!((&v["name"], &v["overflow"]), (&json!("Long"), &json!([a.0])), "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatched_and_missing_bindings_are_skipped_and_counted() {
|
||||
let mut s = session();
|
||||
@@ -896,6 +928,26 @@ fn datasets_csv_keeps_line_breaks_in_quoted_cells() {
|
||||
assert!(s.execute("dataset.import", &json!({"csv": "name,Name\nX,\"open"})).is_err(), "unclosed quote");
|
||||
}
|
||||
|
||||
/// A quoted text cell keeps its leading and trailing spaces, so a padded value comes back from
|
||||
/// the exported CSV as it went out; an unquoted cell, and spaces after a closing quote, are trimmed.
|
||||
#[test]
|
||||
fn datasets_csv_keeps_spaces_in_quoted_cells() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "One");
|
||||
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "A", "values": {"Name": " padded "}})).unwrap();
|
||||
let csv = s.execute("dataset.export", &json!({})).unwrap()["csv"].as_str().unwrap().to_string();
|
||||
s.execute("dataset.delete", &json!({"names": ["A"]})).unwrap();
|
||||
s.execute("dataset.import", &json!({"csv": csv})).unwrap();
|
||||
s.execute("dataset.import", &json!({"csv": "name,Name\nB, typed \nC,\" quoted \" \n"})).unwrap();
|
||||
let value = |name: &str| s.doc().unwrap().doc.variables.dataset(name).and_then(|d| d.values.get("Name")).cloned();
|
||||
let text_value = |v: &str| Some(vectorcraft_doc::DataValue::Text(v.into()));
|
||||
assert_eq!(value("A"), text_value(" padded "));
|
||||
assert_eq!(value("B"), text_value("typed"));
|
||||
assert_eq!(value("C"), text_value(" quoted "));
|
||||
}
|
||||
|
||||
/// Import / export datasets as CSV: header `name` then variables; round-trip keeps values.
|
||||
#[test]
|
||||
fn datasets_csv_round_trips_and_rejects_bad_input() {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
//! Artboard clicks fit the object without changing its art or selection, and are one undo step.
|
||||
//! The second click of a double-click on an object does not fit another artboard, and that
|
||||
//! double-click does not open Artboard Options. Empty artboard space still does.
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::NodeId;
|
||||
use vectorcraft_engine::{Session, ViewInfo};
|
||||
use vectorcraft_engine::{Session, UiRequest, ViewInfo};
|
||||
use vectorcraft_geom::Rect;
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
|
||||
|
||||
@@ -20,6 +22,27 @@ fn pointer(s: &mut Session, kind: PointerKind, x: f64, y: f64) {
|
||||
s.pointer(&PointerEvent::new(kind, x, y), ViewInfo::default()).unwrap();
|
||||
}
|
||||
|
||||
fn pointer_reqs(s: &mut Session, kind: PointerKind, x: f64, y: f64) -> Vec<UiRequest> {
|
||||
s.pointer(&PointerEvent::new(kind, x, y), ViewInfo::default()).unwrap()
|
||||
}
|
||||
|
||||
fn is_dialog(r: &UiRequest) -> bool {
|
||||
matches!(r, UiRequest::Dialog(_, _))
|
||||
}
|
||||
|
||||
/// The two clicks of a double-click, then the double-click. `suppress` is what the canvas does on
|
||||
/// the second release, so that click does not fit a second artboard.
|
||||
fn double_click(s: &mut Session, x: f64, y: f64, suppress: bool) -> Vec<UiRequest> {
|
||||
pointer(s, PointerKind::Down, x, y);
|
||||
pointer(s, PointerKind::Up, x, y);
|
||||
pointer(s, PointerKind::Down, x, y);
|
||||
if suppress {
|
||||
s.set_tool_option("suppressFitClick", &json!(true));
|
||||
}
|
||||
pointer(s, PointerKind::Up, x, y);
|
||||
pointer_reqs(s, PointerKind::DoubleClick, x, y)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicks_inside_and_outside_an_artboard_fit_the_visible_object_in_one_undo_step() {
|
||||
for (x, y) in [(100.0, 100.0), (650.0, 700.0)] {
|
||||
@@ -128,3 +151,85 @@ fn locked_hidden_modifier_handle_and_drag_gestures_keep_their_existing_meaning()
|
||||
assert_eq!(s.doc().unwrap().doc.artboards.len(), 1, "a drag returning to its start does not become an object click");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_single_click_on_the_same_object_fits_another_artboard() {
|
||||
let (mut s, _) = fixture(100.0, 100.0);
|
||||
pointer(&mut s, PointerKind::Down, 140.0, 125.0);
|
||||
pointer(&mut s, PointerKind::Up, 140.0, 125.0);
|
||||
pointer(&mut s, PointerKind::Down, 140.0, 125.0);
|
||||
pointer(&mut s, PointerKind::Up, 140.0, 125.0);
|
||||
assert_eq!(s.doc().unwrap().doc.artboards.len(), 3, "each single click fits one artboard");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_double_click_on_an_object_fits_once_and_skips_options() {
|
||||
let (mut s, id) = fixture(100.0, 100.0);
|
||||
s.execute("artboard.new", &json!({"x": 2000.0, "y": 0.0, "width": 80.0, "height": 40.0, "name": "Far"})).unwrap();
|
||||
s.execute("artboard.new", &json!({"x": 205.0, "y": 95.0, "width": 40.0, "height": 40.0, "name": "Touch"})).unwrap();
|
||||
s.execute("artboard.new", &json!({"x": 180.0, "y": 150.0, "width": 40.0, "height": 40.0, "name": "Overlap"})).unwrap();
|
||||
let before: Vec<_> = s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect();
|
||||
let layers = json!(s.doc().unwrap().doc.layers);
|
||||
let sel = s.doc().unwrap().selection.clone();
|
||||
let history = s.doc().unwrap().history.undo.len();
|
||||
let reqs = double_click(&mut s, 140.0, 125.0, true);
|
||||
assert!(reqs.iter().all(|r| !is_dialog(r)), "no Artboard Options on the object, {reqs:?}");
|
||||
let st = s.doc().unwrap();
|
||||
let fitted = Rect::new(95.0, 95.0, 205.0, 165.0);
|
||||
let rects: Vec<_> = st.doc.artboards.iter().map(|a| a.rect).collect();
|
||||
assert_eq!(rects.iter().filter(|r| **r == fitted).count(), 1, "the second click does not fit another: {rects:?}");
|
||||
for kept in &before {
|
||||
assert!(rects.contains(kept), "existing artboards stay: missing {kept:?} in {rects:?}");
|
||||
}
|
||||
assert_eq!(rects.len(), before.len() + 1);
|
||||
assert_eq!(st.doc.bounds_of(&[id], true), Some(fitted));
|
||||
assert_eq!(json!(st.doc.layers), layers);
|
||||
assert_eq!(json!(st.selection), json!(sel));
|
||||
assert_eq!(st.history.undo.len(), history + 1, "only the first click's new artboard");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let after: Vec<_> = s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect();
|
||||
assert_eq!(after, before);
|
||||
assert_eq!(json!(s.doc().unwrap().selection), json!(sel));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_the_canvas_flag_the_second_release_still_fits_another_artboard() {
|
||||
let (mut s, _) = fixture(100.0, 100.0);
|
||||
let reqs = double_click(&mut s, 140.0, 125.0, false);
|
||||
assert!(reqs.iter().all(|r| !is_dialog(r)), "{reqs:?}");
|
||||
let fitted = Rect::new(95.0, 95.0, 205.0, 165.0);
|
||||
let n = s.doc().unwrap().doc.artboards.iter().filter(|a| a.rect == fitted).count();
|
||||
assert_eq!(n, 2, "each release fits until the canvas suppresses the second one");
|
||||
assert!(s.doc().unwrap().doc.artboards.iter().any(|a| a.rect == Rect::new(0.0, 0.0, 500.0, 500.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_double_click_on_an_object_does_not_open_options_or_change_the_document() {
|
||||
let (mut s, _) = fixture(100.0, 100.0);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let history = s.doc().unwrap().history.undo.len();
|
||||
let reqs = pointer_reqs(&mut s, PointerKind::DoubleClick, 140.0, 125.0);
|
||||
assert!(reqs.iter().all(|r| !is_dialog(r)), "{reqs:?}");
|
||||
assert_eq!(json!(s.doc().unwrap().doc), json!(before));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), history);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_click_on_an_artboard_handle_opens_options() {
|
||||
let (mut s, _) = fixture(0.0, 0.0);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let reqs = pointer_reqs(&mut s, PointerKind::DoubleClick, 0.0, 0.0);
|
||||
assert!(reqs.iter().any(is_dialog), "a handle keeps Artboard Options, {reqs:?}");
|
||||
assert_eq!(json!(s.doc().unwrap().doc), json!(before));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_click_on_empty_artboard_space_opens_options_and_changes_nothing() {
|
||||
let (mut s, _) = fixture(100.0, 100.0);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let history = s.doc().unwrap().history.undo.len();
|
||||
let reqs = pointer_reqs(&mut s, PointerKind::DoubleClick, 300.0, 300.0);
|
||||
assert!(reqs.iter().any(is_dialog), "{reqs:?}");
|
||||
assert_eq!(json!(s.doc().unwrap().doc), json!(before));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), history);
|
||||
}
|
||||
@@ -121,6 +121,23 @@ fn bug_huge_transform_makes_document_unloadable() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Distribute with a huge spacing from each of the three selected objects as the key object leaves
|
||||
/// a document that saves and opens again.
|
||||
#[test]
|
||||
fn distribute_with_a_huge_spacing_from_the_key_object() {
|
||||
for key in 0..3 {
|
||||
for p in [json!({"horizontal": "left", "spacing": 1e308}), json!({"vertical": "bottom", "spacing": -1e308})] {
|
||||
let mut s = Fixture::Multi.session();
|
||||
let id = s.doc().unwrap().selection.objects[key];
|
||||
s.execute("select.key", &json!({"id": id.0})).unwrap();
|
||||
if no_panic(s.execute("object.distribute", &p)).is_ok() {
|
||||
check_session(&s).unwrap_or_else(|e| panic!("key {key}, {p}: {e}"));
|
||||
vectorcraft_testkit::invariants::check_native_roundtrip(&s.doc().unwrap().doc).unwrap_or_else(|e| panic!("key {key}, {p}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bug_artboard_huge_props_break_svg() {
|
||||
for key in ["x", "y", "width", "height"] {
|
||||
@@ -305,6 +322,7 @@ fn structured_junk() {
|
||||
("effect.apply", json!({"effect": "pixelate.crystallize", "params": {"cellSize": -1e308}})),
|
||||
("effect.apply", json!({"effect": "pixelate.mezzotint", "params": {"type": 5}})),
|
||||
("effect.apply", json!({"effect": "pixelate.pointillize", "params": {"cellSize": "NaN"}})),
|
||||
("effect.apply", json!({"effect": "sketch.halftonePattern", "params": {"size": -1e308, "contrast": "NaN", "patternType": 3}})),
|
||||
("effect.apply", json!({"effect": "texture.craquelure", "params": {"crackSpacing": -1e308, "crackDepth": "NaN", "crackBrightness": 1e308}})),
|
||||
("effect.apply", json!({"effect": "texture.grain", "params": {"intensity": 1e308, "contrast": [1], "grainType": 7}})),
|
||||
("effect.apply", json!({"effect": "texture.mosaicTiles", "params": {"tileSize": "1e999", "groutWidth": -1, "lightenGrout": null}})),
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
//! `edit.findReplace` keeps per-paragraph styles lined up with the text when a find or replace
|
||||
//! string holds a line break, as `text.editRange` does.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{Justify, NodeId, NodeKind};
|
||||
use vectorcraft_testkit::fixtures::{self, exec, id_of};
|
||||
|
||||
/// "A\nB\nC" with B centred and C right-aligned.
|
||||
fn setup() -> (vectorcraft_engine::Session, NodeId) {
|
||||
let mut s = fixtures::session_with(400.0, 300.0);
|
||||
let id = id_of(&exec(&mut s, "text.create", json!({"x": 20, "y": 100, "text": "A\nB\nC", "size": 20})));
|
||||
exec(&mut s, "text.setStyle", json!({"id": id.0, "start": 2, "end": 3, "justify": "center"}));
|
||||
exec(&mut s, "text.setStyle", json!({"id": id.0, "start": 4, "end": 5, "justify": "right"}));
|
||||
exec(&mut s, "select.none", json!({}));
|
||||
(s, id)
|
||||
}
|
||||
|
||||
fn state(s: &vectorcraft_engine::Session, id: NodeId) -> (String, Vec<Justify>) {
|
||||
match &s.doc().unwrap().doc.node(id).unwrap().kind {
|
||||
NodeKind::Text(t) => (t.plain_text(), t.paragraph_styles().iter().map(|p| p.justify).collect()),
|
||||
_ => panic!("not text"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacement_adding_a_line_break_keeps_later_paragraph_styles() {
|
||||
let (mut s, id) = setup();
|
||||
assert_eq!(state(&s, id).1, [Justify::Auto, Justify::Center, Justify::Right]);
|
||||
exec(&mut s, "edit.findReplace", json!({"find": "A", "replace": "A\nA2", "ids": [id.0]}));
|
||||
let (text, styles) = state(&s, id);
|
||||
assert_eq!(text, "A\nA2\nB\nC");
|
||||
assert_eq!(styles, [Justify::Auto, Justify::Auto, Justify::Center, Justify::Right]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_text_with_a_line_break_merges_paragraphs_like_edit_range() {
|
||||
let (mut s, id) = setup();
|
||||
exec(&mut s, "edit.findReplace", json!({"find": "A\nB", "replace": "AB", "ids": [id.0]}));
|
||||
let (text, styles) = state(&s, id);
|
||||
assert_eq!(text, "AB\nC");
|
||||
assert_eq!(styles, [Justify::Auto, Justify::Right]);
|
||||
}
|
||||
@@ -1875,10 +1875,12 @@ impl<'a> Reader<'a> {
|
||||
/// `alpha` (its alpha channel, one byte a pixel) as its opacity mask when it hides part of the
|
||||
/// image.
|
||||
fn add_raster(&mut self, blob: ImageBlob, alpha: Option<&[u8]>, m: &[f64], (w, h): (u32, u32)) -> Result<(), String> {
|
||||
// The matrix maps pixels (y down) into art space with its y flipped.
|
||||
// The matrix is an art-space matrix (y up) applied to the pixels with their rows running
|
||||
// downwards, (u, -v): pixels → (u, -v) → art space. Flipping the matrix's result instead
|
||||
// gives the same place only when the image isn't rotated (b = c = 0).
|
||||
let [a, b, c, d, tx, ty] = m else { return Ok(()) };
|
||||
let to_doc = self.space();
|
||||
let xf = to_doc * FLIP * Affine::new([*a, *b, *c, *d, *tx, -*ty]);
|
||||
let xf = to_doc * Affine::new([*a, *b, *c, *d, *tx, *ty]) * FLIP;
|
||||
if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -632,6 +632,33 @@ fn images_with_alpha_channels() {
|
||||
assert!(art(&r.doc).is_empty() && r.warnings.iter().any(|w| w.contains("cut short")));
|
||||
}
|
||||
|
||||
/// The raster matrix `[a b c d tx ty]` is an art-space matrix (y up) applied to the image's pixels
|
||||
/// with their rows running downwards, `(u, -v)`: art x = a·u − c·v + tx, art y = b·u − d·v + ty.
|
||||
/// Without rotation (b = c = 0) that is the same as flipping the matrix's result; a rotated image
|
||||
/// tells the two apart (#2269).
|
||||
#[test]
|
||||
fn a_rotated_image_is_placed_as_its_matrix_maps_it() {
|
||||
// Turned 90° counter-clockwise: in art space each row runs upwards, the next row to its right.
|
||||
let doc = read(&image("[ 0 10 -10 0 50 50 ]", (2, 1), ("DeviceRGB", 3, 0), &[255, 0, 0, 0, 0, 255], 6)).unwrap().doc;
|
||||
let xf = pixels(&doc).1;
|
||||
// Art (x, y) is (x, 100 - y) on the page.
|
||||
let page = |x: f64, y: f64| Point::new(x, 100.0 - y);
|
||||
assert_eq!(xf * Point::ZERO, page(50.0, 50.0), "the top left pixel's corner stays at the matrix's origin");
|
||||
assert_eq!(xf * Point::new(2.0, 0.0), page(50.0, 70.0), "the first row runs up");
|
||||
assert_eq!(xf * Point::new(0.0, 1.0), page(60.0, 50.0), "the next row is to its right");
|
||||
// Any rotation and scale: the same formula.
|
||||
let (s, c) = 30f64.to_radians().sin_cos();
|
||||
let m = [3.0 * c, 3.0 * s, -2.0 * s, 2.0 * c, 40.0, 60.0];
|
||||
let matrix = format!("[ {} ]", m.map(|v| v.to_string()).join(" "));
|
||||
let doc = read(&image(&matrix, (2, 1), ("DeviceRGB", 3, 0), &[255, 0, 0, 0, 0, 255], 6)).unwrap().doc;
|
||||
let xf = pixels(&doc).1;
|
||||
for (u, v) in [(0.0, 0.0), (2.0, 0.0), (0.0, 1.0), (2.0, 1.0)] {
|
||||
let want = page(m[0] * u - m[2] * v + m[4], m[1] * u - m[3] * v + m[5]);
|
||||
let got = xf * Point::new(u, v);
|
||||
assert!((got - want).hypot() < 1e-9, "pixel ({u}, {v}): {got:?}, want {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// CMYK images keep their ink amounts, as the file's PDF part keeps them, and an alpha channel
|
||||
/// becomes their opacity mask: a grayscale image over the image.
|
||||
#[test]
|
||||
|
||||
@@ -1005,7 +1005,7 @@ fn build(data: &[u8], visible: Option<&Document>, page: Page, outlined: bool, tr
|
||||
Err(why) => return Err(why),
|
||||
}
|
||||
}
|
||||
None => unmark(&mut doc.layers, true, &mut vec![]),
|
||||
None => unmark(&mut doc.layers, true, true, &mut vec![]),
|
||||
}
|
||||
doc.limit_depth_noting(&mut notes);
|
||||
if small.is_some() {
|
||||
@@ -1047,12 +1047,13 @@ const UNSEEN_TEXT: &str = "text objects that the file keeps but its page doesn't
|
||||
/// How much more of the page (0 to 1) the layers may differ without a text object and still look no worse.
|
||||
const UNSEEN_MARGIN: f64 = 5e-5;
|
||||
|
||||
/// Clear the marks in `nodes` and say which marked objects show, by id.
|
||||
fn unmark(nodes: &mut [Arc<Node>], shown: bool, found: &mut Vec<(NodeId, Option<Rect>)>) {
|
||||
/// Clear the marks in `nodes` and say which marked objects show and print, by id.
|
||||
fn unmark(nodes: &mut [Arc<Node>], shown: bool, prints: bool, found: &mut Vec<(NodeId, Option<Rect>)>) {
|
||||
for n in nodes {
|
||||
let shown = shown && n.visible;
|
||||
let prints = prints && !matches!(n.kind, NodeKind::Layer { printable: false, .. });
|
||||
if let Some(name) = n.name.as_deref().and_then(|m| m.strip_prefix(MADE)) {
|
||||
if shown {
|
||||
if shown && prints {
|
||||
found.push((n.id, n.visual_bounds()));
|
||||
}
|
||||
let name = (!name.is_empty()).then(|| name.to_string());
|
||||
@@ -1060,7 +1061,7 @@ fn unmark(nodes: &mut [Arc<Node>], shown: bool, found: &mut Vec<(NodeId, Option<
|
||||
} else if !matches!(n.kind, NodeKind::Compound { .. })
|
||||
&& let Some(c) = Arc::make_mut(n).children_mut()
|
||||
{
|
||||
unmark(c, shown, found);
|
||||
unmark(c, shown, prints, found);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1080,13 +1081,13 @@ fn take_out(nodes: &mut Vec<Arc<Node>>, id: NodeId, hide: bool) -> bool {
|
||||
nodes.iter_mut().any(|n| !matches!(n.kind, NodeKind::Compound { .. }) && Arc::make_mut(n).children_mut().is_some_and(|c| take_out(c, id, hide)))
|
||||
}
|
||||
|
||||
/// The text objects made from the file's text document that show on the page's area, where the page
|
||||
/// has no type to match them, and that the layers look no worse without, are not drawn by the app
|
||||
/// that wrote the file (which paints every shown text object; it keeps the type of what was turned
|
||||
/// to outlines): they are taken out. How many.
|
||||
/// The text objects made from the file's text document that show and print on the page's area,
|
||||
/// where the page has no type to match them, and that the layers look no worse without, are not
|
||||
/// drawn by the app that wrote the file (which paints every shown text object; it keeps the type
|
||||
/// of what was turned to outlines): they are taken out. How many.
|
||||
fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Document) -> usize {
|
||||
let mut found = vec![];
|
||||
unmark(&mut doc.layers, true, &mut found);
|
||||
unmark(&mut doc.layers, true, true, &mut found);
|
||||
let rect = frame.rect;
|
||||
if found.is_empty() || found.len() > MAX_WEIGHED {
|
||||
return 0;
|
||||
|
||||
@@ -628,6 +628,24 @@ fn a_shown_text_object_on_the_page_that_the_page_doesnt_draw_is_left_out() {
|
||||
assert!(r.warnings.iter().any(|w| w.contains("doesn't draw")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// A text object on a layer that shows and doesn't print, which the page doesn't draw, isn't taken
|
||||
/// out: it comes in from the text document, on its layer.
|
||||
#[test]
|
||||
fn a_shown_text_object_on_a_layer_that_doesnt_print_stays_on_its_layer() {
|
||||
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
|
||||
// The layer's options with its fourth flag, printing, off.
|
||||
let tips = layer("Tips", true, &text_object(0)).replace("1 1 1 1 0 0 1 0 79", "1 1 1 0 0 0 1 0 79");
|
||||
let art = native_with_text(&(layer("Art", true, &square(10, 10)) + &tips), &doc);
|
||||
let (d, w, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, false, 1.0);
|
||||
assert!(read, "{w:?}");
|
||||
assert_eq!(names(&d), ["Art", "Tips"], "{w:?}");
|
||||
let tips = &d.layers[1];
|
||||
assert!(tips.visible && matches!(tips.kind, NodeKind::Layer { printable: false, .. }));
|
||||
let texts: Vec<String> = text_of(tips).iter().map(|t| t.plain_text()).collect();
|
||||
assert_eq!(texts, ["Hi there"], "{w:?}");
|
||||
assert!(!w.iter().any(|w| w.contains("doesn't draw")), "{w:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pieces_of_two_lines_the_page_draws_alternately_go_each_to_its_own_text() {
|
||||
// "Hello" is drawn as "He" and "llo", with "abcd" ("ab", "cd") drawn between them.
|
||||
|
||||
@@ -313,7 +313,7 @@ VCLegacy begin
|
||||
/_row {{currentfile _l readline not {exit} if dup length 1 gt {exit} if pop} loop
|
||||
dup length 1 gt {dup length 1 sub 1 exch getinterval /ASCIIHexDecode filter _s readstring pop} {pop ()} ifelse} bd
|
||||
/XI {pop pop pop pop /_ic exch def /_ib exch def /_ih exch def /_iw exch def pop pop pop pop
|
||||
aload pop neg 6 array astore [1 0 0 -1 0 0] matrix concatmatrix
|
||||
[1 0 0 -1 0 0] exch matrix concatmatrix
|
||||
_on {gsave concat _iw _ih _ib [1 0 0 1 0 0] /_row load false _ic 1 eq {1} {_ic} ifelse colorimage grestore} {pop} ifelse} bd
|
||||
% Type (15) is set by the app that reads the file: its operators take their operands and mark
|
||||
% nothing; encodings (3.3) are skipped.
|
||||
@@ -1201,11 +1201,12 @@ impl<'a> Writer<'a> {
|
||||
|
||||
// ---------- images ----------
|
||||
|
||||
/// An image (§8): `%AI5_BeginRaster`, its matrix (pixels, y down, → art space with y flipped)
|
||||
/// and operands with `XI`, its RGB samples in hex on comment lines, `%AI5_EndRaster`.
|
||||
/// An image (§8): `%AI5_BeginRaster`, its matrix (an art-space matrix applied to the pixels
|
||||
/// with their rows running downwards, `(u, -v)`) and operands with `XI`, its RGB samples in hex
|
||||
/// on comment lines, `%AI5_EndRaster`.
|
||||
fn image(&mut self, im: &ImageObject) {
|
||||
let flip = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]);
|
||||
let m = flip * self.to_art * im.xf;
|
||||
let m = self.to_art * im.xf * flip;
|
||||
if !invertible(m) {
|
||||
return;
|
||||
}
|
||||
@@ -1220,7 +1221,7 @@ impl<'a> Writer<'a> {
|
||||
}
|
||||
// The object places the image's own pixel grid (its width × height) wherever the file's is.
|
||||
let fit = Affine::scale_non_uniform(f64::from(im.width.max(1)) / f64::from(w), f64::from(im.height.max(1)) / f64::from(h));
|
||||
let m = flip * self.to_art * im.xf * fit;
|
||||
let m = self.to_art * im.xf * fit * flip;
|
||||
let rgba = img.to_rgba8();
|
||||
let mut hex = String::with_capacity(rgba.len() / 4 * 7);
|
||||
hex.push('%');
|
||||
@@ -1253,7 +1254,7 @@ impl<'a> Writer<'a> {
|
||||
let [a, b, c, d, e, f] = m.as_coeffs();
|
||||
let s = &mut self.body;
|
||||
s.push_str("%AI5_File:\n%AI5_BeginRaster\n[");
|
||||
ps::push_nums(s, &[a, b, c, d, e, -f]);
|
||||
ps::push_nums(s, &[a, b, c, d, e, f]);
|
||||
s.pop();
|
||||
let _ = writeln!(s, "] 0 0 {w} {h} {w} {h} 8 3 0 0 0 0 XI");
|
||||
s.push_str(&hex);
|
||||
|
||||
@@ -228,6 +228,45 @@ fn writes_the_documented_operators() {
|
||||
assert!(text.contains("] 0 0 2 1 2 1 8 3 0 0 0 0 XI\n%FF00000000FF\n%AI5_EndRaster\n"), "{text}");
|
||||
}
|
||||
|
||||
/// An image's matrix (§8) is an art-space matrix applied to its pixels with their rows running
|
||||
/// downwards, `(u, -v)`: a rotated image is written with the matrix the reader and other apps
|
||||
/// place it by, prints where it is, and reads back where it was (#2269).
|
||||
#[test]
|
||||
fn a_rotated_image_keeps_its_place() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let mut png = vec![];
|
||||
image::RgbaImage::from_raw(2, 1, vec![255, 0, 0, 255, 0, 0, 255, 255])
|
||||
.unwrap()
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let blob = ImageBlob::new("image/png", png);
|
||||
let key = blob.content_key();
|
||||
doc.images.insert(key.clone(), blob);
|
||||
// Turned 90° clockwise on the page: the pixel rows run down, the next row is to the left.
|
||||
let xf = Affine::new([0.0, 10.0, -10.0, 0.0, 100.0, 40.0]);
|
||||
let im =
|
||||
Node::new(doc.alloc_id(), NodeKind::Image(Box::new(ImageObject { key, width: 2, height: 1, xf, link: None, placement: Default::default() })));
|
||||
doc.layers.clear();
|
||||
let layer = doc.add_layer(Some("Art"));
|
||||
doc.insert(Some(layer), usize::MAX, im).unwrap();
|
||||
let out = write(&doc);
|
||||
let text = String::from_utf8(out.bytes.clone()).unwrap();
|
||||
// In art space (y up) the rows run down, (0, -10) a pixel, and the next row is to the left,
|
||||
// (-10, 0): [a b] = (0, -10) and [c d] = (10, 0), as the matrix takes (u, -v).
|
||||
let line = text.lines().find(|l| l.ends_with(" XI")).unwrap();
|
||||
let m: Vec<f64> = line.trim_start_matches('[').split(']').next().unwrap().split_whitespace().map(|v| v.parse().unwrap()).collect();
|
||||
assert_eq!(m[..4], [0.0, -10.0, 10.0, 0.0], "{line}");
|
||||
let read = crate::import(&out.bytes).unwrap().document;
|
||||
let mut found = None;
|
||||
read.walk(|n| {
|
||||
if let NodeKind::Image(im) = &n.kind {
|
||||
found = Some(im.xf);
|
||||
}
|
||||
});
|
||||
let back = found.unwrap();
|
||||
assert!(back.as_coeffs().iter().zip(xf.as_coeffs()).all(|(a, b)| (a - b).abs() < 1e-6), "{back:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gradient_fill_and_its_stroke_are_one_object() {
|
||||
let mut doc = Document::new(100.0, 100.0);
|
||||
|
||||
@@ -79,6 +79,7 @@ fn tool_key(name: &str) -> Option<ToolKey> {
|
||||
"[" | "bracketleft" | "openbracket" => ToolKey::BracketLeft,
|
||||
"]" | "bracketright" | "closebracket" => ToolKey::BracketRight,
|
||||
"tab" => ToolKey::Tab,
|
||||
k if k.len() == 1 && k.bytes().all(|b| b.is_ascii_lowercase()) => ToolKey::Letter(k.chars().next()?),
|
||||
k if k.len() == 1 => ToolKey::Digit(k.parse().ok()?),
|
||||
_ => return None,
|
||||
})
|
||||
@@ -204,7 +205,14 @@ impl Headless {
|
||||
let key = s(p, "key").ok_or("missing `key`")?;
|
||||
let b = |n: &str| p.get(n).and_then(Value::as_bool).unwrap_or(false);
|
||||
let mods = Mods { shift: b("shift"), alt: b("alt"), cmd: b("cmd"), ctrl: b("ctrl"), ..Mods::default() };
|
||||
let tk = tool_key(key);
|
||||
// A letter reaches the tool only when the tool claims it without Cmd, Ctrl or Alt (E while
|
||||
// a selection marquee is dragged); otherwise it is a shortcut.
|
||||
let mut tk = tool_key(key);
|
||||
if let Some(k @ ToolKey::Letter(_)) = tk
|
||||
&& (mods.cmd || mods.ctrl || mods.alt || !self.session.tool_claims_key(k, self.view))
|
||||
{
|
||||
tk = None;
|
||||
}
|
||||
// A busy tool (pen path in progress, shape being dragged) gets its keys first, as do keys
|
||||
// the tool claims (the Gradient tool's selected stop takes Delete and the arrows).
|
||||
if let Some(k) = tk
|
||||
|
||||
@@ -1008,6 +1008,28 @@ fn shift_marquee_gesture_toggles_the_selection() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `press_key {key: "e"}` while a Selection marquee is being dragged makes the marquee select only
|
||||
/// the objects entirely inside it. With nothing dragged, E still chooses its tool.
|
||||
#[test]
|
||||
fn e_during_a_marquee_gesture_selects_only_the_objects_inside() {
|
||||
let mut s = server();
|
||||
let square = |s: &mut Server, id: u64, x: u64| {
|
||||
let r = call(s, id, "draw_shape", json!({"shape": "rectangle", "x": x, "y": 100, "width": 50, "height": 50}));
|
||||
serde_json::from_str::<Value>(&text_of(&r)).unwrap()["id"].as_u64().unwrap()
|
||||
};
|
||||
let (a, _b) = (square(&mut s, 1, 100), square(&mut s, 2, 200));
|
||||
let begin = json!([{"kind": "down", "x": 80, "y": 80}, {"kind": "drag", "x": 220, "y": 170}]);
|
||||
let r = call(&mut s, 3, "pointer_gesture", json!({"tool": "selection", "events": begin}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
let r = call(&mut s, 4, "press_key", json!({"key": "e"}));
|
||||
assert_eq!(serde_json::from_str::<Value>(&text_of(&r)).unwrap()["handledBy"], "tool", "{r}");
|
||||
let end = json!([{"kind": "drag", "x": 230, "y": 170}, {"kind": "up", "x": 230, "y": 170}]);
|
||||
let r = call(&mut s, 5, "pointer_gesture", json!({"tool": "selection", "events": end}));
|
||||
assert_eq!(serde_json::from_str::<Value>(&text_of(&r)).unwrap()["selection"], json!([a]), "{r}");
|
||||
let r = call(&mut s, 6, "press_key", json!({"key": "e"}));
|
||||
assert_eq!(serde_json::from_str::<Value>(&text_of(&r)).unwrap()["handledBy"], "tool.select", "{r}");
|
||||
}
|
||||
|
||||
/// A click on one of two selected objects makes it the key object: `pointer_gesture` replies
|
||||
/// with it as `key` and `inspect_document` lists it, and a click on the key again lets it go.
|
||||
#[test]
|
||||
@@ -1076,6 +1098,30 @@ fn type_preferences_apply_to_agents() {
|
||||
assert_eq!(style(&mut s, 6), (size + 4.0, tracking + 50.0));
|
||||
}
|
||||
|
||||
/// Cmd+Alt+Shift+. and , reach agents: the placeholder text the Type tool selects steps by five
|
||||
/// times the Size/Leading increment (2 pt by default) and back.
|
||||
#[test]
|
||||
fn cmd_alt_shift_period_steps_the_font_size_by_five_for_agents() {
|
||||
let mut s = server();
|
||||
let events = json!([{"kind": "down", "x": 100, "y": 100}, {"kind": "up", "x": 100, "y": 100}]);
|
||||
let r = call(&mut s, 1, "pointer_gesture", json!({"tool": "type", "events": events}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
let id = serde_json::from_str::<Value>(&text_of(&r)).unwrap()["selection"][0].as_u64().unwrap();
|
||||
let r = call(&mut s, 2, "run_command", json!({"command": "text.setRangeStyle", "params": {"id": id, "size": 12}}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
let size = |s: &mut Server, n| {
|
||||
let r = call(s, n, "run_command", json!({"command": "text.getRange", "params": {"id": id, "effective": true}}));
|
||||
serde_json::from_str::<Value>(&text_of(&r)).unwrap()["runs"][0]["style"]["size"].clone()
|
||||
};
|
||||
let r = call(&mut s, 3, "press_key", json!({"key": ".", "mods": {"cmd": true, "alt": true, "shift": true}}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
assert_eq!(serde_json::from_str::<Value>(&text_of(&r)).unwrap()["command"], "type.size.increaseByFive", "{r}");
|
||||
assert_eq!(size(&mut s, 4), json!(22.0));
|
||||
let r = call(&mut s, 5, "press_key", json!({"key": ",", "mods": {"cmd": true, "alt": true, "shift": true}}));
|
||||
assert_eq!(serde_json::from_str::<Value>(&text_of(&r)).unwrap()["command"], "type.size.decreaseByFive", "{r}");
|
||||
assert_eq!(size(&mut s, 6), json!(12.0));
|
||||
}
|
||||
|
||||
/// Cmd+Alt+Q reaches agents: the placeholder text the Type tool selects goes back to tracking 0
|
||||
/// and Auto kerning.
|
||||
#[test]
|
||||
@@ -1096,6 +1142,26 @@ fn cmd_alt_q_resets_tracking_and_kerning_for_agents() {
|
||||
assert_eq!((&runs[0]["style"]["tracking"], &runs[0]["style"]["kerning"]), (&json!(0.0), &Value::Null), "{runs}");
|
||||
}
|
||||
|
||||
/// Cmd+Shift+X reaches agents: the placeholder text the Type tool selects goes back to a
|
||||
/// horizontal scale of 100%.
|
||||
#[test]
|
||||
fn cmd_shift_x_resets_horizontal_scale_for_agents() {
|
||||
let mut s = server();
|
||||
let events = json!([{"kind": "down", "x": 100, "y": 100}, {"kind": "up", "x": 100, "y": 100}]);
|
||||
let r = call(&mut s, 1, "pointer_gesture", json!({"tool": "type", "events": events}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
let id = serde_json::from_str::<Value>(&text_of(&r)).unwrap()["selection"][0].as_u64().unwrap();
|
||||
let r = call(&mut s, 2, "run_command", json!({"command": "text.setRangeStyle", "params": {"id": id, "hScale": 150}}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
let r = call(&mut s, 3, "press_key", json!({"key": "X", "mods": {"cmd": true, "shift": true}}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
assert_eq!(serde_json::from_str::<Value>(&text_of(&r)).unwrap()["command"], "type.resetHorizontalScale", "{r}");
|
||||
let r = call(&mut s, 4, "run_command", json!({"command": "text.getRange", "params": {"id": id}}));
|
||||
let runs = serde_json::from_str::<Value>(&text_of(&r)).unwrap()["runs"].clone();
|
||||
assert_eq!(runs.as_array().unwrap().len(), 1, "{runs}");
|
||||
assert_eq!(runs[0]["style"]["h_scale"], json!(100.0), "{runs}");
|
||||
}
|
||||
|
||||
/// The drawing tools snap to Smart Guides over MCP as with the mouse (#506): a Rectangle drawn
|
||||
/// from near another object's corner starts on it, and a Pen anchor placed beside that object's
|
||||
/// centre lines up with it.
|
||||
|
||||
@@ -40,6 +40,35 @@ fn conventions_catalog_and_strict_keys() {
|
||||
assert_eq!(rpc(&mut s, "ping", json!({}))["result"], json!({}));
|
||||
}
|
||||
|
||||
/// #2173: the command listings take `query` (as `filter`), a `menu` filter, and `brief`, which
|
||||
/// leaves out the long params text, so the catalogue fits a client's output limit.
|
||||
#[test]
|
||||
fn command_listings_take_query_menu_and_brief() {
|
||||
let mut s = Server::new(Box::new(Headless::with_document()));
|
||||
let ids = |v: &Value| v.as_array().unwrap().iter().map(|c| c["id"].as_str().unwrap().to_string()).collect::<Vec<_>>();
|
||||
let by_filter = payload(&call(&mut s, "command_list", json!({"filter":"rectangle"})));
|
||||
let by_query = payload(&call(&mut s, "command_list", json!({"query":"rectangle"})));
|
||||
assert!(ids(&by_filter).contains(&"shape.rectangle".to_string()));
|
||||
assert_eq!(by_filter, by_query);
|
||||
// `menu` keeps the commands under that menu path.
|
||||
let path_menu = payload(&call(&mut s, "command_list", json!({"menu":"object > path"})));
|
||||
assert!(ids(&path_menu).contains(&"object.path.addAnchorPoints".to_string()), "{path_menu}");
|
||||
for c in path_menu.as_array().unwrap() {
|
||||
let menu: Vec<&str> = c["menu"].as_array().unwrap().iter().map(|m| m.as_str().unwrap()).collect();
|
||||
assert_eq!(menu.get(..2), Some(&["Object", "Path"][..]), "{c}");
|
||||
}
|
||||
// `brief` drops the params text and nothing else.
|
||||
let full = payload(&call(&mut s, "command_list", json!({})));
|
||||
let brief = payload(&call(&mut s, "command_list", json!({"brief":true})));
|
||||
assert_eq!(ids(&full), ids(&brief));
|
||||
assert!(full.as_array().unwrap().iter().any(|c| c.get("params").is_some()));
|
||||
assert!(brief.as_array().unwrap().iter().all(|c| c.get("params").is_none() && c.get("label").is_some()));
|
||||
assert!(brief.to_string().len() * 2 < full.to_string().len(), "brief is much shorter");
|
||||
// The other listing takes them too.
|
||||
let r = payload(&call(&mut s, "list_commands", json!({"query":"rectangle","brief":true,"menu":""})));
|
||||
assert_eq!(ids(&r["commands"]), ids(&by_filter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conventions_commands_batch_and_bounded_preview() {
|
||||
let mut s = Server::new(Box::new(Headless::with_document()));
|
||||
|
||||
+42
-12
@@ -102,8 +102,11 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"command_list",
|
||||
"List commands",
|
||||
"Discover command ids, labels, parameter descriptions and enabled state. Returns an array.",
|
||||
obj(json!({"filter": string("Case-insensitive id, label or menu substring"), "enabled_only": {"type":"boolean"}}), &[]),
|
||||
"Discover command ids, labels, parameter descriptions and enabled state. Returns an array. The whole catalogue is large: narrow it with filter (or query), menu, and brief.",
|
||||
obj(
|
||||
json!({"filter": string("Case-insensitive id, label or menu substring"), "query": string("Same as filter"), "menu": string(MENU_FILTER), "enabled_only": {"type":"boolean"}, "brief": {"type":"boolean", "description": BRIEF}}),
|
||||
&[],
|
||||
),
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
@@ -153,7 +156,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
"List commands",
|
||||
"List VectorCraft commands (id, label, menu path, shortcut, parameter description, enabled state). Every editing action is a command; run any of them with run_command. Coordinates are points in document space, y down, origin at the first artboard's top-left.",
|
||||
obj(
|
||||
json!({"filter": string("Case-insensitive substring matched against id, label and menu path (e.g. \"align\", \"Object\")"), "enabledOnly": {"type": "boolean", "description": "Only commands that can run right now"}}),
|
||||
json!({"filter": string("Case-insensitive substring matched against id, label and menu path (e.g. \"align\", \"Object\")"), "query": string("Same as filter"), "menu": string(MENU_FILTER), "enabledOnly": {"type": "boolean", "description": "Only commands that can run right now"}, "brief": {"type": "boolean", "description": BRIEF}}),
|
||||
&[],
|
||||
),
|
||||
true,
|
||||
@@ -838,25 +841,52 @@ fn image_result(png: &[u8], info: Value) -> ToolResult {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `menu` argument of the command listings.
|
||||
const MENU_FILTER: &str = "Case-insensitive substring of the menu path, its levels joined with \" > \" (e.g. \"Object\", \"Object > Path\")";
|
||||
/// The `brief` argument of the command listings.
|
||||
const BRIEF: &str = "Leave out each command's params description (the bulk of the catalogue); run command_list with the id as filter to read it";
|
||||
|
||||
/// The command catalogue narrowed by the listing tools' arguments: `filter` (or `query`) on the
|
||||
/// id, label and menu path, `menu` on the menu path, `enabledOnly`, and `brief` (no `params`
|
||||
/// text: the full catalogue is too large for many clients, #2173).
|
||||
fn filter_commands(all: Value, a: &Args) -> Value {
|
||||
let needle = a.get("filter").and_then(Value::as_str).map(str::to_lowercase).filter(|s| !s.is_empty());
|
||||
let text = |k: &str| a.get(k).and_then(Value::as_str).map(str::to_lowercase).filter(|s| !s.is_empty());
|
||||
let needle = text("filter").or_else(|| text("query"));
|
||||
let in_menu = text("menu");
|
||||
let enabled_only = a.get("enabledOnly").and_then(Value::as_bool).unwrap_or(false);
|
||||
let brief = a.get("brief").and_then(Value::as_bool).unwrap_or(false);
|
||||
let Value::Array(list) = all else { return all };
|
||||
let menu_path = |c: &Value, sep: &str| {
|
||||
c.get("menu")
|
||||
.and_then(Value::as_array)
|
||||
.map(|m| m.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(sep))
|
||||
.unwrap_or_default()
|
||||
.to_lowercase()
|
||||
};
|
||||
let hit = |c: &Value| {
|
||||
if enabled_only && c.get("enabled").and_then(Value::as_bool) == Some(false) {
|
||||
return false;
|
||||
}
|
||||
if let Some(m) = &in_menu
|
||||
&& !menu_path(c, " > ").contains(m.as_str())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Some(n) = &needle else { return true };
|
||||
let field = |k: &str| c.get(k).and_then(Value::as_str).unwrap_or("").to_lowercase();
|
||||
let menu = c
|
||||
.get("menu")
|
||||
.and_then(Value::as_array)
|
||||
.map(|m| m.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(" "))
|
||||
.unwrap_or_default()
|
||||
.to_lowercase();
|
||||
field("id").contains(n) || field("label").contains(n) || menu.contains(n)
|
||||
field("id").contains(n.as_str()) || field("label").contains(n.as_str()) || menu_path(c, " ").contains(n.as_str())
|
||||
};
|
||||
Value::Array(list.into_iter().filter(hit).collect())
|
||||
Value::Array(
|
||||
list.into_iter()
|
||||
.filter(hit)
|
||||
.map(|mut c| {
|
||||
if brief && let Some(o) = c.as_object_mut() {
|
||||
o.remove("params");
|
||||
}
|
||||
c
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<ToolResult, String> {
|
||||
|
||||
@@ -73,14 +73,23 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions) -> Result<ExportR
|
||||
ex.overprint = set.advanced.overprint == crate::Overprint::Preserve;
|
||||
let bleed = set.bleed_of(doc);
|
||||
let marks = set.marks.printer_marks();
|
||||
// In PDF 1.6 and later files that aren't PDF/X files, a page with a side longer than
|
||||
// [`MAX_PAGE_SIDE`] is written in larger units; other files have every page in points.
|
||||
let scales = set.compatibility.has_user_unit() && !set.standard.is_pdfx();
|
||||
let mut units = Vec::with_capacity(pages.len());
|
||||
for (i, boxes) in pages {
|
||||
let (m, r) = (boxes.media, boxes.bleed);
|
||||
let info = if marks.page_info { crate::marks::page_info(doc, &title, i, created_at) } else { String::new() };
|
||||
let art = crate::marks::art(doc, &marks, &boxes, bleed, &info);
|
||||
// The page is the media box, y down from its top-left corner (like ours).
|
||||
let view = Affine::translate((-m.x0, -m.y0));
|
||||
// The page is the media box, y down from its top-left corner (like ours), in units of
|
||||
// `unit` points.
|
||||
let unit = if scales { user_unit(m.width(), m.height()) } else { 1.0 };
|
||||
units.push(unit);
|
||||
let shift = Affine::translate((-m.x0, -m.y0));
|
||||
let view = if unit > 1.0 { Affine::scale(1.0 / unit) * shift } else { shift };
|
||||
let sheet = Sheet {
|
||||
size: (m.width(), m.height()),
|
||||
size: (m.width() / unit, m.height() / unit),
|
||||
unit,
|
||||
trim: view.transform_rect_bbox(boxes.trim),
|
||||
bleed: view.transform_rect_bbox(r),
|
||||
view,
|
||||
@@ -99,6 +108,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions) -> Result<ExportR
|
||||
let (bytes, more) = w.finish()?;
|
||||
let bytes = crate::forms::finish(bytes, doc, set, layers, overprinted)?;
|
||||
warnings.extend(more);
|
||||
let bytes = crate::post::user_units(bytes, &units)?;
|
||||
let bytes = crate::post::finish(bytes, opts, native.is_some(), &mut warnings)?;
|
||||
warnings.dedup();
|
||||
Ok(ExportReport { bytes, warnings })
|
||||
@@ -125,6 +135,18 @@ pub fn page_areas(doc: &Document, opts: &PdfOptions) -> Result<Vec<Rect>, PdfErr
|
||||
Ok(pages(doc, opts)?.into_iter().map(|(_, b)| b.media).collect())
|
||||
}
|
||||
|
||||
/// The largest side of a page in units of its default user space that ISO 32000-1 (Annex C.2)
|
||||
/// gives: 14,400 (200 inches in units of 1/72 inch).
|
||||
pub(crate) const MAX_PAGE_SIDE: f64 = 14_400.0;
|
||||
|
||||
/// The `UserUnit` (PDF 1.6) of a page `w` × `h` points large: 1 when both sides are at most
|
||||
/// [`MAX_PAGE_SIDE`], else the longer side divided by it, rounded up to the next thousandth, so
|
||||
/// that both sides are at most [`MAX_PAGE_SIDE`] units.
|
||||
fn user_unit(w: f64, h: f64) -> f64 {
|
||||
let side = w.abs().max(h.abs());
|
||||
if side.is_finite() && side > MAX_PAGE_SIDE { (side / MAX_PAGE_SIDE * 1000.0).ceil() / 1000.0 } else { 1.0 }
|
||||
}
|
||||
|
||||
/// A PDF being written, shared by export and print: the file's configuration and metadata, then
|
||||
/// its pages ([`Self::page`]), each drawn by an [`Exporter`].
|
||||
pub(crate) struct Writer {
|
||||
@@ -144,8 +166,11 @@ pub(crate) struct Writer {
|
||||
/// One page as [`Writer::page`] draws it: the art placed in a drawing space ([`Self::place`]),
|
||||
/// which [`Self::view`] maps onto the page.
|
||||
pub(crate) struct Sheet<'a> {
|
||||
/// The page's size in points.
|
||||
/// The page's size in its units.
|
||||
pub size: (f64, f64),
|
||||
/// The size of the page's unit in points (its `UserUnit`): a page in points has 1. A side is
|
||||
/// written at least 1 pt long.
|
||||
pub unit: f64,
|
||||
/// The TrimBox and BleedBox, in page space (y down from the top-left corner).
|
||||
pub trim: Rect,
|
||||
pub bleed: Rect,
|
||||
@@ -215,7 +240,8 @@ impl Writer {
|
||||
/// Draw one page: the art of `ex`'s document placed as `sheet` says, then its marks.
|
||||
pub(crate) fn page(&mut self, ex: &mut Exporter, sheet: &Sheet) -> Result<(), PdfError> {
|
||||
let (w, h) = sheet.size;
|
||||
let size = Size::from_wh(w.max(1.0) as f32, h.max(1.0) as f32).ok_or_else(|| PdfError::BadSetting(format!("page size {w} × {h}")))?;
|
||||
let least = 1.0 / sheet.unit;
|
||||
let size = Size::from_wh(w.max(least) as f32, h.max(least) as f32).ok_or_else(|| PdfError::BadSetting(format!("page size {w} × {h}")))?;
|
||||
let at = |b: Rect| krilla::geom::Rect::from_ltrb(b.x0 as f32, b.y0 as f32, b.x1 as f32, b.y1 as f32);
|
||||
let mut page = self.pdf.document_mut().start_page_with(PageSettings::new(size).with_trim_box(at(sheet.trim)).with_bleed_box(at(sheet.bleed)));
|
||||
let mut s = page.surface();
|
||||
|
||||
+18
-1
@@ -2,7 +2,8 @@
|
||||
//! (General › Embed page thumbnails), the seal of the editing data, fast web view (a linearised
|
||||
//! file, [`crate::linearize`]) and encryption ([`crate::encrypt`]), which a linearised file goes
|
||||
//! through between its two steps so that it stays linearised. Also here: what makes a file say it
|
||||
//! is PDF 1.3 ([`declare_pdf13`]), for PDF 1.3 and PDF/X-1a and PDF/X-3 files.
|
||||
//! is PDF 1.3 ([`declare_pdf13`]), for PDF 1.3 and PDF/X-1a and PDF/X-3 files, and the pages'
|
||||
//! user units ([`user_units`]), which the export writes before the thumbnails.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
@@ -60,6 +61,22 @@ fn thumbnails(pdf: Vec<u8>, thumbs: &[Thumbnail], gray: bool) -> Result<Vec<u8>,
|
||||
patch.apply(&pdf, &xref)
|
||||
}
|
||||
|
||||
/// `pdf` with `units` as the `UserUnit` entries of its pages, in page order (a unit of 1 writes
|
||||
/// none); unchanged when every unit is 1.
|
||||
pub(crate) fn user_units(pdf: Vec<u8>, units: &[f64]) -> Result<Vec<u8>, PdfError> {
|
||||
if units.iter().all(|u| *u == 1.0) {
|
||||
return Ok(pdf);
|
||||
}
|
||||
let failed = |why: &str| PdfError::Write(format!("can't write the pages' user units: {why}"));
|
||||
let Parsed { xref, pages, .. } = linearize::parse(&pdf).map_err(failed)?;
|
||||
let mut patch = Patch::new(&xref);
|
||||
for (page, unit) in pages.iter().zip(units).filter(|(_, u)| **u != 1.0) {
|
||||
let (dict, _) = xref.dict(&pdf, *page).ok_or_else(|| failed("a page can't be read"))?;
|
||||
patch.replace(dict, dict, format!("/UserUnit {unit}").into_bytes());
|
||||
}
|
||||
patch.apply(&pdf, &xref)
|
||||
}
|
||||
|
||||
/// Linearise `pdf`, encrypting it on the way as `set` says.
|
||||
fn linearised(pdf: &[u8], set: &PdfSettings) -> Result<Vec<u8>, PdfError> {
|
||||
let (prepared, plan) = linearize::prepare(pdf)?;
|
||||
|
||||
@@ -318,6 +318,7 @@ impl PlannedSheet {
|
||||
fn sheet(&self) -> Sheet<'_> {
|
||||
Sheet {
|
||||
size: self.size,
|
||||
unit: 1.0,
|
||||
trim: self.trim,
|
||||
bleed: self.bleed,
|
||||
view: self.view,
|
||||
|
||||
@@ -109,6 +109,11 @@ impl Compatibility {
|
||||
pub fn has_layers(self) -> bool {
|
||||
matches!(self, Self::Pdf15 | Self::Pdf16 | Self::Pdf17 | Self::Pdf20)
|
||||
}
|
||||
|
||||
/// The version has a page's `UserUnit` (PDF 1.6).
|
||||
pub fn has_user_unit(self) -> bool {
|
||||
matches!(self, Self::Pdf16 | Self::Pdf17 | Self::Pdf20)
|
||||
}
|
||||
}
|
||||
|
||||
choice! {
|
||||
|
||||
@@ -82,6 +82,48 @@ fn bleed_grows_the_media_box_around_the_trim_box() {
|
||||
assert_eq!(page_box(&bytes, CropTo::Trim), Rect::new(3.0, 2.0, 203.0, 102.0));
|
||||
}
|
||||
|
||||
/// Each page's `UserUnit` and media box as the file holds them (in its units).
|
||||
fn units_and_media(bytes: &[u8]) -> Vec<(f32, Rect)> {
|
||||
let file = crate::pages::open(bytes, None).unwrap();
|
||||
file.pages().iter().map(|p| (p.user_unit(), Rect::new(p.media_box().x0, p.media_box().y0, p.media_box().x1, p.media_box().y1))).collect()
|
||||
}
|
||||
|
||||
/// A page with a side longer than ISO 32000-1's 14,400 units is written in larger units (its
|
||||
/// `UserUnit`), so that its sides are 14,400 units at most, and reads back at its size in points.
|
||||
/// Pages up to 14,400 pt, files before PDF 1.6 and PDF/X files keep their sizes in points.
|
||||
#[test]
|
||||
fn a_page_past_14400_pt_is_written_with_a_user_unit_and_reads_back_at_its_size() {
|
||||
let mut d = doc();
|
||||
d.artboards[0].rect = Rect::new(0.0, 0.0, 20_000.0, 10_000.0);
|
||||
let mut small = d.artboards[0].clone();
|
||||
small.rect = Rect::new(0.0, 11_000.0, 14_400.0, 11_300.0);
|
||||
d.artboards.push(small.clone());
|
||||
small.rect = Rect::new(0.0, 12_000.0, 20_000.0, 12_001.0);
|
||||
d.artboards.push(small);
|
||||
let near = |a: Rect, b: Rect| [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 0.01);
|
||||
let bytes = pdf(&d, json!({}));
|
||||
let pages = units_and_media(&bytes);
|
||||
// 20,000 / 14,400 = 1.3889, rounded up to 1.389: the page is 14,398.85 × 7,199.42 units.
|
||||
assert_eq!(pages[0].0, 1.389, "{pages:?}");
|
||||
assert!(pages[0].1.width() <= 14_400.0 && near(pages[0].1.scale_from_origin(1.389), Rect::new(0.0, 0.0, 20_000.0, 10_000.0)), "{pages:?}");
|
||||
assert_eq!(pages[1], (1.0, Rect::new(0.0, 0.0, 14_400.0, 300.0)), "a page of 14,400 pt keeps its points");
|
||||
assert!(pages[2].0 == 1.389 && (pages[2].1.height() * 1.389 - 1.0).abs() < 1e-3, "a side of 1 pt stays 1 pt: {pages:?}");
|
||||
assert_eq!(String::from_utf8_lossy(&bytes).matches("/UserUnit").count(), 2);
|
||||
// Read back in points: the page, its trim box and the square reaching 5 pt left of the page.
|
||||
let page = &info(&bytes, None).unwrap().pages[0];
|
||||
assert!((page.width - 20_000.0).abs() < 0.01 && (page.height - 10_000.0).abs() < 0.01, "{page:?}");
|
||||
assert!(near(page_box(&bytes, CropTo::Trim), Rect::new(0.0, 0.0, 20_000.0, 10_000.0)));
|
||||
let back = import(&bytes).unwrap();
|
||||
assert!(near(back.artboards[0].rect, Rect::new(0.0, 0.0, 20_000.0, 10_000.0)), "{:?}", back.artboards[0].rect);
|
||||
let red = painted(&back, |p| rgb(p, [255, 0, 0]));
|
||||
assert!(red.len() == 1 && near(red[0], Rect::new(-5.0, 10.0, 40.0, 50.0)), "{red:?}");
|
||||
// PDF 1.5 and PDF/X-4 files have the page in points, past 14,400.
|
||||
for v in [json!({"compatibility": "1.5"}), json!({"standard": "pdfX4", "compatibility": "1.6"})] {
|
||||
let pages = units_and_media(&pdf(&d, v.clone()));
|
||||
assert_eq!(pages[0], (1.0, Rect::new(0.0, 0.0, 20_000.0, 10_000.0)), "{v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_in_the_bleed_is_kept_and_clipped_to_the_bleed_box() {
|
||||
let d = doc();
|
||||
|
||||
@@ -268,6 +268,21 @@ fn fonts_whose_licence_forbids_embedding_fall_back_to_outlines() {
|
||||
assert!(paths >= 2, "{paths} outlined words");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_with_outline_object_is_written_as_paths_with_no_font() {
|
||||
let mut d = doc(vec![TextObject::point(Point::new(20.0, 60.0), "Hello World", style(24.0))]);
|
||||
let id = d.layers[0].children().unwrap()[0].id;
|
||||
d.node_mut(id).unwrap().appearance.effects.push(vectorcraft_doc::Effect { id: "path.outlineObject".into(), params: json!({}), visible: true });
|
||||
let r = pdf(&d, false);
|
||||
let text = String::from_utf8_lossy(&r.bytes);
|
||||
assert!(!text.contains("/FontFile") && !text.contains("/Font"), "no font");
|
||||
// Reopened, it is paths, even when type is asked for.
|
||||
let back = import_as(&r.bytes, TextAs::Text);
|
||||
assert!(texts(&back).is_empty());
|
||||
let b = ink(&back);
|
||||
assert!(b.width() > 50.0 && b.height() > 10.0, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subset_thresholds_below_100_percent_are_accepted_with_a_warning() {
|
||||
let d = doc(vec![TextObject::point(Point::new(20.0, 60.0), "Subset", style(24.0))]);
|
||||
|
||||
@@ -37,3 +37,21 @@ fn text_strokes_keep_cap_join_miter_dashes_and_alignment() {
|
||||
// An inside stroke is drawn twice as wide, clipped to the glyphs.
|
||||
assert!(strokes.iter().any(|s| s.dash.is_none() && (s.width - 6.0).abs() < 1e-3), "{strokes:?}");
|
||||
}
|
||||
|
||||
/// Align Stroke on live type: the characters' stroke drawn twice as wide, clipped inside or
|
||||
/// outside the glyphs, as on the canvas; the type stays text.
|
||||
#[test]
|
||||
fn an_aligned_character_stroke_is_clipped_to_its_side_of_the_glyphs() {
|
||||
for (align, clip) in [(StrokeAlign::Inside, false), (StrokeAlign::Outside, true)] {
|
||||
let style = CharStyle { size: 48.0, stroke: Paint::solid(Color::BLACK), stroke_width: 1.5, stroke_align: align, ..Default::default() };
|
||||
let mut d = Document::new(240.0, 100.0);
|
||||
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 70.0), "Wave", style))));
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
let pdf = String::from_utf8_lossy(&export(&d, &PdfOptions::uncompressed()).unwrap()).into_owned();
|
||||
let ops: Vec<&str> = pdf.split_ascii_whitespace().collect();
|
||||
assert!(ops.windows(2).any(|w| w == ["3", "w"]), "{align:?}: twice the weight");
|
||||
assert!(ops.contains(&"BT"), "{align:?}: the type stays text");
|
||||
assert!(ops.contains(&if clip { "W*" } else { "W" }), "{align:?}: clipped to its side");
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ pub mod tga;
|
||||
pub mod tiff;
|
||||
pub mod web;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{Document, Node, NodeKind, SymbolBoxes};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
@@ -191,29 +193,86 @@ pub(crate) fn webp(img: &Rendered) -> Result<Vec<u8>, String> {
|
||||
}
|
||||
|
||||
/// Bounds of the art an export draws: visible objects off template layers (guides left out),
|
||||
/// with their strokes and live effects, symbol instances measured by their art. `None` when
|
||||
/// nothing would be drawn.
|
||||
/// each measured by [`crate::effect_bounds`]: with its strokes and its effects (the effects of
|
||||
/// groups, layers, type and images included), symbol instances by their art, and a Shaper group
|
||||
/// without effects of its own by the art it shows. `None` when nothing would be drawn.
|
||||
pub fn art_bounds(doc: &Document) -> Option<Rect> {
|
||||
let symbols = doc.symbol_boxes();
|
||||
doc.layers.iter().fold(None, |acc, l| vectorcraft_geom::union_opt(acc, drawn_bounds(&symbols, l)))
|
||||
}
|
||||
|
||||
/// Bounds of the art an export draws ([`art_bounds`]) that is on artboard rectangle `rect`: each
|
||||
/// object of a layer or sublayer whose drawn bounds overlap `rect`, with all of its bounds; a group
|
||||
/// or a clipping layer counts as one object. `None` when no object overlaps `rect`.
|
||||
pub fn art_bounds_on(doc: &Document, rect: Rect) -> Option<Rect> {
|
||||
// Art flush with an edge from outside can overlap it by a rounding error (#1295).
|
||||
let rect = rect.inflate(-1e-6, -1e-6);
|
||||
fn on(symbols: &SymbolBoxes, n: &Node, rect: Rect) -> Option<Rect> {
|
||||
match &n.kind {
|
||||
NodeKind::Layer { template: false, clip: false, children, .. } if n.visible => {
|
||||
children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, on(symbols, c, rect)))
|
||||
}
|
||||
_ => drawn_bounds(symbols, n).filter(|b| b.x0 < rect.x1 && b.x1 > rect.x0 && b.y0 < rect.y1 && b.y1 > rect.y0),
|
||||
}
|
||||
}
|
||||
let symbols = doc.symbol_boxes();
|
||||
doc.layers.iter().fold(None, |acc, l| vectorcraft_geom::union_opt(acc, on(&symbols, l, rect)))
|
||||
}
|
||||
|
||||
fn drawn_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
|
||||
if !n.visible {
|
||||
return None;
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. } => None,
|
||||
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
|
||||
children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, drawn_bounds(symbols, c)))
|
||||
}
|
||||
_ if fx::has_fx(n) => fx::visual_bounds(symbols, n),
|
||||
// An instance's box takes in its art, the effects in the art and its own effects.
|
||||
NodeKind::SymbolInstance { .. } => fx::cull_bounds(symbols, n),
|
||||
_ => n.visual_bounds_with(symbols),
|
||||
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } if !fx::has_object_fx(n) => children
|
||||
.iter()
|
||||
.skip(usize::from(n.extras().shaper.is_some()))
|
||||
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, drawn_bounds(symbols, c))),
|
||||
_ => crate::effect_bounds(symbols, without_undrawn(n).as_ref().unwrap_or(n)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `n`, a group or layer that doesn't clip, without the hidden objects, guides and template layers
|
||||
/// in it and in the groups and layers in it that don't clip, or `None` when it holds none of them
|
||||
/// or is anything else. A Shaper group keeps all its members, and so does a group or layer with a
|
||||
/// visible geometry, Pathfinder, Crop Marks or 3D effect, or with a visible effect on one of its
|
||||
/// own visible fills or strokes.
|
||||
fn without_undrawn(n: &Node) -> Option<Node> {
|
||||
use crate::effects;
|
||||
let keeps_all = n.extras().shaper.is_some()
|
||||
|| effects::has_geometry(&n.appearance.effects)
|
||||
|| effects::has_pathfinder(n)
|
||||
|| effects::has_crop_marks(n)
|
||||
|| effects::has_revolve(n)
|
||||
|| effects::has_solid(n)
|
||||
|| n.appearance.items.iter().any(|i| i.visible() && i.effects().iter().any(|e| e.visible));
|
||||
if keeps_all || !matches!(n.kind, NodeKind::Layer { clip: false, .. } | NodeKind::Group { clip: false, .. }) {
|
||||
return None;
|
||||
}
|
||||
let undrawn = |c: &Node| !c.visible || matches!(c.kind, NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. });
|
||||
let mut changed = false;
|
||||
let kept: Vec<Arc<Node>> = n
|
||||
.children()?
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
if undrawn(c) {
|
||||
changed = true;
|
||||
return None;
|
||||
}
|
||||
let part = without_undrawn(c);
|
||||
changed |= part.is_some();
|
||||
Some(part.map_or_else(|| c.clone(), Arc::new))
|
||||
})
|
||||
.collect();
|
||||
if !changed {
|
||||
return None;
|
||||
}
|
||||
let mut out = n.clone();
|
||||
*out.children_mut()? = kept;
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -244,6 +244,87 @@ fn art_bounds_take_in_the_effects_in_a_symbols_art() {
|
||||
assert!(b.x0 <= 20.0 && b.x1 >= 38.0, "the art from x 20 and its shadow to x 38: {b:?}");
|
||||
}
|
||||
|
||||
/// A drop shadow 8 pt to the right, without blur.
|
||||
fn shadow_right() -> vectorcraft_doc::Effect {
|
||||
vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({"x": 8, "y": 0, "blur": 0}), visible: true }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_bounds_take_in_the_effects_of_groups_type_and_images() {
|
||||
let black = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0);
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let square = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 20.0, 20.0)), black);
|
||||
let group = Node::group(d.alloc_id(), vec![std::sync::Arc::new(square)]);
|
||||
let t = TextObject::point(Point::new(10.0, 40.0), "Hill", CharStyle { size: 12.0, ..CharStyle::default() });
|
||||
let text = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
|
||||
let im = vectorcraft_doc::ImageObject {
|
||||
key: "image".into(),
|
||||
width: 30,
|
||||
height: 30,
|
||||
xf: vectorcraft_geom::Affine::translate((20.0, 50.0)),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
};
|
||||
let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
|
||||
let cut_off: Vec<_> = [("group", group), ("type", text), ("image", image)]
|
||||
.into_iter()
|
||||
.filter_map(|(what, mut n)| {
|
||||
let mut d = d.clone();
|
||||
let plain = n.visual_bounds().unwrap();
|
||||
n.appearance.effects.push(shadow_right());
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
let b = art_bounds(&d).unwrap();
|
||||
(b.x0 > plain.x0 || b.x1 < plain.x1 + 8.0).then(|| format!("{what}: {b:?}, the shadow 8 pt right of {plain:?}"))
|
||||
})
|
||||
.collect();
|
||||
assert!(cut_off.is_empty(), "{cut_off:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_bounds_leave_out_the_hidden_members_of_a_group_with_effects() {
|
||||
let black = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0);
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let square = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 20.0, 20.0)), black.clone());
|
||||
let mut hidden = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(200.0, 10.0, 220.0, 20.0)), black.clone());
|
||||
hidden.visible = false;
|
||||
let mut guide = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(-300.0, 10.0, -200.0, 20.0)), black);
|
||||
if let NodeKind::Path { guide: g, .. } = &mut guide.kind {
|
||||
*g = true;
|
||||
}
|
||||
let mut group = Node::group(d.alloc_id(), [square, hidden, guide].map(std::sync::Arc::new).to_vec());
|
||||
// A geometry effect on the group's own fill that is turned off.
|
||||
group.appearance = Appearance::basic(Paint::None, Paint::None, 0.0).into();
|
||||
group.appearance.effects.push(shadow_right());
|
||||
let off = vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"scaleH": 50}), visible: false };
|
||||
group.appearance.fill_mut().unwrap().effects.push(off);
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, group).unwrap();
|
||||
let b = art_bounds(&d).unwrap();
|
||||
assert!(b.x0 >= 0.0 && (28.0..50.0).contains(&b.x1), "the square and its shadow, not the hidden square or the guide: {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_bounds_hold_a_groups_geometry_effect_measured_with_its_hidden_members() {
|
||||
// The Transform effect halves the group's width about the center of all its members, the
|
||||
// hidden one too: the visible rectangle is drawn from x 30 to x 80.
|
||||
let black = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0);
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
let shown = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 10.0, 100.0, 20.0)), black.clone());
|
||||
let mut hidden = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(110.0, 10.0, 120.0, 20.0)), black);
|
||||
hidden.visible = false;
|
||||
let mut group = Node::group(d.alloc_id(), [shown, hidden].map(std::sync::Arc::new).to_vec());
|
||||
let half = serde_json::json!({"scaleH": 50, "scaleV": 100});
|
||||
group.appearance.effects.push(vectorcraft_doc::Effect { id: "distort.transform".into(), params: half, visible: true });
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, group).unwrap();
|
||||
let b = art_bounds(&d).unwrap();
|
||||
assert!(b.x0 <= 30.0 && b.x1 >= 80.0, "{b:?}");
|
||||
let img = Renderer::new().render_region(&d, Rect::new(0.0, 0.0, 200.0, 100.0), 1.0, false);
|
||||
let drawn = |x: u32| (0..100).any(|y| img.pixels[((y * 200 + x) * 4 + 3) as usize] > 0);
|
||||
assert!(drawn(31) && drawn(78) && !drawn(28) && !drawn(82), "the rectangle is drawn from x 30 to x 80");
|
||||
}
|
||||
|
||||
/// A black path round `r` in the first layer of `d`, made a guide when `guide`.
|
||||
fn add_rect(d: &mut Document, r: Rect, guide: bool) {
|
||||
let id = d.alloc_id();
|
||||
|
||||
+15
-5
@@ -67,10 +67,12 @@ pub(crate) fn has_fx(n: &Node) -> bool {
|
||||
}
|
||||
|
||||
/// Does `n` carry visible object-level effects that only apply through its art (type, images,
|
||||
/// symbol instances, live objects; Crop Marks and colour adjustments on anything), or is it a group
|
||||
/// or layer with an appearance of its own (see [`Renderer::draw_object_fx`])?
|
||||
/// symbol instances, live objects; Crop Marks and colour adjustments on anything), is it type with
|
||||
/// a geometry effect on a fill or stroke of its own ([`effects::has_item_geometry`]), or is it a
|
||||
/// group or layer with an appearance of its own (see [`Renderer::draw_object_fx`])?
|
||||
pub(crate) fn has_object_fx(n: &Node) -> bool {
|
||||
(effects::needs_outline(n) && (visible(&n.appearance.effects) || n.projection().is_some()))
|
||||
|| effects::has_item_geometry(n)
|
||||
|| effects::has_container_appearance(n)
|
||||
|| effects::has_crop_marks(n)
|
||||
|| effects::has_adjustment(n)
|
||||
@@ -212,15 +214,23 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Optio
|
||||
children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, inner(c)))
|
||||
}
|
||||
_ if has_fx(n) => visual_bounds(symbols, n),
|
||||
// The reshaped art (a symbol instance's reshaped art is made from its symbol's art), grown
|
||||
// by how far the effects in a symbol instance's art paint past that art.
|
||||
// The reshaped art (a symbol instance's reshaped art is made from its symbol's art; type's
|
||||
// outlines measured one by one with the geometry effects of its own fills and strokes),
|
||||
// grown by how far the effects in a symbol instance's art paint past that art.
|
||||
_ if has_object_fx(n) => {
|
||||
let symbol = match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, .. } => symbols.document().symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let reshaped = effects::reshape(n, symbol.as_deref());
|
||||
let b = reshaped.as_ref().and_then(|r| r.visual_bounds_with(symbols)).or_else(|| n.visual_bounds_with(symbols))?;
|
||||
let b = match &reshaped {
|
||||
Some(r) if matches!(n.kind, NodeKind::Text(_)) => {
|
||||
r.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, inner(c)))
|
||||
}
|
||||
Some(r) => r.visual_bounds_with(symbols),
|
||||
None => None,
|
||||
}
|
||||
.or_else(|| n.visual_bounds_with(symbols))?;
|
||||
let a = match &reshaped {
|
||||
Some(r) if after_reshape => reshaped_art_effects_reach(symbols, n, r),
|
||||
_ => art_effects_reach(symbols, n),
|
||||
|
||||
@@ -81,3 +81,25 @@ fn character_and_object_strokes_on_type_are_dashed() {
|
||||
let p = half.pixel(x0, (y0 + y1) / 2);
|
||||
assert!(p[0] > 90 && p[0] < 200, "a half-opaque stroke is grey: {p:?}");
|
||||
}
|
||||
|
||||
/// Align Stroke on live type: an outside character stroke paints its whole weight around the
|
||||
/// glyphs and leaves them unpainted, an inside one stays within them, as on paths.
|
||||
#[test]
|
||||
fn aligned_character_strokes_keep_to_their_side_of_the_glyphs() {
|
||||
use vectorcraft_doc::StrokeAlign;
|
||||
let glyph = ink(&render_text("I", CharStyle::default(), vec![]));
|
||||
let reach = |b: (u32, u32, u32, u32)| {
|
||||
let d = |a: u32, b: u32| a as i64 - b as i64;
|
||||
d(glyph.0, b.0).max(d(glyph.1, b.1)).max(d(b.2, glyph.2)).max(d(b.3, glyph.3))
|
||||
};
|
||||
let style = |align| CharStyle { fill: Paint::None, stroke_align: align, ..stroked(LineJoin::Round) };
|
||||
let centre = render_text("I", style(StrokeAlign::Center), vec![]);
|
||||
let outside = render_text("I", style(StrokeAlign::Outside), vec![]);
|
||||
let inside = render_text("I", style(StrokeAlign::Inside), vec![]);
|
||||
assert!((7..=9).contains(&reach(ink(¢re))), "centred: half the weight past the glyph, {:?}", ink(¢re));
|
||||
assert!((15..=17).contains(&reach(ink(&outside))), "outside: the whole weight past the glyph, {:?}", ink(&outside));
|
||||
assert!(reach(ink(&inside)) <= 0, "inside: within the glyph, {:?}", ink(&inside));
|
||||
let mid = ((glyph.0 + glyph.2) / 2, (glyph.1 + glyph.3) / 2);
|
||||
assert!(!dark(&outside, mid.0, mid.1), "an outside stroke leaves the glyph itself unpainted");
|
||||
assert!(dark(&inside, mid.0, mid.1), "an inside stroke paints into the glyph");
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! The appearance of groups, layers and type: a container's own fills and strokes paint its
|
||||
//! members' geometry below or above them (the contents slot), its effects apply to the composite
|
||||
//! (one combined shadow) and reshape its members as one piece; type's own fills paint under or
|
||||
//! over its characters.
|
||||
//! (one combined shadow) and reshape its members as one piece; type's own fills and strokes paint
|
||||
//! under or over its characters, reshaped by their geometry effects.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -292,3 +292,59 @@ fn a_fill_below_the_characters_is_hidden_by_the_glyphs() {
|
||||
assert!(red_pixels(&render(&type_doc(false))) > 50, "above: the red fill covers the glyphs");
|
||||
assert_eq!(red_pixels(&render(&type_doc(true))), 0, "below: the black glyphs cover it");
|
||||
}
|
||||
|
||||
/// "IIII" in black at 40 pt with its own red 2 pt stroke under the characters. The stroke carries
|
||||
/// the effects `row`, and the type carries the effects `object`.
|
||||
fn stroked_type_doc(row: &[Effect], object: &[Effect]) -> Document {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let style = CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
|
||||
let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(5.0, 60.0), "IIII", style))));
|
||||
let mut stroke = StrokeLayer::new(solid(1.0, 0.0, 0.0), 2.0);
|
||||
stroke.effects = row.to_vec();
|
||||
n.appearance.items.push(AppearanceItem::Stroke(stroke));
|
||||
n.appearance.set_contents_at(1);
|
||||
n.appearance.effects = object.to_vec();
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// The red pixels more than 2 px outside the box of the black ones.
|
||||
fn red_past_the_glyphs(img: &Rendered) -> usize {
|
||||
let all = || (0..100).flat_map(|y| (0..100).map(move |x| (x, y)));
|
||||
let black: Vec<(u32, u32)> = all().filter(|&(x, y)| img.pixel(x, y)[..3].iter().all(|c| *c < 60)).collect();
|
||||
let (x0, x1) = (black.iter().map(|p| p.0).min().unwrap(), black.iter().map(|p| p.0).max().unwrap());
|
||||
let (y0, y1) = (black.iter().map(|p| p.1).min().unwrap(), black.iter().map(|p| p.1).max().unwrap());
|
||||
let past = |x: u32, y: u32| x + 2 < x0 || x > x1 + 2 || y + 2 < y0 || y > y1 + 2;
|
||||
all().filter(|&(x, y)| past(x, y) && matches!(img.pixel(x, y), [r, g, _, _] if r > 200 && g < 60)).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_offset_path_on_the_types_own_stroke_draws_the_offset_outline() {
|
||||
assert_eq!(red_past_the_glyphs(&render(&stroked_type_doc(&[], &[]))), 0, "the stroke without the effect stays at the glyphs");
|
||||
let offset = [fx("path.offsetPath", json!({"offset": 4}))];
|
||||
assert!(red_past_the_glyphs(&render(&stroked_type_doc(&offset, &[]))) > 50, "the stroke runs 4 pt outside the glyphs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shadow_on_the_type_keeps_the_offset_outline_of_its_own_stroke() {
|
||||
let offset = [fx("path.offsetPath", json!({"offset": 4}))];
|
||||
let shadow = [fx("stylize.dropShadow", json!({"x": 0, "y": 30, "blur": 0, "opacity": 50, "mode": "normal"}))];
|
||||
assert!(red_past_the_glyphs(&render(&stroked_type_doc(&offset, &shadow))) > 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_offset_outline_of_the_types_own_stroke_is_drawn_when_only_it_is_in_view() {
|
||||
// The view's right edge is 5 pt left of the glyphs, so only the stroke 8 pt outside them
|
||||
// reaches into it. The second case also gives the type object Offset Path 0, which leaves its
|
||||
// outlines as they are.
|
||||
let offset = [fx("path.offsetPath", json!({"offset": 8}))];
|
||||
for object in [vec![], vec![fx("path.offsetPath", json!({"offset": 0}))]] {
|
||||
let d = stroked_type_doc(&offset, &object);
|
||||
let glyphs = effects::outline_text(&d.layers[0].children().unwrap()[0]).unwrap().geometric_bounds().unwrap();
|
||||
let view = Affine::translate((105.0 - glyphs.x0, 0.0));
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let img = Renderer::new().render(&d, 100, 100, view, &opts);
|
||||
assert!(red_pixels(&img) > 20, "{} object effects: {} red pixels", object.len(), red_pixels(&img));
|
||||
}
|
||||
}
|
||||
@@ -602,6 +602,83 @@ fn geometry_effects_reshape_type_through_its_outlines() {
|
||||
assert!(text == 0 && black > 0, "{art:?}");
|
||||
}
|
||||
|
||||
/// Pixels that change by more than 8 levels in a channel when the first object of `d` gets
|
||||
/// Outline Object.
|
||||
fn changed_by_outline_object(d: &Document) -> usize {
|
||||
let mut outlined = d.clone();
|
||||
let id = outlined.layers[0].children().unwrap()[0].id;
|
||||
outlined.node_mut(id).unwrap().appearance.effects.push(fx("path.outlineObject", json!({})));
|
||||
differing(&render(d), &render(&outlined))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_object_draws_type_as_its_outlines_and_leaves_its_look() {
|
||||
// The type is drawn through its outlines: plain paths painted with the text's fill.
|
||||
let d = text_doc(vec![fx("path.outlineObject", json!({}))]);
|
||||
let n = &d.layers[0].children().unwrap()[0];
|
||||
let art = vectorcraft_effects::reshape(n, None).unwrap();
|
||||
let (mut text, mut black) = (0, 0);
|
||||
art.walk(&mut |m| {
|
||||
text += usize::from(matches!(m.kind, NodeKind::Text(_)));
|
||||
black += usize::from(m.appearance.fill_paint() == Paint::solid(Color::BLACK));
|
||||
});
|
||||
assert!(text == 0 && black > 0, "{art:?}");
|
||||
// Plain, underlined and scaled stroked type, and 50 % type in a group that carries the effect,
|
||||
// look the same with it.
|
||||
let underlined = CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), underline: true, ..Default::default() };
|
||||
let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
|
||||
let stroked = CharStyle { size: 20.0, fill: Paint::solid(Color::BLACK), stroke: red, stroke_width: 2.0, ..Default::default() };
|
||||
let mut t = text_doc(vec![]).layers[0].children().unwrap()[0].as_ref().clone();
|
||||
t.id = NodeId(500);
|
||||
t.opacity = 0.5;
|
||||
let docs = [
|
||||
text_doc(vec![]),
|
||||
styled_text_doc(underlined, Affine::IDENTITY),
|
||||
styled_text_doc(stroked, Affine::scale(2.0)),
|
||||
doc_with(Document::new(100.0, 100.0), Node::group(NodeId(0), vec![Arc::new(t)])),
|
||||
];
|
||||
let changed: Vec<usize> = docs.iter().map(changed_by_outline_object).collect();
|
||||
assert!(changed.iter().all(|&n| n < 10), "pixels changed: {changed:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_object_leaves_paths_and_images_as_they_are() {
|
||||
let ap = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::solid(Color::BLACK), 4.0);
|
||||
let path = doc_with(Document::new(100.0, 100.0), Node::path(NodeId(0), shapes::ellipse(Rect::new(20.0, 20.0, 80.0, 70.0)), ap));
|
||||
let changed: Vec<usize> = [path, image_doc(vec![])].iter().map(changed_by_outline_object).collect();
|
||||
assert!(changed.iter().all(|&n| n < 10), "pixels changed: {changed:?}");
|
||||
// The image is drawn through its frame, as under any geometry effect.
|
||||
let d = image_doc(vec![fx("path.outlineObject", json!({}))]);
|
||||
assert!(vectorcraft_effects::reshape(&d.layers[0].children().unwrap()[0], None).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rectangle_on_a_fill_below_type_with_outline_object_fits_the_glyphs() {
|
||||
// "IIII" with a red fill of its own below the characters that carries Convert to Shape ›
|
||||
// Rectangle (the default 18 pt extra width and height).
|
||||
let plain = render(&text_doc(vec![]));
|
||||
let inked = inked_columns(&plain);
|
||||
let gap = (inked[0]..*inked.last().unwrap()).find(|x| !inked.contains(x)).unwrap();
|
||||
let mut d = text_doc(vec![fx("path.outlineObject", json!({}))]);
|
||||
let id = d.layers[0].children().unwrap()[0].id;
|
||||
let mut fill = FillLayer::new(Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
|
||||
fill.effects.push(fx("convertToShape.rectangle", json!({})));
|
||||
let ap = &mut d.node_mut(id).unwrap().appearance;
|
||||
ap.items.push(AppearanceItem::Fill(fill));
|
||||
ap.set_contents_at(1);
|
||||
let img = render(&d);
|
||||
// The rectangle is under the glyphs and fills the gap between two of them.
|
||||
assert_eq!(img.pixel(gap, 50), [255, 0, 0, 255], "column {gap}");
|
||||
assert_eq!(lum(img.pixel(inked[0] + 1, 50)), 0);
|
||||
// It reaches 9 pt past the glyph outlines on each side.
|
||||
let glyphs = vectorcraft_effects::outline_text(d.node(id).unwrap()).unwrap().geometric_bounds().unwrap();
|
||||
let (x, y) = (glyphs.center().x as u32, glyphs.center().y as u32);
|
||||
assert_eq!(img.pixel(glyphs.x1 as u32 + 6, y), [255, 0, 0, 255]);
|
||||
assert_eq!(lum(img.pixel(glyphs.x1 as u32 + 12, y)), WHITE);
|
||||
assert_eq!(img.pixel(x, glyphs.y1 as u32 + 6), [255, 0, 0, 255]);
|
||||
assert_eq!(lum(img.pixel(x, glyphs.y1 as u32 + 12)), WHITE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_strokes_own_glow_paints_around_that_stroke_only() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
|
||||
@@ -1716,7 +1716,9 @@ impl Writer<'_> {
|
||||
let saved = std::mem::replace(&mut self.xf, to_user);
|
||||
let fill = self.paint(&st.fill, Some(bounds));
|
||||
p.push(("fill", fill));
|
||||
if st.has_stroke() {
|
||||
// An inside or outside stroke is drawn on the glyph outlines instead
|
||||
// ([`Self::aligned_char_strokes`]): SVG strokes text centred only.
|
||||
if st.has_stroke() && !aligned(st) {
|
||||
// Weight, cap, join, miter limit and dashes as for object strokes; a gradient spans
|
||||
// the text grown by half the weight, as on the canvas.
|
||||
let layer = st.stroke_layer();
|
||||
@@ -1744,7 +1746,10 @@ impl Writer<'_> {
|
||||
let outlined = self.opts.outline_text || t.vertical || (inline && matches!(t.kind, TextKind::OnPath { .. }));
|
||||
let chars = |w: &mut Self, n: &Node| if outlined { w.text_outlines(n, t) } else { w.text(n, t) };
|
||||
let painted = n.appearance.items.iter().any(|i| i.visible() && !i.paint().is_none());
|
||||
if !painted && !inline {
|
||||
// Live type with an inside or outside character stroke writes that stroke apart from the
|
||||
// text, in the group.
|
||||
let apart = !outlined && t.runs.iter().any(|r| aligned(&r.style));
|
||||
if !painted && !inline && !apart {
|
||||
return chars(self, n);
|
||||
}
|
||||
let id = self.id_attr(n);
|
||||
@@ -1898,6 +1903,40 @@ impl Writer<'_> {
|
||||
}
|
||||
s.push_str("</text>");
|
||||
self.line(&s);
|
||||
self.aligned_char_strokes(t);
|
||||
}
|
||||
|
||||
/// The inside and outside character strokes of live type `t`, over its text (which goes
|
||||
/// without them, see [`Self::char_props`]): each such run's stroke on its glyph outlines, drawn
|
||||
/// twice as wide and clipped or masked to its side as aligned path strokes are
|
||||
/// ([`Self::side_attr`]). The text stays live; only the stroke follows the outlines.
|
||||
fn aligned_char_strokes(&mut self, t: &TextObject) {
|
||||
if !t.runs.iter().any(|r| aligned(&r.style)) {
|
||||
return;
|
||||
}
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let lay = vectorcraft_text::layout(db, t);
|
||||
let bars = vectorcraft_text::decorations(&lay, db, t);
|
||||
// The glyphs stay in text space; the text's transform joins the document's meanwhile.
|
||||
let saved = self.xf;
|
||||
self.xf = saved * t.xf;
|
||||
for (i, run) in t.runs.iter().enumerate().filter(|(_, r)| aligned(&r.style)) {
|
||||
let mut bp = BezPath::new();
|
||||
for g in lay.glyphs.iter().filter(|g| g.run == i) {
|
||||
bp.extend(g.outline.iter());
|
||||
}
|
||||
for (_, bar) in bars.iter().filter(|(r, _)| *r == i) {
|
||||
bp.extend(bar.iter());
|
||||
}
|
||||
if bp.elements().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let pd = PathData::from_bezpath(&bp);
|
||||
let ap = vectorcraft_doc::Appearance { items: vec![AppearanceItem::Stroke(run.style.stroke_layer())], ..Default::default() };
|
||||
let d = self.path_d(&pd, self.xf);
|
||||
self.shape(&Node::path(NodeId(u64::MAX), pd.clone(), ap), &d, &[&pd], FillRule::NonZero, Some(lay.bounds));
|
||||
}
|
||||
self.xf = saved;
|
||||
}
|
||||
|
||||
/// One line of laid-out text starting at `start`. Default: a positioned `<tspan>` per segment
|
||||
@@ -2046,9 +2085,15 @@ impl Writer<'_> {
|
||||
}
|
||||
s.push_str("</textPath></text>");
|
||||
self.line(&s);
|
||||
self.aligned_char_strokes(t);
|
||||
}
|
||||
}
|
||||
|
||||
/// Does a character style stroke inside or outside its glyphs (not centred)?
|
||||
fn aligned(st: &CharStyle) -> bool {
|
||||
st.has_stroke() && st.stroke_align != StrokeAlign::Center
|
||||
}
|
||||
|
||||
/// What the `<tspan>` of a run whose properties are `run` sets over its `<text>`'s `base`: the
|
||||
/// properties that differ, and the initial value of each one `base` sets and the run doesn't.
|
||||
fn run_diff(base: &Props, run: Props) -> Props {
|
||||
|
||||
@@ -78,3 +78,27 @@ fn a_run_without_the_first_runs_stroke_resets_it() {
|
||||
);
|
||||
assert!(svg.contains("stroke-linejoin=\"round\"") && svg.contains("stroke-dasharray=\"3 1\""), "{svg}");
|
||||
}
|
||||
|
||||
/// Align Stroke on live type: the text stays live (filled, without the stroke) and the aligned
|
||||
/// stroke is drawn on the glyph outlines, twice as wide and clipped (inside) or masked (outside),
|
||||
/// as aligned strokes on paths are; outlined type carries it on its glyphs.
|
||||
#[test]
|
||||
fn an_aligned_character_stroke_keeps_the_text_live() {
|
||||
use vectorcraft_doc::StrokeAlign;
|
||||
for (align, side) in [(StrokeAlign::Outside, "mask=\"url(#"), (StrokeAlign::Inside, "clip-path=\"url(#")] {
|
||||
let style = CharStyle { size: 40.0, stroke: Paint::solid(Color::BLACK), stroke_width: 2.0, stroke_align: align, ..Default::default() };
|
||||
let mut t = TextObject::point(Point::new(20.0, 100.0), "Live", style);
|
||||
t.runs.push(TextRun { text: " plain".into(), style: CharStyle { size: 40.0, ..stroked() }, inline: None });
|
||||
for outline_text in [false, true] {
|
||||
let svg = export(&text_doc(t.clone(), vec![]), &ExportOptions { outline_text, ..Default::default() });
|
||||
assert_eq!(svg.contains("<text"), !outline_text, "{svg}");
|
||||
assert!(svg.contains("stroke-width=\"4\"") && svg.contains(side), "{align:?} {outline_text}: {svg}");
|
||||
if !outline_text {
|
||||
let text = &svg[svg.find("<text").unwrap()..svg.find("</text>").unwrap()];
|
||||
assert!(!text.contains("stroke-width=\"4\""), "the live text doesn't stroke the aligned run: {text}");
|
||||
assert!(text.contains("stroke-width=\"2\""), "a centred run keeps its stroke on the text: {text}");
|
||||
assert!(svg[svg.find("</text>").unwrap()..].contains("stroke-width=\"4\""), "the aligned stroke lies over the text");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
-12
@@ -56,6 +56,9 @@ pub(crate) struct Params {
|
||||
/// Lines from this index on all have the same width, so their line numbers are interchangeable
|
||||
/// (keeps the number of active breakpoints small).
|
||||
pub uniform_from: usize,
|
||||
/// Hyphen Limit: the most consecutive lines that may end with a hyphen before a hyphenation
|
||||
/// point is no longer a break; 0 sets no limit. A soft hyphen stays a break and counts.
|
||||
pub hyphen_limit: usize,
|
||||
}
|
||||
|
||||
struct Node {
|
||||
@@ -64,7 +67,9 @@ struct Node {
|
||||
line: usize,
|
||||
fitness: u8,
|
||||
demerits: f64,
|
||||
hyphenated: bool,
|
||||
/// Lines in a row ending with a hyphen up to this break (at most `hyphen_limit`, or 1 with no
|
||||
/// limit); 0 when this break has no hyphen.
|
||||
hyphens: usize,
|
||||
prev: Option<usize>,
|
||||
}
|
||||
|
||||
@@ -120,11 +125,14 @@ pub(crate) fn compose(
|
||||
all.push(Breakpoint { end: n, hyphen: 0.0 });
|
||||
let (adj_demerits, hyphen_penalty) =
|
||||
if p.ragged { (ADJ_DEMERITS_RAGGED, HYPHEN_PENALTY_RAGGED) } else { (ADJ_DEMERITS_JUSTIFIED, HYPHEN_PENALTY) };
|
||||
let mut nodes = vec![Node { pos: 0, line: 0, fitness: 1, demerits: 0.0, hyphenated: false, prev: None }];
|
||||
let mut nodes = vec![Node { pos: 0, line: 0, fitness: 1, demerits: 0.0, hyphens: 0, prev: None }];
|
||||
let mut active: Vec<usize> = vec![0];
|
||||
for bp in &all {
|
||||
let last = bp.end == n;
|
||||
let mut best: Vec<(usize, u8, f64, usize)> = vec![]; // (line, fitness, demerits, from)
|
||||
let hyphen = bp.hyphen > 0.0;
|
||||
// `automatic` is true for a hyphenation point and false for a soft hyphen typed in the text.
|
||||
let automatic = hyphen && !bp.end.checked_sub(1).and_then(|k| g.get(k)).is_some_and(SGlyph::is_soft_hyphen);
|
||||
let mut best: Vec<(usize, u8, usize, f64, usize)> = vec![]; // (line, fitness, hyphens, demerits, from)
|
||||
let mut keep = Vec::with_capacity(active.len());
|
||||
for &ai in &active {
|
||||
let Some(a) = nodes.get(ai) else { continue };
|
||||
@@ -164,14 +172,17 @@ pub(crate) fn compose(
|
||||
continue;
|
||||
}
|
||||
keep.push(ai);
|
||||
if automatic && p.hyphen_limit > 0 && a.hyphens >= p.hyphen_limit {
|
||||
continue;
|
||||
}
|
||||
let free_last = last && (p.ragged || !p.justify_last);
|
||||
if ratio > p.tolerance && !(last && (p.ragged || (ratio.is_infinite() && p.justify_last))) {
|
||||
continue;
|
||||
}
|
||||
let badness = if ratio.is_finite() { (100.0 * ratio.abs().powi(3)).min(10_000.0) } else { 10_000.0 };
|
||||
let penalty = if bp.hyphen > 0.0 { hyphen_penalty } else { 0.0 };
|
||||
let penalty = if hyphen { hyphen_penalty } else { 0.0 };
|
||||
let mut d = (LINE_PENALTY + badness).powi(2) + penalty * penalty;
|
||||
if bp.hyphen > 0.0 && a.hyphenated {
|
||||
if hyphen && a.hyphens > 0 {
|
||||
d += DOUBLE_HYPHEN_DEMERITS;
|
||||
}
|
||||
// A ragged last line's length says nothing about the rag: it keeps its neighbour's class.
|
||||
@@ -181,15 +192,21 @@ pub(crate) fn compose(
|
||||
}
|
||||
let total = a.demerits + d;
|
||||
let line = (a.line + 1).min(p.uniform_from);
|
||||
match best.iter_mut().find(|b| b.0 == line && b.1 == fit) {
|
||||
Some(b) if b.2 <= total => {}
|
||||
Some(b) => *b = (line, fit, total, ai),
|
||||
None => best.push((line, fit, total, ai)),
|
||||
// With a limit, breaks reached with different runs of hyphenated lines stay apart.
|
||||
let hyphens = match (hyphen, p.hyphen_limit) {
|
||||
(false, _) => 0,
|
||||
(true, 0) => 1,
|
||||
(true, limit) => (a.hyphens + 1).min(limit),
|
||||
};
|
||||
match best.iter_mut().find(|b| b.0 == line && b.1 == fit && b.2 == hyphens) {
|
||||
Some(b) if b.3 <= total => {}
|
||||
Some(b) => *b = (line, fit, hyphens, total, ai),
|
||||
None => best.push((line, fit, hyphens, total, ai)),
|
||||
}
|
||||
}
|
||||
active = keep;
|
||||
for (line, fitness, demerits, from) in best {
|
||||
nodes.push(Node { pos: bp.end, line, fitness, demerits, hyphenated: bp.hyphen > 0.0, prev: Some(from) });
|
||||
for (line, fitness, hyphens, demerits, from) in best {
|
||||
nodes.push(Node { pos: bp.end, line, fitness, demerits, hyphens, prev: Some(from) });
|
||||
active.push(nodes.len() - 1);
|
||||
}
|
||||
if active.is_empty() || active.len() > MAX_ACTIVE {
|
||||
@@ -202,7 +219,7 @@ pub(crate) fn compose(
|
||||
while let Some(i) = cur {
|
||||
let nd = nodes.get(i)?;
|
||||
if nd.prev.is_some() {
|
||||
out.push((nd.pos, nd.hyphenated));
|
||||
out.push((nd.pos, nd.hyphens > 0));
|
||||
}
|
||||
cur = nd.prev;
|
||||
if out.len() > n {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Pattern-free English hyphenation: break long words between syllables at vowel–consonant
|
||||
//! boundaries (V-CV: "ty-po", and VC-CV: "hap-pen"), never leaving fewer than [`MIN_BEFORE`]
|
||||
//! letters before or [`MIN_AFTER`] after the hyphen.
|
||||
//! boundaries (V-CV: "ty-po", and VC-CV: "hap-pen"). A paragraph's Hyphenation options
|
||||
//! ([`HyphenationOptions`]) set the shortest word that breaks, the fewest letters left before and
|
||||
//! after the hyphen ([`MIN_BEFORE`] and [`MIN_AFTER`] by default), and whether capitalized words
|
||||
//! break.
|
||||
//!
|
||||
//! Preferences › Hyphenation › Exceptions (comma separated) override the points for named words:
|
||||
//! `typography` never breaks, `ty-pog-ra-phy` breaks only where the hyphens mark. See
|
||||
@@ -9,6 +11,8 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, MutexGuard, OnceLock};
|
||||
|
||||
use vectorcraft_doc::HyphenationOptions;
|
||||
|
||||
/// Only words at least this long are hyphenated (Illustrator's default: longer than 5 letters).
|
||||
pub const MIN_WORD: usize = 6;
|
||||
pub const MIN_BEFORE: usize = 3;
|
||||
@@ -120,10 +124,19 @@ fn onset_pair(a: char, b: char) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// Allowed hyphenation points in `word`, as char indices (a hyphen goes *before* the char).
|
||||
/// Words containing non-letters (other than a trailing apostrophe) are not hyphenated.
|
||||
/// Preferences exceptions override the pattern for a matching word.
|
||||
/// Allowed hyphenation points in `word`, as char indices (a hyphen goes *before* the char), with
|
||||
/// the default Hyphenation options.
|
||||
pub fn hyphen_points(word: &str) -> Vec<usize> {
|
||||
hyphen_points_with(word, &HyphenationOptions::default())
|
||||
}
|
||||
|
||||
/// Allowed hyphenation points in `word`, as char indices (a hyphen goes *before* the char), within
|
||||
/// a paragraph's Hyphenation options: words with more letters than Words Longer Than, at least
|
||||
/// After First letters before the hyphen and Before Last after it (at least 1 each), and no word
|
||||
/// that starts with a capital when Hyphenate Capitalized Words is off. Words containing non-letters
|
||||
/// (other than a trailing apostrophe) are not hyphenated. Preferences exceptions override the
|
||||
/// pattern and these options for a matching word.
|
||||
pub fn hyphen_points_with(word: &str, opts: &HyphenationOptions) -> Vec<usize> {
|
||||
if let Some(ex) = exception_for(word) {
|
||||
return match ex {
|
||||
Exception::Never => vec![],
|
||||
@@ -132,16 +145,22 @@ pub fn hyphen_points(word: &str) -> Vec<usize> {
|
||||
}
|
||||
let c: Vec<char> = word.chars().collect();
|
||||
let n = c.len();
|
||||
if n < MIN_WORD || !c.iter().all(|c| c.is_alphabetic() || matches!(c, '\'' | '’')) {
|
||||
let longer_than = usize::try_from(opts.words_longer_than).unwrap_or(usize::MAX);
|
||||
if n <= longer_than || !c.iter().all(|c| c.is_alphabetic() || matches!(c, '\'' | '’')) {
|
||||
return vec![];
|
||||
}
|
||||
// A capitalised run like "NASA" or a proper name stays whole only when all caps.
|
||||
if c.iter().all(|c| c.is_uppercase()) {
|
||||
return vec![];
|
||||
}
|
||||
if !opts.capitalized && c.first().is_some_and(|c| c.is_uppercase()) {
|
||||
return vec![];
|
||||
}
|
||||
let before = usize::try_from(opts.after_first).unwrap_or(usize::MAX).max(1);
|
||||
let after = usize::try_from(opts.before_last).unwrap_or(usize::MAX).max(1);
|
||||
let v: Vec<bool> = c.iter().map(|&x| is_vowel(x)).collect();
|
||||
let mut out = vec![];
|
||||
for i in MIN_BEFORE..=n.saturating_sub(MIN_AFTER) {
|
||||
for i in before..=n.saturating_sub(after) {
|
||||
if !c[i].is_alphabetic() || !c[i - 1].is_alphabetic() {
|
||||
continue;
|
||||
}
|
||||
@@ -224,6 +243,43 @@ mod tests {
|
||||
assert_eq!(hyphen_points("typography"), pattern, "override cleared");
|
||||
}
|
||||
|
||||
/// The default Hyphenation options are the limits of [`hyphen_points`]: words of 6 letters or
|
||||
/// more, 3 letters on each side of the hyphen.
|
||||
#[test]
|
||||
fn default_hyphenation_options_keep_the_default_limits() {
|
||||
let d = HyphenationOptions::default();
|
||||
assert_eq!((d.words_longer_than as usize + 1, d.after_first as usize, d.before_last as usize), (MIN_WORD, MIN_BEFORE, MIN_AFTER));
|
||||
for w in ["typography", "happen", "hyphenation", "language", "aromatic", "Language", "short"] {
|
||||
assert_eq!(hyphen_points_with(w, &d), hyphen_points(w), "{w}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Paragraph panel menu › Hyphenation…: Words Longer Than, After First, Before Last and
|
||||
/// Hyphenate Capitalized Words limit where a word breaks.
|
||||
#[test]
|
||||
fn hyphenation_options_limit_the_points() {
|
||||
let opts = |f: &dyn Fn(&mut HyphenationOptions)| {
|
||||
let mut o = HyphenationOptions::default();
|
||||
f(&mut o);
|
||||
o
|
||||
};
|
||||
// An 8-letter word breaks by default, and not when only longer words may.
|
||||
assert_eq!(hyphen_points("language"), vec![3]);
|
||||
assert!(hyphen_points_with("language", &opts(&|o| o.words_longer_than = 10)).is_empty());
|
||||
assert!(hyphen_points_with("language", &opts(&|o| o.words_longer_than = 8)).is_empty());
|
||||
assert_eq!(hyphen_points_with("language", &opts(&|o| o.words_longer_than = 7)), vec![3]);
|
||||
// After First and Before Last: "aro-ma-tic" by default, nothing with 4 and 4, "a-ro-ma-tic" with 1 and 1.
|
||||
assert_eq!(hyphen_points("aromatic"), vec![3, 5]);
|
||||
assert!(hyphen_points_with("aromatic", &opts(&|o| (o.after_first, o.before_last) = (4, 4))).is_empty());
|
||||
assert_eq!(hyphen_points_with("aromatic", &opts(&|o| (o.after_first, o.before_last) = (1, 1))), vec![1, 3, 5]);
|
||||
// 0 letters on either side still leaves the hyphen inside the word.
|
||||
assert_eq!(hyphen_points_with("aromatic", &opts(&|o| (o.after_first, o.before_last) = (0, 0))), vec![1, 3, 5]);
|
||||
// Capitalized words.
|
||||
assert_eq!(hyphen_points("Language"), vec![3]);
|
||||
assert!(hyphen_points_with("Language", &opts(&|o| o.capitalized = false)).is_empty());
|
||||
assert_eq!(hyphen_points_with("language", &opts(&|o| o.capitalized = false)), vec![3]);
|
||||
}
|
||||
|
||||
/// Process-wide store used by `prefs.set` / restore.
|
||||
#[test]
|
||||
fn set_hyphenation_exceptions_round_trips_the_source_string() {
|
||||
|
||||
+56
-20
@@ -5,13 +5,14 @@ use std::ops::Range;
|
||||
use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape, Vec2};
|
||||
use unicode_bidi::{BidiInfo, Level};
|
||||
use vectorcraft_doc::{
|
||||
Burasagari, CharStyle, InlineArt, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, TextKind, TextObject,
|
||||
Burasagari, CharStyle, HyphenationOptions, InlineArt, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect,
|
||||
TextKind, TextObject,
|
||||
};
|
||||
use vectorcraft_geom::{ArcPath, PathData};
|
||||
|
||||
use crate::composer::{Breakpoint, Params, compose};
|
||||
use crate::fontdb::FontDb;
|
||||
use crate::hyphen::hyphen_points;
|
||||
use crate::hyphen::hyphen_points_with;
|
||||
use crate::shape::{
|
||||
Punct, SGlyph, Tcy, cap_x_heights, hyphen_glyph, is_cjk, mark_word_ends, no_line_end, no_line_start, punct, shape_range, soft_line_start,
|
||||
style_metrics,
|
||||
@@ -1220,7 +1221,18 @@ fn word_width(g: &[SGlyph], i: usize) -> f64 {
|
||||
|
||||
/// Greedy break: returns (end glyph index, hyphenated) for a line starting at `i` of `width`.
|
||||
/// With burasagari, a comma or full stop that doesn't fit ends the line, hanging outside it.
|
||||
fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool, burasagari: Burasagari, kinsoku: Kinsoku) -> (usize, bool) {
|
||||
/// `hyphenate` holds the paragraph's Hyphenation options and the Hyphenation Zone in force when
|
||||
/// the line may hyphenate a word; a line whose text before the overflowing word already ends
|
||||
/// within the zone breaks before that word.
|
||||
fn break_line(
|
||||
text: &str,
|
||||
g: &[SGlyph],
|
||||
i: usize,
|
||||
width: f64,
|
||||
hyphenate: Option<(&HyphenationOptions, f64)>,
|
||||
burasagari: Burasagari,
|
||||
kinsoku: Kinsoku,
|
||||
) -> (usize, bool) {
|
||||
if !width.is_finite() {
|
||||
return (g.len(), false);
|
||||
}
|
||||
@@ -1263,13 +1275,24 @@ fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool, b
|
||||
}
|
||||
// Hyphenate the word that overflows.
|
||||
let word_start = last_break.map_or(i, |b| b.0);
|
||||
if hyphenate && g[j].is_letter() {
|
||||
let in_zone = |zone: f64| {
|
||||
let mut te = word_start;
|
||||
while te > i && g[te - 1].is_space() {
|
||||
te -= 1;
|
||||
}
|
||||
te > i && zone > 0.0 && width - g[i..te].iter().map(|g| g.adv).sum::<f64>() <= zone + EPS
|
||||
};
|
||||
if let Some((opts, zone)) = hyphenate
|
||||
&& g[j].is_letter()
|
||||
&& !in_zone(zone)
|
||||
{
|
||||
let mut we = j;
|
||||
while we < g.len() && !g[we].is_space() && !g[we].break_after() {
|
||||
we += 1;
|
||||
}
|
||||
let x_ws: f64 = g[i..word_start].iter().map(|g| g.adv).sum();
|
||||
let pts: Vec<usize> = hyphen_breaks(text, g, word_start, we).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku)).collect();
|
||||
let pts: Vec<usize> =
|
||||
hyphen_breaks(text, g, word_start, we, opts).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku)).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;
|
||||
@@ -1367,7 +1390,7 @@ mod wrapping_tests {
|
||||
let text = "بَت";
|
||||
let glyphs = synthetic_glyphs(&[(0, 4, 2.0, 'ب'), (0, 4, 2.0, 'ب'), (4, 2, 1.0, 'ت')]);
|
||||
|
||||
let (end, hyphenated) = break_line(text, &glyphs, 0, 3.0, false, Burasagari::None, Kinsoku::Hard);
|
||||
let (end, hyphenated) = break_line(text, &glyphs, 0, 3.0, None, Burasagari::None, Kinsoku::Hard);
|
||||
|
||||
assert!(!hyphenated);
|
||||
assert_eq!(end, 2, "oversized base-plus-mark cluster must stay together");
|
||||
@@ -1380,11 +1403,11 @@ mod wrapping_tests {
|
||||
let text = "بَتَث";
|
||||
let glyphs = synthetic_glyphs(&[(0, 4, 1.0, 'ب'), (4, 4, 2.0, 'ت'), (4, 4, 2.0, 'ت'), (8, 2, 1.0, 'ث')]);
|
||||
|
||||
let (first_end, _) = break_line(text, &glyphs, 0, 3.0, false, Burasagari::None, Kinsoku::Hard);
|
||||
let (first_end, _) = break_line(text, &glyphs, 0, 3.0, None, Burasagari::None, Kinsoku::Hard);
|
||||
assert_eq!(first_end, 1);
|
||||
assert_eq!(source_range(&glyphs, 0, first_end), 0..4);
|
||||
|
||||
let (second_end, hyphenated) = break_line(text, &glyphs, first_end, 3.0, false, Burasagari::None, Kinsoku::Hard);
|
||||
let (second_end, hyphenated) = break_line(text, &glyphs, first_end, 3.0, None, Burasagari::None, Kinsoku::Hard);
|
||||
|
||||
assert!(!hyphenated);
|
||||
assert_eq!(second_end, 3, "oversized cluster at the next line start must stay together");
|
||||
@@ -1473,8 +1496,8 @@ fn soft_kinsoku_lets_small_kana_and_the_prolonged_sound_mark_start_a_line() {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
/// Glyph indices inside `g[ws..we]` (a word) where a hyphenated break may go under `opts`.
|
||||
fn hyphen_breaks(text: &str, g: &[SGlyph], ws: usize, we: usize, opts: &HyphenationOptions) -> Vec<usize> {
|
||||
// Strip leading/trailing punctuation (quotes, commas).
|
||||
let (mut a, mut b) = (ws, we);
|
||||
while a < b && !g[a].is_letter() {
|
||||
@@ -1489,7 +1512,7 @@ fn hyphen_breaks(text: &str, g: &[SGlyph], ws: usize, we: usize) -> Vec<usize> {
|
||||
let (s, e) = (g[a].byte, g[b - 1].byte + g[b - 1].len);
|
||||
let Some(word) = text.get(s..e) else { return vec![] };
|
||||
let offs: Vec<usize> = word.char_indices().map(|(o, _)| s + o).collect();
|
||||
hyphen_points(word)
|
||||
hyphen_points_with(word, opts)
|
||||
.into_iter()
|
||||
.filter_map(|ci| {
|
||||
let byte = *offs.get(ci)?;
|
||||
@@ -1499,15 +1522,18 @@ fn hyphen_breaks(text: &str, g: &[SGlyph], ws: usize, we: usize) -> Vec<usize> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Break candidates for the every-line composer.
|
||||
fn candidates(text: &str, g: &[SGlyph], hyphenate: bool, kinsoku: Kinsoku) -> Vec<Breakpoint> {
|
||||
/// Break candidates for the every-line composer: hyphenation points under `hyphenate` (the
|
||||
/// paragraph's Hyphenation options) when it is given.
|
||||
fn candidates(text: &str, g: &[SGlyph], hyphenate: Option<&HyphenationOptions>, kinsoku: Kinsoku) -> Vec<Breakpoint> {
|
||||
let mut v = vec![];
|
||||
let mut ws = 0;
|
||||
for (j, gl) in g.iter().enumerate() {
|
||||
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).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku)) {
|
||||
if let Some(opts) = hyphenate
|
||||
&& j > ws
|
||||
{
|
||||
for k in hyphen_breaks(text, g, ws, j, opts).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku)) {
|
||||
v.push(Breakpoint { end: k, hyphen: hyphen_glyph(&g[k - 1]).adv });
|
||||
}
|
||||
}
|
||||
@@ -1518,8 +1544,10 @@ fn candidates(text: &str, g: &[SGlyph], hyphenate: bool, kinsoku: Kinsoku) -> Ve
|
||||
ws = j + 1;
|
||||
}
|
||||
}
|
||||
if hyphenate && g.len() > ws {
|
||||
for k in hyphen_breaks(text, g, ws, g.len()).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku)) {
|
||||
if let Some(opts) = hyphenate
|
||||
&& g.len() > ws
|
||||
{
|
||||
for k in hyphen_breaks(text, g, ws, g.len(), opts).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku)) {
|
||||
v.push(Breakpoint { end: k, hyphen: hyphen_glyph(&g[k - 1]).adv });
|
||||
}
|
||||
}
|
||||
@@ -1571,9 +1599,10 @@ fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], para: &ParaStyle, pen: &Pen<'_>, rt
|
||||
let width = |k: usize| widths.get(k).copied().unwrap_or(last);
|
||||
// Lines from `uniform_from` on are interchangeable (same width).
|
||||
let uniform_from = widths.iter().rposition(|&w| (w - last).abs() > EPS).map_or(0, |k| k + 1);
|
||||
let cands = candidates(cx.text, sg, para.hyphenate, para.kinsoku);
|
||||
let cands = candidates(cx.text, sg, para.hyphenate.then_some(¶.hyphenation), para.kinsoku);
|
||||
let justify_last = para.justify == Justify::JustifyAll;
|
||||
let params = |tolerance| Params { justify_last, ragged, tolerance, uniform_from };
|
||||
let hyphen_limit = usize::try_from(para.hyphenation.hyphen_limit).unwrap_or(usize::MAX);
|
||||
let params = |tolerance| Params { justify_last, ragged, tolerance, uniform_from, hyphen_limit };
|
||||
// Ragged: first look for breaks that leave at most one rag zone (a sixth of the width) on
|
||||
// each line, then accept any lines that fit.
|
||||
let (strict, loose) = if ragged { (1.0, f64::INFINITY) } else { (1.0, 4.0) };
|
||||
@@ -1629,6 +1658,11 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
|
||||
}
|
||||
let n = sg.len();
|
||||
let composed = compose_para(cx, &sg, para, &pen, rtl);
|
||||
// Hyphenation options: the Hyphenation Zone applies with the Single-line Composer, and the
|
||||
// Hyphen Limit counts the lines in a row that end with a hyphen.
|
||||
let zone = if cx.opts.composer.unwrap_or(para.composer) == Composer::SingleLine { para.hyphenation.zone } else { 0.0 };
|
||||
let hyphen_limit = usize::try_from(para.hyphenation.hyphen_limit).unwrap_or(usize::MAX);
|
||||
let mut hyphen_run = 0;
|
||||
let mut li_para = 0;
|
||||
let mut i = 0;
|
||||
loop {
|
||||
@@ -1658,9 +1692,10 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
|
||||
break 'paras;
|
||||
};
|
||||
let width = x1 - x0 - ind_l - para.right_indent;
|
||||
let hyphenate = (para.hyphenate && (hyphen_limit == 0 || hyphen_run < hyphen_limit)).then_some((¶.hyphenation, zone));
|
||||
let (end, hyph) = match composed.as_ref().and_then(|c| c.get(li_para)) {
|
||||
Some(&(e, h)) if e > i => (e, h),
|
||||
_ if i < n => break_line(cx.text, &sg, i, width, para.hyphenate, para.burasagari, para.kinsoku),
|
||||
_ if i < n => break_line(cx.text, &sg, i, width, hyphenate, para.burasagari, para.kinsoku),
|
||||
_ => (n, false),
|
||||
};
|
||||
let m = Metrics::max(&sg[i..end]).unwrap_or(pm);
|
||||
@@ -1685,6 +1720,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
|
||||
}
|
||||
break (baseline, x0, x1, end, hyph, m);
|
||||
};
|
||||
hyphen_run = if hyph && end > i { hyphen_run + 1 } else { 0 };
|
||||
let (ax0, ax1) = if regions.is_some() { (x0 + ind_l, x1 - para.right_indent) } else { (x0, x1) };
|
||||
let width = ax1 - ax0;
|
||||
pen.pending = 0.0;
|
||||
|
||||
@@ -585,6 +585,142 @@ fn hyphenation_exceptions_keep_listed_words_whole_in_layout() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The width of the frame [`the_language`] makes: "The lan-" and "language" fit, and "The
|
||||
/// language" does not.
|
||||
fn the_language_width() -> f64 {
|
||||
natural_width("The lan-", 12.0).max(natural_width("Language", 12.0)) + 1.0
|
||||
}
|
||||
|
||||
/// "The language" (or "The Language") in a frame that holds "The lan-" and not the whole of it,
|
||||
/// hyphenated.
|
||||
fn the_language(word: &str, composer: Composer) -> TextObject {
|
||||
let text = format!("The {word}");
|
||||
let w = the_language_width();
|
||||
assert!(w < natural_width(&text, 12.0));
|
||||
let mut t = area(&text, style(12.0), Rect::new(0.0, 0.0, w, 400.0), Justify::Left);
|
||||
t.para.hyphenate = true;
|
||||
t.para.composer = composer;
|
||||
t
|
||||
}
|
||||
|
||||
/// Paragraph panel menu › Hyphenation… › Words Longer Than: an 8-letter word that breaks with
|
||||
/// the default (longer than 5 letters) stays whole when only words longer than 10 letters may
|
||||
/// break.
|
||||
#[test]
|
||||
fn hyphenation_words_longer_than_keeps_shorter_words_whole() {
|
||||
let mut t = the_language("language", Composer::SingleLine);
|
||||
assert_eq!(line_texts(&t, &layout(db(), &t)), ["The lan", "guage"]);
|
||||
t.para.hyphenation.words_longer_than = 10;
|
||||
assert_eq!(line_texts(&t, &layout(db(), &t)), ["The", "language"]);
|
||||
let mut t = the_language("language", Composer::EveryLine);
|
||||
t.para.hyphenation.words_longer_than = 10;
|
||||
assert_eq!(line_texts(&t, &layout(db(), &t)), ["The", "language"]);
|
||||
}
|
||||
|
||||
/// Hyphenation… › After First and Before Last: the fewest letters on each side of the hyphen.
|
||||
#[test]
|
||||
fn hyphenation_after_first_and_before_last_limit_the_break() {
|
||||
let mut t = the_language("language", Composer::SingleLine);
|
||||
t.para.hyphenation.after_first = 4;
|
||||
assert_eq!(line_texts(&t, &layout(db(), &t)), ["The", "language"]);
|
||||
t.para.hyphenation.after_first = 3;
|
||||
t.para.hyphenation.before_last = 6;
|
||||
assert_eq!(line_texts(&t, &layout(db(), &t)), ["The", "language"]);
|
||||
}
|
||||
|
||||
/// Hyphenation… › Hyphenate Capitalized Words: when it is off, a word that starts with a capital stays whole.
|
||||
#[test]
|
||||
fn hyphenation_leaves_capitalized_words_whole_when_off() {
|
||||
for composer in [Composer::SingleLine, Composer::EveryLine] {
|
||||
let mut t = the_language("Language", composer);
|
||||
if composer == Composer::SingleLine {
|
||||
assert_eq!(line_texts(&t, &layout(db(), &t)), ["The Lan", "guage"]);
|
||||
}
|
||||
t.para.hyphenation.capitalized = false;
|
||||
assert_eq!(line_texts(&t, &layout(db(), &t)), ["The", "Language"], "{composer:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Hyphenation… › Hyphenation Zone: with the Single-line Composer, a line whose text before the
|
||||
/// next word already ends within the zone breaks before that word; the Every-line Composer
|
||||
/// ignores the zone.
|
||||
#[test]
|
||||
fn hyphenation_zone_breaks_before_the_word_with_the_single_line_composer() {
|
||||
let mut t = the_language("language", Composer::SingleLine);
|
||||
let gap = the_language_width() - natural_width("The", 12.0);
|
||||
t.para.hyphenation.zone = gap - 1.0;
|
||||
assert_eq!(line_texts(&t, &layout(db(), &t)), ["The lan", "guage"]);
|
||||
t.para.hyphenation.zone = gap + 1.0;
|
||||
assert_eq!(line_texts(&t, &layout(db(), &t)), ["The", "language"]);
|
||||
let mut every = the_language("language", Composer::EveryLine);
|
||||
let without = line_texts(&every, &layout(db(), &every));
|
||||
every.para.hyphenation.zone = gap + 1.0;
|
||||
assert_eq!(line_texts(&every, &layout(db(), &every)), without);
|
||||
}
|
||||
|
||||
/// The default Hyphenation options keep the line breaks of hyphenated text: the copy twice over
|
||||
/// at three widths, ragged and justified, with either composer. The constant is an FNV-1a hash of
|
||||
/// each line's end and whether it ends with a hyphen.
|
||||
#[test]
|
||||
fn default_hyphenation_options_keep_the_line_breaks() {
|
||||
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
let mut mix = |v: u64| {
|
||||
for b in v.to_le_bytes() {
|
||||
hash = (hash ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3);
|
||||
}
|
||||
};
|
||||
for width in [150.0, 190.0, 240.0] {
|
||||
for justify in [Justify::Left, Justify::JustifyLeft] {
|
||||
for composer in [Composer::SingleLine, Composer::EveryLine] {
|
||||
let mut t = area(©.repeat(2), style(12.0), Rect::new(0.0, 0.0, width, 4000.0), justify);
|
||||
t.para.hyphenate = true;
|
||||
t.para.composer = composer;
|
||||
let l = layout(db(), &t);
|
||||
for li in &l.lines {
|
||||
let hyphenated = li.glyph_end > li.glyph_start && l.glyphs.get(li.glyph_end - 1).is_some_and(|g| g.len == 0);
|
||||
mix(li.end as u64 * 2 + u64::from(hyphenated));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(hash, 8_558_443_895_699_068_077);
|
||||
}
|
||||
|
||||
/// The longest run of consecutive lines of `l` that end with a hyphen.
|
||||
fn hyphen_run(l: &TextLayout) -> usize {
|
||||
let mut run = 0;
|
||||
let mut longest = 0;
|
||||
for li in &l.lines {
|
||||
let hyphenated = li.glyph_end > li.glyph_start && l.glyphs.get(li.glyph_end - 1).is_some_and(|g| g.len == 0);
|
||||
run = if hyphenated { run + 1 } else { 0 };
|
||||
longest = longest.max(run);
|
||||
}
|
||||
longest
|
||||
}
|
||||
|
||||
/// Hyphenation… › Hyphen Limit: at most that many lines in a row end with a hyphen, with either
|
||||
/// composer; 0 sets no limit.
|
||||
#[test]
|
||||
fn hyphen_limit_caps_consecutive_hyphenated_lines() {
|
||||
let text = "Extraordinarily comprehensive typographical considerations notwithstanding everything. ".repeat(3);
|
||||
for composer in [Composer::SingleLine, Composer::EveryLine] {
|
||||
for justify in [Justify::Left, Justify::JustifyLeft] {
|
||||
let mut t = area(text.trim_end(), style(14.0), Rect::new(0.0, 0.0, 120.0, 2000.0), justify);
|
||||
t.para.hyphenate = true;
|
||||
t.para.composer = composer;
|
||||
let free = layout(db(), &t);
|
||||
assert!(hyphen_run(&free) >= 2, "{composer:?} {justify:?}: lines in a row end with hyphens without a limit");
|
||||
for limit in [1, 2] {
|
||||
t.para.hyphenation.hyphen_limit = limit;
|
||||
let l = layout(db(), &t);
|
||||
assert!(!l.overflow);
|
||||
assert!(hyphen_run(&l) >= 1, "{composer:?} {justify:?} {limit}: still hyphenated");
|
||||
assert!(hyphen_run(&l) <= limit as usize, "{composer:?} {justify:?} {limit}: {}", hyphen_run(&l));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soft_hyphen_is_invisible_mid_line() {
|
||||
let l = layout(db(), &TextObject::point(Point::ZERO, "co\u{00AD}operate", style(20.0)));
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
//! (the selected ones when anchors are selected; Alt-click cycles their kind, double-click opens
|
||||
//! the Corners dialog).
|
||||
//! Group Selection: click selects the leaf; each further click on it adds the next enclosing group;
|
||||
//! its marquee works as Direct Selection's.
|
||||
//! its marquee works as Direct Selection's, and E pressed during the drag makes it select only the
|
||||
//! objects entirely inside it (members of groups too), whole.
|
||||
//! In isolation mode both pick only the isolated object and what is inside it: anchors, segments,
|
||||
//! paths and groups outside it stay put.
|
||||
//! Both move objects selected as a whole as the Selection tool does, snapping alike.
|
||||
@@ -80,6 +81,9 @@ enum State {
|
||||
cur: Point,
|
||||
/// Shift: the anchors inside leave the selection if selected, else join it.
|
||||
toggle: bool,
|
||||
/// Group Selection, E pressed an odd number of times during the drag: the marquee selects
|
||||
/// only the objects that lie entirely inside it, whole.
|
||||
enclosed: bool,
|
||||
},
|
||||
Corner(CornerDrag),
|
||||
/// Dragging a bracket of type on a path.
|
||||
@@ -163,6 +167,12 @@ impl DirectSelectionTool {
|
||||
click_segment: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `key` the E that Group Selection takes while a marquee is being dragged? Direct
|
||||
/// Selection takes no letter.
|
||||
fn claims_letter(&self, key: ToolKey) -> bool {
|
||||
self.group && key == ToolKey::Letter('e') && matches!(self.state, State::Marquee { .. })
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `id` may be picked while `scope` is isolated: it is that object, or it sits inside it.
|
||||
@@ -363,7 +373,15 @@ impl Tool for DirectSelectionTool {
|
||||
}
|
||||
/// Escape during a drag (anchors, handles, segments, objects, type areas, blend spines, a
|
||||
/// marquee) cancels it: what it moved goes back and the tool waits for the next press (#1743).
|
||||
/// With Group Selection, E during a marquee drag makes the marquee select only the objects
|
||||
/// entirely inside it, whole, and E pressed again makes it select what it selects by default.
|
||||
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
||||
if self.claims_letter(key)
|
||||
&& let State::Marquee { enclosed, .. } = &mut self.state
|
||||
{
|
||||
*enclosed = !*enclosed;
|
||||
return vec![];
|
||||
}
|
||||
if key == ToolKey::Escape && self.guide.busy() {
|
||||
return self.guide.cancel();
|
||||
}
|
||||
@@ -376,6 +394,9 @@ impl Tool for DirectSelectionTool {
|
||||
(self.click, self.click_segment) = (None, None);
|
||||
vec![Action::Cancel]
|
||||
}
|
||||
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
|
||||
self.claims_letter(key)
|
||||
}
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
if let Some(out) = self.guide.pointer(cx, ev) {
|
||||
return out;
|
||||
@@ -390,7 +411,7 @@ impl Tool for DirectSelectionTool {
|
||||
return out;
|
||||
}
|
||||
let Some(h) = hit_test(cx.doc, p, cx.hit_options()) else {
|
||||
self.state = State::Marquee { start: p, cur: p, toggle: ev.mods.shift };
|
||||
self.state = State::Marquee { start: p, cur: p, toggle: ev.mods.shift, enclosed: false };
|
||||
return vec![];
|
||||
};
|
||||
// Walk up from the leaf: select the first ancestor not yet selected (below the layer).
|
||||
@@ -539,7 +560,7 @@ impl Tool for DirectSelectionTool {
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
self.state = State::Marquee { start: p, cur: p, toggle: ev.mods.shift };
|
||||
self.state = State::Marquee { start: p, cur: p, toggle: ev.mods.shift, enclosed: false };
|
||||
vec![]
|
||||
}
|
||||
(PointerKind::Drag, State::MoveAnchors { start, grab, began }) => {
|
||||
@@ -671,8 +692,8 @@ impl Tool for DirectSelectionTool {
|
||||
self.state = State::Idle;
|
||||
self.mesh.release().unwrap_or_default()
|
||||
}
|
||||
(PointerKind::Drag, State::Marquee { start, toggle, .. }) => {
|
||||
self.state = State::Marquee { start, cur: p, toggle };
|
||||
(PointerKind::Drag, State::Marquee { start, toggle, enclosed, .. }) => {
|
||||
self.state = State::Marquee { start, cur: p, toggle, enclosed };
|
||||
vec![]
|
||||
}
|
||||
(PointerKind::Drag, State::Corner(mut c)) => {
|
||||
@@ -719,12 +740,21 @@ impl Tool for DirectSelectionTool {
|
||||
self.guides.clear();
|
||||
vec![Action::Commit]
|
||||
}
|
||||
(PointerKind::Up, State::Marquee { start, toggle, .. }) => {
|
||||
(PointerKind::Up, State::Marquee { start, toggle, enclosed, .. }) => {
|
||||
self.state = State::Idle;
|
||||
let r = Rect::from_points(start, p);
|
||||
if r.width() < cx.tol(3.0) && r.height() < cx.tol(3.0) {
|
||||
return if toggle { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] };
|
||||
}
|
||||
// With Group Selection's enclosed marquee, the tool selects the objects inside it
|
||||
// (members of groups too), each whole, as a click selects one.
|
||||
if enclosed {
|
||||
let mut touched = vectorcraft_doc::hit::marquee(cx.doc, r, cx.isolation, true);
|
||||
touched.retain(|id| cx.doc.is_editable(*id));
|
||||
let ids: Vec<Value> = crate::select::inside(cx, r, touched).into_iter().map(|id| json!(id.0)).collect();
|
||||
let art = Action::Exec(if toggle { "select.toggle" } else { "select.set" }.into(), json!({ "ids": ids }));
|
||||
return std::iter::once(art).chain(crate::rulerguide::marquee_select(cx, r, true)).collect();
|
||||
}
|
||||
// Anchors inside the rect, on editable paths inside isolation mode.
|
||||
let mut sel: Vec<(NodeId, Vec<AnchorRef>)> = vec![];
|
||||
walk_pickable(cx.doc, cx.isolation, |n| {
|
||||
@@ -746,7 +776,7 @@ impl Tool for DirectSelectionTool {
|
||||
sel.retain(|(id, _)| cx.doc.is_editable(*id));
|
||||
let items: Vec<Value> = sel.iter().map(|(id, v)| json!({"id": id.0, "anchors": anchors_json(v)})).collect();
|
||||
let art = Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": if toggle { "toggle" } else { "set" }}));
|
||||
std::iter::once(art).chain(crate::rulerguide::marquee_select(cx, r)).collect()
|
||||
std::iter::once(art).chain(crate::rulerguide::marquee_select(cx, r, false)).collect()
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
@@ -1226,6 +1256,43 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Group Selection: E pressed during a marquee drag makes it select the objects entirely inside
|
||||
/// it whole, a group's member without its group. Direct Selection does not take E.
|
||||
#[test]
|
||||
fn e_during_a_group_selection_marquee_selects_the_objects_inside() {
|
||||
use vectorcraft_doc::{Appearance, Node};
|
||||
let (mut d, id) = doc_with_rect();
|
||||
let l = d.layers[0].id;
|
||||
let rect = |id, r| Node::path(id, vectorcraft_geom::shapes::rectangle(r), Appearance::default_art());
|
||||
let (g, a, b) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
|
||||
d.insert(Some(l), 1, Node::group(g, vec![])).unwrap();
|
||||
d.insert(Some(g), 0, rect(a, Rect::new(100.0, 220.0, 120.0, 240.0))).unwrap();
|
||||
d.insert(Some(g), 1, rect(b, Rect::new(100.0, 300.0, 120.0, 320.0))).unwrap();
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let e = ToolKey::Letter('e');
|
||||
let drag = |group: bool, mods: Mods| {
|
||||
let mut t = DirectSelectionTool::new(group);
|
||||
assert!(!t.claims_key(&cx, e), "no marquee yet");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 50.0).with_mods(mods));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 250.0).with_mods(mods));
|
||||
assert_eq!(t.claims_key(&cx, e), group);
|
||||
t.key(&cx, e, Mods::default());
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 300.0, 250.0).with_mods(mods))
|
||||
};
|
||||
assert_eq!(drag(true, Mods::default()), vec![Action::Exec("select.set".into(), json!({"ids": [id.0, a.0]}))]);
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
assert_eq!(drag(true, shift), vec![Action::Exec("select.toggle".into(), json!({"ids": [id.0, a.0]}))]);
|
||||
// Direct Selection's marquee still selects the anchors inside it.
|
||||
let anchors = |id: NodeId, list: Value| json!({"id": id.0, "anchors": list});
|
||||
let all = json!([[0, 0], [0, 1], [0, 2], [0, 3]]);
|
||||
assert_eq!(
|
||||
drag(false, Mods::default()),
|
||||
vec![Action::Exec("select.anchorsMany".into(), json!({"items": [anchors(id, all.clone()), anchors(a, all)], "mode": "set"}))]
|
||||
);
|
||||
}
|
||||
|
||||
/// An open path from (100, 300) to (200, 300) arching up through (150, 262.5).
|
||||
fn arch_doc() -> (vectorcraft_doc::Document, NodeId) {
|
||||
let (mut d, _) = doc_with_rect();
|
||||
@@ -1528,6 +1595,29 @@ mod tests {
|
||||
assert_eq!(drag(&mut t, &c, (300.0, 300.0), (201.0, 199.0), none).0, json!({"dx": -99.0, "dy": -101.0}));
|
||||
}
|
||||
|
||||
/// #2120: the end of a slanted straight line, dragged roughly along it, stays on its direction
|
||||
/// (Line Extension) and the guide runs along it from the other end.
|
||||
#[test]
|
||||
fn dragged_end_of_a_slanted_line_stays_on_its_direction() {
|
||||
let (mut d, _) = doc_with_rect();
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let line = vectorcraft_geom::shapes::line(Point::new(300.0, 400.0), Point::new(380.0, 340.0));
|
||||
d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap();
|
||||
let p = paint();
|
||||
let end = anchors_of(id, &[(0, 1)]);
|
||||
let c = cx(&d, &end, &p);
|
||||
let mut t = DirectSelectionTool::new(false);
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 380.0, 340.0));
|
||||
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 421.5, 312.0));
|
||||
let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") };
|
||||
let (dx, dy) = (v["dx"].as_f64().unwrap(), v["dy"].as_f64().unwrap());
|
||||
assert!((dx - 40.0).abs() < 1e-9 && (dy + 30.0).abs() < 1e-9, "{v}");
|
||||
let guides: Vec<(Point, Point)> =
|
||||
t.overlays(&c).into_iter().filter_map(|o| if let Overlay::Line { a, b, .. } = o { Some((a, b)) } else { None }).collect();
|
||||
assert!(matches!(guides[..], [(a, b)] if a == Point::new(300.0, 400.0) && (b - Point::new(420.0, 310.0)).hypot() < 1e-9), "{guides:?}");
|
||||
}
|
||||
|
||||
/// Group Selection (and Direct Selection on a whole path) moves objects snapping as the
|
||||
/// Selection tool does: B's left side lines up with A's right one.
|
||||
#[test]
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
//!
|
||||
//! Add: click a segment to insert an anchor without changing the shape (Alt = delete).
|
||||
//! Delete: click an anchor to remove it, re-fitting the neighbouring curve (Alt = add).
|
||||
//! Anchor Point: click a smooth anchor → corner; drag an anchor → pull out smooth handles; drag a
|
||||
//! handle → move it independently (Shift: at 45° steps round its anchor); drag a segment → reshape
|
||||
//! the curve, a selected blend's spine's too, straight or curved (#1111).
|
||||
//! Anchor Point: click a smooth anchor → corner; drag an anchor → pull out smooth handles; click a
|
||||
//! handle end → retract that handle only (#2267); drag a handle → move it independently (Shift: at
|
||||
//! 45° steps round its anchor); drag a segment → reshape the curve, a selected blend's spine's too,
|
||||
//! straight or curved (#1111).
|
||||
//! Scissors: click a path to split it there.
|
||||
|
||||
use serde_json::json;
|
||||
@@ -31,6 +32,7 @@ enum State {
|
||||
si: usize,
|
||||
ai: usize,
|
||||
out: bool,
|
||||
start: Point,
|
||||
began: bool,
|
||||
},
|
||||
/// Bending a segment: a path's, or (`spine`) a selected blend's spine's.
|
||||
@@ -110,7 +112,7 @@ impl Tool for AnchorTool {
|
||||
}
|
||||
("anchorPoint", PointerKind::Down) => {
|
||||
if let Some((id, si, ai, out)) = hit_handle(cx, p, point) {
|
||||
self.state = State::Handle { id, si, ai, out, began: false };
|
||||
self.state = State::Handle { id, si, ai, out, start: p, began: false };
|
||||
self.snap = HandleSnap::default();
|
||||
} else if let Some((id, si, ai)) = hit_anchor(cx, p, point) {
|
||||
self.state = State::Convert { id, si, ai, start: p, began: false };
|
||||
@@ -125,8 +127,9 @@ impl Tool for AnchorTool {
|
||||
let mut out = vec![];
|
||||
let (began, far) = match self.state {
|
||||
State::Idle => return out,
|
||||
State::Convert { began, start, .. } | State::Reshape { began, start, .. } => (began, p.distance(start) >= cx.tol(2.0)),
|
||||
State::Handle { began, .. } => (began, true),
|
||||
State::Convert { began, start, .. } | State::Handle { began, start, .. } | State::Reshape { began, start, .. } => {
|
||||
(began, p.distance(start) >= cx.tol(2.0))
|
||||
}
|
||||
};
|
||||
if !began {
|
||||
if !far {
|
||||
@@ -183,6 +186,15 @@ impl Tool for AnchorTool {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
State::Handle { id, si, ai, out, began: false, .. } => {
|
||||
let Some(a) = cx.doc.node(id).and_then(|n| n.path_data()).and_then(|pd| pd.subpaths.get(si)?.anchors.get(ai)) else {
|
||||
return vec![];
|
||||
};
|
||||
vec![Action::Exec(
|
||||
"path.setHandle".into(),
|
||||
json!({"id": id.0, "subpath": si, "anchor": ai, "which": if out { "out" } else { "in" }, "x": a.p.x, "y": a.p.y, "independent": true}),
|
||||
)]
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
@@ -305,4 +317,39 @@ mod tests {
|
||||
let single = ToolContext { handles_multiple: false, ..cx(&d, &s, &p) };
|
||||
assert_eq!(dragged(press(&single, 240.0, 301.0)), "path.reshapeSegment");
|
||||
}
|
||||
|
||||
/// #2267: clicking a handle end retracts that handle only; the anchor becomes a corner and
|
||||
/// keeps the other handle, as Illustrator's Anchor Point tool does.
|
||||
#[test]
|
||||
fn anchor_point_click_on_a_handle_retracts_only_that_handle() {
|
||||
let (mut d, _) = doc_with_rect();
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0), Point::new(300.0, 300.0)], false);
|
||||
sp.anchors[1] = vectorcraft_geom::Anchor::smooth(Point::new(200.0, 300.0), Point::new(240.0, 320.0));
|
||||
let path = vectorcraft_geom::PathData::single(sp);
|
||||
d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, path, vectorcraft_doc::Appearance::default_art())).unwrap();
|
||||
let mut s = Selection::default();
|
||||
s.set([id]);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let retract = |which: &str| {
|
||||
vec![Action::Exec(
|
||||
"path.setHandle".into(),
|
||||
json!({"id": id.0, "subpath": 0, "anchor": 1, "which": which, "x": 200.0, "y": 300.0, "independent": true}),
|
||||
)]
|
||||
};
|
||||
let mut t = AnchorTool::new("anchorPoint");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 240.0, 320.0));
|
||||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 240.0, 320.0)), retract("out"));
|
||||
// A hand that shakes by a pixel still clicks; the incoming handle sits opposite, at (160, 280).
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 160.0, 280.0));
|
||||
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 160.5, 280.5)).is_empty());
|
||||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 160.5, 280.5)), retract("in"));
|
||||
// The command leaves the other handle where it was.
|
||||
let mut a = vectorcraft_geom::Anchor::smooth(Point::new(200.0, 300.0), Point::new(240.0, 320.0));
|
||||
a.set_handle(true, a.p, true);
|
||||
assert!(!a.has_out());
|
||||
assert_eq!(a.h_in, Point::new(160.0, 280.0));
|
||||
}
|
||||
}
|
||||
@@ -954,6 +954,19 @@ mod tests {
|
||||
assert_eq!(press(&mut t, &cx, 100.0, 0.0, false).len(), 1);
|
||||
}
|
||||
|
||||
/// #2120: the dragged end of a slanted straight path keeps to its extension (Smart Guides).
|
||||
#[test]
|
||||
fn a_dragged_end_of_a_straight_path_keeps_to_its_extension() {
|
||||
let (d, id) = doc_with(SubPath::polyline(&[pt(300.0, 400.0), pt(380.0, 340.0)], false));
|
||||
let (s, p) = (selected(id, None), paint());
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = CurvatureTool::default();
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 380.0, 340.0));
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 421.5, 312.0));
|
||||
let moved = a.get(1).and_then(edit_op).map(|(op, v)| (op, v["x"].as_f64().unwrap(), v["y"].as_f64().unwrap()));
|
||||
assert!(matches!(moved, Some(("move", x, y)) if (x - 420.0).abs() < 1e-9 && (y - 310.0).abs() < 1e-9), "{a:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_click_or_double_click_toggles_and_delete_removes() {
|
||||
let (d, id) = doc_with(wave());
|
||||
|
||||
Loaded 100 of 191 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user