Compare commits
102
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75c587c8a0 | ||
|
|
10f1c56a86 | ||
|
|
ab49f6af8b | ||
|
|
51e24f2944 | ||
|
|
2048084396 | ||
|
|
cee73d03a3 | ||
|
|
3ee9543b3f | ||
|
|
c183c47df3 | ||
|
|
2d2e0c6992 | ||
|
|
2972e2e6bf | ||
|
|
02e93902b3 | ||
|
|
d7339804e6 | ||
|
|
33d6d38ec8 | ||
|
|
53f8516cc6 | ||
|
|
db39545a85 | ||
|
|
aa17dd7d2c | ||
|
|
b26612142f | ||
|
|
a7279231cb | ||
|
|
77667945b1 | ||
|
|
faa3678a3f | ||
|
|
2ddbd9293f | ||
|
|
f5bf327447 | ||
|
|
1622a70f85 | ||
|
|
cd59b9cc94 | ||
|
|
1b3cf83e04 | ||
|
|
580ada65d5 | ||
|
|
7abce8426a | ||
|
|
0eb1e463b9 | ||
|
|
0b0ccfb35a | ||
|
|
0abb4157c9 | ||
|
|
96344f0761 | ||
|
|
a2530621fc | ||
|
|
d93e4e9cf1 | ||
|
|
a22bced150 | ||
|
|
a9724275cc | ||
|
|
04e714e9dd | ||
|
|
adad531adb | ||
|
|
c94d38e53c | ||
|
|
340200a6ec | ||
|
|
d5bc4ceeee | ||
|
|
a3316d2383 | ||
|
|
c6486ea50d | ||
|
|
145ae2e877 | ||
|
|
f76c834066 | ||
|
|
d23973de4f | ||
|
|
bd55d66072 | ||
|
|
9ea05d5d45 | ||
|
|
b5d25c1102 | ||
|
|
c11b516d34 | ||
|
|
2bfb0887d8 | ||
|
|
b2800fb529 | ||
|
|
1b761c68c1 | ||
|
|
683b1e9845 | ||
|
|
a0438f73f9 | ||
|
|
b23a402df8 | ||
|
|
fd52ca32c7 | ||
|
|
0497c9e46d | ||
|
|
cacc9e4f36 | ||
|
|
33b6a6323d | ||
|
|
148ef1870a | ||
|
|
7f17d980d4 | ||
|
|
eeb213e5a8 | ||
|
|
e77d15c70d | ||
|
|
3416bf49cd | ||
|
|
178f99d60e | ||
|
|
c403e57d2a | ||
|
|
e606c56064 | ||
|
|
d15bc61818 | ||
|
|
428894df22 | ||
|
|
0cb1beaf21 | ||
|
|
c05ad3985c | ||
|
|
05bb1ac97f | ||
|
|
7915bfc0e1 | ||
|
|
219b9da346 | ||
|
|
683f6439ca | ||
|
|
67a0ffca4d | ||
|
|
e0d494b2eb | ||
|
|
9e09d53fd9 | ||
|
|
6d5fdc8a2a | ||
|
|
057c2ee2c1 | ||
|
|
5a9c6e2e06 | ||
|
|
130bacde12 | ||
|
|
311d75fc97 | ||
|
|
c845c7f9e0 | ||
|
|
2ff5688511 | ||
|
|
0d697ed79d | ||
|
|
57050b0987 | ||
|
|
743fe2521e | ||
|
|
6bf2a42e22 | ||
|
|
93cadc8994 | ||
|
|
2f984f2e7a | ||
|
|
2f458842ae | ||
|
|
3b742b6c0c | ||
|
|
de17a00eb2 | ||
|
|
059bd82676 | ||
|
|
6eeabcd742 | ||
|
|
bfbe9e1eb6 | ||
|
|
ac6d72b44a | ||
|
|
004d015cfe | ||
|
|
e860b01ed8 | ||
|
|
4c011cf42e | ||
|
|
6f7f32c64d |
No files matched your search
@@ -377,11 +377,91 @@ jobs:
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
linux-riscv64:
|
||||
name: Linux riscv64
|
||||
needs: version
|
||||
# Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release
|
||||
# from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's
|
||||
# riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the
|
||||
# CLI is smoke-tested under QEMU (package.sh runs `vectorcraft-cli --version` through $EMULATOR).
|
||||
runs-on: ubuntu-24.04
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
env:
|
||||
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
|
||||
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: riscv64gc-unknown-linux-gnu
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-linux-riscv64
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$VECTORCRAFT_VERSION"
|
||||
- name: Install cross-compilation dependencies
|
||||
run: |
|
||||
. /etc/os-release
|
||||
sudo dpkg --add-architecture riscv64
|
||||
# Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports.
|
||||
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
|
||||
sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
|
||||
fi
|
||||
if [ -f /etc/apt/sources.list ]; then
|
||||
sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list
|
||||
sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list
|
||||
fi
|
||||
for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do
|
||||
echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse"
|
||||
done | sudo tee /etc/apt/sources.list.d/riscv64.list
|
||||
sudo apt-get update
|
||||
# The riscv64 variants of the linux job's -dev packages.
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \
|
||||
libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \
|
||||
libxi-dev:riscv64 libgl1-mesa-dev:riscv64 \
|
||||
desktop-file-utils appstream
|
||||
- name: Build and package (tar.gz)
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
|
||||
CC_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-gcc
|
||||
CXX_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-g++
|
||||
AR_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-ar
|
||||
PKG_CONFIG_SYSROOT_DIR: /
|
||||
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
|
||||
PKG_CONFIG_ALLOW_CROSS: "1"
|
||||
CROSS_ARCH: riscv64
|
||||
CROSS_TARGET: riscv64gc-unknown-linux-gnu
|
||||
CROSS_COMPILE: riscv64-linux-gnu-
|
||||
EMULATOR: qemu-riscv64
|
||||
QEMU_LD_PREFIX: /usr/riscv64-linux-gnu
|
||||
run: packaging/linux/package.sh --formats tar
|
||||
- name: Check the package
|
||||
run: |
|
||||
tar -tzf dist/release/vectorcraft-*-linux-riscv64.tar.gz \
|
||||
| grep -E '/bin/vectorcraft$|/bin/vectorcraft-cli$|/share/applications/ai\.storyteller\.vectorcraft\.desktop$'
|
||||
file target/riscv64gc-unknown-linux-gnu/release/vectorcraft | grep -F 'RISC-V'
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-linux-riscv64
|
||||
path: dist/release/*.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
name: Draft GitHub Release
|
||||
# Needs the signed jobs, so a branch dry run (where the environment refuses them) never
|
||||
# drafts a release.
|
||||
needs: [version, macos, windows, linux, flatpak, freebsd, web]
|
||||
needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web]
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions:
|
||||
|
||||
Generated
+29
-29
@@ -2167,9 +2167,9 @@ checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
|
||||
|
||||
[[package]]
|
||||
name = "linesweeper"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c19728333c060c6569a53c9a0e56c4be0df52cb4e6e07a8fbe16084cecce769"
|
||||
checksum = "a25df1b0c5432893f41bba817150e323703ee5f6d974f3a6808ac7fd7f046d18"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4320,7 +4320,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"block2 0.6.2",
|
||||
@@ -4350,7 +4350,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-affinity"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4361,7 +4361,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-brush"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
@@ -4373,7 +4373,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cad"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4389,7 +4389,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cli"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"objc2-core-foundation",
|
||||
@@ -4410,7 +4410,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-color"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"moxcms",
|
||||
@@ -4422,7 +4422,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-doc"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"jpeg-encoder",
|
||||
@@ -4439,7 +4439,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-effects"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde_json",
|
||||
@@ -4454,7 +4454,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-engine"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4489,7 +4489,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4509,7 +4509,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-format"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
@@ -4523,7 +4523,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
@@ -4535,7 +4535,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-mcp"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4554,7 +4554,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-metafile"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4568,7 +4568,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"linesweeper",
|
||||
@@ -4580,7 +4580,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pdf"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"flate2",
|
||||
@@ -4609,7 +4609,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-plugins"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4623,7 +4623,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-render"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"gif",
|
||||
@@ -4648,7 +4648,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-svg"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4672,7 +4672,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-testkit"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4689,7 +4689,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-text"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4707,14 +4707,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-three-d"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"vectorcraft-geom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-tools"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4729,7 +4729,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-trace"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4742,7 +4742,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-ui-egui"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui_extras",
|
||||
@@ -4773,7 +4773,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-web"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5883,7 +5883,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -61,7 +61,7 @@ usvg = "0.48"
|
||||
krilla = "0.8"
|
||||
skrifa = "0.47"
|
||||
harfrust = "0.13"
|
||||
linesweeper = "0.4"
|
||||
linesweeper = "0.5"
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
# Zstandard decoder (pure Rust, builds for wasm32 too): the editing copy an EPS carries of its art.
|
||||
ruzstd = { version = "0.9", default-features = false, features = ["std"] }
|
||||
|
||||
+1
-1
@@ -123,7 +123,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2) |
|
||||
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). |
|
||||
| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) |
|
||||
| 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day |
|
||||
| 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections |
|
||||
|
||||
@@ -292,7 +292,21 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
|
||||
let mut h = Headless::new();
|
||||
let mut out = std::io::stdout().lock();
|
||||
let mut emit = |v: Value| writeln!(out, "{v}").map_err(|e| e.to_string());
|
||||
// A reader that stopped early (a closed pipe) isn't an error, and the batch goes on: its later
|
||||
// steps (an export…) still run, unprinted.
|
||||
let mut closed = false;
|
||||
let mut emit = |v: Value| {
|
||||
if closed {
|
||||
return Ok(());
|
||||
}
|
||||
match writeln!(out, "{v}") {
|
||||
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {
|
||||
closed = true;
|
||||
Ok(())
|
||||
}
|
||||
r => r.map_err(|e| e.to_string()),
|
||||
}
|
||||
};
|
||||
if let Some(path) = &input {
|
||||
let r = h.call("app.open", json!({"path": path})).map_err(|e| format!("open {path}: {e}"))?;
|
||||
emit(json!({"step": "open", "path": path, "result": r}))?;
|
||||
|
||||
@@ -297,3 +297,21 @@ fn closed_stdout_ends_quietly() {
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success() && !err.contains("panicked"), "{:?}: {err}", out.status);
|
||||
}
|
||||
|
||||
/// #1005: `run` whose reader has gone (`vectorcraft-cli run … | true`) ends quietly with status 0,
|
||||
/// and its later steps still run; a failing step still fails.
|
||||
#[test]
|
||||
fn run_with_a_closed_stdout_ends_quietly_and_finishes_the_batch() {
|
||||
let svg = tmp("closed-run.svg");
|
||||
let _ = std::fs::remove_file(&svg);
|
||||
let rect = r#"{"x":1,"y":1,"width":5,"height":5}"#;
|
||||
let args = ["run", "--cmd", "document.json", "--cmd", "shape.rectangle", "--params", rect, "--export", svg.to_str().unwrap()];
|
||||
let mut child = Command::new(BIN).args(args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
drop(child.stdout.take());
|
||||
let out = child.wait_with_output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success() && err.is_empty(), "{:?}: {err}", out.status);
|
||||
assert!(svg.is_file(), "the export after the closed reader still ran");
|
||||
let out = Command::new(BIN).args(["run", "--cmd", "not-a-command"]).stdout(Stdio::null()).output().unwrap();
|
||||
assert!(!out.status.success());
|
||||
}
|
||||
@@ -58,3 +58,7 @@ sync_github_name = false
|
||||
real_name = "Fabio William Conceição"
|
||||
display_name = "Messhias"
|
||||
sync_github_name = false
|
||||
|
||||
[people.KattKnight]
|
||||
display_name = "KattKnight (ayla.dsc)"
|
||||
sync_github_name = false
|
||||
@@ -129,7 +129,7 @@ fn default_ratio(h: &reader::Header) -> (Unit, f64) {
|
||||
|
||||
/// The name of `$INSUNITS` code `code`.
|
||||
fn units_name(code: i64) -> &'static str {
|
||||
const NAMES: [&str; 22] = [
|
||||
const NAMES: [&str; 25] = [
|
||||
"Unitless",
|
||||
"Inches",
|
||||
"Feet",
|
||||
@@ -152,6 +152,9 @@ fn units_name(code: i64) -> &'static str {
|
||||
"Light years",
|
||||
"Parsecs",
|
||||
"US survey feet",
|
||||
"US survey inches",
|
||||
"US survey yards",
|
||||
"US survey miles",
|
||||
];
|
||||
usize::try_from(code).ok().and_then(|i| NAMES.get(i)).copied().unwrap_or("Unitless")
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ pub(crate) struct Build<'d, 'a> {
|
||||
/// The unit of `$INSUNITS` code `code` in points (0, unitless: none).
|
||||
pub(crate) fn insunits_points(code: i64) -> Option<f64> {
|
||||
let m = 72.0 / 0.0254;
|
||||
// The US survey foot: exactly 1200/3937 m.
|
||||
let survey_ft = m * 1200.0 / 3937.0;
|
||||
Some(match code {
|
||||
1 => 72.0,
|
||||
2 => 864.0,
|
||||
@@ -87,7 +89,10 @@ pub(crate) fn insunits_points(code: i64) -> Option<f64> {
|
||||
18 => m * 1.495_978_707e11,
|
||||
19 => m * 9.460_730_472_580_8e15,
|
||||
20 => m * 3.085_677_581e16,
|
||||
21 => m * 1200.0 / 3937.0,
|
||||
21 => survey_ft,
|
||||
22 => survey_ft / 12.0,
|
||||
23 => survey_ft * 3.0,
|
||||
24 => survey_ft * 5280.0,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -128,6 +128,20 @@ fn units_choose_the_default_scale() {
|
||||
let um = info(&drawing(&[(9, "$INSUNITS"), (70, "13")], &[], &[], LINE)).unwrap();
|
||||
assert_eq!((um.unit, um.units.as_str()), (Unit::Millimeters, "Microns"));
|
||||
assert!((um.scale - 1000.0).abs() < 1e-6);
|
||||
// #1011: US survey inches, yards and miles (from the survey foot, 1200/3937 m), not unitless.
|
||||
let survey_ft = 72.0 / 0.0254 * 1200.0 / 3937.0;
|
||||
for (code, name, pt) in [
|
||||
("21", "US survey feet", survey_ft),
|
||||
("22", "US survey inches", survey_ft / 12.0),
|
||||
("23", "US survey yards", survey_ft * 3.0),
|
||||
("24", "US survey miles", survey_ft * 5280.0),
|
||||
] {
|
||||
let i = info(&drawing(&[(9, "$INSUNITS"), (70, code)], &[], &[], LINE)).unwrap();
|
||||
assert_eq!((i.unit, i.units.as_str()), (Unit::Millimeters, name));
|
||||
assert!((i.scale * pt - 72.0 / 25.4).abs() < 1e-9, "{name}: 1 mm is {} units", i.scale);
|
||||
}
|
||||
let survey_in = open(&drawing(&[(9, "$INSUNITS"), (70, "22")], &[], &[], LINE), &ImportOptions::default()).document;
|
||||
assert!((survey_in.artboards[0].rect.width() - 7200.0144000288).abs() < 1e-6, "{:?}", survey_in.artboards[0].rect);
|
||||
let imperial = info(&drawing(&[(9, "$MEASUREMENT"), (70, "0")], &[], &[], LINE)).unwrap();
|
||||
assert_eq!((imperial.unit, imperial.scale), (Unit::Inches, 1.0));
|
||||
// A ratio: 1 cm = 10 units is 1:1 in millimetres again.
|
||||
|
||||
@@ -131,10 +131,18 @@ impl Gradient {
|
||||
std::iter::once((s.offset, s.color, s.opacity, None)).chain(mid)
|
||||
})
|
||||
}
|
||||
/// The same ramp the other way round: the stops in reverse order at mirrored offsets, each
|
||||
/// segment's midpoint mirrored with it.
|
||||
pub fn reverse(&mut self) {
|
||||
self.stops.reverse();
|
||||
for s in &mut self.stops {
|
||||
s.offset = 1.0 - s.offset;
|
||||
for i in 0..self.stops.len() {
|
||||
// A midpoint belongs to the segment after its stop: reversed, stop i's segment is the
|
||||
// one the next stop (not yet mirrored) had, measured from the other end.
|
||||
let mid = self.stops.get(i + 1).map_or(0.5, |n| 1.0 - n.midpoint);
|
||||
if let Some(s) = self.stops.get_mut(i) {
|
||||
s.offset = 1.0 - s.offset;
|
||||
s.midpoint = mid;
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn sort(&mut self) {
|
||||
@@ -569,6 +577,32 @@ mod tests {
|
||||
assert_eq!(g.stops[0].offset, 0.0);
|
||||
}
|
||||
|
||||
/// #1007: reversing mirrors off-centre midpoints with their segments, so the ramp is the same
|
||||
/// the other way round.
|
||||
#[test]
|
||||
fn reverse_mirrors_midpoints() {
|
||||
let stop = |offset, hex, midpoint| GradientStop { midpoint, ..GradientStop::new(offset, Color::from_hex(hex).unwrap()) };
|
||||
let two = Gradient { kind: GradientKind::Linear, stops: vec![stop(0.0, "#ff0000", 0.2), stop(1.0, "#0000ff", 0.5)] };
|
||||
let three =
|
||||
Gradient { kind: GradientKind::Linear, stops: vec![stop(0.0, "#ff0000", 0.2), stop(0.3, "#00ff00", 0.8), stop(1.0, "#0000ff", 0.5)] };
|
||||
for g in [two, three] {
|
||||
let mut r = g.clone();
|
||||
r.reverse();
|
||||
let mids: Vec<f32> = r.stops.iter().map(|s| s.midpoint).collect();
|
||||
let want: Vec<f32> = g.stops.iter().rev().skip(1).map(|s| 1.0 - s.midpoint).chain([0.5]).collect();
|
||||
assert!(mids.iter().zip(&want).all(|(a, b)| (a - b).abs() < 1e-6), "{mids:?} {want:?}");
|
||||
for i in 0..=20 {
|
||||
let t = i as f32 / 20.0;
|
||||
let ((a, ao), (b, bo)) = (g.sample(t), r.sample(1.0 - t));
|
||||
let same = a.to_rgba8(1.0).iter().zip(b.to_rgba8(1.0)).all(|(x, y)| x.abs_diff(y) <= 1);
|
||||
assert!(same && (ao - bo).abs() < 1e-6, "t {t}: {a:?} vs {b:?}");
|
||||
}
|
||||
r.reverse();
|
||||
let back = r.stops.iter().zip(&g.stops).all(|(a, b)| (a.offset - b.offset).abs() < 1e-6 && (a.midpoint - b.midpoint).abs() < 1e-6);
|
||||
assert!(back, "reversed twice, it is as it was: {:?}", r.stops);
|
||||
}
|
||||
}
|
||||
|
||||
fn close(a: Point, b: Point) -> bool {
|
||||
a.distance(b) < 1e-9
|
||||
}
|
||||
|
||||
+73
-1
@@ -402,12 +402,17 @@ pub struct Guide {
|
||||
/// and moves, is copied and is deleted with it. None: a canvas guide, across the whole canvas.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub artboard: Option<u32>,
|
||||
/// The layer (or sublayer) it is on: it shows, hides and locks with that layer and is listed
|
||||
/// in it in the Layers panel, and goes when the layer is deleted. None: on no layer (guides
|
||||
/// from before guides had layers, and from files that don't say), always there.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub layer: Option<NodeId>,
|
||||
}
|
||||
|
||||
impl Guide {
|
||||
/// A canvas guide.
|
||||
pub fn new(vertical: bool, pos: f64) -> Self {
|
||||
Self { vertical, pos, artboard: None }
|
||||
Self { vertical, pos, artboard: None, layer: None }
|
||||
}
|
||||
|
||||
/// Moved by `d` (a vertical guide across, a horizontal one down).
|
||||
@@ -990,6 +995,73 @@ impl Document {
|
||||
let r = self.artboards.iter().find(|a| Some(a.id) == g.artboard)?.rect;
|
||||
Some(if g.vertical { (r.y0, r.y1) } else { (r.x0, r.x1) })
|
||||
}
|
||||
/// The layer (or sublayer) ruler guide `g` is on, if it is on one this document has.
|
||||
fn guide_layer(&self, g: &Guide) -> Option<NodeId> {
|
||||
g.layer.filter(|l| self.node(*l).is_some_and(Node::is_layer))
|
||||
}
|
||||
/// Is ruler guide `g` shown: on no layer, or on a layer that (with the layers around it) is
|
||||
/// visible? A guide whose layer is gone shows as one on no layer.
|
||||
pub fn guide_shown(&self, g: &Guide) -> bool {
|
||||
self.guide_layer(g).is_none_or(|l| self.is_visible(l))
|
||||
}
|
||||
/// Can ruler guide `g` be picked, moved and deleted: shown, and its layer unlocked?
|
||||
pub fn guide_editable(&self, g: &Guide) -> bool {
|
||||
self.guide_layer(g).is_none_or(|l| self.is_editable(l))
|
||||
}
|
||||
/// Put the ruler guides on no layer this document has (from files saved before guides had
|
||||
/// layers, or of layers merged into `layer`) on layer `layer`. Returns how many.
|
||||
pub fn rehome_guides(&mut self, layer: NodeId) -> usize {
|
||||
let lost: Vec<bool> = self.guides.iter().map(|g| self.guide_layer(g).is_none()).collect();
|
||||
let mut n = 0;
|
||||
for (g, lost) in self.guides.iter_mut().zip(lost) {
|
||||
if lost {
|
||||
g.layer = Some(layer);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
/// Put the ruler guides on no layer (from files saved before guides had layers), or on one that
|
||||
/// is gone, on the top visible, unlocked layer ([`Self::default_layer`]). Returns how many.
|
||||
pub fn adopt_guides(&mut self) -> usize {
|
||||
self.default_layer().map_or(0, |home| self.rehome_guides(home))
|
||||
}
|
||||
/// Delete the ruler guides whose layer was deleted (they go with it), keeping `sel`'s guides in
|
||||
/// step. Guides on no layer stay. Returns how many went.
|
||||
pub fn drop_guides_of_deleted_layers(&mut self, sel: &mut Selection) -> usize {
|
||||
let gone: Vec<bool> = self.guides.iter().map(|g| g.layer.is_some() && self.guide_layer(g).is_none()).collect();
|
||||
self.retain_guides(sel, |i, _| !gone.get(i).copied().unwrap_or(false))
|
||||
}
|
||||
/// Copy the ruler guides of layer `from` and of its sublayers onto their copies in `copy` (made
|
||||
/// from `from` by [`Self::reid`]: the same tree with fresh ids), as duplicating a layer copies
|
||||
/// the objects on it. Returns how many.
|
||||
pub fn copy_layer_guides(&mut self, from: &Node, copy: &Node) -> usize {
|
||||
let layers = |n: &Node| {
|
||||
let mut out = vec![];
|
||||
n.walk(&mut |c| {
|
||||
if c.is_layer() {
|
||||
out.push(c.id);
|
||||
}
|
||||
});
|
||||
out
|
||||
};
|
||||
let pairs: Vec<(NodeId, NodeId)> = layers(from).into_iter().zip(layers(copy)).collect();
|
||||
let copies: Vec<Guide> = self
|
||||
.guides
|
||||
.iter()
|
||||
.filter_map(|g| {
|
||||
let to = pairs.iter().find(|(a, _)| Some(*a) == g.layer)?.1;
|
||||
Some(Guide { layer: Some(to), ..g.clone() })
|
||||
})
|
||||
.collect();
|
||||
let n = copies.len();
|
||||
self.guides.extend(copies);
|
||||
n
|
||||
}
|
||||
/// The ruler guides on layer `layer` (not its sublayers'), by index.
|
||||
pub fn guides_on(&self, layer: NodeId) -> impl Iterator<Item = (usize, &Guide)> + '_ {
|
||||
self.guides.iter().enumerate().filter(move |(_, g)| g.layer == Some(layer))
|
||||
}
|
||||
/// Does ruler guide `g` run past `p` (up to `tol` beyond its ends)?
|
||||
pub fn guide_passes(&self, g: &Guide, p: Point, tol: f64) -> bool {
|
||||
let along = if g.vertical { p.y } else { p.x };
|
||||
|
||||
@@ -29,8 +29,9 @@ pub struct Selection {
|
||||
/// clears them.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub slices: Vec<NodeId>,
|
||||
/// Ruler guides selected with a selection tool, as indexes into [`Document::guides`]. They are
|
||||
/// selected on their own: any change to the object selection clears them.
|
||||
/// Ruler guides selected with a selection tool, as indexes into [`Document::guides`]. A new
|
||||
/// selection ([`Self::set`]) clears them; Shift-adding or -removing objects keeps them, so
|
||||
/// guides and objects can be selected together, in either order.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub guides: Vec<usize>,
|
||||
}
|
||||
@@ -66,7 +67,6 @@ impl Selection {
|
||||
pub fn add(&mut self, id: NodeId) {
|
||||
self.target = None;
|
||||
self.slices.clear();
|
||||
self.guides.clear();
|
||||
if !self.objects.contains(&id) {
|
||||
self.objects.push(id);
|
||||
}
|
||||
@@ -74,7 +74,6 @@ impl Selection {
|
||||
pub fn remove(&mut self, id: NodeId) {
|
||||
self.target = None;
|
||||
self.slices.clear();
|
||||
self.guides.clear();
|
||||
self.objects.retain(|x| *x != id);
|
||||
self.anchors.remove(&id);
|
||||
if self.key == Some(id) {
|
||||
@@ -99,6 +98,15 @@ impl Selection {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Add ruler guide `i` to the selection, or take it out, keeping the selected objects (a
|
||||
/// Shift-click on a guide).
|
||||
pub fn toggle_guide(&mut self, i: usize) {
|
||||
if let Some(k) = self.guides.iter().position(|g| *g == i) {
|
||||
self.guides.remove(k);
|
||||
} else {
|
||||
self.guides.push(i);
|
||||
}
|
||||
}
|
||||
pub fn toggle(&mut self, id: NodeId) {
|
||||
if self.contains(id) { self.remove(id) } else { self.add(id) }
|
||||
}
|
||||
@@ -117,7 +125,8 @@ impl Selection {
|
||||
self.target = None;
|
||||
}
|
||||
self.slices.retain(|id| doc.is_slice(*id));
|
||||
self.guides.retain(|i| *i < doc.guides.len());
|
||||
// Guides on a hidden or locked layer can't stay selected.
|
||||
self.guides.retain(|i| doc.guides.get(*i).is_some_and(|g| doc.guide_editable(g)));
|
||||
}
|
||||
/// Target `id` (see [`Selection::target`]): a layer gets its visible, unlocked art selected
|
||||
/// (the art of its sublayers too), anything else is selected itself.
|
||||
@@ -180,8 +189,16 @@ mod tests {
|
||||
s.set_guides([2, 0, 2]);
|
||||
assert_eq!((s.objects.clone(), s.guides.clone()), (vec![], vec![2, 0]));
|
||||
assert!(s.has_objects_or_guides() && s.is_empty());
|
||||
// Shift-adding an object keeps them; a new selection doesn't.
|
||||
s.add(NodeId(1));
|
||||
assert_eq!(s.guides, vec![2, 0]);
|
||||
s.set([NodeId(1)]);
|
||||
assert!(s.guides.is_empty());
|
||||
// Toggled, a guide joins (or leaves) the selected objects.
|
||||
s.toggle_guide(0);
|
||||
assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![0]));
|
||||
s.toggle_guide(0);
|
||||
assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![]));
|
||||
// Pruning drops guides the document no longer has.
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
d.guides.push(crate::Guide::new(true, 10.0));
|
||||
@@ -190,6 +207,36 @@ mod tests {
|
||||
assert_eq!(s.guides, vec![0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guides_show_and_lock_with_their_layer() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let layer = d.layers[0].id;
|
||||
let g = crate::Guide { layer: Some(layer), ..crate::Guide::new(true, 10.0) };
|
||||
assert!(d.guide_shown(&g) && d.guide_editable(&g));
|
||||
d.node_mut(layer).unwrap().locked = true;
|
||||
assert!(d.guide_shown(&g) && !d.guide_editable(&g), "locked: shown, not editable");
|
||||
d.node_mut(layer).unwrap().visible = false;
|
||||
assert!(!d.guide_shown(&g) && !d.guide_editable(&g), "hidden");
|
||||
// On no layer, or a layer that's gone: always there.
|
||||
assert!(d.guide_shown(&crate::Guide::new(false, 5.0)));
|
||||
let gone = crate::Guide { layer: Some(NodeId(9999)), ..crate::Guide::new(true, 1.0) };
|
||||
assert!(d.guide_shown(&gone) && d.guide_editable(&gone));
|
||||
d.guides.push(g);
|
||||
assert_eq!(d.guides_on(layer).count(), 1);
|
||||
// Guides on no layer, or a gone one, are adopted by the top visible, unlocked layer.
|
||||
d.node_mut(layer).unwrap().visible = true;
|
||||
d.node_mut(layer).unwrap().locked = false;
|
||||
d.guides.push(crate::Guide::new(false, 5.0));
|
||||
d.guides.push(gone);
|
||||
assert_eq!(d.adopt_guides(), 2);
|
||||
assert!(d.guides.iter().all(|g| g.layer == Some(layer)));
|
||||
assert_eq!(d.adopt_guides(), 0, "once");
|
||||
// A guide saved before guides had layers reads as one on no layer.
|
||||
let old: crate::Guide = serde_json::from_str(r#"{"vertical": true, "pos": 5.0}"#).unwrap();
|
||||
assert_eq!(old.layer, None);
|
||||
assert!(!serde_json::to_string(&crate::Guide::new(true, 5.0)).unwrap().contains("layer"), "nothing new written");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_changes_clear_the_target() {
|
||||
let mut s = Selection::default();
|
||||
|
||||
@@ -80,12 +80,13 @@ fn clean_name(name: &str) -> Option<String> {
|
||||
|
||||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "assets.add";
|
||||
let ids = match ids_param(p, "ids") {
|
||||
Some(ids) => {
|
||||
let d = &s.doc()?.doc;
|
||||
roots_of(d, d.paint_order(ids))
|
||||
}
|
||||
None => selected_roots(s)?,
|
||||
let ids = if p.get("ids").is_some_and(|v| !v.is_null()) {
|
||||
// An explicit list is authoritative: never silently drop malformed or stale ids.
|
||||
let ids = checked_ids_param(s, p, "ids", C)?;
|
||||
let d = &s.doc()?.doc;
|
||||
roots_of(d, d.paint_order(ids))
|
||||
} else {
|
||||
selected_roots(s)?
|
||||
};
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "select the art to collect (or give its ids)"));
|
||||
|
||||
@@ -603,7 +603,7 @@ fn symbol_place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let centre = d.artboards.first().map(|a| a.rect.center()).unwrap_or(Point::ZERO);
|
||||
let c = Point::new(f64_or(p, "x", centre.x), f64_or(p, "y", centre.y));
|
||||
let size = natural_size(d, &name);
|
||||
let parent = s.doc()?.insertion_parent();
|
||||
let parent = s.doc()?.target_parent()?;
|
||||
let id = s.edit("Place Symbol Instance", |d, sel| {
|
||||
let id = d.alloc_id();
|
||||
d.insert(parent, usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: name.clone(), xf: place_xf(c, size) }))?;
|
||||
@@ -843,7 +843,8 @@ fn symbol_spray(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let alt = bool_or(p, "alt", false);
|
||||
let size = natural_size(&st.doc, &name);
|
||||
let set = st.selection.objects.first().copied().filter(|id| st.selection.len() == 1 && st.doc.node(*id).is_some_and(is_symbol_set));
|
||||
let parent = st.insertion_parent();
|
||||
// Only a new Symbol Set needs a layer that takes new art.
|
||||
let parent = if alt || set.is_some() { st.insertion_parent() } else { st.target_parent()? };
|
||||
if alt {
|
||||
// Remove instances of this symbol under the brush (within the set, or anywhere).
|
||||
let d = &st.doc;
|
||||
|
||||
@@ -79,7 +79,7 @@ pub(crate) fn add_node(s: &mut Session, label: &str, kind: NodeKind, appearance:
|
||||
|
||||
/// Insert a new object looking like `look` (drawing modes apply); select it.
|
||||
pub(crate) fn add_look(s: &mut Session, label: &str, kind: NodeKind, look: NewArt, name: Option<String>) -> Result<Value> {
|
||||
let parent = s.doc()?.insertion_parent();
|
||||
let parent = s.doc()?.target_parent()?;
|
||||
let mode = s.draw_mode;
|
||||
let inside = s.draw_inside;
|
||||
let behind_of = s.doc()?.selection.in_paint_order(&s.doc()?.doc).first().copied();
|
||||
@@ -298,7 +298,7 @@ fn flare(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
parts.push(("Ring", circle(at, rr), radial(at, rr, stops), 1.0));
|
||||
}
|
||||
let parent = s.doc()?.insertion_parent();
|
||||
let parent = s.doc()?.target_parent()?;
|
||||
let id = s.edit("Flare", |d, sel| {
|
||||
let children = parts
|
||||
.into_iter()
|
||||
@@ -352,7 +352,7 @@ fn arc(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn grid_group(s: &mut Session, label: &str, paths: Vec<PathData>) -> Result<Value> {
|
||||
let parent = s.doc()?.insertion_parent();
|
||||
let parent = s.doc()?.target_parent()?;
|
||||
let look = s.new_art_look(vectorcraft_color::Paint::None, s.paint.stroke.clone(), s.paint.stroke_width);
|
||||
let id = s.edit(label, |d, sel| {
|
||||
let children = paths
|
||||
@@ -385,15 +385,24 @@ fn polar_grid(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
grid_group(s, "Polar Grid", shapes::polar_grid(r, c, rad))
|
||||
}
|
||||
|
||||
pub(crate) fn anchor_from_json(v: &Value) -> Option<Anchor> {
|
||||
let p = Point::new(v.get("x")?.as_f64()?, v.get("y")?.as_f64()?);
|
||||
let h_in = point_param(v, "in").unwrap_or(p);
|
||||
let h_out = point_param(v, "out").unwrap_or(p);
|
||||
let mut a = Anchor::with_handles(p, h_in, h_out);
|
||||
if v.get("smooth").and_then(Value::as_bool) == Some(true) {
|
||||
a.kind = AnchorKind::Smooth;
|
||||
/// An anchor `{x, y, in?, out?, smooth?}` given to `cmd` (handles `[x, y]`, at the anchor when
|
||||
/// absent or null): anything malformed fails rather than being left out or replaced by a default,
|
||||
/// which would change the path's shape.
|
||||
pub(crate) fn anchor_from_json(v: &Value, cmd: &str) -> Result<Anchor> {
|
||||
let coord =
|
||||
|key: &str| v.get(key).and_then(Value::as_f64).filter(|n| n.is_finite()).ok_or_else(|| bad(cmd, format!("anchor `{key}` must be a number")));
|
||||
let p = Point::new(coord("x")?, coord("y")?);
|
||||
let handle = |key: &str| match v.get(key).filter(|h| !h.is_null()) {
|
||||
None => Ok(p),
|
||||
Some(h) => finite_numbers(h).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad(cmd, format!("anchor `{key}` must be [x, y]"))),
|
||||
};
|
||||
let mut anchor = Anchor::with_handles(p, handle("in")?, handle("out")?);
|
||||
match v.get("smooth").filter(|b| !b.is_null()) {
|
||||
None | Some(Value::Bool(false)) => {}
|
||||
Some(Value::Bool(true)) => anchor.kind = AnchorKind::Smooth,
|
||||
Some(_) => return Err(bad(cmd, "anchor `smooth` must be true or false")),
|
||||
}
|
||||
Some(a)
|
||||
Ok(anchor)
|
||||
}
|
||||
|
||||
fn path_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -406,8 +415,8 @@ fn path_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| bad("path.create", "missing anchors"))?
|
||||
.iter()
|
||||
.filter_map(anchor_from_json)
|
||||
.collect();
|
||||
.map(|a| anchor_from_json(a, "path.create"))
|
||||
.collect::<Result<_>>()?;
|
||||
if anchors.is_empty() {
|
||||
return Err(bad("path.create", "need at least one anchor"));
|
||||
}
|
||||
|
||||
@@ -221,8 +221,8 @@ fn rect_cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.rectCut";
|
||||
let rect = match p.get("rect") {
|
||||
Some(v) => {
|
||||
let a: Vec<f64> = v.as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
|
||||
let [x, y, w, h] = a[..] else { return Err(bad(C, "rect must be [x, y, width, height]")) };
|
||||
// All four or nothing: a malformed entry dropped would cut a different region.
|
||||
let [x, y, w, h] = finite_numbers(v).ok_or_else(|| bad(C, "rect must be [x, y, width, height]"))?;
|
||||
Rect::new(x, y, x + w, y + h).abs()
|
||||
}
|
||||
None => Rect::from_points(point_req(p, "from", C)?, point_req(p, "to", C)?),
|
||||
|
||||
@@ -160,7 +160,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add Guide",
|
||||
[],
|
||||
None,
|
||||
"{vertical: bool, pos: pt (x for vertical, y for horizontal), artboard?: index (an artboard guide: it runs across that artboard only and moves, is copied and is deleted with it; default a canvas guide, across the whole canvas)} → {index}",
|
||||
"{vertical: bool, pos: pt (x for vertical, y for horizontal), artboard?: index (an artboard guide: it runs across that artboard only and moves, is copied and is deleted with it; default a canvas guide, across the whole canvas), layer?: id (the layer it is on, shown, hidden, locked and deleted with it; default the current layer)} → {index}",
|
||||
has_doc,
|
||||
guide_add
|
||||
),
|
||||
@@ -169,7 +169,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Guides",
|
||||
[],
|
||||
None,
|
||||
"{} → [{index, vertical, pos, selected, artboard?: index (an artboard guide's)}…] the ruler guides",
|
||||
"{} → [{index, vertical, pos, selected, artboard?: index (an artboard guide's), layer?: id (the layer it is on), shown, editable (its layer shown and unlocked)}…] the ruler guides",
|
||||
has_doc,
|
||||
guide_list
|
||||
),
|
||||
@@ -178,7 +178,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Select Guides",
|
||||
[],
|
||||
None,
|
||||
"{indexes: [index…], toggle?: bool} select ruler guides on their own (deselecting the art); toggle adds or removes them instead → {selected: [index…]}",
|
||||
"{indexes: [index…], toggle?: bool} select ruler guides on their own (deselecting the art); toggle adds or removes them instead, keeping the selected art (a Shift-click on a guide) → {selected: [index…]}",
|
||||
guides_unlocked,
|
||||
guide_select
|
||||
),
|
||||
@@ -200,6 +200,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
guides_unlocked,
|
||||
guide_move
|
||||
),
|
||||
cmd!(
|
||||
"guide.setLayer",
|
||||
"Move Guide to Layer",
|
||||
[],
|
||||
None,
|
||||
"{index?, indexes?: [index…], layer: id} put ruler guide `index` (or guides `indexes`), else the selected guides, on layer `layer` (a layer or sublayer), as dragging their rows onto that layer's row in the Layers panel does → {count}",
|
||||
guides_unlocked,
|
||||
guide_set_layer
|
||||
),
|
||||
cmd!("file.closeAll", "Close All", ["File"], Some("Cmd+Alt+W"), "{} → {closed}", has_doc, close_all),
|
||||
cmd!(
|
||||
"file.documentColorMode",
|
||||
@@ -507,8 +516,15 @@ fn guide_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let st = s.doc()?;
|
||||
let layer = match id_param(p, "layer") {
|
||||
Some(l) if st.doc.node(l).is_some_and(Node::is_layer) => Some(l),
|
||||
Some(_) => return Err(bad("guide.add", "layer must name a layer or sublayer")),
|
||||
// Where new art goes (refused when no layer is shown and unlocked), or that group's layer.
|
||||
None => st.target_parent()?.and_then(|p| if st.doc.node(p).is_some_and(Node::is_layer) { Some(p) } else { st.doc.layer_containing(p) }),
|
||||
};
|
||||
let i = s.edit("New Guide", |d, _| {
|
||||
d.guides.push(Guide { artboard, ..Guide::new(vertical, pos) });
|
||||
d.guides.push(Guide { artboard, layer, ..Guide::new(vertical, pos) });
|
||||
Ok(d.guides.len() - 1)
|
||||
})?;
|
||||
Ok(json!({ "index": i }))
|
||||
@@ -521,6 +537,11 @@ fn guide_list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
if let Some(ab) = st.doc.artboards.iter().position(|a| Some(a.id) == g.artboard) {
|
||||
r["artboard"] = json!(ab);
|
||||
}
|
||||
if let Some(l) = g.layer {
|
||||
r["layer"] = json!(l.0);
|
||||
}
|
||||
r["shown"] = json!(st.doc.guide_shown(g));
|
||||
r["editable"] = json!(st.doc.guide_editable(g));
|
||||
r
|
||||
};
|
||||
Ok(Value::Array(st.doc.guides.iter().enumerate().map(row).collect()))
|
||||
@@ -535,19 +556,26 @@ fn guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result<usize> {
|
||||
Ok(i)
|
||||
}
|
||||
|
||||
/// [`guide_index`] of a guide that can be moved or deleted: not on a hidden or locked layer.
|
||||
fn editable_guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result<usize> {
|
||||
let i = guide_index(s, v, cmd)?;
|
||||
let d = &s.doc()?.doc;
|
||||
if !d.guides.get(i).is_some_and(|g| d.guide_editable(g)) {
|
||||
return Err(EngineError::Other("the guide is on a hidden or locked layer".into()));
|
||||
}
|
||||
Ok(i)
|
||||
}
|
||||
|
||||
fn guide_select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let list = p.get("indexes").and_then(Value::as_array).ok_or_else(|| bad("guide.select", "missing indexes"))?;
|
||||
let picked = list.iter().map(|v| guide_index(s, Some(v), "guide.select")).collect::<Result<Vec<_>>>()?;
|
||||
if bool_or(p, "toggle", false) {
|
||||
let mut now = s.doc()?.selection.guides.clone();
|
||||
for i in picked {
|
||||
if let Some(k) = now.iter().position(|g| *g == i) {
|
||||
now.remove(k);
|
||||
} else {
|
||||
now.push(i);
|
||||
// Added to (or taken from) what is selected, the art included.
|
||||
s.select(|_, sel| {
|
||||
for i in picked {
|
||||
sel.toggle_guide(i);
|
||||
}
|
||||
}
|
||||
s.select(|_, sel| sel.set_guides(now))?;
|
||||
})?;
|
||||
} else {
|
||||
s.select(|_, sel| sel.set_guides(picked))?;
|
||||
}
|
||||
@@ -557,7 +585,7 @@ fn guide_select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Delete ruler guide `index`, else the selected guides, in one undo step → {count}.
|
||||
pub(crate) fn guide_remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let gone: Vec<usize> = match p.get("index") {
|
||||
Some(v) => vec![guide_index(s, Some(v), "guide.remove")?],
|
||||
Some(v) => vec![editable_guide_index(s, Some(v), "guide.remove")?],
|
||||
None => s.doc()?.selection.guides.clone(),
|
||||
};
|
||||
if gone.is_empty() {
|
||||
@@ -571,10 +599,39 @@ pub(crate) fn guide_remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "count": gone.len() }))
|
||||
}
|
||||
|
||||
/// Put ruler guide `index`, else the selected guides, on layer `layer`, in one undo step.
|
||||
fn guide_set_layer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "guide.setLayer";
|
||||
let d = &s.doc()?.doc;
|
||||
let layer =
|
||||
id_param(p, "layer").filter(|l| d.node(*l).is_some_and(Node::is_layer)).ok_or_else(|| bad(C, "layer must name a layer or sublayer"))?;
|
||||
// As objects dragged in the Layers panel: never onto a locked layer.
|
||||
if super::layer::locked_within(d, layer) {
|
||||
return Err(EngineError::Other("the layer or group is locked".into()));
|
||||
}
|
||||
let moving: Vec<usize> = match (p.get("index"), p.get("indexes").and_then(Value::as_array)) {
|
||||
(Some(v), _) => vec![editable_guide_index(s, Some(v), C)?],
|
||||
(None, Some(list)) => list.iter().take(10_000).map(|v| editable_guide_index(s, Some(v), C)).collect::<Result<Vec<_>>>()?,
|
||||
(None, None) => s.doc()?.selection.guides.clone(),
|
||||
};
|
||||
if moving.is_empty() {
|
||||
return Err(bad(C, "no guide selected (or give an index)"));
|
||||
}
|
||||
s.edit("Move Guide to Layer", |d, _| {
|
||||
for i in &moving {
|
||||
if let Some(g) = d.guides.get_mut(*i) {
|
||||
g.layer = Some(layer);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "count": moving.len() }))
|
||||
}
|
||||
|
||||
/// Put ruler guide `index` at `pos`, else move (or copy) the selected guides, in one undo step.
|
||||
pub(crate) fn guide_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = p.get("index") {
|
||||
let i = guide_index(s, Some(v), "guide.move")?;
|
||||
let i = editable_guide_index(s, Some(v), "guide.move")?;
|
||||
let pos = f64_req(p, "pos", "guide.move")?;
|
||||
s.edit("Move Guide", |d, _| {
|
||||
if let Some(g) = d.guides.get_mut(i) {
|
||||
|
||||
@@ -268,6 +268,8 @@ fn clear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return super::docmenu::guide_remove(s, &json!({}));
|
||||
}
|
||||
let st = s.doc()?;
|
||||
// Guides selected with the art go with it (not when ids are given).
|
||||
let with_guides = explicit.is_none();
|
||||
let targets = explicit.unwrap_or_else(|| st.selection.in_paint_order(&st.doc));
|
||||
// Keep the selected ids themselves, without promoting compound members. Delete a selected
|
||||
// ancestor just once; its selected descendants leave with it. Layers themselves are kept.
|
||||
@@ -284,6 +286,10 @@ fn clear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for id in &ids {
|
||||
d.remove(*id)?;
|
||||
}
|
||||
if with_guides {
|
||||
let gone = sel.guides.clone();
|
||||
d.retain_guides(sel, |i, _| !gone.contains(&i));
|
||||
}
|
||||
sel.clear();
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -346,7 +352,7 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
let off = s.prefs.paste_offset;
|
||||
let st = s.doc()?;
|
||||
let same_doc = clip.source_doc == Some(st.uid);
|
||||
let parent = st.insertion_parent();
|
||||
let parent = st.target_parent()?;
|
||||
// A copied artboard comes back with its art: right of the last artboard, or where it was
|
||||
// (Paste in Place, in Front, in Back). Paste on All Artboards pastes only the art.
|
||||
let board = clip
|
||||
|
||||
@@ -108,6 +108,15 @@ showpage
|
||||
let inner = outer.children().map(|c| (c.len(), c[1].children().map(Vec::len))).unwrap();
|
||||
assert!(matches!(outer.kind, vectorcraft_doc::NodeKind::Group { clip: false, .. }), "{:?}", outer.kind);
|
||||
assert_eq!(inner, (2, Some(2)));
|
||||
// One that names the prolog defining its operators without including it (as other apps write
|
||||
// the format) opens with the layers its program has (#1027).
|
||||
let named = b"%!PS-Adobe-3.0\n%%BoundingBox: 0 0 100 100\n%%DocumentNeededResources: procset Adobe_IllustratorA_AI3 1.0 0\n%AI3_TemplateBox: 50 50 50 50\n%%EndComments\n\
|
||||
%%BeginProlog\n%%IncludeResource: procset Adobe_IllustratorA_AI3 1.0 0\n%%EndProlog\n%%BeginSetup\nAdobe_IllustratorA_AI3 /initialize get exec\n%%EndSetup\n\
|
||||
%AI5_BeginLayer\n1 1 1 1 0 0 2 0 0 0 Lb\n(PLAN::Walls) Ln\n0 0 0 1 K\n0 i 1 w\n10 10 m\n90 90 L\nS\nLB\n%AI5_EndLayer--\n%%PageTrailer\n%%Trailer\n%%EOF\n";
|
||||
let r = open(&mut s, "cad.ai", named).unwrap();
|
||||
assert_eq!(r["warnings"].as_array().map_or(0, Vec::len), 0, "{r}");
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
assert_eq!((d.layers.len(), d.layers[0].name.as_deref(), objects(&s)), (1, Some("PLAN::Walls"), 1));
|
||||
// A .ait one opens as a new untitled document.
|
||||
let r = open(&mut s, "legacy.ait", ps).unwrap();
|
||||
assert!(r["title"].as_str().unwrap().starts_with("Untitled"), "{r}");
|
||||
|
||||
@@ -270,13 +270,8 @@ pub(crate) fn apply_gradient_edit_in(
|
||||
gp.swatch = None;
|
||||
}
|
||||
if bool_or(p, "reverse", false) {
|
||||
// Midpoints mirrored with their segments.
|
||||
gp.gradient.reverse();
|
||||
// Midpoints belong to the segment to the right; mirror them.
|
||||
let n = gp.gradient.stops.len();
|
||||
let mids: Vec<f32> = gp.gradient.stops.iter().map(|s| s.midpoint).collect();
|
||||
for i in 0..n {
|
||||
gp.gradient.stops[i].midpoint = if i + 1 < n { 1.0 - mids[n - 2 - i] } else { 0.5 };
|
||||
}
|
||||
}
|
||||
// Angle and aspect edits keep the focal point in its place in the extent ellipse.
|
||||
let before = gp.geom;
|
||||
|
||||
@@ -831,7 +831,7 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
spec.categories = vec!["A".into(), "B".into(), "C".into(), "D".into()];
|
||||
spec.rows = vec![vec![3.0, 2.0], vec![5.0, 4.0], vec![4.0, 6.0], vec![7.0, 5.0]];
|
||||
}
|
||||
let parent = s.doc()?.insertion_parent();
|
||||
let parent = s.doc()?.target_parent()?;
|
||||
let id = s.edit("Graph", |d, sel| {
|
||||
let gid = d.alloc_id();
|
||||
let mut g = Node::group(gid, vec![]);
|
||||
|
||||
@@ -221,7 +221,7 @@ pub(crate) fn accepts(d: &Document, parent: Option<NodeId>, n: &Node) -> bool {
|
||||
}
|
||||
|
||||
/// Whether `id` or a container around it is locked.
|
||||
fn locked_within(d: &Document, id: NodeId) -> bool {
|
||||
pub(crate) fn locked_within(d: &Document, id: NodeId) -> bool {
|
||||
d.ancestry(id).unwrap_or_default().iter().any(|a| d.node(*a).is_some_and(|n| n.locked))
|
||||
}
|
||||
|
||||
@@ -462,6 +462,8 @@ fn delete_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for id in &ids {
|
||||
d.remove(*id)?;
|
||||
}
|
||||
// The guides on the deleted layers go with them.
|
||||
d.drop_guides_of_deleted_layers(sel);
|
||||
sel.prune(d);
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -487,6 +489,7 @@ fn duplicate_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut c = d.reid(&n);
|
||||
if n.is_layer() {
|
||||
c.name = Some(format!("{} copy", n.display_name()));
|
||||
d.copy_layer_guides(&n, &c);
|
||||
}
|
||||
out.push(d.insert(par, idx + 1, c)?);
|
||||
}
|
||||
@@ -679,7 +682,11 @@ fn move_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for id in &ids {
|
||||
nodes.push(if copy {
|
||||
let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?;
|
||||
d.reid(&n)
|
||||
let c = d.reid(&n);
|
||||
if n.is_layer() {
|
||||
d.copy_layer_guides(&n, &c);
|
||||
}
|
||||
c
|
||||
} else {
|
||||
Arc::unwrap_or_clone(d.remove(*id)?)
|
||||
});
|
||||
|
||||
@@ -263,6 +263,8 @@ fn merge_into(d: &mut Document, into: NodeId, others: &[NodeId]) -> Result<()> {
|
||||
all.extend(taken);
|
||||
all.sort_by(|a, b| keys.get(&a.id).cmp(&keys.get(&b.id)));
|
||||
*ch = all;
|
||||
// The merged layers' guides go to it too.
|
||||
d.rehome_guides(into);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -311,6 +313,8 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for h in &hidden {
|
||||
d.remove(*h)?;
|
||||
}
|
||||
// Hidden layers are discarded with their guides.
|
||||
d.drop_guides_of_deleted_layers(sel);
|
||||
merge_into(d, into, &others)?;
|
||||
sel.prune(d);
|
||||
Ok(())
|
||||
|
||||
@@ -702,7 +702,7 @@ fn trim_marks(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if rects.is_empty() {
|
||||
return Err(bad(C, "nothing to mark"));
|
||||
}
|
||||
let parent = s.doc()?.insertion_parent();
|
||||
let parent = s.doc()?.target_parent()?;
|
||||
let ids = s.edit("Create Trim Marks", |d, sel| {
|
||||
let mut ids = vec![];
|
||||
for r in &rects {
|
||||
|
||||
@@ -324,6 +324,17 @@ pub(crate) fn checked_id(s: &Session, value: &Value, command: &str) -> Result<No
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// `v` as exactly `N` finite numbers (`[x, y]`, `[x, y, width, height]`…): `None` when it isn't an
|
||||
/// array of that length or an entry isn't a number, so nothing malformed is left out or padded.
|
||||
pub(crate) fn finite_numbers<const N: usize>(v: &Value) -> Option<[f64; N]> {
|
||||
let a = v.as_array().filter(|a| a.len() == N)?;
|
||||
let mut out = [0.0; N];
|
||||
for (o, n) in out.iter_mut().zip(a) {
|
||||
*o = n.as_f64().filter(|n| n.is_finite())?;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
pub(crate) fn point_param(p: &Value, key: &str) -> Option<Point> {
|
||||
let a = p.get(key)?.as_array()?;
|
||||
Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))
|
||||
|
||||
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fill",
|
||||
[],
|
||||
None,
|
||||
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself)",
|
||||
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself); while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only",
|
||||
has_doc,
|
||||
|s, p| set_paint(s, p, true)
|
||||
),
|
||||
@@ -375,15 +375,35 @@ fn apply_to_proxy(s: &mut Session, p: &Value, paint: Paint) -> Result<Value> {
|
||||
/// remember the paint.
|
||||
fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<Value> {
|
||||
let cmd = if fill { "paint.setFill" } else { "paint.setStroke" };
|
||||
// New art gets the paint fitted to itself, not placed where this one is.
|
||||
if fill {
|
||||
s.paint.fill = unplaced(&paint);
|
||||
} else {
|
||||
s.paint.stroke = unplaced(&paint);
|
||||
}
|
||||
if bool_or(p, "focus", true) {
|
||||
s.fill_active = fill;
|
||||
// Characters selected with the Type tool take the paint, not the whole type object (#1063).
|
||||
if p.get("ids").is_none()
|
||||
&& p.get("item").is_none()
|
||||
&& let Some((id, a, b)) = super::textedit::editing_range(s)?
|
||||
&& a != b
|
||||
{
|
||||
s.edit(if fill { "Fill Color" } else { "Stroke Color" }, |d, _| {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
|
||||
let (xf, lb) = (t.xf, t.local_bounds());
|
||||
vectorcraft_text::edit::style_range(&mut t.runs, a, b, |st| {
|
||||
if fill {
|
||||
st.fill = place_run_paint(&paint, p, xf, lb);
|
||||
} else {
|
||||
if !paint.is_none() {
|
||||
run_stroke_weight(st);
|
||||
}
|
||||
st.stroke = place_run_paint(&paint, p, xf, vectorcraft_doc::appearance::stroke_paint_bounds(lb, st.stroke_width));
|
||||
}
|
||||
});
|
||||
super::typecmd::refresh_bounds(t);
|
||||
Ok(())
|
||||
})?;
|
||||
if bool_or(p, "focus", true) {
|
||||
s.fill_active = fill;
|
||||
}
|
||||
s.remember_paint(&paint);
|
||||
return ok();
|
||||
}
|
||||
// Resolve the requested targets before mutating defaults or the active proxy.
|
||||
let item = item_target(s, p, cmd)?.of_kind(s, fill);
|
||||
let selected = item.targets(s, p)?;
|
||||
let ids = super::graph::paint_targets(&s.doc()?.doc, &selected);
|
||||
@@ -423,6 +443,16 @@ fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<V
|
||||
Ok(())
|
||||
},
|
||||
)?;
|
||||
// Rejected edits leave the next shape's appearance and active proxy untouched.
|
||||
// New art takes an unplaced gradient so that it fits the new object's own bounds.
|
||||
if fill {
|
||||
s.paint.fill = unplaced(&paint);
|
||||
} else {
|
||||
s.paint.stroke = unplaced(&paint);
|
||||
}
|
||||
if bool_or(p, "focus", true) {
|
||||
s.fill_active = fill;
|
||||
}
|
||||
s.remember_paint(&paint);
|
||||
ok()
|
||||
}
|
||||
@@ -436,7 +466,6 @@ fn set_run_stroke(style: &mut CharStyle, paint: Paint) {
|
||||
}
|
||||
|
||||
fn swap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
std::mem::swap(&mut s.paint.fill, &mut s.paint.stroke);
|
||||
let ids = paint_targets(s, p)?;
|
||||
if !ids.is_empty() {
|
||||
s.edit("Swap Fill and Stroke", |d, _| {
|
||||
@@ -458,11 +487,11 @@ fn swap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
std::mem::swap(&mut s.paint.fill, &mut s.paint.stroke);
|
||||
ok()
|
||||
}
|
||||
|
||||
fn default_paint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
super::newart::reset(s);
|
||||
let ids = paint_targets(s, p)?;
|
||||
if !ids.is_empty() {
|
||||
s.edit("Default Fill and Stroke", |d, _| {
|
||||
@@ -481,6 +510,7 @@ fn default_paint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
super::newart::reset(s);
|
||||
ok()
|
||||
}
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ fn path_mut(d: &mut vectorcraft_doc::Document, id: NodeId) -> Result<&mut PathDa
|
||||
|
||||
fn append_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("path.appendAnchor", "missing id"))?;
|
||||
let a = anchor_from_json(p).ok_or_else(|| bad("path.appendAnchor", "missing x/y"))?;
|
||||
let a = anchor_from_json(p, "path.appendAnchor")?;
|
||||
s.edit("Pen", |d, sel| {
|
||||
let path = path_mut(d, id)?;
|
||||
match path.subpaths.last_mut() {
|
||||
@@ -217,12 +217,21 @@ fn set_anchors(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let data = PathData::new(
|
||||
subs.iter()
|
||||
.map(|sp| {
|
||||
SubPath::new(
|
||||
sp.get("anchors").and_then(Value::as_array).map(|a| a.iter().filter_map(anchor_from_json).collect()).unwrap_or_default(),
|
||||
bool_or(sp, "closed", false),
|
||||
)
|
||||
let anchors = sp
|
||||
.get("anchors")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| bad("path.setAnchors", "each subpath needs an anchors array"))?
|
||||
.iter()
|
||||
.map(|a| anchor_from_json(a, "path.setAnchors"))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let closed = match sp.get("closed") {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(Value::Bool(v)) => *v,
|
||||
Some(_) => return Err(bad("path.setAnchors", "closed must be a boolean")),
|
||||
};
|
||||
Ok(SubPath::new(anchors, closed))
|
||||
})
|
||||
.collect(),
|
||||
.collect::<Result<Vec<_>>>()?,
|
||||
);
|
||||
s.edit("Reshape", |d, _| {
|
||||
*path_mut(d, id)? = data;
|
||||
|
||||
@@ -309,13 +309,22 @@ fn transformed(mut node: Node, m: Affine) -> Node {
|
||||
/// `[x, y, width, height]` with a positive size.
|
||||
fn rect_param(p: &Value) -> Result<Option<Rect>> {
|
||||
let Some(v) = p.get("rect") else { return Ok(None) };
|
||||
let n: Vec<f64> = v.as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
|
||||
match n[..] {
|
||||
[x, y, w, h] if w > 0.0 && h > 0.0 => Ok(Some(Rect::new(x, y, x + w, y + h))),
|
||||
// All four or nothing: a malformed entry dropped would shift the others ([0, "bad", 10, 40, 50]).
|
||||
match finite_numbers(v) {
|
||||
Some([x, y, w, h]) if w > 0.0 && h > 0.0 => Ok(Some(Rect::new(x, y, x + w, y + h))),
|
||||
_ => Err(bad(PLACE, "rect must be [x, y, width, height] with a positive width and height")),
|
||||
}
|
||||
}
|
||||
|
||||
/// `at`, the `[x, y]` the art is centred on (absent or null: none); anything else fails rather than
|
||||
/// placing the art as if no point were given.
|
||||
fn at_param(p: &Value) -> Result<Option<Point>> {
|
||||
p.get("at")
|
||||
.filter(|v| !v.is_null())
|
||||
.map(|v| finite_numbers(v).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad(PLACE, "at must be [x, y]")))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// The transform that puts new art with the natural box `natural` where `old` is: an image's scale
|
||||
/// and rotation relative to its 100% size (its resolution's) and its centre are kept (with
|
||||
/// `image_xf`); other objects keep their centre.
|
||||
@@ -342,7 +351,7 @@ fn fit(natural: Rect, r: Rect) -> Affine {
|
||||
fn place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let replace = bool_or(p, "replace", false);
|
||||
let template = bool_or(p, "template", false);
|
||||
let at = point_param(p, "at");
|
||||
let at = at_param(p)?;
|
||||
let rect = rect_param(p)?;
|
||||
if replace && (template || at.is_some() || rect.is_some()) {
|
||||
return Err(bad(PLACE, "replace keeps the replaced object's place and transform: drop template, at and rect"));
|
||||
|
||||
@@ -152,7 +152,8 @@ fn render(doc: &Document, nodes: Vec<Node>, region: Rect, scale: f64, anti_alias
|
||||
tmp.layers = vec![Arc::new(layer)];
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
r.threads = 0;
|
||||
r.render_region_with(&tmp, region, scale, &RenderOptions { skip_templates: true, anti_alias, ..Default::default() })
|
||||
// Art Optimized is supersampled, as in exports: shapes meeting edge to edge leave no seam.
|
||||
r.render_region_with(&tmp, region, scale, &RenderOptions { skip_templates: true, anti_alias, supersample: true, ..Default::default() })
|
||||
}
|
||||
|
||||
/// Pixels per point of raster effects rendered as images (the document's raster effects
|
||||
|
||||
@@ -294,17 +294,35 @@ fn key(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// `[[subpath, anchor]…]` anchor references (malformed entries are left out).
|
||||
pub(crate) fn parse_refs(v: Option<&Value>) -> Vec<(usize, usize)> {
|
||||
v.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(|x| Some((x.get(0)?.as_u64()? as usize, x.get(1)?.as_u64()? as usize))).collect())
|
||||
.unwrap_or_default()
|
||||
/// `[[subpath, anchor]…]` anchor references of `cmd`: a malformed entry fails rather than being
|
||||
/// left out, which would act on other anchors than the ones given.
|
||||
pub(crate) fn checked_refs(v: Option<&Value>, cmd: &str) -> Result<Vec<(usize, usize)>> {
|
||||
let refs = v.and_then(Value::as_array).ok_or_else(|| bad(cmd, "`anchors` must be an array of [subpath, anchor] pairs"))?;
|
||||
refs.iter()
|
||||
.map(|v| {
|
||||
let Some([subpath, anchor]) = v.as_array().map(Vec::as_slice) else {
|
||||
return Err(bad(cmd, format!("invalid anchor reference {v}: expected [subpath, anchor]")));
|
||||
};
|
||||
let coordinate = |v: &Value| {
|
||||
v.as_u64()
|
||||
.and_then(|n| usize::try_from(n).ok())
|
||||
.ok_or_else(|| bad(cmd, format!("invalid anchor index {v}: expected a non-negative integer")))
|
||||
};
|
||||
Ok((coordinate(subpath)?, coordinate(anchor)?))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn anchors(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("select.anchors", "missing id"))?;
|
||||
let refs = parse_refs(p.get("anchors"));
|
||||
let mode = str_param(p, "mode").unwrap_or("set").to_string();
|
||||
const C: &str = "select.anchors";
|
||||
let id = checked_id(s, p.get("id").ok_or_else(|| bad(C, "missing id"))?, C)?;
|
||||
let refs = checked_refs(p.get("anchors"), C)?;
|
||||
let mode = match str_param(p, "mode") {
|
||||
None | Some("set") => "set",
|
||||
Some("add") => "add",
|
||||
Some("toggle") => "toggle",
|
||||
Some(m) => return Err(bad(C, format!("unknown mode {m:?}"))),
|
||||
};
|
||||
s.select(|_, sel| {
|
||||
if mode == "set" {
|
||||
sel.clear();
|
||||
@@ -323,11 +341,16 @@ fn anchors(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn anchors_many(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let items: Vec<(NodeId, BTreeSet<(usize, usize)>)> = p
|
||||
.get("items")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(|it| Some((NodeId(it.get("id")?.as_u64()?), parse_refs(it.get("anchors")).into_iter().collect()))).collect())
|
||||
.unwrap_or_default();
|
||||
const C: &str = "select.anchorsMany";
|
||||
let raw = p.get("items").and_then(Value::as_array).ok_or_else(|| bad(C, "`items` must be an array of {id, anchors}"))?;
|
||||
let items: Vec<(NodeId, BTreeSet<(usize, usize)>)> = raw
|
||||
.iter()
|
||||
.map(|it| {
|
||||
let id = checked_id(s, it.get("id").ok_or_else(|| bad(C, "each item needs an id"))?, C)?;
|
||||
let refs = checked_refs(it.get("anchors"), C)?;
|
||||
Ok((id, refs.into_iter().collect()))
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
// `add: true` is the older spelling of mode add.
|
||||
let mode = match str_param(p, "mode") {
|
||||
Some(m @ ("set" | "add" | "toggle" | "subtract")) => m,
|
||||
|
||||
@@ -100,7 +100,12 @@ impl StrokeChange {
|
||||
let dash = match p.get("dash") {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
Some(Value::Array(a)) => Some(Some(a.iter().filter_map(Value::as_f64).collect::<Vec<_>>()).filter(|d| !d.is_empty())),
|
||||
Some(Value::Array(a)) => {
|
||||
// Do not silently omit malformed segments and change the user's dash pattern.
|
||||
let values: Vec<f64> =
|
||||
a.iter().map(|v| v.as_f64().ok_or_else(|| bad(cmd, format!("dash lengths must be numbers, got {v}")))).collect::<Result<_>>()?;
|
||||
Some(Some(values).filter(|d| !d.is_empty()))
|
||||
}
|
||||
Some(v) => return Err(bad(cmd, format!("dash must be a list of lengths or null, got {v}"))),
|
||||
};
|
||||
Ok(Self {
|
||||
@@ -167,9 +172,6 @@ fn no_targets(ids: &[vectorcraft_doc::NodeId], p: &Value) -> bool {
|
||||
fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "stroke.set";
|
||||
let change = StrokeChange::parse(p, C)?;
|
||||
if let Some(w) = change.weight {
|
||||
s.paint.stroke_width = w;
|
||||
}
|
||||
let item = item_target(s, p, C)?.of_kind(s, false);
|
||||
let mut ids = item.targets(s, p)?;
|
||||
let new_art = no_targets(&ids, p);
|
||||
@@ -216,10 +218,6 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
st.profile = pr.clone();
|
||||
}
|
||||
};
|
||||
// With nothing selected the Stroke panel sets up the next object drawn.
|
||||
if new_art && let Some(st) = s.new_art_stroke_mut() {
|
||||
set(st);
|
||||
}
|
||||
edit_items(s, &ids, item, C, "Stroke", false, |n, index| {
|
||||
if index.is_none()
|
||||
&& let NodeKind::Text(t) = &mut n.kind
|
||||
@@ -235,6 +233,14 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
// Commit the new-art template only after the complete request is validated
|
||||
// and the selected artwork edit has succeeded.
|
||||
if let Some(w) = change.weight {
|
||||
s.paint.stroke_width = w;
|
||||
}
|
||||
if new_art && let Some(st) = s.new_art_stroke_mut() {
|
||||
set(st);
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Area / Path Type",
|
||||
[],
|
||||
None,
|
||||
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, fit?: none|autoHeight|shrinkText, fitMinPercent? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id}",
|
||||
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, style?, color? (as text.create), fit?: none|autoHeight|shrinkText, fitMinPercent? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id}",
|
||||
has_doc,
|
||||
create_in_path
|
||||
),
|
||||
@@ -485,16 +485,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !on_path && !path.is_closed() && path.bounds().is_none_or(|b| b.width() < 1.0 || b.height() < 1.0) {
|
||||
return Err(bad(C, "area type needs a path that encloses an area"));
|
||||
}
|
||||
let mut style = CharStyle::default();
|
||||
if let Some(v) = p.get("size").and_then(Value::as_f64) {
|
||||
style.size = v.clamp(0.1, 1296.0);
|
||||
}
|
||||
if let Some(f) = str_param(p, "font") {
|
||||
style.font_family = f.to_string();
|
||||
}
|
||||
if !s.paint.fill.is_none() && s.paint.fill != Paint::solid(vectorcraft_color::Color::WHITE) {
|
||||
style.fill = s.paint.fill.clone();
|
||||
}
|
||||
let style = super::create::new_type_style(s, p);
|
||||
let text = str_param(p, "text").unwrap_or("").to_string();
|
||||
let start = match point_param(p, "at") {
|
||||
Some(at) if on_path => vectorcraft_geom::ArcPath::new(path).fraction_at(at).unwrap_or(0.0),
|
||||
@@ -551,7 +542,7 @@ enum Step {
|
||||
}
|
||||
|
||||
/// The Type tool's selected text while it edits (its typing session ends first: one undo step).
|
||||
fn editing_range(s: &mut Session) -> Result<Option<(NodeId, usize, usize)>> {
|
||||
pub(crate) fn editing_range(s: &mut Session) -> Result<Option<(NodeId, usize, usize)>> {
|
||||
let o = s.tool_options();
|
||||
let Some(id) = o.get("editing").and_then(Value::as_u64).map(NodeId) else { return Ok(None) };
|
||||
if super::edit::typing_in_progress(s) {
|
||||
|
||||
@@ -133,14 +133,44 @@ fn to_attrs<T: Serialize>(v: &T) -> Map<String, Value> {
|
||||
m
|
||||
}
|
||||
|
||||
/// Every attribute name `T` reads, including those its JSON leaves out at their defaults (Auto
|
||||
/// leading and kerning, no mojikumi…): the field names serde's derive hands `deserialize_struct`.
|
||||
/// Not `justify_auto`, which [`to_map`] turns into `"justify": "Auto"`.
|
||||
fn attr_names<T: DeserializeOwned>() -> Vec<&'static str> {
|
||||
use serde::de::{Error as _, Visitor, value::Error};
|
||||
|
||||
struct Names<'a>(&'a mut &'static [&'static str]);
|
||||
impl<'de> serde::Deserializer<'de> for Names<'_> {
|
||||
type Error = Error;
|
||||
fn deserialize_any<V: Visitor<'de>>(self, _: V) -> std::result::Result<V::Value, Error> {
|
||||
Err(Error::custom("not a struct"))
|
||||
}
|
||||
fn deserialize_struct<V: Visitor<'de>>(self, _: &'static str, fields: &'static [&'static str], _: V) -> std::result::Result<V::Value, Error> {
|
||||
*self.0 = fields;
|
||||
Err(Error::custom("field names only"))
|
||||
}
|
||||
serde::forward_to_deserialize_any! {
|
||||
bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string bytes byte_buf option unit
|
||||
unit_struct newtype_struct seq tuple tuple_struct map enum identifier ignored_any
|
||||
}
|
||||
}
|
||||
|
||||
let mut fields: &'static [&'static str] = &[];
|
||||
// Always an error: the probe only records the names.
|
||||
let _ = T::deserialize(Names(&mut fields));
|
||||
fields.iter().copied().filter(|&f| f != "justify_auto").collect()
|
||||
}
|
||||
|
||||
/// `base` with `attrs` written over it (unknown or ill-typed attributes are an error).
|
||||
fn with_attrs<T: Serialize + DeserializeOwned>(base: &T, attrs: &Map<String, Value>, cmd: &str) -> Result<T> {
|
||||
let mut m = to_attrs(base);
|
||||
if let Some(full) = to_map(base) {
|
||||
m.insert("style_name".into(), full.get("style_name").cloned().unwrap_or(Value::Null));
|
||||
}
|
||||
let names = attr_names::<T>();
|
||||
for (k, v) in attrs {
|
||||
if !m.contains_key(k) {
|
||||
// Not only the keys `base` writes: those at their defaults (Auto leading…) are left out.
|
||||
if !m.contains_key(k) && !names.contains(&k.as_str()) {
|
||||
return Err(bad(cmd, format!("unknown attribute `{k}`")));
|
||||
}
|
||||
m.insert(k.clone(), v.clone());
|
||||
@@ -575,6 +605,44 @@ mod tests {
|
||||
assert_eq!(s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == "Pink").unwrap().attrs["fill"], fill);
|
||||
}
|
||||
|
||||
/// #1001: attributes their JSON leaves out at the defaults (Auto leading and kerning, no
|
||||
/// mojikumi) are still attributes a style can set, and a style captured with one applies to
|
||||
/// text at the default, is duplicated and comes back out of a library.
|
||||
#[test]
|
||||
fn styles_take_attributes_left_out_at_their_defaults() {
|
||||
let (mut s, id) = session_with_text("A");
|
||||
s.execute("charStyle.new", &json!({"name": "Kerned", "attrs": {"kerning": 40, "leading": 30}})).unwrap();
|
||||
s.execute("charStyle.setAttrs", &json!({"name": "Kerned", "attrs": {"kerning": 20, "small_caps": 70}})).unwrap();
|
||||
s.execute("paraStyle.new", &json!({"name": "Punctuation", "attrs": {"mojikumi": "lineEndHalf"}})).unwrap();
|
||||
s.execute("paraStyle.setAttrs", &json!({"name": "Punctuation", "attrs": {"direction": "rightToLeft"}})).unwrap();
|
||||
for bad in [json!({"nope": 1}), json!({"justify_auto": true})] {
|
||||
assert!(s.execute("paraStyle.new", &json!({"name": "Bad", "attrs": bad})).is_err(), "{bad}");
|
||||
}
|
||||
// Captured from text with explicit leading, applied to fresh Auto-leading text.
|
||||
s.execute("text.setStyle", &json!({"leading": 30})).unwrap();
|
||||
s.execute("charStyle.new", &json!({"name": "Spaced"})).unwrap();
|
||||
let fresh = s.execute("text.create", &json!({"x": 10, "y": 90, "text": "B"})).unwrap()["id"].as_u64().unwrap();
|
||||
assert_eq!(runs(&s, fresh)[0].style.leading, None);
|
||||
for clear in [false, true] {
|
||||
s.execute("charStyle.apply", &json!({"name": "Spaced", "id": fresh, "clearOverrides": clear})).unwrap();
|
||||
assert_eq!(runs(&s, fresh)[0].style.leading, Some(30.0));
|
||||
}
|
||||
let copy = s.execute("charStyle.duplicate", &json!({"name": "Spaced"})).unwrap()["name"].as_str().unwrap().to_string();
|
||||
assert_eq!(s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == copy).unwrap().attrs["leading"], 30.0);
|
||||
s.execute("charStyle.apply", &json!({"name": "Kerned", "id": fresh})).unwrap();
|
||||
let st = runs(&s, fresh)[0].style.clone();
|
||||
assert_eq!((st.kerning, st.small_caps), (Some(20.0), Some(70.0)));
|
||||
s.execute("paraStyle.apply", &json!({"name": "Punctuation", "id": id})).unwrap();
|
||||
let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!() };
|
||||
assert_eq!((t.para.mojikumi, t.para.direction), (vectorcraft_doc::Mojikumi::LineEndHalf, Some(vectorcraft_doc::ParaDirection::RightToLeft)));
|
||||
// A library keeps the captured attributes and makes the style again where it's used.
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
let lib = s.execute("library.add", &json!({"kind": "charStyle", "name": "Lib Spaced"})).unwrap();
|
||||
let other = s.execute("text.create", &json!({"x": 10, "y": 150, "text": "C"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("library.use", &json!({"library": lib["library"], "kind": "charStyle", "item": lib["name"]})).unwrap();
|
||||
assert_eq!(runs(&s, other)[0].style.leading, Some(30.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_styles_and_survive_save() {
|
||||
let (mut s, id) = session_with_text("Body");
|
||||
|
||||
@@ -628,7 +628,7 @@ fn area_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn reshape_area(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.reshapeArea";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let mut refs = super::select::parse_refs(p.get("anchors"));
|
||||
let mut refs = super::select::checked_refs(p.get("anchors"), C)?;
|
||||
refs.sort_unstable();
|
||||
refs.dedup();
|
||||
if refs.is_empty() {
|
||||
|
||||
@@ -582,8 +582,14 @@ pub fn render(doc: &Document, s: &WebSettings) -> std::result::Result<WebRender,
|
||||
let doc = doc.without_edit_modes();
|
||||
let (region, scale) = region(&doc, s)?;
|
||||
let page = (doc.setup.background == vectorcraft_doc::Background::White).then_some([255; 4]);
|
||||
let opts =
|
||||
vectorcraft_render::RenderOptions { background: page, skip_templates: true, anti_alias: s.anti_alias, precise: true, ..Default::default() };
|
||||
let opts = vectorcraft_render::RenderOptions {
|
||||
background: page,
|
||||
skip_templates: true,
|
||||
anti_alias: s.anti_alias,
|
||||
precise: true,
|
||||
supersample: true,
|
||||
..Default::default()
|
||||
};
|
||||
let img = vectorcraft_render::Renderer::new().render_region_with(&doc, region, scale, &opts);
|
||||
Ok(WebRender { rgba: img.to_straight(), width: img.width, height: img.height, region, scale })
|
||||
}
|
||||
|
||||
@@ -132,32 +132,29 @@ fn warp_node(n: &mut Node, pr: &Projective) {
|
||||
}
|
||||
|
||||
fn distort(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let corners: Vec<Point> = p
|
||||
const C: &str = "object.distort";
|
||||
// All four corners or nothing, each exactly [x, y]: nothing malformed is left out.
|
||||
let corners: Option<Vec<Point>> = p
|
||||
.get("corners")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(|c| Some(Point::new(c.get(0)?.as_f64()?, c.get(1)?.as_f64()?))).collect())
|
||||
.unwrap_or_default();
|
||||
if corners.len() != 4 {
|
||||
return Err(bad("object.distort", "corners must be 4 [x,y] points (TL, TR, BR, BL)"));
|
||||
.filter(|a| a.len() == 4)
|
||||
.and_then(|a| a.iter().map(|c| finite_numbers(c).map(|[x, y]| Point::new(x, y))).collect());
|
||||
let Some(&[tl, tr, br, bl]) = corners.as_deref() else { return Err(bad(C, "corners must be 4 [x, y] points (TL, TR, BR, BL)")) };
|
||||
let ids = if p.get("ids").is_some_and(|v| !v.is_null()) { checked_ids_param(s, p, "ids", C)? } else { selected_roots(s)? };
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "nothing to distort"));
|
||||
}
|
||||
let ids = match ids_param(p, "ids") {
|
||||
Some(v) => v,
|
||||
None => selected_roots(s)?,
|
||||
};
|
||||
let src = match p.get("from").and_then(Value::as_array) {
|
||||
Some(a) if a.len() == 4 => {
|
||||
let f: Vec<f64> = a.iter().filter_map(Value::as_f64).collect();
|
||||
if f.len() != 4 {
|
||||
return Err(bad("object.distort", "from must be [x0,y0,x1,y1]"));
|
||||
}
|
||||
Rect::new(f[0], f[1], f[2], f[3])
|
||||
let src = match p.get("from").filter(|v| !v.is_null()) {
|
||||
None => s.doc()?.doc.bounds_of(&ids, false).ok_or_else(|| bad(C, "nothing to distort"))?,
|
||||
Some(v) => {
|
||||
let [x0, y0, x1, y1] = finite_numbers(v).ok_or_else(|| bad(C, "from must be [x0, y0, x1, y1]"))?;
|
||||
Rect::new(x0, y0, x1, y1)
|
||||
}
|
||||
_ => s.doc()?.doc.bounds_of(&ids, false).ok_or_else(|| EngineError::Other("nothing to distort".into()))?,
|
||||
};
|
||||
if src.width().abs() < 1e-9 || src.height().abs() < 1e-9 {
|
||||
return Err(EngineError::Other("cannot distort a zero-size bounding box".into()));
|
||||
if !src.width().is_finite() || !src.height().is_finite() || src.width().abs() < 1e-9 || src.height().abs() < 1e-9 {
|
||||
return Err(bad(C, "cannot distort a zero-size or non-finite bounding box"));
|
||||
}
|
||||
let pr = Projective::from_rect(src, [corners[0], corners[1], corners[2], corners[3]]);
|
||||
let pr = Projective::from_rect(src, [tl, tr, br, bl]);
|
||||
s.edit("Free Distort", |d, _| {
|
||||
for id in &ids {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
|
||||
@@ -346,6 +346,14 @@ impl DocState {
|
||||
// A sublayer takes new art only while it and the layers around it are shown and unlocked.
|
||||
self.active_layer.filter(|l| self.doc.node(*l).is_some_and(|n| n.is_layer()) && self.doc.is_editable(*l)).or_else(|| self.doc.default_layer())
|
||||
}
|
||||
/// [`Self::insertion_parent`] for new art, which a locked or hidden layer never takes: an error
|
||||
/// when no layer is shown and unlocked, so the art is refused rather than hidden or locked away.
|
||||
pub fn target_parent(&self) -> Result<Option<NodeId>> {
|
||||
match self.insertion_parent() {
|
||||
Some(p) if !self.doc.is_editable(p) => Err(EngineError::Other("the target layer is locked or hidden".into())),
|
||||
parent => Ok(parent),
|
||||
}
|
||||
}
|
||||
/// The highlighted Layers panel rows that still exist (ids are reused after undo, so a
|
||||
/// remembered row must still be in the document).
|
||||
pub fn highlighted_rows(&self) -> Vec<NodeId> {
|
||||
@@ -1033,6 +1041,8 @@ impl Session {
|
||||
/// Add a document and make it active.
|
||||
pub fn add_document(&mut self, mut doc: Document, path: Option<String>) -> usize {
|
||||
Self::refresh_text_bounds(&mut doc);
|
||||
// Guides from files saved before guides had layers go on a layer.
|
||||
doc.adopt_guides();
|
||||
self.reset_tool_for_doc_switch();
|
||||
let mut st = DocState::new(doc, path);
|
||||
st.history.limit = self.prefs.history_states as usize;
|
||||
@@ -1048,6 +1058,7 @@ impl Session {
|
||||
return false;
|
||||
}
|
||||
Self::refresh_text_bounds(&mut doc);
|
||||
doc.adopt_guides();
|
||||
if self.active == Some(index) {
|
||||
// Pending tool work (typing, a drag) belongs to the content being thrown away.
|
||||
self.reset_tool_for_doc_switch();
|
||||
@@ -1621,6 +1632,8 @@ mod tests_recolor;
|
||||
#[cfg(test)]
|
||||
mod tests_recovery;
|
||||
#[cfg(test)]
|
||||
mod tests_reflecthit;
|
||||
#[cfg(test)]
|
||||
mod tests_registration;
|
||||
#[cfg(test)]
|
||||
mod tests_save;
|
||||
|
||||
@@ -134,6 +134,74 @@ fn fill_and_stroke_commands() {
|
||||
s.execute("paint.setFill", &json!({"gradient": {"kind": "radial"}})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_rejects_invalid_dash_components_and_preserves_defaults_on_error() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 0.0, 0.0, 40.0, 30.0);
|
||||
s.execute("stroke.set", &json!({"weight": 3, "dash": [6, 3]})).unwrap();
|
||||
let previous_weight = s.paint.stroke_width;
|
||||
let previous_stroke = s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().cloned();
|
||||
|
||||
for params in [
|
||||
json!({"weight": 10, "dash": [6, "not a number", 3]}),
|
||||
json!({"weight": 10, "ids": "not an array"}),
|
||||
json!({"weight": 10, "item": "invalid"}),
|
||||
json!({"weight": 10, "arrowAlign": "unknown"}),
|
||||
] {
|
||||
assert!(s.execute("stroke.set", ¶ms).is_err(), "{params} must fail");
|
||||
assert_eq!(s.paint.stroke_width, previous_weight, "failed stroke edit changed new-art defaults");
|
||||
assert_eq!(s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().cloned(), previous_stroke);
|
||||
}
|
||||
|
||||
// A valid dash list still updates the selected stroke and new-art default width.
|
||||
s.execute("stroke.set", &json!({"weight": 7, "dash": [4, 2]})).unwrap();
|
||||
assert_eq!(s.paint.stroke_width, 7.0);
|
||||
assert_eq!(s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().width, 7.0);
|
||||
|
||||
// With nothing selected the panel sets up the next object drawn: a rejected edit doesn't.
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let next = s.new_art();
|
||||
assert!(s.execute("stroke.set", &json!({"weight": 12, "dash": [2, 2], "arrowAlign": "unknown"})).is_err());
|
||||
assert_eq!(s.new_art(), next);
|
||||
s.execute("stroke.set", &json!({"weight": 12, "dash": [2, 2]})).unwrap();
|
||||
let next = s.new_art();
|
||||
let stroke = next.stroke().unwrap();
|
||||
assert_eq!((stroke.width, stroke.dash.as_ref().map(|d| d.pattern.clone())), (12.0, Some(vec![2.0, 2.0])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_paint_edits_leave_new_art_defaults_and_focus_unchanged() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap();
|
||||
let defaults = s.paint.clone();
|
||||
let focused = s.fill_active;
|
||||
let original_fill = s.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint();
|
||||
let original_stroke = s.doc().unwrap().doc.node(id).unwrap().appearance.stroke_paint();
|
||||
|
||||
// Both shortcut commands used to change defaults before checking invalid ids.
|
||||
for (command, params) in [
|
||||
("paint.swap", json!({"ids": "not an array"})),
|
||||
("paint.default", json!({"ids": ["not an object id"]})),
|
||||
("paint.setFill", json!({"color": "#123456", "ids": [false]})),
|
||||
("paint.setStroke", json!({"color": "#123456", "ids": "not an array"})),
|
||||
("paint.setFill", json!({"color": "#123456", "item": "invalid"})),
|
||||
] {
|
||||
assert!(s.execute(command, ¶ms).is_err(), "{command} should reject {params}");
|
||||
assert_eq!(s.paint, defaults, "{command} changed new-art defaults on failure");
|
||||
assert_eq!(s.fill_active, focused, "{command} changed the active proxy on failure");
|
||||
let appearance = &s.doc().unwrap().doc.node(id).unwrap().appearance;
|
||||
assert_eq!(appearance.fill_paint(), original_fill);
|
||||
assert_eq!(appearance.stroke_paint(), original_stroke);
|
||||
}
|
||||
|
||||
// Successful operations still update both the selected object and new-art defaults.
|
||||
s.execute("paint.swap", &json!({})).unwrap();
|
||||
assert_eq!(s.paint.fill, defaults.stroke);
|
||||
assert_eq!(s.paint.stroke, defaults.fill);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_same_fill() {
|
||||
let mut s = session();
|
||||
@@ -892,3 +960,66 @@ fn given_targets_are_used_as_given_and_range_style_takes_the_selected_type() {
|
||||
assert!(s.execute("text.setRangeStyle", &json!({"start": 0, "end": 3, "size": 40})).is_err(), "two type objects: which one?");
|
||||
assert!(s.execute("text.setRangeStyle", &json!({"id": "two", "size": 40})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_anchor_selection_never_replaces_or_partially_changes_selection() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0, 10.0, 30.0, 20.0);
|
||||
let b = rect(&mut s, 50.0, 10.0, 30.0, 20.0);
|
||||
let expected = s.doc().unwrap().selection.objects.clone();
|
||||
let expected_anchors = s.doc().unwrap().selection.anchors.clone();
|
||||
for p in [
|
||||
json!({"id": a.0, "anchors": [[0, 0], ["bad", 2]]}),
|
||||
json!({"id": a.0, "anchors": [[0, 0, 1]]}),
|
||||
json!({"id": a.0, "anchors": "not an array"}),
|
||||
json!({"id": u64::MAX, "anchors": []}),
|
||||
json!({"id": a.0, "anchors": [], "mode": "missing"}),
|
||||
] {
|
||||
assert!(s.execute("select.anchors", &p).is_err(), "{p}");
|
||||
assert_eq!(s.doc().unwrap().selection.objects, expected);
|
||||
assert_eq!(s.doc().unwrap().selection.anchors, expected_anchors);
|
||||
}
|
||||
for p in [
|
||||
json!({"items": [{"id": a.0, "anchors": [[0, 0]]}, {"id": b.0, "anchors": [[0, "bad"]]}]}),
|
||||
json!({"items": [{"id": a.0, "anchors": [[0, 0]]}, {"id": "bad", "anchors": [[0, 0]]}]}),
|
||||
json!({"items": [{"id": u64::MAX, "anchors": []}]}),
|
||||
json!({"items": [{"id": a.0}]}),
|
||||
] {
|
||||
assert!(s.execute("select.anchorsMany", &p).is_err(), "{p}");
|
||||
assert_eq!(s.doc().unwrap().selection.objects, expected);
|
||||
assert_eq!(s.doc().unwrap().selection.anchors, expected_anchors);
|
||||
}
|
||||
s.execute("select.anchorsMany", &json!({"items": [{"id": a.0, "anchors": [[0, 0]]}]})).unwrap();
|
||||
assert!(s.doc().unwrap().selection.contains(a));
|
||||
assert!(!s.doc().unwrap().selection.contains(b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_commands_reject_bad_anchors_before_modifying_geometry() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0, 20.0, 50.0, 30.0);
|
||||
let original_points: Vec<_> = s.doc().unwrap().doc.node(a).unwrap().path_data().unwrap().anchors().map(|(_, _, an)| an.p).collect();
|
||||
let history = s.doc().unwrap().history.undo.len();
|
||||
let valid = json!({"x": 10, "y": 20});
|
||||
for invalid in [
|
||||
json!({"x": "bad", "y": 40}),
|
||||
json!({"x": 50}),
|
||||
json!({"x": 50, "y": 40, "in": [0]}),
|
||||
json!({"x": 50, "y": 40, "out": [1, "bad"]}),
|
||||
json!({"x": 50, "y": 40, "smooth": "yes"}),
|
||||
] {
|
||||
let anchors = json!([valid.clone(), invalid]);
|
||||
assert!(s.execute("path.create", &json!({"anchors": anchors})).is_err(), "{anchors}");
|
||||
assert!(s.execute("path.setAnchors", &json!({"id": a.0, "subpaths": [{"anchors": anchors}]})).is_err(), "{anchors}");
|
||||
let points: Vec<_> = s.doc().unwrap().doc.node(a).unwrap().path_data().unwrap().anchors().map(|(_, _, an)| an.p).collect();
|
||||
assert_eq!(points, original_points);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), history);
|
||||
}
|
||||
assert!(s.execute("path.setAnchors", &json!({"id": a.0, "subpaths": [{"anchors": []}, {"closed": true}]})).is_err());
|
||||
assert!(s.execute("path.setAnchors", &json!({"id": a.0, "subpaths": [{"anchors": [valid.clone()], "closed": "yes"}]})).is_err());
|
||||
// The Pen's append reads its anchor the same way.
|
||||
assert!(s.execute("path.appendAnchor", &json!({"id": a.0, "x": 5, "y": 5, "out": [1]})).is_err());
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), history);
|
||||
s.execute("path.setAnchors", &json!({"id": a.0, "subpaths": [{"anchors": [{"x": 0, "y": 0}, {"x": 30, "y": 40}], "closed": false}]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.node(a).unwrap().path_data().unwrap().anchor_count(), 2);
|
||||
}
|
||||
@@ -265,3 +265,21 @@ fn assets_export_into_a_folder() {
|
||||
assert!(dir.join("2x/Logo@2x.png").is_file() && dir.join("PDF/Logo.pdf").is_file());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collecting_explicit_assets_rejects_invalid_ids_without_adding_anything() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
|
||||
let b = rect(&mut s, 50.0, 10.0, 20.0, 20.0);
|
||||
let original_selection = s.doc().unwrap().selection.objects.clone();
|
||||
let before = undo_len(&s);
|
||||
for ids in [json!([a, "not an object"]), json!([a, 3.5]), json!([a, u64::MAX]), json!({"id": a})] {
|
||||
assert!(s.execute("assets.add", &json!({"ids": ids})).is_err(), "invalid ids: {ids}");
|
||||
assert!(assets(&mut s).is_empty(), "invalid ids must not collect only the valid subset");
|
||||
assert_eq!(undo_len(&s), before);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, original_selection);
|
||||
}
|
||||
// Valid ids still collect art in document paint order.
|
||||
assert_eq!(run(&mut s, "assets.add", json!({"ids": [b, a]}))["added"], 2);
|
||||
assert_eq!(assets(&mut s).len(), 2);
|
||||
}
|
||||
@@ -118,6 +118,24 @@ fn line_cut_that_misses_changes_nothing() {
|
||||
assert!(s.execute("path.lineCut", &json!({"from": [5, 5]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rect_cut_rejects_non_numeric_rectangle_elements_without_modifying_art() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
select(&mut s, &[id]);
|
||||
let original = bounds(&s, id);
|
||||
let history = undo_depth(&s);
|
||||
// The old parser dropped the string and used the four remaining values.
|
||||
for params in [json!([0, "ignored", 10, 50, 50]), json!([0, 10, 50, null, 50]), json!([0, 10, 50, 50, 5])] {
|
||||
assert!(s.execute("path.rectCut", &json!({"rect": params})).is_err());
|
||||
assert!(near(bounds(&s, id), original), "failed cut must not change geometry");
|
||||
assert_eq!(undo_depth(&s), history, "failed cut must not add undo history");
|
||||
}
|
||||
// Proper rectangular cuts still work.
|
||||
s.execute("path.rectCut", &json!({"rect": [10, 10, 70, 70]})).unwrap();
|
||||
assert!(near(bounds(&s, id), Rect::new(10.0, 10.0, 80.0, 80.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rect_cut_crops_to_real_geometry() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -870,6 +870,10 @@ fn the_pen_continues_any_open_path_and_joins_another() {
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
|
||||
assert_eq!(s.cursor(Point::new(300.0, 200.0), none, v), Cursor::PenJoin);
|
||||
assert_eq!(s.cursor(Point::new(350.0, 200.0), none, v), Cursor::Pen, "the middle of the other path");
|
||||
// #1009: Use Precise Cursors makes the join pointer a crosshair too.
|
||||
s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": true})).unwrap();
|
||||
assert_eq!(s.cursor(Point::new(300.0, 200.0), none, v), Cursor::Crosshair);
|
||||
s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": false})).unwrap();
|
||||
assert_eq!(gesture(&mut s, &[(300.0, 200.0)], none), 1);
|
||||
let pts = |s: &Session, id| path(s, id).subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|x| (x.p.x, x.p.y))).collect::<Vec<_>>();
|
||||
assert_eq!(pts(&s, a), [(100.0, 100.0), (200.0, 100.0), (300.0, 200.0), (400.0, 200.0)]);
|
||||
|
||||
@@ -78,6 +78,36 @@ fn art_in_a_sublayer_is_selected_as_objects_not_the_sublayer() {
|
||||
assert!(s.doc().unwrap().doc.node(a).is_none());
|
||||
}
|
||||
|
||||
/// #1002: with no layer shown and unlocked, new art is refused (the document unchanged) rather
|
||||
/// than put on a hidden or locked layer; another layer that takes art still gets it.
|
||||
#[test]
|
||||
fn new_art_is_refused_when_no_layer_is_shown_and_unlocked() {
|
||||
for prop in ["visible", "locked"] {
|
||||
let mut s = session();
|
||||
let layer = s.doc().unwrap().doc.layers[0].id;
|
||||
let copied = rect(&mut s, 0.0);
|
||||
run(&mut s, "select.set", json!({"ids": [copied.0]}));
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
run(&mut s, "select.none", json!({}));
|
||||
run(&mut s, "layer.setProps", json!({"ids": [layer.0], prop: prop == "locked"}));
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
for (cmd, p) in [
|
||||
("shape.ellipse", json!({"x": 10, "y": 10, "width": 20, "height": 20})),
|
||||
("shape.polarGrid", json!({"x": 10, "y": 10, "width": 20, "height": 20})),
|
||||
("text.create", json!({"x": 10, "y": 40, "text": "A"})),
|
||||
("edit.paste", json!({})),
|
||||
] {
|
||||
let e = s.execute(cmd, &p).unwrap_err();
|
||||
assert_eq!(e.to_string(), "the target layer is locked or hidden", "{prop} {cmd}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().doc.layers, before.layers, "{prop}: nothing was added");
|
||||
let other = id(&run(&mut s, "layer.new", json!({})));
|
||||
run(&mut s, "layer.setCurrent", json!({"id": layer.0}));
|
||||
let n = rect(&mut s, 50.0);
|
||||
assert_eq!(parent(&s, n), Some(other), "{prop}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_rows_at_any_depth_highlights_them_and_sets_the_current_layer() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -785,7 +785,11 @@ fn ruler_guides_select_move_and_delete() {
|
||||
assert_eq!(s.execute("guide.select", &json!({"indexes": [0]})).unwrap()["selected"], json!([0]));
|
||||
assert!(selected(&s).is_empty());
|
||||
assert_eq!(s.execute("guide.select", &json!({"indexes": [1], "toggle": true})).unwrap()["selected"], json!([0, 1]));
|
||||
assert_eq!(s.execute("guide.list", &json!({})).unwrap()[1], json!({"index": 1, "vertical": false, "pos": 50.0, "selected": true}));
|
||||
let layer = s.doc().unwrap().current_layer().unwrap().0;
|
||||
assert_eq!(
|
||||
s.execute("guide.list", &json!({})).unwrap()[1],
|
||||
json!({"index": 1, "vertical": false, "pos": 50.0, "selected": true, "layer": layer, "shown": true, "editable": true})
|
||||
);
|
||||
assert!(s.execute("guide.select", &json!({"indexes": [7]})).is_err());
|
||||
// A move takes each selected guide along its own axis, in one undo step.
|
||||
let n = undo_len(&s);
|
||||
@@ -823,6 +827,40 @@ fn ruler_guides_select_move_and_delete() {
|
||||
assert!(matches!(s.execute("edit.clear", &json!({})), Err(EngineError::Disabled(..))));
|
||||
}
|
||||
|
||||
/// Shift selects ruler guides and art together, in either order; Delete takes both in one step.
|
||||
#[test]
|
||||
fn guides_and_art_are_shift_selected_together() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0, 10.0, 40.0, 20.0);
|
||||
let b = rect(&mut s, 70.0, 50.0, 60.0, 30.0);
|
||||
s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap();
|
||||
s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap();
|
||||
// Art first, then a Shift-click on a guide: the art stays selected.
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap();
|
||||
assert_eq!((selected(&s), selected_guides(&s)), (vec![a], vec![0]));
|
||||
// A guide first, then Shift-added art: the guide stays selected.
|
||||
s.execute("guide.select", &json!({"indexes": [1]})).unwrap();
|
||||
s.execute("select.add", &json!({"ids": [b.0]})).unwrap();
|
||||
assert_eq!((selected(&s), selected_guides(&s)), (vec![b], vec![1]));
|
||||
// A new selection drops them.
|
||||
sel(&mut s, &[a]);
|
||||
assert!(selected_guides(&s).is_empty());
|
||||
// Delete takes the art and the guides, in one step; undo brings both back.
|
||||
s.execute("guide.select", &json!({"indexes": [1], "toggle": true})).unwrap();
|
||||
let n = undo_len(&s);
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
assert_eq!((guides(&s), undo_len(&s)), (vec![(true, 200.0)], n + 1));
|
||||
assert!(s.doc().unwrap().doc.node(a).is_none() && s.doc().unwrap().doc.node(b).is_some());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(guides(&s).len(), 2);
|
||||
// Ids given: only those go, the guides stay.
|
||||
s.execute("guide.select", &json!({"indexes": [0]})).unwrap();
|
||||
s.execute("select.add", &json!({"ids": [b.0]})).unwrap();
|
||||
s.execute("edit.clear", &json!({"ids": [b.0]})).unwrap();
|
||||
assert_eq!(guides(&s).len(), 2);
|
||||
}
|
||||
|
||||
/// #451: an artboard guide (`guide.add {artboard}`) runs across its artboard only, and is moved,
|
||||
/// copied and deleted with it; canvas guides stay put.
|
||||
#[test]
|
||||
@@ -832,11 +870,12 @@ fn artboard_guides_go_with_their_artboard() {
|
||||
s.execute("guide.add", &json!({"vertical": true, "pos": 1000, "artboard": 1})).unwrap();
|
||||
s.execute("guide.add", &json!({"vertical": false, "pos": 100})).unwrap();
|
||||
let list = |s: &mut Session| s.execute("guide.list", &json!({})).unwrap();
|
||||
let layer = s.doc().unwrap().current_layer().unwrap().0;
|
||||
assert_eq!(
|
||||
list(&mut s),
|
||||
json!([
|
||||
{"index": 0, "vertical": true, "pos": 1000.0, "selected": false, "artboard": 1},
|
||||
{"index": 1, "vertical": false, "pos": 100.0, "selected": false},
|
||||
{"index": 0, "vertical": true, "pos": 1000.0, "selected": false, "artboard": 1, "layer": layer, "shown": true, "editable": true},
|
||||
{"index": 1, "vertical": false, "pos": 100.0, "selected": false, "layer": layer, "shown": true, "editable": true},
|
||||
])
|
||||
);
|
||||
let spans = |s: &Session| {
|
||||
@@ -875,7 +914,10 @@ fn artboard_guides_go_with_their_artboard() {
|
||||
let n = undo_len(&s);
|
||||
s.execute("artboard.delete", &json!({"index": 0})).unwrap();
|
||||
assert_eq!((guides(&s).len(), selected_guides(&s)), (3, vec![0, 1]));
|
||||
assert_eq!(list(&mut s)[0], json!({"index": 0, "vertical": false, "pos": 100.0, "selected": true}));
|
||||
assert_eq!(
|
||||
list(&mut s)[0],
|
||||
json!({"index": 0, "vertical": false, "pos": 100.0, "selected": true, "layer": layer, "shown": true, "editable": true})
|
||||
);
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((guides(&s).len(), selected_guides(&s)), (4, vec![1, 2]));
|
||||
@@ -917,12 +959,120 @@ fn guides_dragged_out_of_a_ruler_snap_and_take_the_artboard_tool_s_artboard() {
|
||||
s.set_tool_option("active", &json!(1));
|
||||
s.ruler_guide(false, &ev(PointerKind::Drag, 1000.0, 50.0), true, v).unwrap();
|
||||
s.ruler_guide(false, &ev(PointerKind::Up, 1000.0, 50.0), true, v).unwrap();
|
||||
let layer = s.doc().unwrap().current_layer().unwrap().0;
|
||||
assert_eq!(
|
||||
s.execute("guide.list", &json!({})).unwrap()[1],
|
||||
json!({"index": 1, "vertical": false, "pos": 50.0, "selected": false, "artboard": 1})
|
||||
json!({"index": 1, "vertical": false, "pos": 50.0, "selected": false, "artboard": 1, "layer": layer, "shown": true, "editable": true})
|
||||
);
|
||||
}
|
||||
|
||||
/// Guides saved before guides had layers are put on a layer when the document opens.
|
||||
#[test]
|
||||
fn guides_from_older_files_are_put_on_a_layer() {
|
||||
let mut s = Session::new();
|
||||
let mut d = vectorcraft_doc::Document::new(400.0, 300.0);
|
||||
d.guides.push(vectorcraft_doc::Guide::new(true, 50.0));
|
||||
d.guides.push(vectorcraft_doc::Guide::new(false, 80.0));
|
||||
s.add_document(d, None);
|
||||
let st = s.doc().unwrap();
|
||||
let top = st.doc.default_layer();
|
||||
assert!(top.is_some() && st.doc.guides.iter().all(|g| g.layer == top), "{:?}", st.doc.guides);
|
||||
}
|
||||
|
||||
/// Ruler guides are layer objects: made on the current layer, hidden, locked and deleted with it,
|
||||
/// and moved to another layer.
|
||||
#[test]
|
||||
fn ruler_guides_live_on_layers() {
|
||||
let mut s = session();
|
||||
let first = s.doc().unwrap().current_layer().unwrap();
|
||||
s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.guides[0].layer, Some(first), "on the current layer");
|
||||
let second = id_of(&s.execute("layer.new", &json!({"name": "Guides"})).unwrap());
|
||||
s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.guides[1].layer, Some(second), "the new layer is current");
|
||||
assert!(s.execute("guide.add", &json!({"vertical": true, "pos": 1, "layer": 99999})).is_err());
|
||||
// Hidden or locked with its layer.
|
||||
s.execute("layer.setProps", &json!({"ids": [second.0], "visible": false})).unwrap();
|
||||
assert_eq!(s.execute("guide.list", &json!({})).unwrap()[1]["shown"], false);
|
||||
s.execute("layer.setProps", &json!({"ids": [second.0], "visible": true, "locked": true})).unwrap();
|
||||
let row = s.execute("guide.list", &json!({})).unwrap()[1].clone();
|
||||
assert_eq!((row["shown"].clone(), row["editable"].clone()), (json!(true), json!(false)));
|
||||
s.execute("layer.setProps", &json!({"ids": [second.0], "locked": false})).unwrap();
|
||||
// Moved to the first layer, then back.
|
||||
s.execute("guide.setLayer", &json!({"index": 1, "layer": first.0})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.guides[1].layer, Some(first));
|
||||
s.execute("guide.setLayer", &json!({"index": 1, "layer": second.0})).unwrap();
|
||||
assert!(s.execute("guide.setLayer", &json!({"index": 1, "layer": 99999})).is_err());
|
||||
// Several at once, as dragging their rows onto a layer's row does.
|
||||
s.execute("guide.setLayer", &json!({"indexes": [0, 1], "layer": first.0})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.guides.iter().all(|g| g.layer == Some(first)));
|
||||
s.execute("guide.setLayer", &json!({"indexes": [1], "layer": second.0})).unwrap();
|
||||
assert!(s.execute("guide.setLayer", &json!({"indexes": [7], "layer": first.0})).is_err());
|
||||
// Deleting a layer deletes its guides; undo brings them back.
|
||||
s.execute("layer.delete", &json!({"ids": [second.0]})).unwrap();
|
||||
assert_eq!(guides(&s), [(true, 200.0)]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(guides(&s).len(), 2);
|
||||
}
|
||||
|
||||
/// Guides on a hidden or locked layer leave the selection and can't be moved, deleted or moved to
|
||||
/// another layer; new guides skip a locked current layer as new art does; no guide goes onto a
|
||||
/// locked layer.
|
||||
#[test]
|
||||
fn guides_on_hidden_or_locked_layers_are_left_alone() {
|
||||
let mut s = session();
|
||||
let first = s.doc().unwrap().current_layer().unwrap();
|
||||
s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap();
|
||||
let second = id_of(&s.execute("layer.new", &json!({})).unwrap());
|
||||
s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap();
|
||||
s.execute("guide.select", &json!({"indexes": [0, 1]})).unwrap();
|
||||
s.execute("layer.setProps", &json!({"ids": [first.0], "locked": true})).unwrap();
|
||||
assert_eq!(selected_guides(&s), [1], "the locked layer's guide is deselected");
|
||||
assert_eq!(s.execute("guide.select", &json!({"indexes": [0]})).unwrap()["selected"], json!([]), "and can't be selected");
|
||||
assert!(s.execute("guide.move", &json!({"index": 0, "pos": 50})).is_err());
|
||||
assert!(s.execute("guide.remove", &json!({"index": 0})).is_err());
|
||||
assert!(s.execute("guide.setLayer", &json!({"index": 0, "layer": second.0})).is_err());
|
||||
assert!(s.execute("guide.setLayer", &json!({"index": 1, "layer": first.0})).is_err(), "not onto a locked layer");
|
||||
s.execute("layer.setProps", &json!({"ids": [second.0], "visible": false})).unwrap();
|
||||
assert!(selected_guides(&s).is_empty(), "the hidden layer's guide is deselected");
|
||||
// Both layers hidden or locked: a new guide is refused, as new art is.
|
||||
assert!(s.execute("guide.add", &json!({"vertical": true, "pos": 10})).is_err());
|
||||
s.execute("layer.setProps", &json!({"ids": [second.0], "visible": true, "locked": true})).unwrap();
|
||||
s.execute("layer.setProps", &json!({"ids": [first.0], "locked": false})).unwrap();
|
||||
// The current layer locked: the new guide goes on the top shown, unlocked layer.
|
||||
s.execute("guide.add", &json!({"vertical": true, "pos": 20})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.guides[2].layer, Some(first));
|
||||
assert_eq!(guides(&s).len(), 3);
|
||||
}
|
||||
|
||||
/// Guides follow their layers through Duplicate, Merge Selected and Flatten Artwork, in one undo
|
||||
/// step each.
|
||||
#[test]
|
||||
fn guides_go_with_their_layers_when_duplicated_merged_and_flattened() {
|
||||
let mut s = session();
|
||||
let first = s.doc().unwrap().current_layer().unwrap();
|
||||
s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap();
|
||||
let second = id_of(&s.execute("layer.new", &json!({})).unwrap());
|
||||
s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap();
|
||||
let on = |s: &Session, l: NodeId| s.doc().unwrap().doc.guides_on(l).map(|(_, g)| (g.vertical, g.pos)).collect::<Vec<_>>();
|
||||
// Duplicate Layer copies its guides onto the copy.
|
||||
let copy = s.execute("layer.duplicate", &json!({"ids": [second.0]})).unwrap()["ids"][0].as_u64().map(NodeId).unwrap();
|
||||
assert_eq!((on(&s, second), on(&s, copy)), (vec![(false, 300.0)], vec![(false, 300.0)]));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(guides(&s).len(), 2);
|
||||
// Merged into the first layer, the second layer's guide goes with its art.
|
||||
s.execute("layer.merge", &json!({"ids": [second.0, first.0]})).unwrap();
|
||||
assert_eq!(on(&s, first), [(true, 200.0), (false, 300.0)]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((on(&s, first), on(&s, second)), (vec![(true, 200.0)], vec![(false, 300.0)]));
|
||||
// Flattened into the first layer, a hidden layer is discarded with its guides.
|
||||
s.execute("layer.setProps", &json!({"ids": [second.0], "visible": false})).unwrap();
|
||||
s.execute("layer.flatten", &json!({"id": first.0})).unwrap();
|
||||
assert_eq!(guides(&s), [(true, 200.0)]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(guides(&s).len(), 2);
|
||||
}
|
||||
|
||||
/// #451: art moved with the Selection tool lands flush on the artboard's edges and centre (Smart
|
||||
/// Guides).
|
||||
#[test]
|
||||
|
||||
@@ -56,6 +56,13 @@ fn edit_gradient_angle_reverse_and_errors() {
|
||||
s.execute("paint.editGradient", &json!({"stroke": false, "reverse": true})).unwrap();
|
||||
let Paint::Gradient(g) = node(&s, id).appearance.fill_paint() else { panic!() };
|
||||
assert_eq!(g.gradient.stops[0].color.to_hex(), "#000000");
|
||||
// #1007: an off-centre midpoint is mirrored, not reset.
|
||||
let stops = json!([{"offset": 0, "color": "#ff0000", "midpoint": 0.2}, {"offset": 1, "color": "#0000ff"}]);
|
||||
s.execute("paint.editGradient", &json!({"stroke": false, "stops": stops})).unwrap();
|
||||
s.execute("paint.editGradient", &json!({"stroke": false, "reverse": true})).unwrap();
|
||||
let Paint::Gradient(g) = node(&s, id).appearance.fill_paint() else { panic!() };
|
||||
assert_eq!(g.gradient.stops[0].color.to_hex(), "#0000ff");
|
||||
assert!((g.gradient.stops[0].midpoint - 0.8).abs() < 1e-6, "{:?}", g.gradient.stops);
|
||||
assert!(s.execute("paint.editGradient", &json!({"stops": [{"offset": 0, "color": "#fff"}]})).is_err());
|
||||
assert!(s.execute("paint.editGradient", &json!({"kind": "conic"})).is_err());
|
||||
}
|
||||
|
||||
@@ -57,6 +57,37 @@ fn serialize(s: &mut Session, format: &str) -> Vec<u8> {
|
||||
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_place_coordinates_are_rejected_instead_of_silently_changed() {
|
||||
let mut s = session();
|
||||
let png = png(4, 2, [20, 40, 60, 255], None);
|
||||
let original = s.doc().unwrap().doc.layers.clone();
|
||||
// Previously filter_map dropped the string from a five-element rectangle and
|
||||
// treated the remaining four values as a different, valid placement.
|
||||
for rect in [json!([0, "ignored", 10, 40, 50]), json!([10, 20, 40, null, 50]), json!([10, 20, 40, 50, 60])] {
|
||||
let mut params = file("tiny.png", &png);
|
||||
params["rect"] = rect;
|
||||
assert!(s.execute("file.place", ¶ms).is_err(), "{params}");
|
||||
assert_eq!(s.doc().unwrap().doc.layers, original, "failed Place must not add art");
|
||||
}
|
||||
// An explicitly supplied invalid position formerly behaved as though no
|
||||
// position were supplied and centred the image on the artboard.
|
||||
for at in [json!(["not-a-number", 20]), json!([10, null]), json!([10, 20, 30]), json!("invalid")] {
|
||||
let mut params = file("tiny.png", &png);
|
||||
params["at"] = at;
|
||||
assert!(s.execute("file.place", ¶ms).is_err(), "{params}");
|
||||
assert_eq!(s.doc().unwrap().doc.layers, original);
|
||||
}
|
||||
let mut params = file("tiny.png", &png);
|
||||
params["rect"] = json!([10, 20, 80, 60]);
|
||||
let placed = s.execute("file.place", ¶ms).unwrap();
|
||||
assert_eq!(placed["ids"].as_array().unwrap().len(), 1);
|
||||
// A null position is no position, as for the other optional params.
|
||||
let mut params = file("tiny.png", &png);
|
||||
params["at"] = Value::Null;
|
||||
assert_eq!(s.execute("file.place", ¶ms).unwrap()["ids"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_300_px_png_at_300_ppi_places_as_72_pt_centred_selected_and_undoable() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//! A reflected object is still clicked by its fill, not only by its outline.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_geom::Point;
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn id_of(v: &Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
/// A click with the Selection tool at (x, y): what it selects.
|
||||
fn click(s: &mut Session, x: f64, y: f64) -> Vec<NodeId> {
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(kind, x, y).with_mods(Mods::default()), v).unwrap();
|
||||
}
|
||||
s.doc().unwrap().selection.objects.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reflected_shape_is_clicked_by_its_fill() {
|
||||
const HEART: &str = "M200 290C190 280 110 230 110 170C110 140 130 120 155 120C175 120 192 132 200 148C208 132 225 120 245 120C270 120 290 140 290 170C290 230 210 280 200 290Z";
|
||||
let makers: [(&str, Value); 5] = [
|
||||
("shape.rectangle", json!({"x": 150, "y": 150, "width": 100, "height": 100})),
|
||||
("shape.ellipse", json!({"x": 150, "y": 150, "width": 100, "height": 100})),
|
||||
("shape.polygon", json!({"cx": 200, "cy": 200, "radius": 60, "sides": 5})),
|
||||
("shape.star", json!({"cx": 200, "cy": 200, "radius1": 70, "radius2": 40, "points": 5})),
|
||||
("path.create", json!({"d": HEART})),
|
||||
];
|
||||
for (cmd, params) in makers {
|
||||
for axis in ["horizontal", "vertical"] {
|
||||
let mut s = session();
|
||||
let id = id_of(&s.execute(cmd, ¶ms).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("object.reflect", &json!({"axis": axis})).unwrap();
|
||||
let b = s.doc().unwrap().doc.bounds_of(&[id], false).unwrap();
|
||||
s.execute("select.set", &json!({"ids": []})).unwrap();
|
||||
let c = b.center();
|
||||
assert_eq!(click(&mut s, c.x, c.y), vec![id], "{cmd} reflected {axis}: a click in its fill at {c:?} (bounds {b:?})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hearts as SVG files bring them: a plain path, a compound path, inside a group, under a
|
||||
/// transform. Reflected with the button and with a bounding-box handle dragged across.
|
||||
#[test]
|
||||
fn a_reflected_imported_heart_is_clicked_by_its_fill() {
|
||||
const D: &str = "M200 290C190 280 110 230 110 170C110 140 130 120 155 120C175 120 192 132 200 148C208 132 225 120 245 120C270 120 290 140 290 170C290 230 210 280 200 290Z";
|
||||
let files = [
|
||||
format!(r##"<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400"><path fill="#e2384d" d="{D}"/></svg>"##),
|
||||
format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400"><path fill="#e2384d" fill-rule="evenodd" d="{D} M190 200h20v20h-20z"/></svg>"##
|
||||
),
|
||||
format!(r##"<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400"><g><g fill="#e2384d"><path d="{D}"/></g></g></svg>"##),
|
||||
format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400"><g transform="translate(10 0) scale(1.2)"><path fill="#e2384d" transform="matrix(1 0 0 1 -20 -20)" d="{D}"/></g></svg>"##
|
||||
),
|
||||
format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400" viewBox="0 0 200 200"><path style="fill:#e2384d;stroke:none" d="{D}" transform="scale(0.5)"/></svg>"##
|
||||
),
|
||||
];
|
||||
for (i, svg) in files.iter().enumerate() {
|
||||
for how in ["horizontal", "vertical", "handle"] {
|
||||
let mut s = Session::new();
|
||||
s.add_document(vectorcraft_svg::import(svg).unwrap(), None);
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
let ids = s.doc().unwrap().selection.objects.clone();
|
||||
assert!(!ids.is_empty(), "file {i}: nothing imported");
|
||||
let b = s.doc().unwrap().doc.bounds_of(&ids, false).unwrap();
|
||||
if how == "handle" {
|
||||
// Drag the top handle down past the bottom: a top-to-bottom flip.
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
let (from, to) = (Point::new(b.center().x, b.y0), Point::new(b.center().x, b.y1 + b.height()));
|
||||
for (kind, p) in
|
||||
[(PointerKind::Down, from), (PointerKind::Drag, Point::new(from.x, b.center().y)), (PointerKind::Drag, to), (PointerKind::Up, to)]
|
||||
{
|
||||
s.pointer(&PointerEvent::new(kind, p.x, p.y).with_mods(Mods::default()), v).unwrap();
|
||||
}
|
||||
} else {
|
||||
s.execute("object.reflect", &json!({"axis": how})).unwrap();
|
||||
}
|
||||
let b = s.doc().unwrap().doc.bounds_of(&ids, false).unwrap();
|
||||
s.execute("select.set", &json!({"ids": []})).unwrap();
|
||||
// Inside the heart away from the compound's square hole: a third of the way down.
|
||||
let c = Point::new(b.center().x, if how == "horizontal" || how == "handle" { b.y1 - b.height() * 0.3 } else { b.y0 + b.height() * 0.3 });
|
||||
let picked = click(&mut s, c.x, c.y);
|
||||
assert_eq!(picked, ids, "file {i} flipped by {how}: a click in its fill at {c:?} (bounds {b:?})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Still selected after the flip, a drag from inside the fill moves it.
|
||||
#[test]
|
||||
fn a_reflected_selected_shape_drags_by_its_fill() {
|
||||
const HEART: &str = "M200 290C190 280 110 230 110 170C110 140 130 120 155 120C175 120 192 132 200 148C208 132 225 120 245 120C270 120 290 140 290 170C290 230 210 280 200 290Z";
|
||||
let makers: [(&str, Value); 4] = [
|
||||
("shape.rectangle", json!({"x": 150, "y": 150, "width": 100, "height": 100})),
|
||||
("shape.polygon", json!({"cx": 200, "cy": 200, "radius": 60, "sides": 3})),
|
||||
("shape.star", json!({"cx": 200, "cy": 200, "radius1": 70, "radius2": 40, "points": 5})),
|
||||
("path.create", json!({"d": HEART})),
|
||||
];
|
||||
for (cmd, params) in makers {
|
||||
for axis in ["horizontal", "vertical"] {
|
||||
let mut s = session();
|
||||
let id = id_of(&s.execute(cmd, ¶ms).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("object.reflect", &json!({"axis": axis})).unwrap();
|
||||
let b = s.doc().unwrap().doc.bounds_of(&[id], false).unwrap();
|
||||
let c = b.center();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
for (kind, x) in
|
||||
[(PointerKind::Down, c.x), (PointerKind::Drag, c.x + 20.0), (PointerKind::Drag, c.x + 40.0), (PointerKind::Up, c.x + 40.0)]
|
||||
{
|
||||
s.pointer(&PointerEvent::new(kind, x, c.y).with_mods(Mods::default()), v).unwrap();
|
||||
}
|
||||
let after = s.doc().unwrap().doc.bounds_of(&[id], false).unwrap();
|
||||
assert!((after.x0 - b.x0 - 40.0).abs() < 1e-6, "{cmd} reflected {axis}: a drag from {c:?} left it at {after:?} (was {b:?})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An open path (an anchor deleted from a closed one) still fills as if closed, and its fill is
|
||||
/// clicked on both sides of that closing line, reflected or not.
|
||||
#[test]
|
||||
fn an_open_filled_path_is_clicked_by_its_fill_either_way_round() {
|
||||
// The sliver of a strawberry, open, its closing line down its left side.
|
||||
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><path fill="#c22a3f" d="M86.311 69.612C100.544 78.261 110.506 88.64 110.506 100.748C110.506 111.127 100.544 118.046 86.311 118.046C93.214 102.045 91.407 102.403 91.407 97.61"/></svg>"##;
|
||||
for axis in [None, Some("vertical"), Some("horizontal")] {
|
||||
let mut s = Session::new();
|
||||
s.add_document(vectorcraft_svg::import(svg).unwrap(), None);
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
let ids = s.doc().unwrap().selection.objects.clone();
|
||||
if let Some(axis) = axis {
|
||||
s.execute("object.reflect", &json!({"axis": axis})).unwrap();
|
||||
}
|
||||
let b = s.doc().unwrap().doc.bounds_of(&ids, false).unwrap();
|
||||
s.execute("select.set", &json!({"ids": []})).unwrap();
|
||||
// The middle of the sliver's widest part, well inside it.
|
||||
let x = if axis == Some("vertical") { b.x0 + b.width() * 0.4 } else { b.x0 + b.width() * 0.6 };
|
||||
assert_eq!(click(&mut s, x, b.center().y), ids, "reflected {axis:?}: a click in the fill (bounds {b:?})");
|
||||
}
|
||||
}
|
||||
@@ -563,3 +563,35 @@ fn automatic_alignment_updates_during_edits_and_can_be_overridden() {
|
||||
legacy["para"]["justify"] = json!("Left");
|
||||
assert_eq!(serde_json::from_value::<TextObject>(legacy).unwrap().para.justify, vectorcraft_doc::Justify::Left);
|
||||
}
|
||||
|
||||
/// #1063: a fill (or stroke) chosen while the Type tool has characters selected colours just
|
||||
/// those characters, as one undo step; the rest of the type keeps its colour.
|
||||
#[test]
|
||||
fn a_fill_chosen_with_characters_selected_colours_only_them() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
click(&mut s, 50.0, 300.0);
|
||||
let id = s.doc().unwrap().selection.objects[0];
|
||||
s.tool_text("Sample Text", v).unwrap();
|
||||
let before = obj(&s, id).runs[0].style.fill.clone();
|
||||
s.set_tool_option("select", &json!({"start": 0, "end": 6}));
|
||||
s.execute("paint.setFill", &json!({"color": "#00a000"})).unwrap();
|
||||
let t = obj(&s, id);
|
||||
assert_eq!(t.runs.len(), 2);
|
||||
assert_eq!(t.runs[0].text, "Sample");
|
||||
assert_eq!(t.runs[0].style.fill, Paint::solid(vectorcraft_color::Color::rgb8(0, 160, 0)));
|
||||
assert_eq!(t.runs[1].text, " Text");
|
||||
assert_eq!(t.runs[1].style.fill, before);
|
||||
// A stroke goes on the selected characters only, with a weight to show.
|
||||
s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap();
|
||||
let t = obj(&s, id);
|
||||
assert!(t.runs[0].style.stroke_width > 0.0 && !t.runs[0].style.stroke.is_none());
|
||||
assert!(t.runs[1].style.stroke.is_none());
|
||||
// One undo step each.
|
||||
let labels = undo_labels(&s);
|
||||
assert_eq!(&labels[labels.len() - 2..], ["Fill Color", "Stroke Color"]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(obj(&s, id).runs.iter().all(|r| r.style.fill == before));
|
||||
}
|
||||
@@ -236,6 +236,9 @@ fn reshape_area_takes_untrusted_params() {
|
||||
json!({"id": id.0, "dx": 1, "dy": 1}),
|
||||
json!({"id": id.0, "anchors": [], "dx": 1, "dy": 1}),
|
||||
json!({"id": id.0, "anchors": [["a", 1], [0], "x"], "dx": 1, "dy": 1}),
|
||||
// A malformed reference among good ones fails rather than moving only the good ones.
|
||||
json!({"id": id.0, "anchors": [[0, 2], [0, "3"]], "dx": 1, "dy": 1}),
|
||||
json!({"id": id.0, "anchors": [[0, 2], [0, 3, 1]], "dx": 1, "dy": 1}),
|
||||
json!({"id": id.0, "anchors": [[0, 9]], "dx": 1, "dy": 1}),
|
||||
json!({"id": id.0, "anchors": [[0, 1], [7, 0]], "dx": 1, "dy": 1}),
|
||||
json!({"id": id.0, "anchors": [[0, 2]], "dy": 1}),
|
||||
|
||||
@@ -572,3 +572,27 @@ fn copy_from_requires_source() {
|
||||
assert!(s.execute("appearance.copyFrom", &json!({})).is_err());
|
||||
assert!(s.execute("appearance.copyFrom", &json!({"source": 9999})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distort_rejects_malformed_coordinates_and_ids_without_editing_art() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
let original = bounds(&s, a);
|
||||
let history = undo_len(&s);
|
||||
let corners = json!([[10, 0], [90, 0], [100, 100], [0, 100]]);
|
||||
for p in [
|
||||
json!({"corners": [[10, 0, 50], [90, 0], [100, 100], [0, 100]]}),
|
||||
json!({"corners": [[10, 0], [90, 0], ["bad", 100], [0, 100]]}),
|
||||
json!({"corners": [[10, 0], [90, 0], [100, 100], [0, 100], ["bad"]]}),
|
||||
json!({"corners": corners, "from": [0, 0, 100]}),
|
||||
json!({"corners": corners, "from": "not bounds"}),
|
||||
json!({"corners": corners, "ids": [a.0, "bad"]}),
|
||||
json!({"corners": corners, "ids": [a.0, u64::MAX]}),
|
||||
] {
|
||||
assert!(s.execute("object.distort", &p).is_err(), "invalid input: {p}");
|
||||
assert!(close(bounds(&s, a), original));
|
||||
assert_eq!(undo_len(&s), history);
|
||||
}
|
||||
s.execute("object.distort", &json!({"corners": corners, "from": [0, 0, 100, 100], "ids": [a.0]})).unwrap();
|
||||
assert_eq!(undo_len(&s), history + 1);
|
||||
}
|
||||
@@ -1280,11 +1280,17 @@ proptest! {
|
||||
|
||||
/// Hostile PostScript programs: operators in any order with any operands, opened and placed;
|
||||
/// as files in the legacy Illustrator format too (the creator line is what turns on its
|
||||
/// documented `u` … `U` groups), the groups unbalanced, deep and among clips.
|
||||
/// documented `u` … `U` groups), the groups unbalanced, deep and among clips; in a layer, which
|
||||
/// the editing data reader reads from the program too.
|
||||
#[test]
|
||||
fn eps_hostile_programs_never_panic(tokens in prop::collection::vec(arb_ps_token(), 0..60), illustrator in any::<bool>()) {
|
||||
fn eps_hostile_programs_never_panic(tokens in prop::collection::vec(arb_ps_token(), 0..60), illustrator in any::<bool>(), layers in any::<bool>()) {
|
||||
let head = if illustrator { "%%Creator: Adobe Illustrator(R) 8.0\n%%EndComments\n/u {} def /U {} def" } else { "%%EndComments" };
|
||||
let ps = format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 200\n{head}\n{}\nshowpage\n", tokens.join(" "));
|
||||
let (open, close) = if layers {
|
||||
("/Lb {10 {pop} repeat} def /Ln {pop} def /LB {} def\n%AI5_BeginLayer\n1 1 1 1 0 0 -1 0 0 0 Lb\n(L) Ln\n", "\nLB\n%AI5_EndLayer--")
|
||||
} else {
|
||||
("", "")
|
||||
};
|
||||
let ps = format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 200\n{head}\n{open}{}{close}\nshowpage\n", tokens.join(" "));
|
||||
survive("hostile PostScript", || vectorcraft_engine::cmd::fileio::load("x.eps", ps.as_bytes()).ok().map(|l| l.doc))?;
|
||||
let r = catch_quiet(|| {
|
||||
let mut s = rich_session();
|
||||
@@ -1702,6 +1708,9 @@ fn ascii85(data: &[u8]) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// A pattern's definition in editing data: its name and tile, its art on `%_` lines, its end.
|
||||
const PATTERN: &str = "%AI3_BeginPattern: (Dots)\n(Dots) 0 0 6 6\n%_0 A\n%_0 Ae\n%_u\n%_0 0 m\n%_0 6 L\n%_6 6 L\n%_6 0 L\n%_n\n%_0 0 0 1 K\n%_1 J 1 w\n%_3 3 m\n%_3 3 L\n%_S\n%_U\n%_9 () XW\nE\n%AI3_EndPattern\n";
|
||||
|
||||
/// The editing data of a file with every sort of object the layers reader knows, and some it doesn't.
|
||||
fn editing_text() -> String {
|
||||
let square = |x: u32| format!("0 0 1 0 k\n{x} 10 m\n{} 10 L\n{} 14 L\n{x} 14 L\nf", x + 4, x + 4);
|
||||
@@ -1724,13 +1733,16 @@ fn editing_text() -> String {
|
||||
"0 Xw".into(),
|
||||
"1 0 0 0 1 0 Bg".into(),
|
||||
"0 1 w 2 J 0 j 4 M [3 2]0 d 1 D".into(),
|
||||
"0 0 0 1 (Black global) 0.4 0 Xk".into(),
|
||||
"(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p".into(),
|
||||
square(60),
|
||||
]
|
||||
.join("\n");
|
||||
let layer = |name: &str, visible: u8, body: &str| {
|
||||
format!("%AI5_BeginLayer\n{visible} 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n({name}) Ln\n{body}\nLB\n%AI5_EndLayer--\n")
|
||||
};
|
||||
format!(
|
||||
"%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n{}{}%%Trailer\n",
|
||||
"%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n{PATTERN}{}{}%%Trailer\n",
|
||||
layer("One", 1, &format!("{body}\n{}", layer("Sub", 1, &square(70)))),
|
||||
layer("Two", 0, &square(80))
|
||||
)
|
||||
@@ -1872,6 +1884,35 @@ proptest! {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `.ai` in PostScript form as other apps write it: the procsets that define its operators named,
|
||||
/// not included, so its program can't be run and its layers are read from it.
|
||||
const NAMED_PROLOG_AI: &str = "%!PS-Adobe-3.0\n%%BoundingBox: 0 0 200 100\n%%DocumentNeededResources: procset Adobe_packedarray 2.0 0\n%AI3_TemplateBox: 288 384 288 384\n%%EndComments\n\
|
||||
%%BeginProlog\n%%IncludeResource: procset Adobe_packedarray 2.0 0\nAdobe_packedarray /initialize get exec\n%%EndProlog\n%%BeginSetup\n%AI5_Begin_NonPrinting\nNp\n%AI5_End_NonPrinting--\n%%EndSetup\n\
|
||||
%AI5_BeginLayer\n1 1 1 1 0 0 -1 191 63 255 Lb\n(A::B) Ln\n0 A\n0 R\n0 0 0 1 K\n0 i 1 J 1 j 0.8 w 4 M [2 1]0 d\n0 D\n10 10 m\n90 10 L\n90 90 L\n10 90 L\n10 10 L\nS\n\
|
||||
u\n0.5 g\n20 20 m\n40 20 L\n40 40 L\nf\nU\n%AI5_BeginLayer\n0 1 1 1 0 0 3 0 0 0 Lb\n(Sub) Ln\n110 10 m\n190 90 L\nS\nLB\n%AI5_EndLayer--\nLB\n%AI5_EndLayer--\n\
|
||||
%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n";
|
||||
|
||||
/// The fixture, as it is, opens with its layer and sublayer.
|
||||
#[test]
|
||||
fn the_named_prolog_fixture_opens_with_its_layers() {
|
||||
let l = vectorcraft_engine::cmd::fileio::load("x.ai", NAMED_PROLOG_AI.as_bytes()).unwrap();
|
||||
let names: Vec<_> = l.doc.layers.iter().map(|l| l.name.clone()).collect();
|
||||
assert_eq!(names, [Some("A::B".to_string())], "{:?}", l.warnings);
|
||||
assert_eq!(l.doc.layers[0].children().unwrap().len(), 3);
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(config())]
|
||||
|
||||
/// A `.ai` in PostScript form whose prolog is named, not included, damaged: read from its
|
||||
/// layers, or refused.
|
||||
#[test]
|
||||
fn ps_form_ai_never_panics(cut in 0usize..1_200, edits in prop::collection::vec((0usize..1_200, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', '[', ']', '%', 'u', 'U', 'L', 'b', 'B', 'i', 'A', 'K'])), 0..12)) {
|
||||
let text = mutate_text(NAMED_PROLOG_AI, cut, &edits);
|
||||
survive("mutated PostScript-form .ai", || vectorcraft_engine::cmd::fileio::load("x.ai", text.as_bytes()).ok().map(|l| l.doc))?;
|
||||
}
|
||||
}
|
||||
|
||||
/// The operators, operands and section comments of hostile editing data.
|
||||
fn arb_ai_token() -> impl Strategy<Value = String> {
|
||||
prop_oneof![
|
||||
@@ -1980,6 +2021,16 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
|
||||
"/Binary : /ASCII85Decode ,",
|
||||
"~>",
|
||||
"p",
|
||||
"P",
|
||||
"(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p",
|
||||
"(Dots) 0 0 1 1 0 0 0 0 0 [0 0 0 0 1e308 0] P",
|
||||
"Xk",
|
||||
"XK",
|
||||
"0 0 0 1 (Global) 0.4 0 Xk",
|
||||
"1 0.5 0 (Global) 0 1 XK",
|
||||
"E",
|
||||
"\n%AI3_BeginPattern: (Dots)\n(Dots) 0 0 6 6\n",
|
||||
"\n%AI3_EndPattern\n",
|
||||
"To",
|
||||
"frobnicate",
|
||||
"\n%AI5_BeginLayer\n",
|
||||
|
||||
@@ -81,6 +81,16 @@ impl<'a> Lexer<'a> {
|
||||
rest.get(..n).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Where the next token is looked for.
|
||||
pub fn offset(&self) -> usize {
|
||||
self.pos
|
||||
}
|
||||
|
||||
/// The data from `from` to `to` (empty where that isn't in it).
|
||||
pub fn slice(&self, from: usize, to: usize) -> &'a [u8] {
|
||||
self.src.get(from..to).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Skip past the next `needle` (the end of data that isn't tokens) → what was skipped.
|
||||
pub fn skip_past(&mut self, needle: &[u8]) -> &'a [u8] {
|
||||
let rest = self.rest();
|
||||
|
||||
@@ -52,6 +52,11 @@ pub fn eps_data(ps: &[u8]) -> Option<Result<Vec<u8>, String>> {
|
||||
Some(eps_section(section))
|
||||
}
|
||||
|
||||
/// Does the EPS `ps` carry editing data ([`eps_data`] isn't `None`)?
|
||||
pub fn has_eps_data(ps: &[u8]) -> bool {
|
||||
find(ps, EPS_BEGIN).is_some()
|
||||
}
|
||||
|
||||
/// The editing data between the EPS markers: header comments, then the data (encoded or not).
|
||||
fn eps_section(section: &[u8]) -> Result<Vec<u8>, String> {
|
||||
let mut header = Vec::new();
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
//! - Colours: `g`/`G` grey (1 is white), `k`/`K` CMYK, `x`/`X` a named CMYK colour at a tint
|
||||
//! (`c m y k (name) tint`; tint 0 is the full colour), `Xa`/`XA` RGB (`c m y k r g b`, the CMYK
|
||||
//! values its equivalent), `Xx`/`XX` a named colour of either model
|
||||
//! (`c m y k [r g b] (name) tint type`). Lower case paints fills, upper case strokes.
|
||||
//! (`c m y k [r g b] (name) tint type`), `Xk`/`XK` a global process colour (its operands as
|
||||
//! `Xx`'s: a global swatch that isn't an ink of its own). Lower case paints fills, upper case
|
||||
//! strokes.
|
||||
//! - Gradient definitions: `(name) type stops Bd`, its stops as `colour style [opacity 6]
|
||||
//! midpoint ramp Bs` (style 0 grey, 1 CMYK, 2 RGB with its CMYK, 3 and 4 named colours), `BD`.
|
||||
//! - A gradient on an object: `[flag] Bb` (1: the stroke), `flag (name) x y angle length a b c d
|
||||
@@ -28,6 +30,8 @@ pub(super) struct Named {
|
||||
/// The full colour.
|
||||
pub color: Color,
|
||||
pub tint: f32,
|
||||
/// An ink of its own (a spot colour), not a global process colour.
|
||||
pub spot: bool,
|
||||
}
|
||||
|
||||
/// The paint of a colour operator's operands (`op`: its name, lower case or not), with the named
|
||||
@@ -48,6 +52,7 @@ pub(super) fn color_op(op: &str, vals: &[V]) -> Option<(Paint, Option<Named>)> {
|
||||
}
|
||||
"x" => named(vals, Some(false)),
|
||||
"xx" => named(vals, None),
|
||||
"xk" => named(vals, None).map(|(p, n)| (p, n.map(|n| Named { spot: false, ..n }))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -72,7 +77,7 @@ fn named(vals: &[V], rgb: Option<bool>) -> Option<(Paint, Option<Named>)> {
|
||||
Color::cmyk(unit(*c), unit(*m), unit(*y), unit(*k))
|
||||
};
|
||||
let paint = Paint::Solid { color: color.tinted(tint), swatch: Some(name.clone()), tint };
|
||||
Some((paint, Some(Named { name, color, tint })))
|
||||
Some((paint, Some(Named { name, color, tint, spot: true })))
|
||||
}
|
||||
|
||||
/// The colour of a gradient stop's colour values and style.
|
||||
@@ -162,7 +167,7 @@ pub(super) struct Instance {
|
||||
hilight: Vec2,
|
||||
}
|
||||
|
||||
fn affine(n: &[f64]) -> Option<Affine> {
|
||||
pub(super) fn affine(n: &[f64]) -> Option<Affine> {
|
||||
let [a, b, c, d, e, f] = n else { return None };
|
||||
let m = Affine::new([*a, *b, *c, *d, *e, *f]);
|
||||
(m.as_coeffs().iter().all(|v| v.is_finite())).then_some(m)
|
||||
@@ -247,6 +252,20 @@ mod tests {
|
||||
assert!((named.tint - 0.6).abs() < 1e-6);
|
||||
assert!(matches!(p, Paint::Solid { swatch: Some(_), .. }));
|
||||
assert!(color_op("k", &nums(&[1.0])).is_none());
|
||||
// A global process colour: CMYK (type 0) or RGB (type 1), not an ink.
|
||||
let mut v = nums(&[0.0, 0.0, 0.0, 1.0]);
|
||||
v.extend([V::Str(b"K100".to_vec()), V::Num(0.4), V::Num(0.0)]);
|
||||
let (p, named) = color_op("XK", &v).unwrap();
|
||||
let named = named.unwrap();
|
||||
assert_eq!((named.name.as_str(), named.color, named.spot), ("K100", Color::cmyk(0.0, 0.0, 0.0, 1.0), false));
|
||||
assert!((named.tint - 0.6).abs() < 1e-6);
|
||||
assert!(
|
||||
matches!(p, Paint::Solid { swatch: Some(s), tint, color: Color::Cmyk { k, .. } } if s == "K100" && (tint - 0.6).abs() < 1e-6 && (k - 0.6).abs() < 1e-6)
|
||||
);
|
||||
let mut v = nums(&[0.0, 0.0, 0.0, 0.0, 1.0, 0.5, 0.0]);
|
||||
v.extend([V::Str(b"Orange".to_vec()), V::Num(0.0), V::Num(1.0)]);
|
||||
let (p, _) = color_op("Xk", &v).unwrap();
|
||||
assert_eq!(p.color(), Some(Color::rgb(1.0, 0.5, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2,23 +2,31 @@
|
||||
//!
|
||||
//! - Art space has y up; the first artboard's top left corner is the document's origin.
|
||||
//! - Layers: `%AI5_BeginLayer`, `visible preview unlocked printing dimmed … colorIndex r g b …
|
||||
//! dimPercent … Lb`, `(name) Ln`, its art, `LB`.
|
||||
//! dimPercent … Lb` (ten operands in the legacy format, without the one before the colour
|
||||
//! index), `(name) Ln`, its art, `LB`.
|
||||
//! - Groups `u` … `U`, compound paths `*u` … `*U`, clipping groups `q` … `Q` (the path painted
|
||||
//! with `W` clips); a group's transparency and name follow its end.
|
||||
//! - Paths: `m`, `l`/`L`, `c`/`C`, `v`/`V`, `y`/`Y`, painted by `N n F f S s B b` (lower case
|
||||
//! closes), `(op) *` for a guide. Stroke `w J j M d`, fill rule `XR`, overprint `O`/`R`.
|
||||
//! - State: hidden `Xw`, locked `A`, transparency `mode opacity isolate knockout shape Xy`.
|
||||
//! - Names: an `/ArtDictionary` after an object, its `AI10_ArtUID` the name's XML id.
|
||||
//! - Artboards: the `ArtboardArray` of the `/Document` dictionary, else `%AI3_Cropmarks`.
|
||||
//! - Artboards: the `ArtboardArray` of the `/Document` dictionary, else `%AI3_Cropmarks`, else
|
||||
//! the art size around the template box's centre, else (a file in the legacy format another
|
||||
//! app wrote) the bounding box.
|
||||
//! - Images: `%AI5_BeginRaster`, the colour space `XN`, `[matrix] bounds w h bits type alpha …`
|
||||
//! and the samples after `XI`.
|
||||
//! - Art with an appearance the format can't write as plain art (several fills or strokes,
|
||||
//! effects, brushes) comes first as that look, drawn (a group), then the object itself on `%_`
|
||||
//! lines and `1 (style) XW`: the drawn look is kept, named after the object.
|
||||
//! - Patterns: `%AI3_BeginPattern: (name)`, `(name) llx lly urx ury` (the tile, in the pattern's
|
||||
//! space, y up), the tile's art (on `%_` lines in newer files), `E`, `%AI3_EndPattern`; each is
|
||||
//! read by a reader of its own and becomes a pattern swatch. A fill or stroke with one:
|
||||
//! `(name) x y scaleX scaleY angle reflect axis shear shearAxis [matrix] p` (`P`), the matrix
|
||||
//! taking the pattern's space to art space (the other operands are what it is made of).
|
||||
//!
|
||||
//! Sections the reader doesn't need (patterns, brushes, symbols, styles, swatches, filters) are
|
||||
//! skipped; type, symbols, pattern fills, placed files, legacy type and operators it doesn't know
|
||||
//! are errors.
|
||||
//! Sections the reader doesn't need (brushes, symbols, styles, swatches, filters) are skipped;
|
||||
//! type, symbols, fills with a pattern it can't read, placed files, legacy type and operators it
|
||||
//! doesn't know are errors.
|
||||
|
||||
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||
use std::rc::Rc;
|
||||
@@ -30,7 +38,7 @@ use vectorcraft_color::swatch::Swatch;
|
||||
use vectorcraft_color::{BlendMode, Paint};
|
||||
use vectorcraft_doc::{
|
||||
Appearance, AppearanceItem, Artboard, ColorMode, Dash, Document, FillLayer, ImageBlob, ImageObject, Knockout, LayerColor, LineCap, LineJoin,
|
||||
Node, NodeId, NodeKind, StrokeLayer,
|
||||
Node, NodeId, NodeKind, PatternDef, StrokeLayer,
|
||||
};
|
||||
use vectorcraft_geom::{FillRule, PathData};
|
||||
|
||||
@@ -51,6 +59,10 @@ const MAX_POINTS: usize = 4_000_000;
|
||||
const MAX_PIXELS: u64 = 1 << 25;
|
||||
/// Largest side of the document and its artboards (points).
|
||||
const MAX_SIDE: f64 = 1e6;
|
||||
/// Art space (y up) → a pattern's space in the document's terms (y down).
|
||||
const FLIP: Affine = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]);
|
||||
/// Most patterns read (others can't fill art).
|
||||
const MAX_PATTERNS: usize = 4096;
|
||||
|
||||
/// What the editing data holds: the document of its art, and where its art space is.
|
||||
#[derive(Debug)]
|
||||
@@ -197,6 +209,16 @@ struct Frame {
|
||||
clips: Vec<usize>,
|
||||
}
|
||||
|
||||
/// What a reader of a pattern's definition has of its head, `(name) llx lly urx ury`: the operands
|
||||
/// before its first operator.
|
||||
#[derive(Debug, Default)]
|
||||
struct PatternHead {
|
||||
taken: bool,
|
||||
name: String,
|
||||
/// The tile in the pattern's space (y up).
|
||||
tile: Option<Rect>,
|
||||
}
|
||||
|
||||
/// An image's operands, from `XN` and before `XI`.
|
||||
#[derive(Debug, Default)]
|
||||
struct Raster {
|
||||
@@ -260,6 +282,10 @@ struct Reader<'a> {
|
||||
/// The artboard's size and centre in files without artboards or crop marks.
|
||||
art_size: Option<(f64, f64)>,
|
||||
template_center: Option<(f64, f64)>,
|
||||
/// This reader reads a pattern's definition (see [`Reader::define_pattern`]).
|
||||
pattern: Option<PatternHead>,
|
||||
/// The tile's top left (y down) of each pattern read, by name.
|
||||
pattern_tiles: HashMap<String, Point>,
|
||||
}
|
||||
|
||||
/// Operators that change nothing the reader keeps.
|
||||
@@ -281,6 +307,8 @@ const IGNORED: &[&str] = &[
|
||||
"Xm",
|
||||
"XP",
|
||||
"Np",
|
||||
// Flatness (`i`): how finely curves print.
|
||||
"i",
|
||||
"TE",
|
||||
"TZ",
|
||||
"Xs",
|
||||
@@ -377,6 +405,8 @@ impl<'a> Reader<'a> {
|
||||
slot_names: HashMap::new(),
|
||||
art_size: None,
|
||||
template_center: None,
|
||||
pattern: None,
|
||||
pattern_tiles: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,6 +459,12 @@ impl<'a> Reader<'a> {
|
||||
let line = String::from_utf8_lossy(c);
|
||||
let line = line.trim_end();
|
||||
let m = marker(line);
|
||||
// A pattern's definition (one inside another's isn't read).
|
||||
if m == "BeginPattern" && self.pattern.is_none() {
|
||||
let section = self.section("EndPattern")?;
|
||||
self.define_pattern(section);
|
||||
return Ok(());
|
||||
}
|
||||
if let Some((_, end)) = SKIPPED.iter().find(|(b, _)| *b == m) {
|
||||
return self.skip_to(end);
|
||||
}
|
||||
@@ -481,10 +517,18 @@ impl<'a> Reader<'a> {
|
||||
/// Skip to the comment with marker `end` (sections of the same kind nested in it count, and
|
||||
/// versioned content in an alternate).
|
||||
fn skip_to(&mut self, end: &str) -> Result<(), String> {
|
||||
self.section(end).map(|_| ())
|
||||
}
|
||||
|
||||
/// [`Self::skip_to`] → what was skipped, up to the comment that ends it.
|
||||
fn section(&mut self, end: &str) -> Result<&'a [u8], String> {
|
||||
let begin = SKIPPED.iter().find(|(_, e)| *e == end).map_or("", |(b, _)| *b);
|
||||
let begin = if end == "End_Versioned_Content" { "Begin_Content_if_version_gt" } else { begin };
|
||||
let start = self.lex.offset();
|
||||
let mut depth = 0usize;
|
||||
while let Some(t) = self.lex.next_token() {
|
||||
loop {
|
||||
let before = self.lex.offset();
|
||||
let Some(t) = self.lex.next_token() else { break };
|
||||
if let Tok::Comment(c) = t.tok {
|
||||
let line = String::from_utf8_lossy(c);
|
||||
let m = marker(&line);
|
||||
@@ -492,7 +536,7 @@ impl<'a> Reader<'a> {
|
||||
depth += 1;
|
||||
} else if m == end {
|
||||
if depth == 0 {
|
||||
return Ok(());
|
||||
return Ok(self.lex.slice(start, before));
|
||||
}
|
||||
depth -= 1;
|
||||
}
|
||||
@@ -501,6 +545,58 @@ impl<'a> Reader<'a> {
|
||||
Err(format!("its section ending with `%{end}` doesn't end"))
|
||||
}
|
||||
|
||||
/// A pattern's definition (`section`: what follows `%AI3_BeginPattern`), read by a reader of its
|
||||
/// own into a pattern swatch, its art in the pattern's space (y down). A definition it can't
|
||||
/// read whole (an operator it doesn't know, type, unbalanced groups, no tile) isn't kept: art
|
||||
/// filled with it can't be read.
|
||||
fn define_pattern(&mut self, section: &[u8]) {
|
||||
if self.pattern_tiles.len() >= MAX_PATTERNS {
|
||||
return;
|
||||
}
|
||||
let mut sub = Reader::new(section);
|
||||
sub.pattern = Some(PatternHead::default());
|
||||
// The gradients defined so far, lent (and those it defines kept).
|
||||
sub.gradients = std::mem::take(&mut self.gradients);
|
||||
sub.nodes = self.nodes;
|
||||
let read = sub.open(Kind::Group, false).and_then(|()| sub.run());
|
||||
self.nodes = sub.nodes;
|
||||
self.gradients = std::mem::take(&mut sub.gradients);
|
||||
let whole = read.is_ok() && sub.unsupported.is_empty() && sub.unknown.is_empty() && sub.frames.len() == 1;
|
||||
let (Some(head), Some(frame)) = (sub.pattern.take(), sub.frames.pop()) else { return };
|
||||
let Some(tile) = head.tile.filter(|t| whole && sane(*t) && t.width() > 0.0 && t.height() > 0.0 && !frame.children.is_empty()) else {
|
||||
return;
|
||||
};
|
||||
let art: Vec<Arc<Node>> = frame.children.iter().map(|n| Arc::new(self.doc.reid(n))).collect();
|
||||
let mut def = PatternDef::new(&head.name, art);
|
||||
def.tile = FLIP.transform_rect_bbox(tile);
|
||||
if self.pattern_tiles.insert(def.name.clone(), def.tile.origin()).is_some() {
|
||||
self.doc.patterns.retain(|p| p.name != def.name);
|
||||
}
|
||||
self.doc.patterns.push(def);
|
||||
for (key, blob) in sub.doc.images {
|
||||
self.doc.images.entry(key).or_insert(blob);
|
||||
}
|
||||
self.spots.extend(sub.spots);
|
||||
self.cmyk_colors += sub.cmyk_colors;
|
||||
self.rgb_colors += sub.rgb_colors;
|
||||
for w in &sub.warnings {
|
||||
self.warn(w);
|
||||
}
|
||||
}
|
||||
|
||||
/// In a pattern's definition, the operands of its first operator start with its head,
|
||||
/// `(name) llx lly urx ury`: taken off them.
|
||||
fn take_head(&mut self, mut vals: Vec<V>) -> Vec<V> {
|
||||
let Some(head) = self.pattern.as_mut().filter(|h| !h.taken) else { return vals };
|
||||
head.taken = true;
|
||||
let Some(at) = vals.iter().position(|v| matches!(v, V::Str(_))) else { return vals };
|
||||
let tile = Self::nums(vals.get(at + 1..at + 5).unwrap_or_default());
|
||||
let ([x0, y0, x1, y1], Some(name)) = (tile.as_slice(), vals.get(at).and_then(V::text)) else { return vals };
|
||||
head.name = name;
|
||||
head.tile = Some(Rect::new(*x0, *y0, *x1, *y1));
|
||||
vals.split_off(at + 5)
|
||||
}
|
||||
|
||||
// ---- frames ----------------------------------------------------------------------------
|
||||
|
||||
fn open(&mut self, kind: Kind, hidden: bool) -> Result<(), String> {
|
||||
@@ -682,6 +778,7 @@ impl<'a> Reader<'a> {
|
||||
self.after_object = false;
|
||||
}
|
||||
let vals = self.operands();
|
||||
let vals = self.take_head(vals);
|
||||
if let Some(d) = self.gradient_def.as_mut() {
|
||||
match w {
|
||||
"Bs" => d.stop(&vals, &mut self.spots),
|
||||
@@ -728,8 +825,10 @@ impl<'a> Reader<'a> {
|
||||
self.paint(&op, hidden, true)?;
|
||||
}
|
||||
// Colours.
|
||||
"g" | "G" | "k" | "K" | "x" | "X" | "Xa" | "XA" | "Xx" | "XX" => self.color(w, &vals),
|
||||
"p" | "P" => self.unreadable("pattern fills"),
|
||||
"g" | "G" | "k" | "K" | "x" | "X" | "Xa" | "XA" | "Xx" | "XX" | "Xk" | "XK" => self.color(w, &vals),
|
||||
"p" | "P" => self.pattern_paint(w, &vals),
|
||||
// A pattern's definition ends.
|
||||
"E" if self.pattern.is_some() => self.done = true,
|
||||
"O" => self.gs.overprint_fill = Self::nums(&vals).last() == Some(&1.0),
|
||||
"R" => self.gs.overprint_stroke = Self::nums(&vals).last() == Some(&1.0),
|
||||
"XR" => self.gs.rule = if Self::nums(&vals).last() == Some(&1.0) { FillRule::EvenOdd } else { FillRule::NonZero },
|
||||
@@ -856,6 +955,9 @@ impl<'a> Reader<'a> {
|
||||
|
||||
/// A text object (`/AI11Text`): an empty group standing for it, named after its story.
|
||||
fn text_slot(&mut self, o: &Obj) -> Result<(), String> {
|
||||
if self.pattern.is_some() {
|
||||
self.unreadable("type in a pattern");
|
||||
}
|
||||
let index = |key: &str| o.nums(key).first().copied().filter(|v| (0.0..f64::from(u32::MAX)).contains(v)).map(|v| v as u32);
|
||||
let (story, frame) = (index("StoryIndex"), index("FrameIndex").unwrap_or(0));
|
||||
let gs = self.gs.clone();
|
||||
@@ -893,7 +995,7 @@ impl<'a> Reader<'a> {
|
||||
self.warn(NON_NATIVE_ART_UNREAD);
|
||||
return Ok(());
|
||||
}
|
||||
let target = self.ensure_space().transform_rect_bbox(art_box);
|
||||
let target = self.space().transform_rect_bbox(art_box);
|
||||
let opts = vectorcraft_pdf::ImportOptions { layers: false, ..Default::default() };
|
||||
let read = match vectorcraft_pdf::import_with_report(&pdf, &opts) {
|
||||
Ok(r) => r,
|
||||
@@ -1001,7 +1103,8 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `… Lb`: a layer's options.
|
||||
/// `… Lb`: a layer's options. The legacy format's has ten operands, its colour index one place
|
||||
/// earlier (`visible preview unlocked printing dimmed masks colorIndex r g b`).
|
||||
fn layer_attrs(&mut self, vals: &[V]) {
|
||||
let n = Self::nums(vals);
|
||||
let Some(Frame { kind: Kind::Layer(a), .. }) = self.frames.last_mut() else { return };
|
||||
@@ -1012,15 +1115,44 @@ impl<'a> Reader<'a> {
|
||||
a.printable = flag(3, true);
|
||||
let dim_pct = n.get(12).copied().filter(|v| v.is_finite()).unwrap_or(50.0).clamp(0.0, 100.0);
|
||||
a.dim = flag(4, false).then_some(dim_pct.round() as u8);
|
||||
let index = n.get(7).copied().unwrap_or(0.0);
|
||||
let rgb = |i: usize| n.get(i).map_or(0, |v| v.clamp(0.0, 255.0) as u8);
|
||||
let at = if n.len() <= 10 { 6 } else { 7 };
|
||||
let index = n.get(at).copied().unwrap_or(0.0);
|
||||
let rgb = |i: usize| n.get(at + i).map_or(0, |v| v.clamp(0.0, 255.0) as u8);
|
||||
a.color = Some(if (0.0..vectorcraft_doc::LAYER_COLORS.len() as f64).contains(&index) && index.fract() == 0.0 {
|
||||
LayerColor::Preset(index as u8)
|
||||
} else {
|
||||
LayerColor::Custom([rgb(8), rgb(9), rgb(10)])
|
||||
LayerColor::Custom([rgb(1), rgb(2), rgb(3)])
|
||||
});
|
||||
}
|
||||
|
||||
/// `(name) x y scaleX scaleY angle reflect axis shear shearAxis [matrix] p` (`P`: the stroke's):
|
||||
/// the pattern swatch `name`, its space taken to art space by the matrix. A pattern this hasn't
|
||||
/// read can't be drawn.
|
||||
fn pattern_paint(&mut self, w: &str, vals: &[V]) {
|
||||
// In the setup it paints nothing (and the art's space isn't fixed yet).
|
||||
if self.frames.is_empty() {
|
||||
return;
|
||||
}
|
||||
let name = vals.iter().find_map(|v| if let V::Str(_) = v { v.text() } else { None });
|
||||
let Some(origin) = name.as_deref().and_then(|n| self.pattern_tiles.get(n)).map(|p| p.to_vec2()) else {
|
||||
self.unreadable("pattern fills");
|
||||
return;
|
||||
};
|
||||
let matrix = vals.iter().rev().find_map(|v| if let V::Arr(a) = v { paint::affine(&Self::nums(a)) } else { None });
|
||||
// The tile's art (y down from the tile's top left) → the pattern's space → art space.
|
||||
let xf = self.space() * matrix.unwrap_or(Affine::IDENTITY) * FLIP * Affine::translate(origin);
|
||||
if xf.determinant().abs() < 1e-12 || !xf.as_coeffs().iter().all(|v| v.is_finite()) {
|
||||
self.unreadable("pattern fills");
|
||||
return;
|
||||
}
|
||||
let p = Paint::Pattern { pattern: name.unwrap_or_default(), xf };
|
||||
if w == "p" {
|
||||
self.gs.fill = p;
|
||||
} else {
|
||||
self.gs.stroke = p;
|
||||
}
|
||||
}
|
||||
|
||||
fn color(&mut self, w: &str, vals: &[V]) {
|
||||
let Some((p, named)) = paint::color_op(w, vals) else { return };
|
||||
match p.color() {
|
||||
@@ -1031,7 +1163,7 @@ impl<'a> Reader<'a> {
|
||||
if let Some(n) = named {
|
||||
self.spots.push(n);
|
||||
}
|
||||
if w.chars().all(|c| c.is_ascii_lowercase()) || w == "Xa" || w == "Xx" {
|
||||
if w.ends_with(|c: char| c.is_ascii_lowercase()) {
|
||||
self.gs.fill = p;
|
||||
} else {
|
||||
self.gs.stroke = p;
|
||||
@@ -1165,7 +1297,7 @@ impl<'a> Reader<'a> {
|
||||
if op.chars().next().is_some_and(|c| c.is_ascii_lowercase()) {
|
||||
close(&mut bp);
|
||||
}
|
||||
let to_doc = self.ensure_space();
|
||||
let to_doc = self.space();
|
||||
let path = PathData::from_bezpath(&(to_doc * bp));
|
||||
let gs = self.gs.clone();
|
||||
let (fill, stroke) = match op.to_ascii_lowercase().as_str() {
|
||||
@@ -1200,7 +1332,7 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
|
||||
fn set_instance(&mut self, i: &Instance) {
|
||||
let to_doc = self.ensure_space();
|
||||
let to_doc = self.space();
|
||||
match i.paint(&self.gradients, to_doc) {
|
||||
Some(p) if i.stroke => self.pending_stroke = Some(p),
|
||||
Some(p) => self.pending_fill = Some(p),
|
||||
@@ -1286,9 +1418,8 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
// The matrix maps pixels (y down) into art space with its y flipped.
|
||||
let [a, b, c, d, tx, ty] = m.as_slice() else { return Ok(()) };
|
||||
let to_doc = self.ensure_space();
|
||||
let flip = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]);
|
||||
let xf = to_doc * flip * Affine::new([*a, *b, *c, *d, *tx, -*ty]);
|
||||
let to_doc = self.space();
|
||||
let xf = to_doc * FLIP * Affine::new([*a, *b, *c, *d, *tx, -*ty]);
|
||||
if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -1314,18 +1445,36 @@ impl<'a> Reader<'a> {
|
||||
(sane(r) && w > 0.0 && h > 0.0).then_some(r)
|
||||
}
|
||||
|
||||
/// The first artboard: the `/Document`'s first, else the crop marks, else [`Self::sized_board`].
|
||||
fn first_board(&self) -> Option<Rect> {
|
||||
self.artboards.first().map(|(_, r)| *r).or(self.cropmarks).or_else(|| self.sized_board())
|
||||
}
|
||||
|
||||
/// The page of a file that says nothing of its artboard (the legacy format as other apps write
|
||||
/// it): its art's bounding box.
|
||||
fn bbox_board(&self) -> Option<Rect> {
|
||||
let [x0, y0, x1, y1] = self.hires.or(self.bbox)?;
|
||||
Some(Rect::new(x0, y0, x1, y1)).filter(|r| r.width() > 0.0 && r.height() > 0.0)
|
||||
}
|
||||
|
||||
/// Art space → the document (fixed when the art starts, from the artboards read by then).
|
||||
fn ensure_space(&mut self) -> Affine {
|
||||
if let Some(m) = self.to_doc {
|
||||
return m;
|
||||
}
|
||||
let first = self.artboards.first().map(|(_, r)| *r).or(self.cropmarks).or_else(|| self.sized_board());
|
||||
let first = self.first_board().or_else(|| self.bbox_board());
|
||||
let top_left = first.map_or(Point::new(0.0, 792.0), |r| Point::new(r.x0, r.y1));
|
||||
let m = Affine::new([1.0, 0.0, 0.0, -1.0, -top_left.x, top_left.y]);
|
||||
self.to_doc = Some(m);
|
||||
m
|
||||
}
|
||||
|
||||
/// Art space → where the art being read goes: the document, or while a pattern's definition is
|
||||
/// read, the pattern's space (y down).
|
||||
fn space(&mut self) -> Affine {
|
||||
if self.pattern.is_some() { FLIP } else { self.ensure_space() }
|
||||
}
|
||||
|
||||
fn warn(&mut self, w: &str) {
|
||||
if !self.warnings.iter().any(|x| x == w) && self.warnings.len() < 64 {
|
||||
self.warnings.push(w.to_string());
|
||||
@@ -1345,11 +1494,11 @@ impl<'a> Reader<'a> {
|
||||
return Err("it has no layers".into());
|
||||
}
|
||||
let to_doc = self.ensure_space();
|
||||
let artboard = self.artboards.first().map(|(_, r)| *r).or(self.cropmarks).or_else(|| self.sized_board());
|
||||
let artboard = self.first_board();
|
||||
drop_commented_copies(&mut self.doc.layers, &self.hidden_ids);
|
||||
let mut doc = std::mem::replace(&mut self.doc, Document::new(1.0, 1.0));
|
||||
let boards: Vec<(String, Rect)> = if self.artboards.is_empty() {
|
||||
self.cropmarks.or_else(|| self.sized_board()).map(|r| vec![("Artboard 1".to_string(), r)]).unwrap_or_default()
|
||||
artboard.or_else(|| self.bbox_board()).map(|r| vec![("Artboard 1".to_string(), r)]).unwrap_or_default()
|
||||
} else {
|
||||
self.artboards.clone()
|
||||
};
|
||||
@@ -1374,7 +1523,12 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
for n in &self.spots {
|
||||
if doc.swatch(&n.name).is_none() {
|
||||
doc.swatches.push(Swatch { name: n.name.clone(), paint: Paint::solid(n.color), global: true, spot: true });
|
||||
doc.swatches.push(Swatch { name: n.name.clone(), paint: Paint::solid(n.color), global: true, spot: n.spot });
|
||||
}
|
||||
}
|
||||
for name in doc.patterns.iter().map(|p| p.name.clone()).collect::<Vec<_>>() {
|
||||
if doc.swatch(&name).is_none() {
|
||||
doc.swatches.push(Swatch { paint: Paint::Pattern { pattern: name.clone(), xf: Affine::IDENTITY }, name, global: false, spot: false });
|
||||
}
|
||||
}
|
||||
let mut warnings = self.warnings;
|
||||
|
||||
@@ -132,6 +132,92 @@ fn paths_colours_and_strokes() {
|
||||
assert!(a[4].appearance.fill().is_none() && a[4].appearance.stroke().is_some());
|
||||
}
|
||||
|
||||
/// `Xk` and `XK` are global process colours: a name, a tint and whether the colour is RGB, as
|
||||
/// `Xx` has them (#1025). They come in as global swatches that aren't inks, linked at their tint.
|
||||
#[test]
|
||||
fn global_process_colours_are_global_swatches_at_their_tint() {
|
||||
let art_ = format!("0 0 0 1 (Black global) 0.4 0 Xk\n0 0 0 0 1 0.5 0 (Brand orange) 0 1 XK\n{}b\n", rect_path(0.0, 0.0, 10.0, 10.0));
|
||||
let doc = read_ok(&layer(&art_)).doc;
|
||||
let a = art(&doc);
|
||||
let Paint::Solid { swatch, tint, color } = fill(&a[0]) else { panic!("{:?}", fill(&a[0])) };
|
||||
assert_eq!(swatch.as_deref(), Some("Black global"));
|
||||
assert!((tint - 0.6).abs() < 1e-6 && matches!(color, Color::Cmyk { k, .. } if (k - 0.6).abs() < 1e-6), "{tint} {color:?}");
|
||||
let stroke = a[0].appearance.stroke().unwrap().paint.clone();
|
||||
assert!(
|
||||
matches!(&stroke, Paint::Solid { swatch: Some(s), tint, color } if s == "Brand orange" && *tint == 1.0 && *color == Color::rgb(1.0, 0.5, 0.0)),
|
||||
"{stroke:?}"
|
||||
);
|
||||
for name in ["Black global", "Brand orange"] {
|
||||
let sw = doc.swatch(name).unwrap();
|
||||
assert!(sw.global && !sw.spot, "{sw:?}");
|
||||
}
|
||||
assert_eq!(doc.swatch("Black global").unwrap().paint.color(), Some(Color::cmyk(0.0, 0.0, 0.0, 1.0)), "the swatch is the full colour");
|
||||
}
|
||||
|
||||
/// A pattern's definition (`%AI3_BeginPattern`): its name and tile (8 points square here, y up),
|
||||
/// then its art on `%_` lines (a group of the tile's unpainted box and a dot, a round-capped
|
||||
/// stroke of no length), `E`.
|
||||
const DOTS: &str = "%AI3_BeginPattern: (Dots)\n(Dots) 0 0 8 8\n%_0 A\n%_0 Xw\n%_0 Ae\n%_u\n%_0 O\n%_0 8 m\n%_8 8 L\n%_8 0 L\n%_0 0 L\n%_0 8 L\n%_n\n\
|
||||
%_0 R\n%_0 0 0 1 K\n%_1 J 0 j 2 w 4 M []0 d\n%_4 4 m\n%_4 4 L\n%_S\n%_U\n%_/ArtDictionary :\n%_;\n%_\n%_9 () XW\nE\n%AI3_EndPattern\n";
|
||||
|
||||
/// Patterns are read into pattern swatches, and `p` fills with one (#1025): the art keeps its
|
||||
/// layers, the fill places the tiles by its matrix.
|
||||
#[test]
|
||||
fn patterns_are_pattern_swatches_and_fill_with_them() {
|
||||
let sub = |name: &str, art: &str| format!("%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n({name}) Ln\n{art}LB\n%AI5_EndLayer--\n");
|
||||
let body = format!(
|
||||
"{DOTS}%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(Ground) Ln\n{}{}LB\n%AI5_EndLayer--\n",
|
||||
sub("Lawn", &format!("(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(10.0, 20.0, 70.0, 80.0))),
|
||||
sub("Paving", &format!("(Dots) 10 20 2 2 0 0 0 0 0 [2 0 0 2 10 20] P\n{}S\n", rect_path(90.0, 20.0, 150.0, 80.0))),
|
||||
);
|
||||
let s = read_ok(&body);
|
||||
let doc = &s.doc;
|
||||
let ground = &doc.layers[0];
|
||||
let subs: Vec<_> = ground.children().unwrap().iter().map(|l| l.name.clone().unwrap_or_default()).collect();
|
||||
assert_eq!(subs, ["Lawn", "Paving"]);
|
||||
let def = doc.pattern("Dots").unwrap();
|
||||
assert_eq!(def.tile, Rect::new(0.0, -8.0, 8.0, 0.0), "the tile, y down");
|
||||
assert_eq!(def.art.len(), 1, "the tile's group");
|
||||
let tile_box = &def.art[0].children().unwrap()[0];
|
||||
assert_eq!(tile_box.geometric_bounds(), Some(def.tile), "the tile's box is where the tile is");
|
||||
assert_eq!(def.art[0].children().unwrap().len(), 2);
|
||||
assert!(matches!(&doc.swatch("Dots").unwrap().paint, Paint::Pattern { pattern, .. } if pattern == "Dots"));
|
||||
let lawn = &ground.children().unwrap()[0].children().unwrap()[0];
|
||||
let Paint::Pattern { pattern, xf } = fill(lawn) else { panic!("{:?}", fill(lawn)) };
|
||||
assert_eq!(pattern, "Dots");
|
||||
// The tile's top left (0, 8) in art space is (0, 92) on the page (crop marks 100 high); a dot
|
||||
// centre (4, 4) is at (4, 96).
|
||||
assert_eq!(xf * Point::ZERO, Point::new(0.0, 92.0));
|
||||
assert_eq!(xf * Point::new(4.0, 4.0), Point::new(4.0, 96.0));
|
||||
// A stroke with it, scaled twice and moved by (10, 20): the tile's top left is at (10, 36) in
|
||||
// art space.
|
||||
let paving = &ground.children().unwrap()[1].children().unwrap()[0];
|
||||
let Some(Paint::Pattern { xf, .. }) = paving.appearance.stroke().map(|st| st.paint.clone()) else { panic!() };
|
||||
assert_eq!(xf * Point::ZERO, Point::new(10.0, 64.0));
|
||||
assert!(s.warnings.is_empty(), "{:?}", s.warnings);
|
||||
}
|
||||
|
||||
/// A pattern whose definition it can't read whole isn't kept: a fill with it is something the file
|
||||
/// has that isn't read, where it shows. Its definition costs nothing else.
|
||||
#[test]
|
||||
fn a_pattern_it_cant_read_is_an_error_only_where_it_fills() {
|
||||
let unread = "%AI3_BeginPattern: (Old)\n(Old) 0 0 8 8 [\n(0 O 0 R 0 g) @\n(\n0 0 m 8 8 L S\n) &\n] E\n%AI3_EndPattern\n\
|
||||
%AI3_BeginPattern: (Odd)\n(Odd) 0 0 8 8\n%_U\nE\n%AI3_EndPattern\n%AI3_BeginPattern: (Flat)\n(Flat) 0 0 8 0\n%_0 0 m\n%_8 0 L\n%_S\nE\n%AI3_EndPattern\n";
|
||||
let s = read_ok(&format!("{unread}{}", layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n"))));
|
||||
assert!(s.doc.patterns.is_empty() && s.doc.swatches.iter().all(|w| !matches!(w.paint, Paint::Pattern { .. })));
|
||||
for name in ["Old", "Odd", "Flat"] {
|
||||
let e = read(
|
||||
stream(&format!("{unread}{}", layer(&format!("({name}) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(0.0, 0.0, 1.0, 1.0)))))
|
||||
.as_bytes(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(e.contains("pattern fills"), "{name}: {e}");
|
||||
}
|
||||
// Type in a pattern isn't read either.
|
||||
let typed = "%AI3_BeginPattern: (Words)\n(Words) 0 0 8 8\n%_/AI11Text :\n%_0 /StoryIndex ,\n%_;\nE\n%AI3_EndPattern\n";
|
||||
assert!(read_ok(&format!("{typed}{}", layer(""))).doc.patterns.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_paths_and_clipping() {
|
||||
let art_ = format!(
|
||||
|
||||
@@ -16,14 +16,18 @@
|
||||
//! 3–8, with the prolog that defines its operators), the group operators `u` … `U` (nested) come in
|
||||
//! as groups. The format's published specification documents its header comments (`%AI…`,
|
||||
//! `%%Creator`) and these operators: Adobe Illustrator File Format Specification, version 7.0
|
||||
//! (1998), listed by PRONOM at https://www.nationalarchives.gov.uk/PRONOM/fmt/423.
|
||||
//! (1998), listed by PRONOM at https://www.nationalarchives.gov.uk/PRONOM/fmt/423. A file in that
|
||||
//! format whose program has its layers (`%AI5_BeginLayer`) comes in through them, as `native` reads
|
||||
//! an Illustrator EPS's editing copy (#1027).
|
||||
//!
|
||||
//! The page is the file's `%%BoundingBox` (`%%HiResBoundingBox` when it has one), the first page
|
||||
//! of a PostScript file without one. A file whose program can't be read (an operator the
|
||||
//! interpreter doesn't know, an error, a limit reached) or that draws nothing comes in as its
|
||||
//! preview (a TIFF image, a Windows metafile or an EPSI bitmap), with a warning that names the
|
||||
//! error, the operator and the procedures it ran in; without a preview, what was drawn before the
|
||||
//! error is kept (with that warning), else the file is refused.
|
||||
//! interpreter doesn't know, an error, a limit reached) or that draws nothing comes in from its
|
||||
//! layers when its program has them (a `.ai` in PostScript form whose prolog is named but not
|
||||
//! included, as other apps write it), else as its preview (a TIFF image, a Windows metafile or an
|
||||
//! EPSI bitmap), with a warning that names the error, the operator and the procedures it ran in;
|
||||
//! without a preview, what was drawn before the error is kept (with that warning), else the file
|
||||
//! is refused.
|
||||
|
||||
mod ai;
|
||||
mod ate;
|
||||
@@ -201,6 +205,13 @@ pub fn import_with(bytes: &[u8], editing_data: bool) -> Result<Imported, String>
|
||||
(Ok(()), false) => "it draws nothing VectorCraft's PostScript reader can show".to_string(),
|
||||
(Err(e), _) => reason(e, fault.as_ref()),
|
||||
};
|
||||
// A file in the legacy Illustrator format whose program can't be run (a `.ai` that names the
|
||||
// prolog defining its operators without including it): its layers, which the program has.
|
||||
let why = match editing_data.then(|| native::program_alone(ps)).flatten() {
|
||||
Some(Ok((document, warnings))) => return Ok(Imported { document, warnings, preview: false }),
|
||||
Some(Err(e)) => format!("{why}; its layers couldn't be read either ({e})"),
|
||||
None => why,
|
||||
};
|
||||
if let Some(p) = preview::preview(bytes, ps, frame) {
|
||||
let warnings = vec![format!("this file's PostScript couldn't be read ({why}): {}", p.what)];
|
||||
return Ok(Imported { document: p.document, warnings, preview: true });
|
||||
@@ -214,7 +225,7 @@ pub fn import_with(bytes: &[u8], editing_data: bool) -> Result<Imported, String>
|
||||
out.warn("the file draws nothing");
|
||||
Ok(finish(out))
|
||||
}
|
||||
Err(e) => Err(format!("this PostScript file can't be read ({}): save it as PDF or SVG in the app that made it", reason(&e, fault.as_ref()))),
|
||||
Err(_) => Err(format!("this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it")),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
//! The layers of an Illustrator EPS or `.ai`.
|
||||
//!
|
||||
//! Besides the page it prints, such a file carries the app's own copy of the art: an EPS after its
|
||||
//! `%%EOF`, a `.ai` in its `AIPrivateData` streams (see `ai` for the containers). It has what the
|
||||
//! page doesn't: the layers with their names and options, the groups, compound paths and clipping
|
||||
//! groups, the objects' names, the hidden objects and layers, the art outside the artboards and
|
||||
//! every artboard. `ai` reads that structure into a document, with the colours, gradients,
|
||||
//! transparency and images of the art.
|
||||
//! `%%EOF`, a `.ai` in its `AIPrivateData` streams (see `ai` for the containers). A file in the
|
||||
//! legacy format (a `.ai` in PostScript form: versions 3 to 8, and the apps that still write it)
|
||||
//! is that copy itself: its program has the layers, and the prolog it names defines the operators
|
||||
//! for printing (other apps name it without including it, so their files can't be run).
|
||||
//!
|
||||
//! The copy has what the page doesn't: the layers with their names and options, the groups,
|
||||
//! compound paths and clipping groups, the objects' names, the hidden objects and layers, the art
|
||||
//! outside the artboards and every artboard. `ai` reads that structure into a document, with the
|
||||
//! colours, gradients, transparency and images of the art.
|
||||
//!
|
||||
//! Text objects are slots in that document: their characters, fonts and places are in the file's
|
||||
//! text document (see `ate`). Type that shows comes from the page, which draws it, into the slot
|
||||
@@ -21,6 +25,7 @@
|
||||
//!
|
||||
//! Where a file doesn't match what this module expects, it is left alone and its page is imported.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -991,12 +996,13 @@ fn compare(frame: &Frame<'_>, view: &mut PageView, layered: &Document) -> Result
|
||||
}))
|
||||
}
|
||||
|
||||
/// The EPS `visible` (read as its page) read through its editing copy instead: its layers (the
|
||||
/// The EPS `visible` (read as its page) read through its editing copy instead (its private data, or
|
||||
/// its program in the legacy format, see [`editing_copy`]): its layers (the
|
||||
/// hidden ones too), groups, compound paths, clipping groups and artboards, with the text and the
|
||||
/// strokes around it of the page. When the file has no editing copy, `visible` as it was; when it
|
||||
/// has one that can't be used, `visible` with a warning that says why.
|
||||
pub(super) fn layered(ps: &[u8], visible: Imported) -> Imported {
|
||||
let Some(data) = ai::eps_data(ps) else { return visible };
|
||||
let Some(data) = editing_copy(ps) else { return visible };
|
||||
let result = data.and_then(|data| build(&data, Some(&visible.document), Page::Art, false));
|
||||
match result {
|
||||
Ok((document, mut warnings)) => {
|
||||
@@ -1023,7 +1029,7 @@ pub(super) fn layered(ps: &[u8], visible: Imported) -> Imported {
|
||||
/// its PDF part).
|
||||
pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>, outlined: bool) -> (Document, Vec<String>) {
|
||||
let Ok(data) = ai::decode_private(private) else { return (visible, warnings) };
|
||||
if !data.windows(15).any(|w| w == b"%AI5_BeginLayer") {
|
||||
if !has_layers(&data) {
|
||||
return (visible, warnings);
|
||||
}
|
||||
match build(&data, Some(&visible), Page::Artboard, outlined) {
|
||||
@@ -1047,6 +1053,27 @@ pub fn ai_alone(private: &[u8]) -> Result<(Document, Vec<String>), String> {
|
||||
build(&data, None, Page::Artboard, false)
|
||||
}
|
||||
|
||||
/// Does the editing data (or a program in the legacy format) have layers?
|
||||
fn has_layers(data: &[u8]) -> bool {
|
||||
data.windows(15).any(|w| w == b"%AI5_BeginLayer")
|
||||
}
|
||||
|
||||
/// The editing copy of the art in the PostScript `ps`: an EPS's private data, else the program
|
||||
/// itself when it has layers (the legacy format). `None` without one.
|
||||
fn editing_copy(ps: &[u8]) -> Option<Result<Cow<'_, [u8]>, String>> {
|
||||
match ai::eps_data(ps) {
|
||||
Some(data) => Some(data.map(Cow::Owned)),
|
||||
None => has_layers(ps).then_some(Ok(Cow::Borrowed(ps))),
|
||||
}
|
||||
}
|
||||
|
||||
/// A file in the legacy format whose page can't be read (a `.ai` in PostScript form that names its
|
||||
/// prolog without including it) from its layers alone (`ps`: its program, which has them) → the
|
||||
/// document and the import's notes. `None` when the layers aren't in the program.
|
||||
pub(super) fn program_alone(ps: &[u8]) -> Option<Result<(Document, Vec<String>), String>> {
|
||||
(!ai::has_eps_data(ps) && has_layers(ps)).then(|| build(ps, None, Page::Art, false))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::clipnest::MAX_NEST;
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
use vectorcraft_doc::{LayerColor, LineCap, Node, NodeKind};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use crate::import::import;
|
||||
|
||||
@@ -80,6 +81,80 @@ fn other_postscript_keeps_its_own_u() {
|
||||
assert_eq!(read("%%Creator: some app\n", "", &body).0, "p p");
|
||||
}
|
||||
|
||||
/// A `.ai` in PostScript form as other apps write it (#1027): its header and prolog name the
|
||||
/// procsets that define its operators (by the DSC resource comments) without including them, so
|
||||
/// its program can't be run. Its layers come from the program itself: names as they are, the
|
||||
/// legacy format's ten-operand `Lb`, flatness `i`, the bounding box as the page.
|
||||
const NAMED_PROLOG: &str = "%!PS-Adobe-3.0\n%%Creator: a CAD app\n%%BoundingBox: 0 0 200 100\n\
|
||||
%%DocumentNeededResources: procset Adobe_packedarray 2.0 0\n%%+ procset Adobe_IllustratorA_AI3 1.0 0\n\
|
||||
%AI3_TemplateBox: 288 384 288 384\n%%EndComments\n%%BeginProlog\n%%IncludeResource: procset Adobe_packedarray 2.0 0\n\
|
||||
Adobe_packedarray /initialize get exec\n%%IncludeResource: procset Adobe_IllustratorA_AI3 1.0 0\n%%EndProlog\n\
|
||||
%%BeginSetup\nAdobe_IllustratorA_AI3 /initialize get exec\n%%EndSetup\n\
|
||||
%AI5_BeginLayer\n1 1 1 1 0 0 -1 0 0 0 Lb\n(SECTION::Cut) Ln\n0 A\n0 R\n0 0 0 1 K\n0 i 1 J 1 j 0.8 w 4 M []0 d\n0 D\n\
|
||||
10 10 m\n90 10 L\n90 90 L\n10 90 L\n10 10 L\nS\nLB\n%AI5_EndLayer--\n\
|
||||
%AI5_BeginLayer\n1 1 1 1 0 0 -1 191 63 255 Lb\n(SECTION::Hatch) Ln\n0 A\n0 R\n0 0 0 1 K\n0.1 w\n110 10 m\n190 90 L\nS\nLB\n%AI5_EndLayer--\n\
|
||||
%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\nAdobe_IllustratorA_AI3 /terminate get exec\n%%EOF\n";
|
||||
|
||||
#[test]
|
||||
fn a_postscript_ai_that_names_its_prolog_without_including_it_opens_with_its_layers() {
|
||||
let r = import(NAMED_PROLOG.as_bytes()).unwrap();
|
||||
assert!(r.warnings.is_empty() && !r.preview, "{:?}", r.warnings);
|
||||
let d = &r.document;
|
||||
let names: Vec<_> = d.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect();
|
||||
assert_eq!(names, ["SECTION::Cut", "SECTION::Hatch"]);
|
||||
let colors: Vec<_> = d.layers.iter().map(|l| if let NodeKind::Layer { color, .. } = l.kind { Some(color) } else { None }).collect();
|
||||
assert_eq!(colors, [Some(LayerColor::Custom([0, 0, 0])), Some(LayerColor::Custom([191, 63, 255]))]);
|
||||
assert_eq!(d.artboards[0].rect, Rect::new(0.0, 0.0, 200.0, 100.0), "the page is the bounding box");
|
||||
let square = &d.layers[0].children().unwrap()[0];
|
||||
assert_eq!(square.geometric_bounds(), Some(Rect::new(10.0, 10.0, 90.0, 90.0)));
|
||||
let stroke = square.appearance.stroke().unwrap();
|
||||
assert_eq!((stroke.width, stroke.cap), (0.8, LineCap::Round));
|
||||
assert_eq!(tree(d.layers[1].children().unwrap()), "p");
|
||||
}
|
||||
|
||||
/// A `.ai` in PostScript form whose layers can't be read either says why, after what stopped its
|
||||
/// program.
|
||||
#[test]
|
||||
fn a_postscript_ai_whose_layers_cant_be_read_says_why() {
|
||||
let damaged = NAMED_PROLOG.replace("0 D\n", "0 D\n1 2 frobnicate\n");
|
||||
let e = import(damaged.as_bytes()).unwrap_err();
|
||||
assert!(e.contains("`Adobe_packedarray`") && e.contains("its layers couldn't be read either") && e.contains("`frobnicate`"), "{e}");
|
||||
}
|
||||
|
||||
/// The editing text of a newer file saved as PostScript (no prolog at all, a non-printing setup):
|
||||
/// it opens with its sublayers.
|
||||
#[test]
|
||||
fn newer_editing_text_as_postscript_opens_with_its_sublayers() {
|
||||
let layer =
|
||||
|name: &str, art: &str| format!("%AI5_BeginLayer\n1 1 1 1 0 0 1 -1 79 128 255 0 50 0 Lb\n({name}) Ln\n0 A\n0 Xw\n{art}LB\n%AI5_EndLayer--\n");
|
||||
let ground = layer("Ground", &(layer("Lawn", "0 0 0 0.3 k\n10 20 m\n70 20 L\n70 80 L\n10 80 L\nf\n") + &layer("Paving", "")));
|
||||
let text = format!(
|
||||
"%!PS-Adobe-3.0\n%%Creator: a script\n%%BoundingBox: 0 0 250 100\n%AI5_FileFormat 14.0\n%AI3_Cropmarks: 0 0 250 100\n%%EndComments\n\
|
||||
%%BeginProlog\n%%EndProlog\n%%BeginSetup\n%AI5_Begin_NonPrinting\nNp\n%AI5_End_NonPrinting--\n%%EndSetup\n{ground}%%PageTrailer\n%%Trailer\n%%EOF\n"
|
||||
);
|
||||
let r = import(text.as_bytes()).unwrap();
|
||||
let d = &r.document;
|
||||
assert_eq!(d.layers.len(), 1, "{:?}", r.warnings);
|
||||
let subs: Vec<_> = d.layers[0].children().unwrap().iter().map(|l| l.name.clone().unwrap_or_default()).collect();
|
||||
assert_eq!(subs, ["Lawn", "Paving"]);
|
||||
}
|
||||
|
||||
/// A file in the legacy format whose prolog is included (here one of our own) runs as its page, and
|
||||
/// comes in through the layers its program has when they look like the page.
|
||||
#[test]
|
||||
fn a_legacy_file_with_its_prolog_comes_in_through_its_layers() {
|
||||
let prolog = "/Lb {10 {pop} repeat} def /Ln {pop} def /LB {} def /A {pop} def /k {setcmykcolor} def";
|
||||
let body = format!("%AI5_BeginLayer\n1 1 1 1 0 0 0 79 128 255 Lb\n(Back) Ln\n0 A\n0 0 0 1 k\n{} LB\n%AI5_EndLayer--\n", tri(10));
|
||||
let file = String::from_utf8(ai(AI8, "", &body)).unwrap().replace("%%EndProlog", &format!("{prolog}\n%%EndProlog"));
|
||||
let r = import(file.as_bytes()).unwrap();
|
||||
let names: Vec<_> = r.document.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect();
|
||||
assert_eq!(names, ["Back"], "{:?}", r.warnings);
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
// Without the editing data, it is its page.
|
||||
let page = crate::import::import_with(file.as_bytes(), false).unwrap();
|
||||
assert_eq!(page.document.layers[0].name.as_deref(), Some("Layer 1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbalanced_and_runaway_groups_are_read_safely() {
|
||||
// Ends without a beginning end nothing; groups left open hold the rest.
|
||||
|
||||
@@ -543,6 +543,33 @@ fn an_ai_file_without_its_pdf_part_reads_its_layers_and_makes_its_type() {
|
||||
assert!(crate::ai_alone(b"%AI24_ZStandard_Data nothing").is_err());
|
||||
}
|
||||
|
||||
/// A global process colour (`Xk`) or a pattern fill on a sublayer no longer sends a `.ai` to its
|
||||
/// PDF part, which has only the top-level layers (#1025).
|
||||
#[test]
|
||||
fn a_global_colour_or_a_pattern_fill_keeps_the_sublayers_of_an_ai_file() {
|
||||
// The PDF part's yellow square, in a global yellow at full tint, on a sublayer; a hidden
|
||||
// sublayer with a pattern fill (the page doesn't have it).
|
||||
let yellow = "0 0 1 0 (Yellow global) 0 0 Xk\n10 10 m\n14 10 L\n14 14 L\n10 14 L\nf";
|
||||
let dots = "%AI3_BeginPattern: (Dots)\n(Dots) 0 0 4 4\n%_0 0 0 1 k\n%_1 1 m\n%_3 1 L\n%_3 3 L\n%_1 3 L\n%_f\nE\n%AI3_EndPattern\n";
|
||||
let patterned = "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n50 50 m\n70 50 L\n70 70 L\n50 70 L\nf";
|
||||
let ground = layer("Ground", true, &(layer("Lawn", true, yellow) + &layer("Paving", false, patterned)));
|
||||
let art = native(&format!("{dots}{ground}"));
|
||||
let (d, warnings) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false);
|
||||
assert_eq!(names(&d), ["Ground"], "{warnings:?}");
|
||||
let subs: Vec<_> = d.layers[0].children().unwrap().iter().map(|l| (l.name.clone().unwrap_or_default(), kinds(l))).collect();
|
||||
assert_eq!(subs, [("Lawn".to_string(), vec!["path"]), ("Paving".to_string(), vec!["path"])]);
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert!(d.swatch("Yellow global").is_some_and(|s| s.global && !s.spot));
|
||||
assert!(d.pattern("Dots").is_some() && d.swatch("Dots").is_some());
|
||||
// Without a PDF part, the pattern fill shows.
|
||||
let shown = native(&format!("{dots}{}", layer("Ground", true, &layer("Lawn", true, patterned))));
|
||||
let (d, notes) = crate::ai_alone(&private(&shown)).unwrap();
|
||||
let lawn = &d.layers[0].children().unwrap()[0];
|
||||
assert_eq!((lawn.name.as_deref(), kinds(lawn)), (Some("Lawn"), vec!["path"]), "{notes:?}");
|
||||
let fill = lawn.children().unwrap()[0].appearance.fill().map(|f| f.paint.clone());
|
||||
assert!(matches!(&fill, Some(vectorcraft_color::Paint::Pattern { pattern, .. }) if pattern == "Dots"), "{fill:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_object_the_page_doesnt_draw_makes_the_file_come_in_as_its_page() {
|
||||
// A 20-point square that the page doesn't draw: 4% of the page, and an object all the same.
|
||||
|
||||
+70
-2
@@ -1,18 +1,60 @@
|
||||
//! Hit testing primitives.
|
||||
|
||||
use kurbo::{BezPath, ParamCurveNearest, Point, Rect, Shape};
|
||||
use std::borrow::Cow;
|
||||
|
||||
use kurbo::{BezPath, ParamCurveNearest, PathEl, Point, Rect, Shape};
|
||||
|
||||
use crate::path::{FillRule, PathData};
|
||||
|
||||
/// Is `p` inside the filled area of `path` under `rule`? Open subpaths are implicitly closed (as when filled).
|
||||
pub fn fill_contains(path: &BezPath, rule: FillRule, p: Point) -> bool {
|
||||
let w = path.winding(p);
|
||||
let w = closed(path).winding(p);
|
||||
match rule {
|
||||
FillRule::NonZero => w != 0,
|
||||
FillRule::EvenOdd => w % 2 != 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// `path` with each open subpath closed by a straight line back to its start, the edge its fill
|
||||
/// has. kurbo's winding leaves that edge out, so a point beside it counted as outside the fill:
|
||||
/// an open path lost its fill clicks on that side (a reflection moved the edge to the other one).
|
||||
fn closed(path: &BezPath) -> Cow<'_, BezPath> {
|
||||
let mut open = false;
|
||||
let mut needs = false;
|
||||
for el in path.elements() {
|
||||
match el {
|
||||
PathEl::MoveTo(_) => {
|
||||
needs |= open;
|
||||
open = true;
|
||||
}
|
||||
PathEl::ClosePath => open = false,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !(needs || open) {
|
||||
return Cow::Borrowed(path);
|
||||
}
|
||||
let mut out = BezPath::new();
|
||||
let mut open = false;
|
||||
for &el in path.elements() {
|
||||
match el {
|
||||
PathEl::MoveTo(_) => {
|
||||
if open {
|
||||
out.close_path();
|
||||
}
|
||||
open = true;
|
||||
}
|
||||
PathEl::ClosePath => open = false,
|
||||
_ => {}
|
||||
}
|
||||
out.push(el);
|
||||
}
|
||||
if open {
|
||||
out.close_path();
|
||||
}
|
||||
Cow::Owned(out)
|
||||
}
|
||||
|
||||
/// Distance from `p` to the nearest point on the path outline.
|
||||
pub fn distance_to_outline(path: &BezPath, p: Point) -> f64 {
|
||||
path.segments().map(|s| s.nearest(p, 1e-9).distance_sq).fold(f64::INFINITY, f64::min).sqrt()
|
||||
@@ -77,6 +119,32 @@ mod tests {
|
||||
assert!(fill_contains(&bp, FillRule::EvenOdd, Point::new(1.0, 5.0)));
|
||||
}
|
||||
|
||||
/// An open path fills as if closed by a straight line back to its start, and is hit there,
|
||||
/// whichever side that line falls on (a reflected open path lost its fill clicks).
|
||||
#[test]
|
||||
fn open_paths_fill_as_closed_on_either_side() {
|
||||
// A filled sliver left open by a deleted anchor; its closing line runs down its left side.
|
||||
let open = BezPath::from_svg(
|
||||
"M86.311 69.612C100.544 78.261 110.506 88.64 110.506 100.748C110.506 111.127 100.544 118.046 86.311 118.046C93.214 102.045 91.407 102.403 91.407 97.61",
|
||||
)
|
||||
.unwrap();
|
||||
let mut closed = open.clone();
|
||||
closed.close_path();
|
||||
for p in [Point::new(100.0, 100.0), Point::new(95.0, 85.0), Point::new(92.0, 112.0)] {
|
||||
assert!(closed.winding(p) != 0, "{p:?} is inside the closed sliver");
|
||||
assert!(fill_contains(&open, FillRule::NonZero, p), "{p:?}: the open sliver's fill");
|
||||
assert!(fill_contains(&open, FillRule::EvenOdd, p), "{p:?}: even-odd too");
|
||||
}
|
||||
assert!(!fill_contains(&open, FillRule::NonZero, Point::new(80.0, 100.0)), "left of the closing line");
|
||||
// Mirrored, the closing line is on the other side: still filled.
|
||||
let flipped = kurbo::Affine::new([-1.0, 0.0, 0.0, 1.0, 200.0, 0.0]) * open.clone();
|
||||
assert!(fill_contains(&flipped, FillRule::NonZero, Point::new(100.0, 100.0)));
|
||||
// Two open subpaths: each closes on its own.
|
||||
let mut two = open.clone();
|
||||
two.extend(kurbo::Affine::translate((0.0, 100.0)) * open.clone());
|
||||
assert!(fill_contains(&two, FillRule::NonZero, Point::new(100.0, 100.0)) && fill_contains(&two, FillRule::NonZero, Point::new(100.0, 200.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marquee() {
|
||||
let p = shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
|
||||
@@ -235,8 +235,9 @@ pub fn polar_grid(r: Rect, concentric: u32, radial: u32) -> Vec<PathData> {
|
||||
let f = i as f64 / (concentric + 1) as f64;
|
||||
out.push(ellipse(Rect::from_center_size(c, (r.width() * f, r.height() * f))));
|
||||
}
|
||||
for i in 0..radial.max(1) {
|
||||
let a = -PI / 2.0 + TAU * i as f64 / radial.max(1) as f64;
|
||||
// No dividers at 0 (the loop never divides by it).
|
||||
for i in 0..radial {
|
||||
let a = -PI / 2.0 + TAU * i as f64 / radial as f64;
|
||||
out.push(line(c, Point::new(c.x + r.width() / 2.0 * a.cos(), c.y + r.height() / 2.0 * a.sin())));
|
||||
}
|
||||
out
|
||||
@@ -402,6 +403,10 @@ mod tests {
|
||||
fn grids() {
|
||||
assert_eq!(rectangular_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 5, 5, true).len(), 11);
|
||||
assert_eq!(polar_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 5, 5).len(), 11);
|
||||
// #1008: no radial dividers is none, only the outer ellipse without concentric ones.
|
||||
for (radial, len) in [(0, 1), (1, 2), (2, 3)] {
|
||||
assert_eq!(polar_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 0, radial).len(), len, "{radial}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -312,6 +312,22 @@ fn inspect_document_reports_the_paint_of_type() {
|
||||
assert_eq!((t["fill"].as_str(), t["stroke"].as_str(), t["strokeWidth"].as_f64()), (Some("#0000ff"), Some("#00ff00"), Some(3.0)), "{t}");
|
||||
}
|
||||
|
||||
/// #1003: type made on or in a path takes add_text's `color` too, over the current fill.
|
||||
#[test]
|
||||
fn add_text_on_or_in_a_path_takes_its_color() {
|
||||
for mode in ["onPath", "area"] {
|
||||
let mut s = server();
|
||||
let r = call(&mut s, 2, "draw_shape", json!({"shape": "rectangle", "x": 40, "y": 40, "width": 180, "height": 60, "fill": "#0000ff"}));
|
||||
let path = serde_json::from_str::<Value>(&text_of(&r)).unwrap()["id"].clone();
|
||||
let r = call(&mut s, 3, "add_text", json!({"text": "Hello", "path": path, "mode": mode, "color": "#ff0000"}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
let id = serde_json::from_str::<Value>(&text_of(&r)).unwrap()["id"].clone();
|
||||
let v: Value = serde_json::from_str(&text_of(&call(&mut s, 4, "inspect_document", json!({})))).unwrap();
|
||||
let t = v["layers"][0]["children"].as_array().unwrap().iter().find(|c| c["id"] == id).cloned().unwrap();
|
||||
assert_eq!(t["fill"].as_str(), Some("#ff0000"), "{mode}: {t}");
|
||||
}
|
||||
}
|
||||
|
||||
/// save_file says it saves in the document's own format or the one the path's extension picks.
|
||||
#[test]
|
||||
fn save_file_describes_the_formats_it_writes() {
|
||||
|
||||
@@ -574,7 +574,7 @@ fn select_ids(b: &mut dyn Backend, a: &Args) -> Result<(), String> {
|
||||
fn add_text(b: &mut dyn Backend, a: &Args) -> Result<Value, String> {
|
||||
let text = a.get("text").and_then(Value::as_str).ok_or("missing string argument `text`")?;
|
||||
let mut style = json!({});
|
||||
for k in ["size", "font"] {
|
||||
for k in ["size", "font", "color"] {
|
||||
if let Some(v) = a.get(k) {
|
||||
style[k] = v.clone();
|
||||
}
|
||||
@@ -592,9 +592,6 @@ fn add_text(b: &mut dyn Backend, a: &Args) -> Result<Value, String> {
|
||||
for (k, v) in style.as_object().into_iter().flatten() {
|
||||
p[k.as_str()] = v.clone();
|
||||
}
|
||||
if let Some(c) = a.get("color") {
|
||||
p["color"] = c.clone();
|
||||
}
|
||||
if let (Some(w), Some(h)) = (a.get("width"), a.get("height")) {
|
||||
p["area"] = json!({"width": w, "height": h});
|
||||
}
|
||||
|
||||
@@ -235,12 +235,16 @@ pub(crate) struct Tidy {
|
||||
keep: Vec<Point>,
|
||||
/// Input curves (with inflated bounds) used to rebuild split pieces exactly.
|
||||
curves: Vec<(kurbo::Rect, CubicBez)>,
|
||||
/// `curves` indices by the left edge of their bounds, and the widest bounds: the curves whose
|
||||
/// bounds can hold a point, without testing them all.
|
||||
by_x: Vec<usize>,
|
||||
max_width: f64,
|
||||
}
|
||||
|
||||
impl Tidy {
|
||||
/// Merge any smooth joint.
|
||||
pub(crate) fn free(precision: f64) -> Self {
|
||||
Self { precision, keep: Vec::new(), curves: Vec::new() }
|
||||
Self { precision, ..Default::default() }
|
||||
}
|
||||
/// Keep every on-curve point of `paths`.
|
||||
pub(crate) fn keeping<'a>(precision: f64, paths: impl IntoIterator<Item = &'a BezPath>) -> Self {
|
||||
@@ -257,7 +261,10 @@ impl Tidy {
|
||||
}
|
||||
}
|
||||
keep.sort_by(|a, b| a.x.total_cmp(&b.x));
|
||||
Self { precision, keep, curves }
|
||||
let mut by_x: Vec<usize> = (0..curves.len()).collect();
|
||||
by_x.sort_by(|&i, &j| curves[i].0.x0.total_cmp(&curves[j].0.x0));
|
||||
let max_width = curves.iter().map(|(r, _)| r.width()).fold(0.0, f64::max);
|
||||
Self { precision, keep, curves, by_x, max_width }
|
||||
}
|
||||
fn is_kept(&self, p: Point) -> bool {
|
||||
let tol = 1e-6 * (1.0 + p.x.abs().max(p.y.abs()));
|
||||
@@ -322,10 +329,11 @@ impl Arrangement {
|
||||
pub(crate) fn contours_to_path(c: &Contours, idx: impl IntoIterator<Item = ContourIdx>, tidy: &Tidy) -> PathData {
|
||||
let mut subpaths = Vec::new();
|
||||
for i in idx {
|
||||
if is_sliver(&c[i].path, tidy.precision) {
|
||||
let path = tidy.repair(&c[i].path);
|
||||
if is_sliver(&path, tidy.precision) {
|
||||
continue;
|
||||
}
|
||||
if let Some(sp) = tidy_bezpath(&c[i].path, tidy) {
|
||||
if let Some(sp) = tidy_bezpath(&path, tidy) {
|
||||
subpaths.push(sp);
|
||||
}
|
||||
}
|
||||
@@ -515,6 +523,84 @@ fn exact_merge(run: &[Seg], tidy: &Tidy) -> Option<CubicBez> {
|
||||
None
|
||||
}
|
||||
|
||||
impl Tidy {
|
||||
/// The input curves whose bounds hold both `a` and `b`.
|
||||
fn curves_around(&self, a: Point, b: Point) -> impl Iterator<Item = &CubicBez> {
|
||||
let x = a.x.min(b.x);
|
||||
let hi = self.by_x.partition_point(|&i| self.curves[i].0.x0 <= x);
|
||||
let lo = self.by_x[..hi].partition_point(|&i| self.curves[i].0.x0 < x - self.max_width);
|
||||
self.by_x[lo..hi].iter().map(|&i| &self.curves[i]).filter(move |(r, _)| r.contains(a) && r.contains(b)).map(|(_, c)| c)
|
||||
}
|
||||
|
||||
/// `bp` with each curve piece that strays from the one input curve through both its ends
|
||||
/// rebuilt as that curve's exact sub-curve. linesweeper 0.5 can emit a piece that doesn't
|
||||
/// follow its curve; out and back between two points of one curve, such pieces are a lens
|
||||
/// instead of nothing (issue #1028). A piece on a curve, or whose ends two different input
|
||||
/// curves pass through (a real lens between them), is left as it is.
|
||||
pub(crate) fn repair(&self, bp: &BezPath) -> BezPath {
|
||||
use kurbo::{ParamCurve, ParamCurveNearest, PathEl};
|
||||
if self.curves.is_empty() {
|
||||
return bp.clone();
|
||||
}
|
||||
let mut out = BezPath::new();
|
||||
let mut cur = Point::ZERO;
|
||||
let mut start = Point::ZERO;
|
||||
for el in bp.iter() {
|
||||
let piece = match el {
|
||||
PathEl::QuadTo(c, p) => Some(kurbo::QuadBez::new(cur, c, p).raise()),
|
||||
PathEl::CurveTo(c1, c2, p) => Some(CubicBez::new(cur, c1, c2, p)),
|
||||
_ => None,
|
||||
};
|
||||
match (el, piece) {
|
||||
(_, Some(raw)) => {
|
||||
let (a, b) = (raw.p0, raw.p3);
|
||||
let scale = 1.0 + a.x.abs().max(a.y.abs()).max(b.x.abs()).max(b.y.abs());
|
||||
let tol = 1e-6 * scale;
|
||||
let mut subs: Vec<CubicBez> = vec![];
|
||||
let mut follows = false;
|
||||
for c in self.curves_around(a, b) {
|
||||
let (na, nb) = (c.nearest(a, 1e-12), c.nearest(b, 1e-12));
|
||||
if na.distance_sq.sqrt() > tol || nb.distance_sq.sqrt() > tol || (na.t - nb.t).abs() < 1e-12 {
|
||||
continue;
|
||||
}
|
||||
if [0.25, 0.5, 0.75].iter().all(|&t| c.nearest(raw.eval(t), 1e-12).distance_sq.sqrt() <= tol * 10.0) {
|
||||
follows = true;
|
||||
break;
|
||||
}
|
||||
let sub = if na.t < nb.t {
|
||||
c.subsegment(na.t..nb.t)
|
||||
} else {
|
||||
let r = c.subsegment(nb.t..na.t);
|
||||
CubicBez::new(r.p3, r.p2, r.p1, r.p0)
|
||||
};
|
||||
subs.push(CubicBez::new(a, sub.p1, sub.p2, b));
|
||||
}
|
||||
let same = |p: &CubicBez, q: &CubicBez| p.p1.distance(q.p1) <= tol * 10.0 && p.p2.distance(q.p2) <= tol * 10.0;
|
||||
match subs.split_first() {
|
||||
Some((sub, rest)) if !follows && rest.iter().all(|q| same(sub, q)) => out.curve_to(sub.p1, sub.p2, b),
|
||||
_ => out.push(el),
|
||||
}
|
||||
cur = b;
|
||||
}
|
||||
(PathEl::MoveTo(p), None) => {
|
||||
out.push(el);
|
||||
(cur, start) = (p, p);
|
||||
}
|
||||
(PathEl::LineTo(p), None) => {
|
||||
out.push(el);
|
||||
cur = p;
|
||||
}
|
||||
(PathEl::ClosePath, None) => {
|
||||
out.push(el);
|
||||
cur = start;
|
||||
}
|
||||
(_, None) => out.push(el),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve self-intersections / overlaps of a single filled path into simple, consistently
|
||||
/// oriented contours (outer contours and holes).
|
||||
pub fn normalize(path: &PathData, rule: FillRule) -> PathData {
|
||||
@@ -592,3 +678,57 @@ pub fn area(path: &PathData, rule: FillRule) -> f64 {
|
||||
Err(_) => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use kurbo::ParamCurve;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A quarter-circle-like arc, the input curve the pieces below are cut from.
|
||||
fn arc() -> CubicBez {
|
||||
CubicBez::new((0.0, 0.0), (0.0, 55.0), (45.0, 100.0), (100.0, 100.0))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stray_piece_out_and_back_along_one_curve_is_no_outline() {
|
||||
// Issue #1028: linesweeper 0.5 handed back a contour running out along an input curve and
|
||||
// back by a piece that doesn't follow it, which refit into a visible lens.
|
||||
let c = arc();
|
||||
let tidy =
|
||||
Tidy::keeping(DEFAULT_PRECISION, [&BezPath::from_vec(vec![kurbo::PathEl::MoveTo(c.p0), kurbo::PathEl::CurveTo(c.p1, c.p2, c.p3)])]);
|
||||
let out = c.subsegment(0.2..0.6);
|
||||
let mut contour = BezPath::new();
|
||||
contour.move_to(out.p0);
|
||||
contour.curve_to(out.p1, out.p2, out.p3);
|
||||
// back to the start, bulging off the curve and overshooting the end like the reported piece
|
||||
contour.curve_to(out.p0 + (out.p0 - out.p3) * 0.15 + kurbo::Vec2::new(4.0, -3.0), out.p1 + kurbo::Vec2::new(3.0, -2.0), out.p0);
|
||||
contour.close_path();
|
||||
assert!(!is_sliver(&contour, tidy.precision), "the stray piece makes a lens");
|
||||
let fixed = tidy.repair(&contour);
|
||||
assert!(is_sliver(&fixed, tidy.precision), "{}", fixed.to_svg());
|
||||
// a piece on its curve is left exactly as it is
|
||||
let mut on = BezPath::new();
|
||||
on.move_to(out.p0);
|
||||
on.curve_to(out.p1, out.p2, out.p3);
|
||||
on.line_to(out.p0);
|
||||
on.close_path();
|
||||
assert_eq!(tidy.repair(&on), on);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lens_between_two_curves_through_the_same_points_stays() {
|
||||
// Two input curves meeting at both ends bound a real face: neither piece is "repaired"
|
||||
// onto the other.
|
||||
let lower = CubicBez::new((0.0, 0.0), (30.0, 20.0), (70.0, 20.0), (100.0, 0.0));
|
||||
let upper = CubicBez::new((0.0, 0.0), (30.0, -20.0), (70.0, -20.0), (100.0, 0.0));
|
||||
let input = |c: CubicBez| BezPath::from_vec(vec![kurbo::PathEl::MoveTo(c.p0), kurbo::PathEl::CurveTo(c.p1, c.p2, c.p3)]);
|
||||
let tidy = Tidy::keeping(DEFAULT_PRECISION, [&input(lower), &input(upper)]);
|
||||
let mut lens = BezPath::new();
|
||||
lens.move_to(lower.p0);
|
||||
lens.curve_to(lower.p1, lower.p2, lower.p3);
|
||||
lens.curve_to(upper.p2, upper.p1, upper.p0);
|
||||
lens.close_path();
|
||||
assert_eq!(tidy.repair(&lens), lens);
|
||||
}
|
||||
}
|
||||
@@ -192,4 +192,29 @@ mod tests {
|
||||
// Ring 16·20 − 8·12 = 224, plus the bar outside it (10·4) and inside its hole (6·4).
|
||||
assert!((area - (224.0 + 40.0 + 24.0)).abs() < 1e-6, "{area}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn miter_offset_finishes_where_the_sweep_used_to_loop() {
|
||||
// Issue #1028: with linesweeper 0.4 the sweep over this miter offset never finished and
|
||||
// its memory grew without bound, freezing the app. A synthetic outline (found by search)
|
||||
// with the same failure as the reported glyph; the exact coordinates matter.
|
||||
const OUTLINE: &str = "M74.359137297823,82.83738194552835 L88.097432485874,103.04979803227248 \
|
||||
L67.06936731196308,105.95779886823588 L53.29382873396223,98.53943925153727 \
|
||||
L29.684713319694094,94.17996020306241 L41.65480824807079,75.639672165115 \
|
||||
Q47.491034578751446,52.12555333014565 46.157302824282816,55.67193079127421 \
|
||||
Q69.08365151786553,53.9608681393331 68.37499772324333,51.17242466680129 \
|
||||
Q74.4136309339628,75.13284223919301 71.3309887459222,73.69128869058812 Z";
|
||||
let path = PathData::from_bezpath(&BezPath::from_svg(OUTLINE).unwrap());
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let input = path.clone();
|
||||
std::thread::spawn(move || tx.send(offset_path(&input, 1.8350459854022054, Join::Miter, 4.0)));
|
||||
let Ok(out) = rx.recv_timeout(std::time::Duration::from_secs(30)) else {
|
||||
// the stuck sweep keeps allocating on its thread: end the test binary now
|
||||
eprintln!("Offset Path didn't finish in 30 s (issue #1028)");
|
||||
std::process::exit(101);
|
||||
};
|
||||
let (before, after) = (crate::area(&path, FillRule::NonZero), crate::area(&out, FillRule::NonZero));
|
||||
assert_eq!(out.subpaths.len(), 1);
|
||||
assert!(after > before, "{before} → {after}");
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ use vectorcraft_geom::{FillRule, PathData};
|
||||
|
||||
use crate::import_color::{Colors, Native};
|
||||
use crate::import_mask::{MaskSpec, contains, is_rectangle, luminance, mask_spec, white_cover};
|
||||
use crate::import_scan::{MAX_NESTING, Ocgs, Scan, all_on, hides_forms, scan_page, tag_key};
|
||||
use crate::import_scan::{FontNames, MAX_NESTING, Ocgs, Scan, all_on, hides_forms, scan_page, tag_key};
|
||||
use crate::import_shading::{clipped, extend_clip, fold_stop_opacity, mesh_shading, shading_gradient};
|
||||
use crate::import_text::{Families, LineFacts, Look, Placement, TextLine, Upright};
|
||||
use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs};
|
||||
@@ -692,8 +692,9 @@ struct Builder<'p> {
|
||||
/// Mask keys of CMYK images whose mask turned out to hide nothing.
|
||||
opaque: HashSet<u128>,
|
||||
warnings: Vec<String>,
|
||||
/// Fonts by cache key → base font name (from [`scan_page`]).
|
||||
fonts: HashMap<u128, String>,
|
||||
/// Fonts by cache key → base font name and whether the file carries its glyphs (from
|
||||
/// [`scan_page`]).
|
||||
fonts: FontNames,
|
||||
font_names: HashMap<u128, FontInfo>,
|
||||
families: Option<Families>,
|
||||
missing_fonts: Vec<String>,
|
||||
@@ -1346,7 +1347,7 @@ impl<'p> Builder<'p> {
|
||||
return n.clone();
|
||||
}
|
||||
let data = o.font_data();
|
||||
let name = self.fonts.get(&key).cloned().or_else(|| data.as_ref().and_then(|d| d.postscript_name.clone()));
|
||||
let name = self.fonts.get(&key).map(|(n, _)| n.clone()).or_else(|| data.as_ref().and_then(|d| d.postscript_name.clone()));
|
||||
let (weight, italic) = data.as_ref().map_or((None, false), |d| (d.weight, d.is_italic));
|
||||
// A subset's six-letter tag.
|
||||
let ps = name.as_deref().map(|n| match n.split_once('+') {
|
||||
@@ -1429,7 +1430,12 @@ impl<'p> Builder<'p> {
|
||||
let upright = width.is_some_and(|w| w.abs() <= 1.0);
|
||||
let top = m * kurbo::Point::new(f64::from(width.filter(|w| *w > 1.0).unwrap_or(1000.0)) * 0.5, 880.0);
|
||||
let mut info = self.font_name(key, o);
|
||||
if let Some(face) = &info.face {
|
||||
// A font the file doesn't carry is drawn with a stand-in: its glyphs can't tell which
|
||||
// style of the installed family it is, so the name decides.
|
||||
let embedded = self.fonts.get(&key).is_none_or(|(_, e)| *e);
|
||||
if let Some(face) = &info.face
|
||||
&& embedded
|
||||
{
|
||||
let decided = match self.matched.get(&key) {
|
||||
Some(m) => m.clone(),
|
||||
// A glyph that can't be compared (several characters, a vertical form) decides
|
||||
|
||||
@@ -248,7 +248,7 @@ struct Walker<'w, 'p> {
|
||||
/// The groups that are off for the interpreter (none once [`all_on`] turned them on).
|
||||
off: HashSet<ObjectIdentifier>,
|
||||
xref: &'p hayro_syntax::xref::XRef,
|
||||
fonts: &'w mut HashMap<u128, String>,
|
||||
fonts: &'w mut FontNames,
|
||||
visible: Vec<bool>,
|
||||
ops: usize,
|
||||
max_ops: usize,
|
||||
@@ -296,7 +296,7 @@ impl Walker<'_, '_> {
|
||||
if let Some(f) = res.fonts.get::<Dict<'_>>(key.as_ref())
|
||||
&& let Some(name) = f.get::<Name<'_>>(b"BaseFont")
|
||||
{
|
||||
self.fonts.entry(f.cache_key()).or_insert_with(|| String::from_utf8_lossy(name.as_ref()).into_owned());
|
||||
self.fonts.entry(f.cache_key()).or_insert_with(|| (String::from_utf8_lossy(name.as_ref()).into_owned(), embeds_glyphs(&f)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -372,13 +372,32 @@ impl Walker<'_, '_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The fonts of a document by cache key: base font name, and whether the file carries the glyphs
|
||||
/// ([`embeds_glyphs`]).
|
||||
pub(crate) type FontNames = HashMap<u128, (String, bool)>;
|
||||
|
||||
/// Whether font `f` carries its own glyphs: a font program in its descriptor (a composite font's in
|
||||
/// its descendant's), or a Type 3 font's glyph procedures. A font that doesn't is drawn with a
|
||||
/// stand-in, whose glyphs say nothing about which installed face the document means.
|
||||
fn embeds_glyphs(f: &Dict<'_>) -> bool {
|
||||
if f.get::<Name<'_>>(b"Subtype").is_some_and(|s| s.as_ref() == b"Type3") {
|
||||
return true;
|
||||
}
|
||||
// A font program is a stream, so always an indirect object.
|
||||
let program = |d: &Dict<'_>| {
|
||||
d.get::<Dict<'_>>(b"FontDescriptor").is_some_and(|fd| [&b"FontFile"[..], b"FontFile2", b"FontFile3"].iter().any(|k| fd.get_ref(*k).is_some()))
|
||||
};
|
||||
program(f) || f.get::<Array<'_>>(b"DescendantFonts").and_then(|a| a.iter::<Dict<'_>>().next()).is_some_and(|d| program(&d))
|
||||
}
|
||||
|
||||
/// Walk `page` as the interpreter will: `all_on` when [`all_on`] turned every group on. Fonts
|
||||
/// found are noted in `fonts` (their cache key → base font name).
|
||||
pub(crate) fn scan_page(page: &Page<'_>, ocgs: &mut Ocgs, all_on: bool, fonts: &mut HashMap<u128, String>) -> Scan {
|
||||
/// found are noted in `fonts` (their cache key → base font name, and whether the file carries its
|
||||
/// glyphs).
|
||||
pub(crate) fn scan_page(page: &Page<'_>, ocgs: &mut Ocgs, all_on: bool, fonts: &mut FontNames) -> Scan {
|
||||
walk_page(page, ocgs, all_on, fonts).out
|
||||
}
|
||||
|
||||
fn walk_page<'w, 'p>(page: &Page<'p>, ocgs: &'w mut Ocgs, all_on: bool, fonts: &'w mut HashMap<u128, String>) -> Walker<'w, 'p> {
|
||||
fn walk_page<'w, 'p>(page: &Page<'p>, ocgs: &'w mut Ocgs, all_on: bool, fonts: &'w mut FontNames) -> Walker<'w, 'p> {
|
||||
let off = if all_on { HashSet::new() } else { ocgs.off.clone() };
|
||||
let mut w = Walker { ocgs, off, xref: page.xref(), fonts, visible: vec![], ops: 0, max_ops: max_ops(), out: Scan::default(), hidden_form: false };
|
||||
w.walk(page.typed_operations(), page.resources(), 0);
|
||||
|
||||
@@ -97,6 +97,8 @@ impl RasterExportOptions {
|
||||
anti_alias: self.anti_alias,
|
||||
// Opaque art stays exactly opaque where translucent edges cross it (#787).
|
||||
precise: true,
|
||||
// Shapes meeting edge to edge leave no seam (#983).
|
||||
supersample: true,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,11 @@ pub struct RenderOptions {
|
||||
/// opaque, where 8-bit compositing leaves alpha 254 on some anti-aliased edges (#787). Off,
|
||||
/// the faster 8-bit pipeline (the canvas).
|
||||
pub precise: bool,
|
||||
/// Exports: Art Optimized edges are supersampled ([`supersample_factor`]): the art is drawn
|
||||
/// with hard edges at several times the size and averaged down, so shapes that meet edge to
|
||||
/// edge leave no seam of the background between them (#983). Off, edges are smoothed shape by
|
||||
/// shape (the canvas).
|
||||
pub supersample: bool,
|
||||
}
|
||||
|
||||
/// The opacity of the art around an isolated group or layer.
|
||||
@@ -186,10 +191,48 @@ impl Default for RenderOptions {
|
||||
smooth_images: true,
|
||||
isolated: None,
|
||||
precise: false,
|
||||
supersample: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The most pixels a supersampled render draws (the frame and its compositing buffers hold a few
|
||||
/// times this many bytes).
|
||||
const MAX_SUPERSAMPLED_PIXELS: u64 = 40_000_000;
|
||||
|
||||
/// How many times wider and taller a `w`×`h` render is drawn when supersampled: 4, or less where
|
||||
/// that would pass [`MAX_SUPERSAMPLED_PIXELS`] or the renderer's 65,535-pixel side; `None` when not
|
||||
/// even twice fits (the edges are then smoothed shape by shape).
|
||||
pub fn supersample_factor(w: u32, h: u32) -> Option<u32> {
|
||||
(2..=4u32).rev().find(|k| {
|
||||
let (sw, sh) = (u64::from(w) * u64::from(*k), u64::from(h) * u64::from(*k));
|
||||
sw <= u64::from(u16::MAX) && sh <= u64::from(u16::MAX) && sw * sh <= MAX_SUPERSAMPLED_PIXELS
|
||||
})
|
||||
}
|
||||
|
||||
/// Average each `k`×`k` block of `src` (`w`×`h` pixels of 4 premultiplied bytes) into one pixel.
|
||||
fn downsample(src: &[u8], w: usize, h: usize, k: usize) -> Vec<u8> {
|
||||
let (ow, oh) = (w / k.max(1), h / k.max(1));
|
||||
let n = (k * k).max(1) as u32;
|
||||
let mut out = vec![0u8; ow * oh * 4];
|
||||
for (o, px) in out.as_chunks_mut::<4>().0.iter_mut().enumerate() {
|
||||
let (ox, oy) = (o % ow.max(1), o / ow.max(1));
|
||||
let mut acc = [0u32; 4];
|
||||
for y in oy * k..(oy + 1) * k {
|
||||
let row = y * w + ox * k;
|
||||
for p in src.get(row * 4..(row + k) * 4).unwrap_or_default().as_chunks::<4>().0 {
|
||||
for (a, v) in acc.iter_mut().zip(p) {
|
||||
*a += u32::from(*v);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (d, a) in px.iter_mut().zip(acc) {
|
||||
*d = ((a + n / 2) / n).min(255) as u8;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A rendered image (premultiplied RGBA8, row-major).
|
||||
pub struct Rendered {
|
||||
pub width: u32,
|
||||
@@ -476,6 +519,16 @@ impl Renderer {
|
||||
/// screen colours (4 bytes a pixel, see [`Self::render_region_inks`]).
|
||||
fn render_as(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions, inks: bool) -> Rendered {
|
||||
let start = now();
|
||||
if opts.supersample && opts.anti_alias == AntiAlias::Art && opts.mask_view.is_none() {
|
||||
let (w, h) = (width.clamp(1, u16::MAX as u32), height.clamp(1, u16::MAX as u32));
|
||||
if let Some(k) = supersample_factor(w, h) {
|
||||
let hard = RenderOptions { anti_alias: AntiAlias::None, supersample: false, ..opts.clone() };
|
||||
let big = self.render_as(doc, w * k, h * k, Affine::scale(f64::from(k)) * view, &hard, inks);
|
||||
let pixels = downsample(&big.pixels, big.width as usize, big.height as usize, k as usize);
|
||||
self.stats.micros = now().saturating_sub(start);
|
||||
return Rendered { width: w, height: h, pixels };
|
||||
}
|
||||
}
|
||||
let prepared = proof::prepare(doc, opts);
|
||||
let doc: &Document = &prepared;
|
||||
let w = width.clamp(1, u16::MAX as u32) as u16;
|
||||
|
||||
@@ -385,3 +385,41 @@ fn images_take_their_nearest_pixel_without_smoothing() {
|
||||
assert!(greys(true) >= 4, "smooth: a ramp from black to white");
|
||||
assert_eq!(greys(false), 0, "nearest: black, then white");
|
||||
}
|
||||
|
||||
/// Art Optimized exports are supersampled (#983): two red shapes meeting along a diagonal leave no
|
||||
/// seam of the white background between them, where shape-by-shape smoothing shows one; the outer
|
||||
/// edges stay smooth.
|
||||
#[test]
|
||||
fn supersampled_art_leaves_no_seam_between_shapes_that_meet() {
|
||||
let red = || Paint::solid(Color::rgb(1.0, 0.0, 0.0));
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let layer = d.layers[0].id;
|
||||
// Two triangles sharing the diagonal of the square (10, 10)–(90, 90).
|
||||
for pts in [[(10.0, 10.0), (90.0, 10.0), (90.0, 90.0)], [(10.0, 10.0), (90.0, 90.0), (10.0, 90.0)]] {
|
||||
let mut b = vectorcraft_geom::BezPath::new();
|
||||
b.move_to(pts[0]);
|
||||
b.line_to(pts[1]);
|
||||
b.line_to(pts[2]);
|
||||
b.close_path();
|
||||
let id = d.alloc_id();
|
||||
d.insert(Some(layer), 0, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&b), Appearance::basic(red(), Paint::None, 0.0))).unwrap();
|
||||
}
|
||||
let draw = |supersample: bool| {
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), precise: true, supersample, ..Default::default() };
|
||||
Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts)
|
||||
};
|
||||
// The palest pixel on the shared diagonal, away from the corners.
|
||||
let palest = |r: &Rendered| (20..80).map(|i| r.pixel(i, i)[1].max(r.pixel(i + 1, i)[1])).max().unwrap();
|
||||
assert!(palest(&draw(false)) > 20, "shape-by-shape smoothing shows the seam");
|
||||
assert_eq!(palest(&draw(true)), 0, "supersampled: no seam");
|
||||
// The outer edge is still smoothed: a pixel half covered is between white and red.
|
||||
let ss = draw(true);
|
||||
let edge = ss.pixel(50, 9);
|
||||
assert!(edge == [255, 255, 255, 255] || (edge[1] > 0 && edge[1] < 255) || ss.pixel(50, 10)[1] < 255, "{edge:?}");
|
||||
// The factor fits the size and the renderer's limits.
|
||||
assert_eq!(supersample_factor(100, 100), Some(4));
|
||||
assert_eq!(supersample_factor(2000, 2000), Some(3));
|
||||
assert_eq!(supersample_factor(3000, 3000), Some(2));
|
||||
assert_eq!(supersample_factor(5000, 5000), None);
|
||||
assert_eq!(supersample_factor(40_000, 10), None);
|
||||
}
|
||||
+11
-10
@@ -47,8 +47,8 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<S
|
||||
usvg::Tree::from_str(&src, &opt).map_err(|e| SvgError::Parse(e.to_string()))?
|
||||
};
|
||||
let size = tree.size();
|
||||
let (kx, ky) = units.k;
|
||||
let mut doc = Document::new(size.width() as f64 * kx, size.height() as f64 * ky);
|
||||
let k = units.k;
|
||||
let mut doc = Document::new(size.width() as f64 * k, size.height() as f64 * k);
|
||||
doc.units = units.unit;
|
||||
let mut im = Importer {
|
||||
doc,
|
||||
@@ -68,7 +68,7 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<S
|
||||
data: found.data,
|
||||
sublayers: found.sublayers,
|
||||
sublayer_groups: HashSet::new(),
|
||||
screen: (kx * ky).sqrt(),
|
||||
screen: k,
|
||||
};
|
||||
if im.files.nested_text() {
|
||||
im.warn("text inside an SVG image isn't imported".into());
|
||||
@@ -76,7 +76,7 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<S
|
||||
|
||||
// usvg wraps everything in an id-less group carrying the viewBox transform when needed.
|
||||
let mut top = tree.root();
|
||||
let mut base = Affine::scale_non_uniform(kx, ky) * aff(top.transform());
|
||||
let mut base = Affine::scale(k) * aff(top.transform());
|
||||
if let Some(g) = im.wrapper(top) {
|
||||
base *= aff(g.transform());
|
||||
top = g;
|
||||
@@ -212,12 +212,14 @@ pub(super) const DEFAULT_FONT_SIZE: f64 = 12.0;
|
||||
/// A pixel (and a unitless user unit) is a point, as we export, and absolute lengths keep their
|
||||
/// physical size at 72 pt per inch: `font-size="12pt"` is 12 pt, `1in` is 72 pt. A root size in
|
||||
/// absolute units (`width="210mm"`) is that physical size, and its user units are CSS pixels of it
|
||||
/// (96 per inch, 0.75 pt each), so a drawing without a `viewBox` keeps its proportions.
|
||||
/// (96 per inch, 0.75 pt each), so a drawing without a `viewBox` keeps its proportions. With one
|
||||
/// side absolute and the other not (`width="96pt" height="96"`), both are: the unitless side is
|
||||
/// CSS pixels too, so the drawing isn't stretched (#1010).
|
||||
struct RootUnits {
|
||||
/// Pixels per inch usvg (and the text pass) convert absolute lengths at.
|
||||
dpi: f64,
|
||||
/// Points per user unit of the root along x and y.
|
||||
k: (f64, f64),
|
||||
/// Points per user unit of the root, along both axes.
|
||||
k: f64,
|
||||
/// Document units: those of the root `width` (pixels when it has none).
|
||||
unit: Unit,
|
||||
}
|
||||
@@ -228,8 +230,7 @@ impl RootUnits {
|
||||
use svgtypes::LengthUnit as U;
|
||||
let physical = |u: Option<U>| matches!(u, Some(U::In | U::Cm | U::Mm | U::Pt | U::Pc));
|
||||
let (w, h) = (unit("width"), unit("height"));
|
||||
let k = |u| if physical(u) { PT_PER_IN / CSS_PX_PER_IN } else { 1.0 };
|
||||
let dpi = if physical(w) || physical(h) { CSS_PX_PER_IN } else { PT_PER_IN };
|
||||
let (dpi, k) = if physical(w) || physical(h) { (CSS_PX_PER_IN, PT_PER_IN / CSS_PX_PER_IN) } else { (PT_PER_IN, 1.0) };
|
||||
let unit = match w {
|
||||
Some(U::Mm) => Unit::Millimeters,
|
||||
Some(U::Cm) => Unit::Centimeters,
|
||||
@@ -238,7 +239,7 @@ impl RootUnits {
|
||||
Some(U::Pc) => Unit::Picas,
|
||||
_ => Unit::Pixels,
|
||||
};
|
||||
Self { dpi, k: (k(w), k(h)), unit }
|
||||
Self { dpi, k, unit }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! repeated (every other period mirrored, for reflect) as many times as the painted area needs,
|
||||
//! and the gradient stretched over those periods.
|
||||
|
||||
use vectorcraft_color::{GradientGeom, GradientKind, GradientStop};
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientStop};
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
|
||||
/// Most periods a gradient is expanded to.
|
||||
@@ -80,17 +80,9 @@ fn whole_period(stops: &[GradientStop]) -> Vec<GradientStop> {
|
||||
|
||||
/// A period mirrored: the stops in reverse, each midpoint measured from the other side.
|
||||
fn mirror(period: &[GradientStop]) -> Vec<GradientStop> {
|
||||
let rev: Vec<&GradientStop> = period.iter().rev().collect();
|
||||
rev.iter()
|
||||
.enumerate()
|
||||
.map(|(i, s)| GradientStop {
|
||||
offset: 1.0 - s.offset,
|
||||
// The midpoint from this stop to the next one is the mirror of the one the next stop
|
||||
// had towards this one.
|
||||
midpoint: rev.get(i + 1).map_or(0.5, |next| 1.0 - next.midpoint),
|
||||
..(*s).clone()
|
||||
})
|
||||
.collect()
|
||||
let mut g = Gradient { kind: GradientKind::Linear, stops: period.to_vec() };
|
||||
g.reverse();
|
||||
g.stops
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -41,8 +41,9 @@
|
||||
//! * One CSS pixel (user unit) is one point, as on export, and absolute lengths (`in`, `cm`, `mm`,
|
||||
//! `pt`, `pc`) keep their physical size (72 pt per inch): `font-size="12pt"` is 12 pt. A root
|
||||
//! `width`/`height` in absolute units keeps its physical size, so a 210 mm SVG opens on a 210 mm
|
||||
//! artboard, its user units being CSS pixels of it (96 per inch). The document's units follow the
|
||||
//! unit of the root `width` (pixels when it has none).
|
||||
//! artboard, its user units being CSS pixels of it (96 per inch), the other side's too when only
|
||||
//! one is absolute. The document's units follow the unit of the root `width` (pixels when it has
|
||||
//! none).
|
||||
//! * `<mask>` imports as a luminance opacity mask; a mask we exported keeps its options and art
|
||||
//! (its `data-vectorcraft-mask="noclip invert"` lists the options that differ from clipping and
|
||||
//! not inverted). Nested clip paths (`<clipPath clip-path>`) are clip groups in clip groups.
|
||||
|
||||
@@ -811,6 +811,31 @@ fn import_absolute_units_keep_their_physical_size() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #1010: a root with one absolute side and one unitless or px side opens as if both were
|
||||
/// absolute (CSS pixels at 96 per inch), so nothing is stretched and the viewport keeps its size.
|
||||
#[test]
|
||||
fn import_mixed_root_units_scale_both_sides_alike() {
|
||||
let rect = |d: &Document| art(d)[0].geometric_bounds().unwrap();
|
||||
for (size, board) in [
|
||||
(r#"width="96pt" height="96""#, Rect::new(0.0, 0.0, 96.0, 72.0)),
|
||||
(r#"width="96pt" height="96px""#, Rect::new(0.0, 0.0, 96.0, 72.0)),
|
||||
(r#"width="96pt" height="72pt""#, Rect::new(0.0, 0.0, 96.0, 72.0)),
|
||||
(r#"width="128" height="72pt""#, Rect::new(0.0, 0.0, 96.0, 72.0)),
|
||||
] {
|
||||
let svg =
|
||||
|extra: &str| format!(r##"<svg xmlns="http://www.w3.org/2000/svg" {size} {extra}><rect width="32" height="32" fill="#f00"/></svg>"##);
|
||||
let d = import(&svg("")).unwrap();
|
||||
assert!(close_rect(d.artboards[0].rect, board, 1e-6), "{size}: {:?}", d.artboards[0].rect);
|
||||
assert!(close_rect(rect(&d), Rect::new(0.0, 0.0, 24.0, 24.0), 1e-6), "{size}: {:?}", rect(&d));
|
||||
// With a viewBox: fitted uniformly (xMidYMid meet), unless preserveAspectRatio is none.
|
||||
let d = import(&svg(r#"viewBox="0 0 32 32""#)).unwrap();
|
||||
assert!(close_rect(d.artboards[0].rect, board, 1e-6), "{size}: {:?}", d.artboards[0].rect);
|
||||
assert!(close_rect(rect(&d), Rect::new(12.0, 0.0, 84.0, 72.0), 1e-6), "{size} viewBox: {:?}", rect(&d));
|
||||
let d = import(&svg(r#"viewBox="0 0 32 32" preserveAspectRatio="none""#)).unwrap();
|
||||
assert!(close_rect(rect(&d), board, 1e-6), "{size} none: {:?}", rect(&d));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_document_roundtrip_is_stable() {
|
||||
// export → import → export produces identical SVG.
|
||||
|
||||
@@ -232,9 +232,15 @@ impl Tool for CropImageTool {
|
||||
if key != "rect" {
|
||||
return;
|
||||
}
|
||||
let n: Vec<f64> = value.as_array().map(|v| v.iter().filter_map(Value::as_f64).filter(|n| n.is_finite()).collect()).unwrap_or_default();
|
||||
self.rect = match n.as_slice() {
|
||||
[x, y, w, h] => Some((None, Rect::new(*x, *y, x + w, y + h))),
|
||||
// Invalid components must discard the whole box, not silently shift the
|
||||
// coordinates by dropping a non-numeric entry.
|
||||
let numbers = value
|
||||
.as_array()
|
||||
.filter(|a| a.len() == 4)
|
||||
.and_then(|a| a.iter().map(Value::as_f64).collect::<Option<Vec<_>>>())
|
||||
.filter(|a| a.iter().all(|n| n.is_finite()));
|
||||
self.rect = match numbers.as_deref() {
|
||||
Some([x, y, w, h]) => Some((None, Rect::new(*x, *y, x + w, y + h))),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
@@ -336,6 +342,17 @@ mod tests {
|
||||
assert!(t.overlays(&cx).is_empty() && !t.claims_key(&cx, ToolKey::Enter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_box_option_requires_exactly_four_finite_numeric_values() {
|
||||
let mut tool = CropImageTool::default();
|
||||
for invalid in [json!([10, "ignored", 20, 30, 40]), json!([10, 20, 30, null, 40]), json!([10, 20, 30, 40, 50]), json!("not a rectangle")] {
|
||||
tool.set_option("rect", &invalid);
|
||||
assert!(tool.options().get("rect").is_none(), "invalid box should reset crop: {invalid}");
|
||||
}
|
||||
tool.set_option("rect", &json!([10, 20, 30, 40]));
|
||||
assert_eq!(tool.options()["rect"], json!([10.0, 20.0, 30.0, 40.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tool_is_made_by_its_id() {
|
||||
assert_eq!(crate::create(ID).id(), ID);
|
||||
|
||||
@@ -372,7 +372,13 @@ impl Targets {
|
||||
/// tools): they pull a point into line where they run.
|
||||
fn with_rulers(mut self, cx: &ToolContext) -> Self {
|
||||
if cx.guides {
|
||||
self.rulers = cx.doc.guides.iter().map(|g| Ruler { vertical: g.vertical, pos: g.pos, span: cx.doc.guide_span(g) }).collect();
|
||||
self.rulers = cx
|
||||
.doc
|
||||
.guides
|
||||
.iter()
|
||||
.filter(|g| cx.doc.guide_shown(g))
|
||||
.map(|g| Ruler { vertical: g.vertical, pos: g.pos, span: cx.doc.guide_span(g) })
|
||||
.collect();
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
@@ -425,6 +425,11 @@ pub enum Cursor {
|
||||
ResizeNwSe,
|
||||
ResizeNeSw,
|
||||
Rotate,
|
||||
/// The rotate cursor beside a bounding box, bowed toward the pointer's side of the box: off
|
||||
/// its centre in direction `0..8` (0 straight up, then clockwise in 45° steps). Its arc wraps
|
||||
/// the corner it is at, so at the top right it opens toward the bottom left. Make it with
|
||||
/// [`Cursor::rotate_about`].
|
||||
RotateToward(u8),
|
||||
/// Over a Live Corners widget (drag to round the corners).
|
||||
CornerRadius,
|
||||
Pen,
|
||||
@@ -476,6 +481,18 @@ pub enum Cursor {
|
||||
}
|
||||
|
||||
impl Cursor {
|
||||
/// The rotate cursor for the pointer at `p` turning art about `center`: [`Cursor::RotateToward`]
|
||||
/// the pointer's side of it, or the plain [`Cursor::Rotate`] on the centre itself.
|
||||
pub fn rotate_about(center: Point, p: Point) -> Self {
|
||||
let d = p - center;
|
||||
if !(d.x.is_finite() && d.y.is_finite()) || d.hypot() < 1e-9 {
|
||||
return Cursor::Rotate;
|
||||
}
|
||||
// y points down the page: atan2(x, -y) is 0 straight up and grows clockwise.
|
||||
let octant = (d.x.atan2(-d.y) / std::f64::consts::FRAC_PI_4).round() as i64;
|
||||
Cursor::RotateToward(octant.rem_euclid(8) as u8)
|
||||
}
|
||||
|
||||
/// General › Use Precise Cursors: the drawing tools' pointers (the Pen's in every state, the
|
||||
/// Eyedropper's, the Slice and Blend tools') become a plain crosshair at the hotspot.
|
||||
pub fn precise(self) -> Self {
|
||||
@@ -485,6 +502,7 @@ impl Cursor {
|
||||
| Cursor::PenDelete
|
||||
| Cursor::PenClose
|
||||
| Cursor::PenContinue
|
||||
| Cursor::PenJoin
|
||||
| Cursor::PenConvert
|
||||
| Cursor::Eyedropper
|
||||
| Cursor::Slice
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
//! selected guides (Alt copies them). A dragged guide snaps ([`GuideSnap`]): with Shift to the
|
||||
//! ruler's ticks, else to whole pixels, the grid, with Smart Guides the art's and the artboards'
|
||||
//! edges, centres and anchors, or with Snap to Point the anchors. Dropped off the canvas onto its
|
||||
//! ruler, a moved guide is deleted. Hidden or locked guides (View › Guides) can't be picked.
|
||||
//! ruler, a moved guide is deleted. Hidden or locked guides (View › Guides), and guides on a hidden
|
||||
//! or locked layer, can't be picked.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_geom::Point;
|
||||
@@ -24,7 +25,7 @@ pub fn guide_at(cx: &ToolContext, p: Point) -> Option<usize> {
|
||||
.guides
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, g)| cx.doc.guide_passes(g, p, tol))
|
||||
.filter(|(_, g)| cx.doc.guide_editable(g) && cx.doc.guide_passes(g, p, tol))
|
||||
.map(|(i, g)| (i, off(g.pos, g.vertical)))
|
||||
.filter(|(_, d)| *d <= tol)
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1).then(b.0.cmp(&a.0)))
|
||||
|
||||
@@ -444,7 +444,7 @@ impl Tool for SelectionTool {
|
||||
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
|
||||
match self.state {
|
||||
State::Rotating { .. } => return Cursor::Rotate,
|
||||
State::Rotating { center, .. } => return Cursor::rotate_about(center, p),
|
||||
State::Scaling { handle, bx, .. } => return handle_cursor(handle, bx.angle),
|
||||
State::Moving { began: true, .. } => return Cursor::Arrow,
|
||||
State::Corner(_) => return Cursor::CornerRadius,
|
||||
@@ -468,7 +468,7 @@ impl Tool for SelectionTool {
|
||||
{
|
||||
match box_hit(cx, &bx, p) {
|
||||
Some(BoxHit::Handle(h)) => return handle_cursor(h, bx.angle),
|
||||
Some(BoxHit::Rotate) => return Cursor::Rotate,
|
||||
Some(BoxHit::Rotate) => return Cursor::rotate_about(bx.center(), p),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
@@ -588,6 +588,24 @@ mod tests {
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::DoubleClick, 160.0, 150.0)).is_empty());
|
||||
}
|
||||
|
||||
/// Just outside each corner of the box the rotate cursor bows toward that corner, its arc
|
||||
/// opening toward the box: at the top right it opens to the bottom left.
|
||||
#[test]
|
||||
fn the_rotate_cursor_wraps_the_corner_it_is_at() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let t = SelectionTool::default();
|
||||
// The box runs from (100, 100) to (200, 200); 10 pt out from each corner, diagonally.
|
||||
for ((x, y), octant) in [((93.0, 93.0), 7), ((207.0, 93.0), 1), ((207.0, 207.0), 3), ((93.0, 207.0), 5)] {
|
||||
assert_eq!(t.cursor(&cx, Point::new(x, y), Mods::default()), Cursor::RotateToward(octant), "at ({x}, {y})");
|
||||
}
|
||||
assert_eq!(Cursor::rotate_about(Point::new(1.0, 1.0), Point::new(1.0, 1.0)), Cursor::Rotate, "on the centre");
|
||||
assert_eq!(Cursor::rotate_about(Point::ORIGIN, Point::new(0.0, -5.0)), Cursor::RotateToward(0), "straight up");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_moves_with_single_undo() {
|
||||
let (d, id) = doc_with_rect();
|
||||
|
||||
@@ -43,7 +43,7 @@ fn snap(cx: &ToolContext, p: Point) -> (Point, Vec<Overlay>) {
|
||||
cx.doc
|
||||
.guides
|
||||
.iter()
|
||||
.filter(|g| g.vertical == vertical && (g.pos - v).abs() <= tol && cx.doc.guide_passes(g, p, tol))
|
||||
.filter(|g| g.vertical == vertical && (g.pos - v).abs() <= tol && cx.doc.guide_shown(g) && cx.doc.guide_passes(g, p, tol))
|
||||
.min_by(|a, b| (a.pos - v).abs().total_cmp(&(b.pos - v).abs()))
|
||||
};
|
||||
if let Some(g) = nearest(true, p.x) {
|
||||
|
||||
@@ -313,7 +313,7 @@ impl Tool for FreeTransformTool {
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
|
||||
let Some(r) = selection_bounds(cx) else { return Cursor::Arrow };
|
||||
match self.drag.map(|d| d.op).or_else(|| self.classify(cx, r, p, m)) {
|
||||
Some(Op::Rotate) => Cursor::Rotate,
|
||||
Some(Op::Rotate) => Cursor::rotate_about(r.center(), p),
|
||||
Some(Op::Move) => Cursor::Move,
|
||||
Some(Op::Scale(h) | Op::Shear(h)) => match h {
|
||||
Handle::Top | Handle::Bottom => Cursor::ResizeV,
|
||||
|
||||
@@ -378,7 +378,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
// Selected guides in the selection colour.
|
||||
let picked = LineLook { color: t.selection, ..look };
|
||||
let selected: &[usize] = app.session.active().map_or(&[], |st| &st.selection.guides);
|
||||
for (i, g) in doc.guides.iter().enumerate() {
|
||||
// Guides on a hidden layer hide with it.
|
||||
for (i, g) in doc.guides.iter().enumerate().filter(|(_, g)| doc.guide_shown(g)) {
|
||||
let at = |along: f64| xf.to_screen(if g.vertical { Point::new(g.pos, along) } else { Point::new(along, g.pos) });
|
||||
// An artboard guide runs across its artboard, a canvas guide across the window.
|
||||
let (a, b) = match doc.guide_span(g) {
|
||||
@@ -487,7 +488,7 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
|
||||
Cursor::ResizeV => C::ResizeVertical,
|
||||
Cursor::ResizeNwSe => C::ResizeNwSe,
|
||||
Cursor::ResizeNeSw => C::ResizeNeSw,
|
||||
Cursor::Rotate => C::Alias,
|
||||
Cursor::Rotate | Cursor::RotateToward(_) => C::Alias,
|
||||
Cursor::Pen | Cursor::PenAdd | Cursor::PenDelete | Cursor::PenClose | Cursor::PenContinue | Cursor::PenJoin | Cursor::PenConvert => {
|
||||
C::Crosshair
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
//! - `ui.dialog.set {field, value}` / `ui.dialog.confirm` / `ui.dialog.cancel`
|
||||
//! - `ui.resize {width, height}`, `ui.focus`, `ui.screenshot {path?, data?}` (`data: true`: the PNG
|
||||
//! comes back as `pngBase64` as well)
|
||||
//! - `ui.render {path?, scale?, data?}`: render the active artboard headlessly (PNG; without a path,
|
||||
//! or with `data: true`, as `pngBase64`)
|
||||
//! - `ui.render {path?, scale?, artboard?, data?}`: render an artboard headlessly (`artboard`: its
|
||||
//! 0-based index, default the first; PNG; without a path, or with `data: true`, as `pngBase64`)
|
||||
//! - `app.open {path}` (any readable format) / `app.save {path?, svg?: {…SVG options}}` / `app.quit`
|
||||
//! (`file.close`, `file.closeAll` and `app.quit` first open a `saveChanges` dialog for each
|
||||
//! modified document: `ui.dialog.confirm` saves, set `discard: true` then confirm to discard)
|
||||
@@ -321,7 +321,9 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
|
||||
"ui.render" => {
|
||||
let Some(st) = app.session.active() else { return err("no document") };
|
||||
let doc = st.doc.clone();
|
||||
let Some(r) = doc.artboards.first().map(|a| a.rect) else { return err("no artboard") };
|
||||
// The artboard asked for (0-based, as headless), else the first.
|
||||
let index = p.get("artboard").and_then(Value::as_u64).map_or(Some(0), |i| usize::try_from(i).ok());
|
||||
let Some(r) = index.and_then(|i| doc.artboards.get(i)).map(|a| a.rect) else { return err("no such artboard") };
|
||||
let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0);
|
||||
if let Err(e) = vectorcraft_render::raster_size(r, scale) {
|
||||
return err(e);
|
||||
@@ -390,3 +392,42 @@ pub fn save_screenshot(app: &mut VectorcraftApp, image: &egui::ColorImage, path:
|
||||
None => json!({"ok": false, "error": "no writer configured"}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use super::ControlRequest;
|
||||
use crate::{Services, VectorcraftApp};
|
||||
|
||||
/// #1004: `ui.render` (the MCP `screenshot` of a connected app) renders the artboard asked
|
||||
/// for, at the scale asked for; the first without one.
|
||||
#[test]
|
||||
fn render_takes_the_artboard_asked_for() {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let mut app = VectorcraftApp::new(Session::new(), Services::default()).with_control(rx);
|
||||
let mut request = |method: &str, params: Value| {
|
||||
let (req, reply) = ControlRequest::new(method, params);
|
||||
tx.send(req).unwrap();
|
||||
app.drain_control(&egui::Context::default());
|
||||
reply.try_recv().unwrap()
|
||||
};
|
||||
let new = json!({"width": 64, "height": 64, "units": "Points", "artboards": 2});
|
||||
request("engine.execute", json!({"command": "file.new", "params": new}));
|
||||
let board = json!({"index": 1, "x": 100, "y": 0, "width": 96, "height": 48});
|
||||
let r = request("engine.execute", json!({"command": "artboard.setProps", "params": board}));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
for (params, size) in [
|
||||
(json!({}), [64, 64]),
|
||||
(json!({"artboard": 0}), [64, 64]),
|
||||
(json!({"artboard": 1}), [96, 48]),
|
||||
(json!({"artboard": 1, "scale": 0.5}), [48, 24]),
|
||||
] {
|
||||
let r = request("ui.render", params.clone());
|
||||
let got = [&r["result"]["width"], &r["result"]["height"]].map(Value::as_u64);
|
||||
assert_eq!(got, size.map(Some), "{params}: {r}");
|
||||
}
|
||||
assert!(request("ui.render", json!({"artboard": 2}))["error"].is_string());
|
||||
}
|
||||
}
|
||||
@@ -113,17 +113,21 @@ fn double_arrow(p: &mut Ink, o: Pos2, dir: egui::Vec2) {
|
||||
}
|
||||
|
||||
/// A curved double arrow over the hotspot: an arc with an arrowhead at each end, pointing on along
|
||||
/// the arc (#701).
|
||||
fn rotate(p: &mut Ink, o: Pos2) {
|
||||
/// the arc (#701). The arc bows up, or toward `octant` (0 up, then clockwise in 45° steps): beside a
|
||||
/// bounding box it wraps the corner the pointer is at.
|
||||
fn rotate(p: &mut Ink, o: Pos2, octant: u8) {
|
||||
const R: f32 = 8.0;
|
||||
let (a0, a1) = (std::f32::consts::PI * 0.12, std::f32::consts::PI * 0.88);
|
||||
let at = |a: f32| o + vec2(a.cos() * R, -a.sin() * R);
|
||||
// Clockwise on screen (y down) by whole octants.
|
||||
let (s, c) = (f32::from(octant % 8) * std::f32::consts::FRAC_PI_4).sin_cos();
|
||||
let turn = |v: Vec2| vec2(v.x * c - v.y * s, v.x * s + v.y * c);
|
||||
let at = |a: f32| o + turn(vec2(a.cos() * R, -a.sin() * R));
|
||||
let pts: Vec<Pos2> = (0..=12).map(|i| at(a0 + (a1 - a0) * i as f32 / 12.0)).collect();
|
||||
p.add(Shape::line(pts.clone(), Stroke::new(3.2, HALO)));
|
||||
p.add(Shape::line(pts, Stroke::new(1.3, INK)));
|
||||
// Each head points away from the arc, along its tangent there.
|
||||
for (a, away) in [(a0, -1.0f32), (a1, 1.0)] {
|
||||
let t = vec2(-a.sin(), -a.cos()) * away;
|
||||
let t = turn(vec2(-a.sin(), -a.cos()) * away);
|
||||
let n = vec2(-t.y, t.x);
|
||||
let (end, tip) = (at(a) - t * 1.5, at(a) + t * 4.5);
|
||||
poly(p, vec![tip, end + n * 1.8, end - n * 1.8], INK, INK);
|
||||
@@ -269,7 +273,8 @@ fn glyph(c: Cursor, p: Pos2) -> Option<Vec<Shape>> {
|
||||
Cursor::ResizeV => double_arrow(ink, p, vec2(0.0, 1.0)),
|
||||
Cursor::ResizeNwSe => double_arrow(ink, p, vec2(1.0, 1.0)),
|
||||
Cursor::ResizeNeSw => double_arrow(ink, p, vec2(1.0, -1.0)),
|
||||
Cursor::Rotate => rotate(ink, p),
|
||||
Cursor::Rotate => rotate(ink, p, 0),
|
||||
Cursor::RotateToward(octant) => rotate(ink, p, octant),
|
||||
Cursor::CornerRadius => corner_radius(ink, p),
|
||||
Cursor::Pen => pen(ink, p, "*"),
|
||||
Cursor::PenAdd => pen(ink, p, "+"),
|
||||
@@ -432,7 +437,7 @@ mod tests {
|
||||
use crate::VectorcraftApp;
|
||||
|
||||
/// Every cursor with a glyph.
|
||||
const GLYPHS: [Cursor; 33] = [
|
||||
const GLYPHS: [Cursor; 34] = [
|
||||
Cursor::Arrow,
|
||||
Cursor::ArrowHollow,
|
||||
Cursor::Move,
|
||||
@@ -442,6 +447,7 @@ mod tests {
|
||||
Cursor::ResizeNwSe,
|
||||
Cursor::ResizeNeSw,
|
||||
Cursor::Rotate,
|
||||
Cursor::RotateToward(1),
|
||||
Cursor::CornerRadius,
|
||||
Cursor::Pen,
|
||||
Cursor::PenAdd,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! The Color Picker (double-click a Fill/Stroke proxy, or `ui.colorPicker`): a colour field with a
|
||||
//! channel slider for H, S, B, R, G or B; HSB, RGB, Lab and CMYK fields and hex; Only Web Colors;
|
||||
//! the out-of-gamut and out-of-web warnings (click to correct); New/Original chips; and a Color
|
||||
//! Swatches list. OK applies the colour to its proxy through `paint.setFill` / `paint.setStroke`.
|
||||
//! Swatches list. With art selected the colour shows on it live as it is picked; OK keeps it as one
|
||||
//! undo step through `paint.setFill` / `paint.setStroke`, Cancel puts the art back.
|
||||
//!
|
||||
//! Fields agents set with `ui.dialog.set`: `hex` (`"00FF00"`) or `color` (any colour the paint
|
||||
//! commands take), `channel` (`hue`, `saturation`, `brightness`, `red`, `green`, `blue`), `webOnly`
|
||||
@@ -15,6 +16,7 @@ use vectorcraft_doc::ColorMode;
|
||||
use vectorcraft_engine::cmd::color_value;
|
||||
|
||||
use super::DialogSpec;
|
||||
use super::form::PREVIEWED;
|
||||
use crate::panels::color::{GAMUT_WARNING, WEB_WARNING, gamut_fix, hex_digits, is_web_safe, parse_hex, warning_chip, web_safe};
|
||||
use crate::panels::{c32, color_json};
|
||||
use crate::state::Dialog;
|
||||
@@ -23,8 +25,15 @@ use crate::{VectorcraftApp, widgets};
|
||||
|
||||
pub(super) const KIND: &str = "colorPicker";
|
||||
|
||||
pub(super) const SPEC: DialogSpec =
|
||||
DialogSpec { heading: |_| tl!("Color Picker").into(), body, confirm, min_width: 600.0, max_width: Some(640.0), ..DialogSpec::FORM };
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec {
|
||||
heading: |_| tl!("Color Picker").into(),
|
||||
body,
|
||||
confirm,
|
||||
preview: true,
|
||||
min_width: 600.0,
|
||||
max_width: Some(640.0),
|
||||
..DialogSpec::FORM
|
||||
};
|
||||
|
||||
/// Side of the colour field (and height of the channel slider).
|
||||
const FIELD: f32 = 240.0;
|
||||
@@ -242,9 +251,39 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
d.fields.insert("swatches".into(), json!(!swatches));
|
||||
}
|
||||
store(d, &pick.unwrap_or(p).snapped(d.bool("webOnly")));
|
||||
preview(app, d);
|
||||
false
|
||||
}
|
||||
|
||||
/// Show the picked colour on the selected art while the picker is open: each new colour replaces
|
||||
/// the last preview. Nothing previews until the colour first changes, nor without a selection
|
||||
/// (the colour then only becomes the default paint, on OK).
|
||||
fn preview(app: &mut VectorcraftApp, d: &mut Dialog) {
|
||||
let (cmd, params) = paint_command(app, d);
|
||||
let first = !app.session.in_interaction();
|
||||
let unchanged = d.fields.get(PREVIEWED) == Some(¶ms) || first && d.fields.get("original").and_then(color_value) == Some(picked(d).color);
|
||||
let selected = app.session.active().is_some_and(|st| !st.selection.objects.is_empty());
|
||||
if unchanged || !selected {
|
||||
return;
|
||||
}
|
||||
let _ = app.session.begin_interaction(if d.bool("stroke") { "Stroke Color" } else { "Fill Color" });
|
||||
if let Err(e) = app.session.preview(cmd, ¶ms) {
|
||||
app.status(e.to_string());
|
||||
}
|
||||
d.fields.insert(PREVIEWED.into(), params);
|
||||
}
|
||||
|
||||
/// The command that applies the picked colour to the proxy, and its params (as CMYK in a CMYK
|
||||
/// document).
|
||||
fn paint_command(app: &VectorcraftApp, d: &Dialog) -> (&'static str, Value) {
|
||||
let mut c = picked(d).color;
|
||||
if matches!(c, Color::Rgb { .. }) && app.session.active().is_some_and(|s| s.doc.color_mode == ColorMode::Cmyk) {
|
||||
let [cy, m, y, k] = c.to_cmyk_managed(cms::active().settings().intent);
|
||||
c = Color::cmyk(cy, m, y, k);
|
||||
}
|
||||
(if d.bool("stroke") { "paint.setStroke" } else { "paint.setFill" }, json!({ "color": color_json(&c) }))
|
||||
}
|
||||
|
||||
/// The New (top) and Original (bottom) chips. Returns true when Original is clicked.
|
||||
fn new_original_chips(ui: &mut Ui, new: &Color, original: &Color) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
@@ -363,15 +402,10 @@ fn swatch_list(app: &VectorcraftApp, ui: &mut Ui, current: &Color) -> Option<Col
|
||||
chosen
|
||||
}
|
||||
|
||||
/// OK: apply the colour to the proxy (as CMYK in a CMYK document).
|
||||
/// OK: apply the colour to the proxy, keeping the live preview as one undo step.
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let mut c = picked(d).color;
|
||||
if matches!(c, Color::Rgb { .. }) && app.session.active().is_some_and(|s| s.doc.color_mode == ColorMode::Cmyk) {
|
||||
let [cy, m, y, k] = c.to_cmyk_managed(cms::active().settings().intent);
|
||||
c = Color::cmyk(cy, m, y, k);
|
||||
}
|
||||
let cmd = if d.bool("stroke") { "paint.setStroke" } else { "paint.setFill" };
|
||||
super::run_and_close(app, cmd, json!({ "color": color_json(&c) }))
|
||||
let (cmd, params) = paint_command(app, d);
|
||||
super::form::commit_preview(app, cmd, params)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -453,6 +487,34 @@ mod tests {
|
||||
assert_eq!(gamut_fix(&mut app, &ctx, "picker-gamut", &Color::rgb8(128, 128, 128)), None);
|
||||
}
|
||||
|
||||
/// The selected art takes each colour as it is picked; OK keeps the last as one undo step and
|
||||
/// Cancel puts the art back.
|
||||
#[test]
|
||||
fn the_selection_shows_the_colour_live() {
|
||||
let mut app = app();
|
||||
let undo = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len();
|
||||
let before = undo(&app);
|
||||
app.run("ui.colorPicker", json!({})).unwrap();
|
||||
frame(&mut app);
|
||||
assert!(!app.session.in_interaction(), "nothing previews before the colour changes");
|
||||
for hex in ["ff0000", "00ff00"] {
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!(hex));
|
||||
frame(&mut app);
|
||||
assert_eq!(fill_hex(&app), format!("#{hex}"), "live, before OK");
|
||||
}
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(fill_hex(&app), "#00ff00");
|
||||
assert_eq!(undo(&app), before + 1, "one step");
|
||||
// Cancel: back to the colour it had.
|
||||
app.run("ui.colorPicker", json!({})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("0000ff"));
|
||||
frame(&mut app);
|
||||
assert_eq!(fill_hex(&app), "#0000ff");
|
||||
super::super::cancel(&mut app);
|
||||
assert_eq!(fill_hex(&app), "#00ff00");
|
||||
assert_eq!(undo(&app), before + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_set_hex_and_confirm_fills() {
|
||||
let mut app = app();
|
||||
|
||||
@@ -248,7 +248,7 @@ pub(super) fn param_fields(
|
||||
match v {
|
||||
Value::Number(n) => {
|
||||
if let Some(x) = crate::widgets::plain_field(ui, ("fx-num", &k), n.as_f64().unwrap_or(0.0), "", 3, 140.0) {
|
||||
d.fields.insert(k, json!(x));
|
||||
d.fields.insert(k, typed_number(x));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
@@ -291,6 +291,13 @@ pub(super) fn param_fields(
|
||||
changed
|
||||
}
|
||||
|
||||
/// A number typed in a command's dialog as JSON: a whole number as an integer, as counts are read
|
||||
/// (`as_u64`: Object Mosaic's columns and rows, #1000), which `12.0` isn't; a fraction stays one.
|
||||
/// Commands that take any number read either.
|
||||
fn typed_number(x: f64) -> Value {
|
||||
if x.fract() == 0.0 && x.abs() < 1e15 { json!(x as i64) } else { json!(x) }
|
||||
}
|
||||
|
||||
/// Editor for a plug-in's parameters from its schema (plug-in filter and effect dialogs): numbers
|
||||
/// within their range, whole numbers, checkboxes and dropdowns, in declaration order. Returns
|
||||
/// true when a value changed. The labels (from the parameter keys) and choices are the plug-in's
|
||||
@@ -552,3 +559,19 @@ pub(super) fn caption(ui: &mut egui::Ui, text: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.label(egui::RichText::new(tl!(text)).size(11.0).color(t.text_dim));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod typed_number_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn whole_numbers_are_integers_and_fractions_stay_fractions() {
|
||||
// Whatever the field held before: a count typed after a fraction is a count again.
|
||||
assert_eq!(typed_number(24.0).as_u64(), Some(24));
|
||||
assert_eq!(typed_number(-4.0), json!(-4));
|
||||
assert_eq!(typed_number(0.0), json!(0));
|
||||
assert_eq!(typed_number(2.5), json!(2.5));
|
||||
// Numbers past exact integers stay floats.
|
||||
assert!(typed_number(1e20).is_f64());
|
||||
}
|
||||
}
|
||||
@@ -434,6 +434,38 @@ fn a_dialog_opens_with_its_first_field_focused_so_typing_and_enter_apply() {
|
||||
assert_eq!(node.appearance.effects.last().map(|e| e.params["scaleH"].clone()), Some(json!(50.0)));
|
||||
}
|
||||
|
||||
/// #1000: a count typed in Object › Create Object Mosaic… reaches the command (a whole number typed
|
||||
/// in a command's dialog is an integer), so the mosaic has that many columns, not the 10 × 10 grid.
|
||||
#[test]
|
||||
fn object_mosaic_makes_the_grid_typed_in_its_dialog() {
|
||||
let mut app = app();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 60, "height": 40})).unwrap();
|
||||
app.run("object.rasterize", json!({"ppi": 72})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
theme::apply(&ctx, Default::default());
|
||||
let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
|
||||
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
let entry = crate::menus::menu_entries(&app).into_iter().find(|e| e.label == "Create Object Mosaic…").expect("menu item");
|
||||
let (cmd, params) = crate::menus::click_target(&entry.label, entry.command.as_deref().unwrap(), &entry.params);
|
||||
crate::menus::invoke(&mut app, &cmd, params);
|
||||
// Its first field, Columns, is focused: type 3 over it and press Enter.
|
||||
frame(&mut app, vec![]);
|
||||
frame(&mut app, vec![egui::Event::Text("3".into())]);
|
||||
let enter = egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
|
||||
frame(&mut app, vec![enter]);
|
||||
assert!(app.ui.dialog.is_none());
|
||||
let mut tiles = None;
|
||||
app.session.doc().unwrap().doc.walk(|n| {
|
||||
if n.name.as_deref() == Some("Object Mosaic") {
|
||||
tiles = n.children().map(|c| c.len());
|
||||
}
|
||||
});
|
||||
assert_eq!(tiles, Some(3 * 10));
|
||||
}
|
||||
|
||||
/// #793: Artboard Options lists its fields as Name, Width, Height, not by key.
|
||||
#[test]
|
||||
fn artboard_options_lists_name_then_width_and_height() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! The right dock: Properties | Layers | Libraries tabs, plus the collapsed icon-panel column
|
||||
//! whose panels pop out to the left. The double arrow at the top of the dock collapses the tabbed
|
||||
//! whose panels pop out to the left, and left of it the floating sets docked as columns
|
||||
//! ([`floating::docked_columns`]). The double arrow at the top of the dock collapses the tabbed
|
||||
//! group to icons at the top of the icon column (`window.collapseDock`); the arrow above the column
|
||||
//! then expands it again.
|
||||
|
||||
@@ -23,6 +24,12 @@ fn column_left_id() -> egui::Id {
|
||||
egui::Id::new("dock-icon-column-left")
|
||||
}
|
||||
|
||||
/// Where the dock's left edge was drawn last: the icon column's, or the leftmost dock column's
|
||||
/// (egui temp memory, one frame old). Popped-out panels open left of it.
|
||||
fn dock_left_id() -> egui::Id {
|
||||
egui::Id::new("dock-left")
|
||||
}
|
||||
|
||||
/// Collapse the tabbed group to icons or expand it again (`window.collapseDock`). Expanding while
|
||||
/// one of its panels is popped out shows that panel's tab instead.
|
||||
pub fn set_collapsed(app: &mut VectorcraftApp, collapsed: bool) {
|
||||
@@ -148,7 +155,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
});
|
||||
let column = column.response.rect;
|
||||
let dock = group.map_or(column, |g| g.union(column));
|
||||
// Floating sets docked as columns, left of the icon column.
|
||||
let left = floating::docked_columns(app, ui).iter().map(|r| r.left()).fold(column.left(), f32::min);
|
||||
ui.ctx().data_mut(|d| {
|
||||
d.insert_temp(dock_left_id(), left);
|
||||
d.insert_temp(column_left_id(), column.left());
|
||||
d.insert_temp(floating::dock_rect_id(), dock);
|
||||
d.insert_temp(floating::icons_rect_id(), column);
|
||||
@@ -226,7 +236,9 @@ pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
} else {
|
||||
screen.right()
|
||||
};
|
||||
let right = column - 6.0;
|
||||
let left = ctx.data(|d| d.get_temp::<f32>(dock_left_id())).filter(|x| x.is_finite() && *x > screen.left() && *x <= column).unwrap_or(column);
|
||||
let right = if app.ui.dock && app.ui.screen_mode < 3 { left } else { column };
|
||||
let right = right - 6.0;
|
||||
// The tabbed group's panels fill their height (Layers) or scroll (Properties): give them one
|
||||
// that keeps the flyout on screen.
|
||||
let width = panel_width(id);
|
||||
|
||||
+917
-122
File diff suppressed because it is too large.
Load diff
@@ -628,6 +628,7 @@
|
||||
% of all commits % všech commitů
|
||||
Lines +/− Řádky +/−
|
||||
Drag to move Přetažením přesunete
|
||||
Drag to move out of the set Přetažením přesunete mimo sadu
|
||||
Drag to float as a toolbar Přetažením vytvoříte plovoucí panel nástrojů
|
||||
Put back in the toolbar Vrátit do panelu nástrojů
|
||||
Float Panel Plovoucí panel
|
||||
@@ -731,3 +732,5 @@ lighting Light Světlo
|
||||
Spatter… Rozstřik…
|
||||
Sprayed Strokes… Stříkané tahy…
|
||||
Sumi-e… Sumi-e…
|
||||
Vertical Guide Svislé vodítko
|
||||
Horizontal Guide Vodorovné vodítko
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -362,6 +362,9 @@
|
||||
Position Panel Above Text Bedienfeld über dem Text positionieren
|
||||
Clear Appearance Aussehen löschen
|
||||
Clear Guides Hilfslinien löschen
|
||||
Vertical Guide Vertikale Hilfslinie
|
||||
Horizontal Guide Horizontale Hilfslinie
|
||||
Move Guide to Layer Hilfslinie auf Ebene verschieben
|
||||
Clear Overrides Abweichungen löschen
|
||||
Clear Recent Files Liste der zuletzt geöffneten Dateien leeren
|
||||
Click Klicken
|
||||
@@ -3213,6 +3216,7 @@ axis Vertical Vertikal
|
||||
axis Both Beide
|
||||
theme Appearance Erscheinungsbild
|
||||
Drag to move Zum Verschieben ziehen
|
||||
Drag to move out of the set Ziehen, um aus dem Satz zu lösen
|
||||
Drag to float as a toolbar Ziehen, um als eigene Werkzeugleiste abzulösen
|
||||
Put back in the toolbar Zurück in die Werkzeugleiste setzen
|
||||
Float Panel Bedienfeld ablösen
|
||||
@@ -3656,6 +3660,8 @@ theme Appearance Erscheinungsbild
|
||||
@msg the image's pixels don't match its size die Pixel des Bilds passen nicht zu seiner Größe
|
||||
@msg the layer has no objects to clip by die Ebene hat keine Objekte, an denen beschnitten werden könnte
|
||||
@msg the layer or group is locked die Ebene oder Gruppe ist gesperrt
|
||||
@msg the guide is on a hidden or locked layer die Hilfslinie liegt auf einer ausgeblendeten oder gesperrten Ebene
|
||||
@msg the target layer is locked or hidden die Zielebene ist gesperrt oder ausgeblendet
|
||||
@msg the layer to flatten into is hidden die Ebene, auf die reduziert werden soll, ist ausgeblendet
|
||||
@msg the object already has an opacity mask das Objekt hat bereits eine Deckkraftmaske
|
||||
@msg the object has no geometry das Objekt hat keine Geometrie
|
||||
@@ -3680,7 +3686,7 @@ theme Appearance Erscheinungsbild
|
||||
@msg the {tool} tool has no options das Werkzeug „{tool}“ hat keine Optionen
|
||||
@msg there are no objects to release es gibt keine Objekte zum Zurückwandeln
|
||||
@msg there are no printer settings to open here hier lassen sich keine Druckereinstellungen öffnen
|
||||
@msg this PostScript file can't be read ({_1}): save it as PDF or SVG in the app that made it diese PostScript-Datei kann nicht gelesen werden ({_1}): Speichern Sie sie in der App, mit der sie erstellt wurde, als PDF oder SVG
|
||||
@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it diese PostScript-Datei kann nicht gelesen werden ({why}): Speichern Sie sie in der App, mit der sie erstellt wurde, als PDF oder SVG
|
||||
@msg this file's PostScript couldn't be read ({why}): {what} das PostScript dieser Datei konnte nicht gelesen werden ({why}): {what}
|
||||
@msg this format holds one artboard: export several as PDF or with document.exportForScreens dieses Format fasst eine Zeichenfläche: Exportieren Sie mehrere als PDF oder mit document.exportForScreens
|
||||
@msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that dies ist eine DWG-Zeichnung, die nicht gelesen werden kann: Speichern Sie sie in der CAD-App als ASCII-DXF und öffnen Sie diese Datei
|
||||
@@ -3768,6 +3774,9 @@ theme Appearance Erscheinungsbild
|
||||
@msg tool (a tool id) is required tool (eine Werkzeug-ID) ist erforderlich
|
||||
@msg {tool} isn't in a toolbar group of several tools {tool} gehört zu keiner Werkzeuggruppe mit mehreren Werkzeugen
|
||||
@msg onto must name a panel floating in another group onto muss ein Bedienfeld nennen, das in einer anderen Gruppe schwebt
|
||||
@msg below and above must name a panel floating in another group below und above müssen ein Bedienfeld nennen, das in einer anderen Gruppe schwebt
|
||||
@msg give only one of onto, below and above nur eines von onto, below und above angeben
|
||||
@msg column must be true or a number from 1 column muss true oder eine Zahl ab 1 sein
|
||||
@msg x and y must both be numbers x und y müssen beide Zahlen sein
|
||||
@msg "{0}" is not a colour: write it as #rrggbb "{0}" ist keine Farbe: Schreiben Sie sie als #rrggbb
|
||||
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead dies ist kein Logo aus flächigen Farben ({_1} % seiner einfarbigen Bereiche entsprechen wenigen flächigen Farben), daher würde der Modus „Flaches Logo“ seine Verläufe in Flecken zerlegen: Verwenden Sie stattdessen den Modus „Farbe“
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3317,6 +3317,7 @@ axis Horizontal Horizontal
|
||||
axis Vertical Vertical
|
||||
axis Both Ambos
|
||||
Drag to move Arrastrar para mover
|
||||
Drag to move out of the set Arrastrar para sacar del conjunto
|
||||
Drag to float as a toolbar Arrastrar para flotar como barra de herramientas
|
||||
Put back in the toolbar Devolver a la barra de herramientas
|
||||
Float Panel Hacer flotar el panel
|
||||
@@ -3759,6 +3760,8 @@ axis Both Ambos
|
||||
@msg the image's pixels don't match its size los píxeles de la imagen no coinciden con su tamaño
|
||||
@msg the layer has no objects to clip by la capa no tiene objetos con los que recortar
|
||||
@msg the layer or group is locked la capa o el grupo está bloqueado
|
||||
@msg the guide is on a hidden or locked layer la guía está en una capa oculta o bloqueada
|
||||
@msg the target layer is locked or hidden la capa de destino está bloqueada u oculta
|
||||
@msg the layer to flatten into is hidden la capa en la que acoplar está oculta
|
||||
@msg the object already has an opacity mask el objeto ya tiene una máscara de opacidad
|
||||
@msg the object has no geometry el objeto no tiene geometría
|
||||
@@ -3783,7 +3786,7 @@ axis Both Ambos
|
||||
@msg the {tool} tool has no options la herramienta {tool} no tiene opciones
|
||||
@msg there are no objects to release no hay objetos que soltar
|
||||
@msg there are no printer settings to open here aquí no hay ajustes de impresora que abrir
|
||||
@msg this PostScript file can't be read ({_1}): save it as PDF or SVG in the app that made it no se puede leer este archivo PostScript ({_1}): guárdalo como PDF o SVG en la aplicación que lo creó
|
||||
@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it no se puede leer este archivo PostScript ({why}): guárdalo como PDF o SVG en la aplicación que lo creó
|
||||
@msg this file's PostScript couldn't be read ({why}): {what} no se pudo leer el PostScript de este archivo ({why}): {what}
|
||||
@msg this format holds one artboard: export several as PDF or with document.exportForScreens este formato admite una sola mesa de trabajo: exporta varias como PDF o con document.exportForScreens
|
||||
@msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that este es un dibujo DWG, que no se puede leer: guárdalo desde la aplicación de CAD como DXF ASCII y abre ese archivo
|
||||
@@ -3872,6 +3875,9 @@ axis Both Ambos
|
||||
@msg tool (a tool id) is required se requiere tool (un id de herramienta)
|
||||
@msg {tool} isn't in a toolbar group of several tools {tool} no está en un grupo de la barra de herramientas con varias herramientas
|
||||
@msg onto must name a panel floating in another group onto debe nombrar un panel que flote en otro grupo
|
||||
@msg below and above must name a panel floating in another group below y above deben nombrar un panel que flote en otro grupo
|
||||
@msg give only one of onto, below and above indica solo uno de onto, below y above
|
||||
@msg column must be true or a number from 1 column debe ser true o un número desde 1
|
||||
@msg x and y must both be numbers x e y deben ser números
|
||||
@msg "{0}" is not a colour: write it as #rrggbb "{0}" no es un color: escríbelo como #rrggbb
|
||||
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead esto no es un logotipo de colores planos ({_1}% de sus zonas sólidas coinciden con unos pocos colores planos), así que el modo Logotipo plano convertiría sus degradados en manchas: usa el modo Color
|
||||
@@ -4092,3 +4098,6 @@ lighting Light Luz
|
||||
Stroke Pressure Presión de pincelada
|
||||
Right Diagonal Diagonal derecha
|
||||
Left Diagonal Diagonal izquierda
|
||||
Vertical Guide Guía vertical
|
||||
Horizontal Guide Guía horizontal
|
||||
Move Guide to Layer Mover guía a capa
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3314,6 +3314,7 @@ axis Horizontal Horizontal
|
||||
axis Vertical Vertical
|
||||
axis Both Les deux
|
||||
Drag to move Faire glisser pour déplacer
|
||||
Drag to move out of the set Faire glisser pour sortir de l’ensemble
|
||||
Drag to float as a toolbar Faire glisser pour détacher en barre d’outils flottante
|
||||
Put back in the toolbar Remettre dans la barre d’outils
|
||||
Float Panel Rendre le panneau flottant
|
||||
@@ -3756,6 +3757,8 @@ axis Both Les deux
|
||||
@msg the image's pixels don't match its size les pixels de l’image ne correspondent pas à sa taille
|
||||
@msg the layer has no objects to clip by le calque n’a aucun objet servant à l’écrêtage
|
||||
@msg the layer or group is locked le calque ou le groupe est verrouillé
|
||||
@msg the guide is on a hidden or locked layer le repère se trouve sur un calque masqué ou verrouillé
|
||||
@msg the target layer is locked or hidden le calque cible est verrouillé ou masqué
|
||||
@msg the layer to flatten into is hidden le calque de destination de l’aplatissement est masqué
|
||||
@msg the object already has an opacity mask l’objet a déjà un masque d’opacité
|
||||
@msg the object has no geometry l’objet n’a pas de géométrie
|
||||
@@ -3780,7 +3783,7 @@ axis Both Les deux
|
||||
@msg the {tool} tool has no options l’outil {tool} n’a pas d’options
|
||||
@msg there are no objects to release il n’y a aucun objet à libérer
|
||||
@msg there are no printer settings to open here il n’y a pas de réglages d’imprimante à ouvrir ici
|
||||
@msg this PostScript file can't be read ({_1}): save it as PDF or SVG in the app that made it impossible de lire ce fichier PostScript ({_1}) : enregistrez-le au format PDF ou SVG dans l’application qui l’a créé
|
||||
@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it impossible de lire ce fichier PostScript ({why}) : enregistrez-le au format PDF ou SVG dans l’application qui l’a créé
|
||||
@msg this file's PostScript couldn't be read ({why}): {what} impossible de lire le PostScript de ce fichier ({why}) : {what}
|
||||
@msg this format holds one artboard: export several as PDF or with document.exportForScreens ce format ne contient qu’un plan de travail : exportez-en plusieurs au format PDF ou avec document.exportForScreens
|
||||
@msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that c’est un dessin DWG, qui ne peut pas être lu : enregistrez-le depuis l’application de CAO au format DXF ASCII et ouvrez ce fichier
|
||||
@@ -3869,6 +3872,9 @@ axis Both Les deux
|
||||
@msg tool (a tool id) is required tool (un identifiant d’outil) est obligatoire
|
||||
@msg {tool} isn't in a toolbar group of several tools {tool} ne fait pas partie d’un groupe de plusieurs outils dans la barre d’outils
|
||||
@msg onto must name a panel floating in another group onto doit désigner un panneau flottant dans un autre groupe
|
||||
@msg below and above must name a panel floating in another group below et above doivent désigner un panneau flottant dans un autre groupe
|
||||
@msg give only one of onto, below and above indiquez un seul de onto, below et above
|
||||
@msg column must be true or a number from 1 column doit être true ou un nombre à partir de 1
|
||||
@msg x and y must both be numbers x et y doivent tous deux être des nombres
|
||||
@msg "{0}" is not a colour: write it as #rrggbb « {0} » n’est pas une couleur : écrivez-la sous la forme #rrggbb
|
||||
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead ce n’est pas un logo en aplats ({_1} % de ses zones pleines correspondent à quelques couleurs unies), donc le mode Logo en aplats transformerait ses dégradés en taches : utilisez le mode Couleur
|
||||
@@ -4102,3 +4108,6 @@ lighting Light Lumière
|
||||
Stroke Pressure Pression des traits
|
||||
Right Diagonal Diagonale droite
|
||||
Left Diagonal Diagonale gauche
|
||||
Vertical Guide Repère vertical
|
||||
Horizontal Guide Repère horizontal
|
||||
Move Guide to Layer Déplacer le repère vers le calque
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3317,6 +3317,7 @@ axis Horizontal Orizzontale
|
||||
axis Vertical Verticale
|
||||
axis Both Entrambi
|
||||
Drag to move Trascina per spostare
|
||||
Drag to move out of the set Trascina per spostare fuori dall’insieme
|
||||
Drag to float as a toolbar Trascina per rendere mobile come barra degli strumenti
|
||||
Put back in the toolbar Riporta nella barra degli strumenti
|
||||
Float Panel Rendi mobile pannello
|
||||
@@ -3759,6 +3760,8 @@ axis Both Entrambi
|
||||
@msg the image's pixels don't match its size i pixel dell'immagine non corrispondono alle sue dimensioni
|
||||
@msg the layer has no objects to clip by il livello non ha oggetti con cui ritagliare
|
||||
@msg the layer or group is locked il livello o il gruppo è bloccato
|
||||
@msg the guide is on a hidden or locked layer la guida si trova su un livello nascosto o bloccato
|
||||
@msg the target layer is locked or hidden il livello di destinazione è bloccato o nascosto
|
||||
@msg the layer to flatten into is hidden il livello in cui appiattire è nascosto
|
||||
@msg the object already has an opacity mask l'oggetto ha già una maschera di opacità
|
||||
@msg the object has no geometry l'oggetto non ha geometria
|
||||
@@ -3783,7 +3786,7 @@ axis Both Entrambi
|
||||
@msg the {tool} tool has no options lo strumento {tool} non ha opzioni
|
||||
@msg there are no objects to release non ci sono oggetti da rilasciare
|
||||
@msg there are no printer settings to open here non ci sono impostazioni della stampante da aprire qui
|
||||
@msg this PostScript file can't be read ({_1}): save it as PDF or SVG in the app that made it impossibile leggere questo file PostScript ({_1}): salvalo come PDF o SVG nell'app che lo ha creato
|
||||
@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it impossibile leggere questo file PostScript ({why}): salvalo come PDF o SVG nell'app che lo ha creato
|
||||
@msg this file's PostScript couldn't be read ({why}): {what} impossibile leggere il PostScript di questo file ({why}): {what}
|
||||
@msg this format holds one artboard: export several as PDF or with document.exportForScreens questo formato contiene una sola tavola da disegno: esportane più di una come PDF o con document.exportForScreens
|
||||
@msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that questo è un disegno DWG, che non può essere letto: salvalo dall'app CAD come DXF ASCII e apri quel file
|
||||
@@ -3872,6 +3875,9 @@ axis Both Entrambi
|
||||
@msg tool (a tool id) is required tool (un id di strumento) è obbligatorio
|
||||
@msg {tool} isn't in a toolbar group of several tools {tool} non fa parte di un gruppo di più strumenti nella barra degli strumenti
|
||||
@msg onto must name a panel floating in another group onto deve indicare un pannello mobile in un altro gruppo
|
||||
@msg below and above must name a panel floating in another group below e above devono indicare un pannello mobile in un altro gruppo
|
||||
@msg give only one of onto, below and above indica solo uno tra onto, below e above
|
||||
@msg column must be true or a number from 1 column deve essere true o un numero da 1
|
||||
@msg x and y must both be numbers x e y devono essere entrambi numeri
|
||||
@msg "{0}" is not a colour: write it as #rrggbb "{0}" non è un colore: scrivilo come #rrggbb
|
||||
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead questo non è un logo a colori piatti ({_1}% delle sue aree piene corrisponde a pochi colori piatti), quindi la modalità Logo piatto trasformerebbe i suoi sfumati in macchie: usa la modalità Colore
|
||||
@@ -4094,3 +4100,6 @@ lighting Light Luce
|
||||
Stroke Pressure Pressione pennellata
|
||||
Right Diagonal Diagonale destra
|
||||
Left Diagonal Diagonale sinistra
|
||||
Vertical Guide Guida verticale
|
||||
Horizontal Guide Guida orizzontale
|
||||
Move Guide to Layer Sposta guida nel livello
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3265,6 +3265,7 @@ axis Horizontal 水平方向
|
||||
axis Vertical 垂直方向
|
||||
axis Both 両方
|
||||
Drag to move 移動するにはドラッグ
|
||||
Drag to move out of the set ドラッグしてセットから出す
|
||||
Drag to float as a toolbar ドラッグしてツールバーとして浮かせる
|
||||
Put back in the toolbar ツールバーに戻す
|
||||
Float Panel パネルをフローティング
|
||||
@@ -3497,6 +3498,8 @@ theme Appearance 外観
|
||||
@msg no selection bounds 選択範囲に境界がありません
|
||||
@msg the layer has no objects to clip by レイヤーにクリッピングマスクとして使えるオブジェクトがありません
|
||||
@msg the layer or group is locked レイヤーまたはグループがロックされています
|
||||
@msg the guide is on a hidden or locked layer ガイドが非表示またはロックされたレイヤー上にあります
|
||||
@msg the target layer is locked or hidden ターゲットレイヤーがロックされているか非表示です
|
||||
@msg the layer to flatten into is hidden 統合先のレイヤーが非表示になっています
|
||||
@msg there are no objects to release レイヤーに分配するオブジェクトがありません
|
||||
@msg no library `{id}` ライブラリ `{id}` が見つかりません
|
||||
@@ -3633,7 +3636,7 @@ theme Appearance 外観
|
||||
@msg shadings of an unknown type were left out 種類が不明なシェーディングは除外されました
|
||||
@msg shadings whose functions are of an unknown type are filled with their middle colour 関数の種類が不明なシェーディングは中間の色で塗りつぶされています
|
||||
@msg the file's preview header points outside the file ファイルのプレビューヘッダーがファイルの範囲外を指しています
|
||||
@msg this PostScript file can't be read ({_1}): save it as PDF or SVG in the app that made it この PostScript ファイルは読み込めません ({_1}): 作成したアプリで PDF または SVG として保存してください
|
||||
@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it この PostScript ファイルは読み込めません ({why}): 作成したアプリで PDF または SVG として保存してください
|
||||
@msg this is not a PostScript file PostScript ファイルではありません
|
||||
@msg a PostScript error ({name} in `{op}`) PostScript エラー (`{op}` での {name})
|
||||
@msg a PostScript error ({name} in `{op}`), in {procs} {procs} 内の PostScript エラー (`{op}` での {name})
|
||||
@@ -3795,6 +3798,9 @@ theme Appearance 外観
|
||||
@msg give the path `id` and the width point `index` パスの `id` と幅ポイントの `index` を指定してください
|
||||
@msg no such width point 指定された幅ポイントが見つかりません
|
||||
@msg onto must name a panel floating in another group onto には、別のグループでフローティングしているパネルを指定してください
|
||||
@msg below and above must name a panel floating in another group below と above には別のグループでフローティングしているパネルを指定してください
|
||||
@msg give only one of onto, below and above onto、below、above のうち 1 つだけを指定してください
|
||||
@msg column must be true or a number from 1 column には true または 1 以上の数値を指定してください
|
||||
@msg unknown panel `{raw}` `{raw}` は不明なパネルです
|
||||
@msg x and y must both be numbers x と y はどちらも数値で指定してください
|
||||
@msg Downloaded {what} {what} をダウンロードしました
|
||||
@@ -4087,3 +4093,6 @@ lighting Light ライト
|
||||
Stroke Pressure ストロークの筆圧
|
||||
Right Diagonal 右斜め
|
||||
Left Diagonal 左斜め
|
||||
Vertical Guide 垂直ガイド
|
||||
Horizontal Guide 水平ガイド
|
||||
Move Guide to Layer ガイドをレイヤーへ移動
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2697,6 +2697,7 @@
|
||||
% of all commits % de todos os commits
|
||||
Lines +/− Linhas +/−
|
||||
Drag to move Arraste para mover
|
||||
Drag to move out of the set Arraste para tirar do conjunto
|
||||
Drag to float as a toolbar Arraste para flutuar como barra de ferramentas
|
||||
Put back in the toolbar Devolver à barra de ferramentas
|
||||
Float Panel Flutuar Painel
|
||||
@@ -2789,3 +2790,5 @@ lighting Light Luz
|
||||
Spatter… Respingo…
|
||||
Sprayed Strokes… Traços Borrifados…
|
||||
Sumi-e… Sumi-e…
|
||||
Vertical Guide Guia vertical
|
||||
Horizontal Guide Guia horizontal
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3313,6 +3313,7 @@ axis Horizontal Горизонтально
|
||||
axis Vertical Вертикально
|
||||
axis Both Оба
|
||||
Drag to move Перетащите для перемещения
|
||||
Drag to move out of the set Перетащите, чтобы вынести из набора
|
||||
Drag to float as a toolbar Перетащите, чтобы сделать плавающей панелью инструментов
|
||||
Put back in the toolbar Вернуть на панель инструментов
|
||||
Float Panel Сделать панель плавающей
|
||||
@@ -3755,6 +3756,8 @@ axis Both Оба
|
||||
@msg the image's pixels don't match its size пиксели изображения не соответствуют его размеру
|
||||
@msg the layer has no objects to clip by в слое нет объектов для обрезки
|
||||
@msg the layer or group is locked слой или группа заблокированы
|
||||
@msg the guide is on a hidden or locked layer направляющая находится на скрытом или заблокированном слое
|
||||
@msg the target layer is locked or hidden целевой слой заблокирован или скрыт
|
||||
@msg the layer to flatten into is hidden слой для объединения скрыт
|
||||
@msg the object already has an opacity mask у объекта уже есть непрозрачная маска
|
||||
@msg the object has no geometry у объекта нет геометрии
|
||||
@@ -3779,7 +3782,7 @@ axis Both Оба
|
||||
@msg the {tool} tool has no options инструмент {tool} не имеет параметров
|
||||
@msg there are no objects to release нет объектов для освобождения
|
||||
@msg there are no printer settings to open here здесь нет настроек принтера для открытия
|
||||
@msg this PostScript file can't be read ({_1}): save it as PDF or SVG in the app that made it этот файл PostScript невозможно прочитать ({_1}): сохраните его как PDF или SVG в приложении, которое его создало
|
||||
@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it этот файл PostScript невозможно прочитать ({why}): сохраните его как PDF или SVG в приложении, которое его создало
|
||||
@msg this file's PostScript couldn't be read ({why}): {what} PostScript этого файла невозможно прочитать ({why}): {what}
|
||||
@msg this format holds one artboard: export several as PDF or with document.exportForScreens этот формат содержит одну монтажную область: экспортируйте несколько как PDF или с помощью document.exportForScreens
|
||||
@msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that это чертёж DWG, который невозможно прочитать: сохраните его из приложения CAD как ASCII DXF и откройте его
|
||||
@@ -3868,6 +3871,9 @@ axis Both Оба
|
||||
@msg tool (a tool id) is required требуется tool (идентификатор инструмента)
|
||||
@msg {tool} isn't in a toolbar group of several tools {tool} не входит в группу панели инструментов из нескольких инструментов
|
||||
@msg onto must name a panel floating in another group onto должен называть панель, плавающую в другой группе
|
||||
@msg below and above must name a panel floating in another group below и above должны называть панель, плавающую в другой группе
|
||||
@msg give only one of onto, below and above укажите только один из onto, below и above
|
||||
@msg column must be true or a number from 1 column должен быть true или числом от 1
|
||||
@msg x and y must both be numbers x и y должны быть числами
|
||||
@msg "{0}" is not a colour: write it as #rrggbb «{0}» не является цветом: запишите его как #rrggbb
|
||||
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead это не логотип из плоских цветов ({_1}% его сплошных областей совпадает с несколькими плоскими цветами), поэтому режим «Плоский логотип» превратил бы градиенты в пятна: используйте режим «Цвет»
|
||||
@@ -4090,3 +4096,6 @@ lighting Light Свет
|
||||
Stroke Pressure Нажим мазка
|
||||
Right Diagonal Диагональ вправо
|
||||
Left Diagonal Диагональ влево
|
||||
Vertical Guide Вертикальная направляющая
|
||||
Horizontal Guide Горизонтальная направляющая
|
||||
Move Guide to Layer Переместить направляющую на слой
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3305,6 +3305,7 @@ axis Horizontal Горизонтальна
|
||||
axis Vertical Вертикальна
|
||||
axis Both Обидві
|
||||
Drag to move Перетягніть для переміщення
|
||||
Drag to move out of the set Перетягніть, щоб винести з набору
|
||||
Drag to float as a toolbar Перетягніть, щоб від’єднати панель інструментів
|
||||
Put back in the toolbar Повернути на панель інструментів
|
||||
Float Panel Від’єднати панель
|
||||
@@ -3742,6 +3743,8 @@ axis Both Обидві
|
||||
@msg the image's pixels don't match its size пікселі зображення не відповідають його розміру
|
||||
@msg the layer has no objects to clip by у шарі немає об’єктів для обрізки
|
||||
@msg the layer or group is locked шар чи група заблоковані
|
||||
@msg the guide is on a hidden or locked layer напрямна розташована на прихованому або заблокованому шарі
|
||||
@msg the target layer is locked or hidden цільовий шар заблокований або прихований
|
||||
@msg the layer to flatten into is hidden шар для об’єднання прихований
|
||||
@msg the object already has an opacity mask об’єкт вже має маску непрозорості.
|
||||
@msg the object has no geometry об’єкт не має геометрії
|
||||
@@ -3766,7 +3769,7 @@ axis Both Обидві
|
||||
@msg the {tool} tool has no options інструмент {tool} не має параметрів
|
||||
@msg there are no objects to release немає об’єктів для звільнення
|
||||
@msg there are no printer settings to open here тут немає налаштувань принтера для відкриття
|
||||
@msg this PostScript file can't be read ({_1}): save it as PDF or SVG in the app that made it цей файл PostScript неможливо прочитати ({_1}): збережіть його як PDF або SVG у додатку, який його створив
|
||||
@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it цей файл PostScript неможливо прочитати ({why}): збережіть його як PDF або SVG у додатку, який його створив
|
||||
@msg this file's PostScript couldn't be read ({why}): {what} PostScript цього файлу неможливо прочитати ({why}): {what}
|
||||
@msg this format holds one artboard: export several as PDF or with document.exportForScreens цей формат містить одну монтажну область: експортуйте кілька як PDF або за допомогою document.exportForScreens
|
||||
@msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that це креслення DWG, яке неможливо прочитати: збережіть його з CAD як ASCII DXF і відкрийте його
|
||||
@@ -3855,6 +3858,9 @@ axis Both Обидві
|
||||
@msg tool (a tool id) is required потрібно tool (ідентифікатор інструменту)
|
||||
@msg {tool} isn't in a toolbar group of several tools {tool} не входить до групи панелі інструментів з кількох інструментів
|
||||
@msg onto must name a panel floating in another group onto має називати панель, що плаває в іншій групі
|
||||
@msg below and above must name a panel floating in another group below і above мають називати панель, що плаває в іншій групі
|
||||
@msg give only one of onto, below and above вкажіть лише один із onto, below і above
|
||||
@msg column must be true or a number from 1 column має бути true або числом від 1
|
||||
@msg x and y must both be numbers x і y мають бути числами
|
||||
@msg "{0}" is not a colour: write it as #rrggbb "{0}" не є кольором: запишіть його як #rrggbb
|
||||
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead це не логотип із плоских кольорів ({_1}% його суцільних областей збігається з кількома плоскими кольорами), тому режим «Плоский логотип» перетворив би градієнти на плями: використовуйте режим «Колір»
|
||||
@@ -4073,3 +4079,6 @@ lighting Light Світло
|
||||
Stroke Pressure Натиск мазка
|
||||
Right Diagonal Діагональ праворуч
|
||||
Left Diagonal Діагональ ліворуч
|
||||
Vertical Guide Вертикальна напрямна
|
||||
Horizontal Guide Горизонтальна напрямна
|
||||
Move Guide to Layer Перемістити напрямну на шар
|
||||
|
Can't render this file because it contains an unexpected character in line 659 and column 40.
|
@@ -3517,6 +3517,7 @@ axis Horizontal 水平
|
||||
axis Vertical 垂直
|
||||
axis Both 两者
|
||||
Drag to move 拖动以移动
|
||||
Drag to move out of the set 拖动以移出该组合
|
||||
Drag to float as a toolbar 拖动使其浮动为工具栏
|
||||
Put back in the toolbar 放回工具栏
|
||||
Float Panel 浮动面板
|
||||
@@ -3723,3 +3724,6 @@ lighting Light 光源
|
||||
Stroke Pressure 描边压力
|
||||
Right Diagonal 右对角线
|
||||
Left Diagonal 左对角线
|
||||
Vertical Guide 垂直参考线
|
||||
Horizontal Guide 水平参考线
|
||||
Move Guide to Layer 将参考线移至图层
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user