mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-22 15:14:16 -04:00
Compare commits
221
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b7969a05c | ||
|
|
f7dde42990 | ||
|
|
ecbcf92f3a | ||
|
|
44e1c3af62 | ||
|
|
1abc5d4066 | ||
|
|
6a394c3a6f | ||
|
|
4d9749f4c7 | ||
|
|
efe2429618 | ||
|
|
8924b24e94 | ||
|
|
6fd1f566e1 | ||
|
|
6beb68f934 | ||
|
|
2bad646821 | ||
|
|
e195fc94e2 | ||
|
|
997dd8d2bd | ||
|
|
4ee7173f11 | ||
|
|
3d9656024e | ||
|
|
5b0e60f865 | ||
|
|
03bf93c203 | ||
|
|
165fea7f9b | ||
|
|
4a29fd4112 | ||
|
|
e20c330bd3 | ||
|
|
a7ee168efe | ||
|
|
5b919c0529 | ||
|
|
48792842ec | ||
|
|
97c173beb9 | ||
|
|
d548b4ff74 | ||
|
|
5e9df83dd4 | ||
|
|
524ecee6ca | ||
|
|
7fd4b33dc5 | ||
|
|
26b9cf84ad | ||
|
|
9b2173611f | ||
|
|
b50fbc08cf | ||
|
|
44c306f142 | ||
|
|
30360a67a0 | ||
|
|
24fadbd322 | ||
|
|
72dce93463 | ||
|
|
40e40bf8cb | ||
|
|
ab330e4adc | ||
|
|
dc38eb6bee | ||
|
|
6b10b159b9 | ||
|
|
7c9edfaf7f | ||
|
|
159e20053c | ||
|
|
4062e74ff3 | ||
|
|
fffc8418d9 | ||
|
|
00f557e02e | ||
|
|
0490ca0788 | ||
|
|
45f6e99eb1 | ||
|
|
329916c2b8 | ||
|
|
2f3ccb7b2c | ||
|
|
dbf6258eae | ||
|
|
4a4c8c9d49 | ||
|
|
6e091a33f5 | ||
|
|
6903bb7ef8 | ||
|
|
b35a6b1573 | ||
|
|
a99d81e9aa | ||
|
|
d73946ba46 | ||
|
|
bd12b48e02 | ||
|
|
cc1a660a6d | ||
|
|
a1d8952bc8 | ||
|
|
4bb6f32911 | ||
|
|
30d1948f51 | ||
|
|
c4ac0c5df5 | ||
|
|
22b6f2a89c | ||
|
|
9a72eadc51 | ||
|
|
781e62b9cc | ||
|
|
3a1d407f1a | ||
|
|
def14fc8b2 | ||
|
|
0919e54785 | ||
|
|
1b7cfc8bee | ||
|
|
49d69d48e5 | ||
|
|
0c41f0af55 | ||
|
|
87fdd4a370 | ||
|
|
9097c4a564 | ||
|
|
a5778fcfca | ||
|
|
bd6d076484 | ||
|
|
cee30f05b6 | ||
|
|
b0be9edabb | ||
|
|
896f3aa4a1 | ||
|
|
ab1f9f2b92 | ||
|
|
2e2e6a2f8e | ||
|
|
a1ceed08fc | ||
|
|
4f9b620dc6 | ||
|
|
439d32fcf8 | ||
|
|
cc499d7e8e | ||
|
|
28ac044c74 | ||
|
|
648a8f5104 | ||
|
|
2d7004f972 | ||
|
|
2f27759bcc | ||
|
|
b3ccdf01a7 | ||
|
|
f73a3df974 | ||
|
|
fd50bd6f84 | ||
|
|
a98ca04196 | ||
|
|
76399175eb | ||
|
|
dee53e3413 | ||
|
|
cbf2d9cc43 | ||
|
|
00fe2be469 | ||
|
|
385f1588fc | ||
|
|
fe8b8c8106 | ||
|
|
fc996e0e03 | ||
|
|
052bbb14b4 | ||
|
|
f553be25f7 | ||
|
|
e56e40dd85 | ||
|
|
bbaf7b02c3 | ||
|
|
5d4cac31cf | ||
|
|
22c83b5951 | ||
|
|
7141932dfb | ||
|
|
bbc9bd3de2 | ||
|
|
423bc71705 | ||
|
|
c60ad8dcfa | ||
|
|
6ee972c4da | ||
|
|
a8bdf5043f | ||
|
|
b0d5ab3f68 | ||
|
|
c49a1faa4b | ||
|
|
5498b62a3d | ||
|
|
a65d3f2313 | ||
|
|
81dca05eb1 | ||
|
|
a2e7a2a562 | ||
|
|
3b8e1aef43 | ||
|
|
8862058388 | ||
|
|
b833187ff8 | ||
|
|
2bf0595f09 | ||
|
|
388c653cd1 | ||
|
|
8735170a34 | ||
|
|
6883235bad | ||
|
|
0414a13ace | ||
|
|
f1dc5e50a3 | ||
|
|
4ae1b3d51a | ||
|
|
36eed1ac77 | ||
|
|
5a7689c3b4 | ||
|
|
af520a7cf3 | ||
|
|
b11b41d5f3 | ||
|
|
6ee357a6d4 | ||
|
|
bb4767fca5 | ||
|
|
b1befcfa02 | ||
|
|
71b5c9c45c | ||
|
|
5359c92a19 | ||
|
|
d6e64cb080 | ||
|
|
fa3d6d9298 | ||
|
|
5013189fd3 | ||
|
|
945fa338d0 | ||
|
|
5a258e4c73 | ||
|
|
7fa5173962 | ||
|
|
379b033728 | ||
|
|
de1182fbe3 | ||
|
|
0834be2989 | ||
|
|
fd8459592d | ||
|
|
ae13bd7dee | ||
|
|
b14fa518fb | ||
|
|
3d4d34fc15 | ||
|
|
15b041be81 | ||
|
|
e4db022f49 | ||
|
|
11eb27b127 | ||
|
|
e5b732baf5 | ||
|
|
4137d6d286 | ||
|
|
317ca00ccc | ||
|
|
438c6194a6 | ||
|
|
56b7dc300d | ||
|
|
d792e7b2be | ||
|
|
e87534342b | ||
|
|
db42a68e3f | ||
|
|
2282122272 | ||
|
|
7c39ce026f | ||
|
|
a98dd3e552 | ||
|
|
bf3e6adc50 | ||
|
|
c43ead202f | ||
|
|
152fc18d9b | ||
|
|
bfc3f5251a | ||
|
|
f639f449d7 | ||
|
|
aef00190ed | ||
|
|
3dd8c3ec28 | ||
|
|
f3863ef50f | ||
|
|
6a4a739178 | ||
|
|
1fbec667ec | ||
|
|
1388b8ec1a | ||
|
|
fe9ceb3dfa | ||
|
|
e33094e11d | ||
|
|
633ad1d749 | ||
|
|
f832625e0b | ||
|
|
2dfe6613ad | ||
|
|
9219e41440 | ||
|
|
490c8da696 | ||
|
|
9bd9f570ae | ||
|
|
66655c67c2 | ||
|
|
17fbf6ad6a | ||
|
|
757eaf9afa | ||
|
|
d275fbf725 | ||
|
|
e72735bcf9 | ||
|
|
dd9c285252 | ||
|
|
ae3a8fe4c3 | ||
|
|
60f4c4f145 | ||
|
|
79dca4c8f9 | ||
|
|
afd66ad695 | ||
|
|
2f893566ee | ||
|
|
caa6962508 | ||
|
|
f64db69b53 | ||
|
|
2b8a11876a | ||
|
|
dc9d751015 | ||
|
|
6066b4b8c7 | ||
|
|
718c65c855 | ||
|
|
a4f5ae3b16 | ||
|
|
de917a138a | ||
|
|
e66bca39b2 | ||
|
|
4e4723699d | ||
|
|
3bb0f07a4b | ||
|
|
92a8a45d32 | ||
|
|
5f6c7b8da9 | ||
|
|
14a70449f7 | ||
|
|
c8082facb1 | ||
|
|
430741f074 | ||
|
|
bd9a508d49 | ||
|
|
b15230bfb5 | ||
|
|
15434b6db4 | ||
|
|
84e76c1d70 | ||
|
|
ede08590a0 | ||
|
|
92f295d2a2 | ||
|
|
cc42a333b7 | ||
|
|
95f503367f | ||
|
|
a339433c4b | ||
|
|
34cde7c3db | ||
|
|
0ac1f133b0 | ||
|
|
b3268a829d |
No files matched your search
@@ -45,6 +45,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
|
|||||||
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
|
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
|
||||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
|
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
|
||||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||||
|
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||||
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
|
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
|
||||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
|
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
|
|||||||
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
|
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
|
||||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
|
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
|
||||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||||
|
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||||
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
|
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
|
||||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
|
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
|
||||||
|
|
||||||
|
|||||||
Generated
+12
@@ -712,6 +712,15 @@ dependencies = [
|
|||||||
"rand_core",
|
"rand_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chrono"
|
||||||
|
version = "0.4.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cipher"
|
name = "cipher"
|
||||||
version = "0.4.4"
|
version = "0.4.4"
|
||||||
@@ -4569,6 +4578,7 @@ version = "0.14.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"arboard",
|
"arboard",
|
||||||
"block2 0.6.2",
|
"block2 0.6.2",
|
||||||
|
"chrono",
|
||||||
"clipboard-win",
|
"clipboard-win",
|
||||||
"craft-print",
|
"craft-print",
|
||||||
"craft-window",
|
"craft-window",
|
||||||
@@ -4678,6 +4688,7 @@ dependencies = [
|
|||||||
name = "vectorcraft-doc"
|
name = "vectorcraft-doc"
|
||||||
version = "0.14.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
"hayro-ccitt",
|
"hayro-ccitt",
|
||||||
"image",
|
"image",
|
||||||
"jpeg-encoder",
|
"jpeg-encoder",
|
||||||
@@ -4846,6 +4857,7 @@ name = "vectorcraft-pdf"
|
|||||||
version = "0.14.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
|
"chrono",
|
||||||
"craft-pdf",
|
"craft-pdf",
|
||||||
"flate2",
|
"flate2",
|
||||||
"image",
|
"image",
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ thiserror = "2"
|
|||||||
serde = { version = "1", features = ["derive", "rc"] }
|
serde = { version = "1", features = ["derive", "rc"] }
|
||||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
|
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
|
||||||
|
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
|
||||||
proptest = "1"
|
proptest = "1"
|
||||||
crc32fast = "1.5"
|
crc32fast = "1.5"
|
||||||
# tests: a TCP port bound without listening (connections to it are refused)
|
# tests: a TCP port bound without listening (connections to it are refused)
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.vectorcraft.png" width="128" height="128" alt="VectorCraft icon: an engraved dragon on a red tile">
|
||||||
|
</p>
|
||||||
|
|
||||||
<h1 align="center">VectorCraft</h1>
|
<h1 align="center">VectorCraft</h1>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -238,8 +242,9 @@ Pathfinder and Shape Builder, paint, gradients, appearance and transparency, typ
|
|||||||
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package).
|
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package).
|
||||||
Affinity documents (`.af` from Affinity 3, `.afdesign`, `.afpub` and, by their content, `.afphoto` from Affinity 1 and 2) open
|
Affinity documents (`.af` from Affinity 3, `.afdesign`, `.afpub` and, by their content, `.afphoto` from Affinity 1 and 2) open
|
||||||
and place natively: layers, groups, artboards and pages, curves and shapes, fills, gradients and strokes, clipping
|
and place natively: layers, groups, artboards and pages, curves and shapes, fills, gradients and strokes, clipping
|
||||||
and masks, text and images, with what didn't come in (effects, adjustments, brushes, master pages…) listed in the
|
and masks, text with each paragraph's indents, spacing and leading, tables (as text frames and lines), master
|
||||||
import warning; a file whose native data can't be read opens as its embedded preview, saying why. VectorCraft
|
pages, shadows, glows, outlines and overlays, embedded Affinity documents and images, with what didn't come in
|
||||||
|
(adjustments, blurs, bevels, brushes…) listed in the import warning; a file whose native data can't be read opens as its embedded preview, saying why. VectorCraft
|
||||||
doesn't write Affinity files. Current `.af` validation includes 33 pinned files, native save/reload
|
doesn't write Affinity files. Current `.af` validation includes 33 pinned files, native save/reload
|
||||||
and every-board SVG/PDF/PSD export, with fixes for Affinity 3 artboards, source-backed JPEGs and text runs.
|
and every-board SVG/PDF/PSD export, with fixes for Affinity 3 artboards, source-backed JPEGs and text runs.
|
||||||
[Scope and limits](crates/affinity/README.md); [source audit and remaining gaps](docs/affinity-validation.md).
|
[Scope and limits](crates/affinity/README.md); [source audit and remaining gaps](docs/affinity-validation.md).
|
||||||
@@ -253,7 +258,7 @@ The scores are self-assessed from our code against public documentation;
|
|||||||
- reliable exchange of .ai files (layer structure in real files; proprietary editing data isn't written);
|
- reliable exchange of .ai files (layer structure in real files; proprietary editing data isn't written);
|
||||||
- an interaction-fidelity pass covering every tool's modifiers and small behaviours;
|
- an interaction-fidelity pass covering every tool's modifiers and small behaviours;
|
||||||
- 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials);
|
- 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials);
|
||||||
- most of the raster effects (9 of 57 still missing; Cutout, Film Grain, Neon Glow, Plastic Wrap, Poster Edges, Smudge Stick, Sponge and Watercolor are available under Artistic, Bas Relief, Chalk & Charcoal, Charcoal, Chrome, Graphic Pen, Halftone Pattern, Note Paper, Photocopy, Plaster, Reticulation, Stamp, Torn Edges and Water Paper under Sketch) and the Effect Gallery;
|
- the Effect Gallery (all 57 Photoshop-style raster filters are available, approximated, under Artistic, Blur, Brush Strokes, Distort, Pixelate, Sharpen, Sketch, Stylize, Texture and Video);
|
||||||
- CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface);
|
- CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface);
|
||||||
- scripting, and the rest of Variables (image and graph kinds, dataset import);
|
- scripting, and the rest of Variables (image and graph kinds, dataset import);
|
||||||
- generative AI features.
|
- generative AI features.
|
||||||
|
|||||||
+11
-4
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
|
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
|
||||||
|
|
||||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range, above or below (right of the column in vertical type), aligned, sized and overhanging or squeezed; saved natively, laid out with line growth, drawn on the canvas and in SVG/PDF (outlines where ruby is present); headline numbers unchanged; previously:; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; Print: Japanese paper sizes in the Print dialog's Media list — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (which keep their ISO 216 sizes), はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; about 90 community PRs of 2026-10-19: Objects on Path, the Simplify slider, Pencil Edit Selected Paths in the middle of a path, Georgian, and more; Background Export progress dialog with Cancel, #2564; about 85 community PRs of 2026-10-18: Justification…, Word .docx placed as area type, CCITT Group 3 TIFF, Roman Hanging Punctuation, blend spines edited with the Anchor Point tool, shadows and glows in stack order, coloured 3D lights, a Dutch catalog completed; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (colour emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; headline numbers unchanged; previously:; Affinity import AF.5: master pages, tables, paragraph formats, shadows, glows, outlines, overlays and embedded documents; ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range, above or below (right of the column in vertical type), aligned, sized and overhanging or squeezed; saved natively, laid out with line growth, drawn on the canvas and in SVG/PDF (outlines where ruby is present); headline numbers unchanged; previously:; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; Print: Japanese paper sizes in the Print dialog's Media list — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (which keep their ISO 216 sizes), はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; about 90 community PRs of 2026-10-19: Objects on Path, the Simplify slider, Pencil Edit Selected Paths in the middle of a path, Georgian, and more; Background Export progress dialog with Cancel, #2564; about 85 community PRs of 2026-10-18: Justification…, Word .docx placed as area type, CCITT Group 3 TIFF, Roman Hanging Punctuation, blend spines edited with the Anchor Point tool, shadows and glows in stack order, coloured 3D lights, a Dutch catalog completed; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||||
|
|
||||||
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
|
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
|
||||||
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
|
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
|
||||||
@@ -29,7 +29,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
|
|||||||
|
|
||||||
| | Value | Kind |
|
| | Value | Kind |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 46/50, Photoshop effects 54/57, 3D 4/4 (initial types, no materials) |
|
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 46/50, Photoshop effects 57/57, 3D 4/4 (initial types, no materials) |
|
||||||
| **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated |
|
| **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated |
|
||||||
| **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated |
|
| **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated |
|
||||||
| **To full parity** | **~360–590 h** one agent · ~95–165 h with 4–6 agents | estimated |
|
| **To full parity** | **~360–590 h** one agent · ~95–165 h with 4–6 agents | estimated |
|
||||||
@@ -76,7 +76,7 @@ The dimension hours overlap (feature rows hold some UI, format and AI work), so
|
|||||||
| Strokes, brushes, width profiles | 88% | 8–15 |
|
| Strokes, brushes, width profiles | 88% | 8–15 |
|
||||||
| Appearance, transparency, graphic styles, masks | 97% | 1–2 |
|
| Appearance, transparency, graphic styles, masks | 97% | 1–2 |
|
||||||
| Live vector effects | 85% | 6–10 |
|
| Live vector effects | 85% | 6–10 |
|
||||||
| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 82% | 8–14 |
|
| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 82% | 4–6 |
|
||||||
| 3D and Materials | ~25% | 30–55 |
|
| 3D and Materials | ~25% | 30–55 |
|
||||||
| Type core — [type-parity.md](docs/type-parity.md) | 80% | 14–19 |
|
| Type core — [type-parity.md](docs/type-parity.md) | 80% | 14–19 |
|
||||||
| Type advanced (CJK, spelling, Touch Type) | 50% | 20–28 |
|
| Type advanced (CJK, spelling, Touch Type) | 50% | 20–28 |
|
||||||
@@ -123,12 +123,16 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
|||||||
2. **`.ai` files from real users** (G9): layer structure, guides, speed: 10–20 h.
|
2. **`.ai` files from real users** (G9): layer structure, guides, speed: 10–20 h.
|
||||||
3. **Launch and stability on real machines** (G10): 20–35 h.
|
3. **Launch and stability on real machines** (G10): 20–35 h.
|
||||||
4. **Core-tool correctness** (G11: Shape Builder regions, Asset Export borders): 8–15 h.
|
4. **Core-tool correctness** (G11: Shape Builder regions, Asset Export borders): 8–15 h.
|
||||||
5. **Raster effects** (G2) alongside: the 10 remaining filters (7 Artistic, 2 Sketch, 1 more) and the Effect Gallery, 8–14 h.
|
5. **Raster effects** (G2) alongside: the Effect Gallery (every Photoshop-style filter is in), 4–6 h.
|
||||||
6. **Then:** type core and advanced type (G4), the Illustrator 2026 additions (G13), 3D (G3).
|
6. **Then:** type core and advanced type (G4), the Illustrator 2026 additions (G13), 3D (G3).
|
||||||
|
|
||||||
## Progress log
|
## Progress log
|
||||||
|
|
||||||
|
- 2026-10-22: Colour emoji fonts with bitmap glyphs draw and export: `sbix` (Apple Color Emoji, macOS's emoji font) and `CBDT` (bitmap Noto Color Emoji) glyphs show as their pictures on the canvas, whatever the type's fill, scale and turn with the type, and export to PDF (in the font's Type 3 glyphs, or as images), SVG (images when type is outlined; live text leaves emoji to the viewer), EPS and legacy `.ai` (images); Create Outlines turns them into embedded images. Fonts with bitmaps but no outlines, such as Noto Color Emoji, are now found among the installed fonts. COLR and OpenType SVG fonts remain; headline numbers unchanged.
|
||||||
|
|
||||||
|
- 2026-10-22: Effect › Sketch › Conté Crayon: a black crayon in the dark areas and a white one in the light areas, on mid-grey paper whose texture (Brick, Burlap, Canvas, Sandstone; Scaling, Relief, Light Direction, Invert) breaks the strokes, with Foreground Level and Background Level; an original approximation, defaults unverified. Every Photoshop-style filter is now in (57/57); the Effect Gallery remains (4–6 h).
|
||||||
- 2026-10-22: Ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range of the selected type — above it in horizontal type, right of the column in vertical type (or below/left), start/centre/end aligned, at 1–100% of the base size (default half), overhanging or squeezed to the base width. It is saved in the native file, laid out with its line growing (following lines move on), drawn on the canvas, and written in SVG and PDF as outlines where ruby is present. UI panel and type on a path are next. Headline numbers unchanged.
|
- 2026-10-22: Ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range of the selected type — above it in horizontal type, right of the column in vertical type (or below/left), start/centre/end aligned, at 1–100% of the base size (default half), overhanging or squeezed to the base width. It is saved in the native file, laid out with its line growing (following lines move on), drawn on the canvas, and written in SVG and PDF as outlines where ruby is present. UI panel and type on a path are next. Headline numbers unchanged.
|
||||||
|
- 2026-10-22: PDF accessibility: titled exports set `/ViewerPreferences << /DisplayDocTitle true >>`, so viewers show the document title rather than the file name and title checks can pass (#3646). Headline numbers unchanged.
|
||||||
|
|
||||||
- 2026-10-14: Document fonts register every explicitly supplied craft-fonts family after the existing Japanese, Arabic and Thai prefix; optional empty input remains supported. Headline scores and estimates are unchanged.
|
- 2026-10-14: Document fonts register every explicitly supplied craft-fonts family after the existing Japanese, Arabic and Thai prefix; optional empty input remains supported. Headline scores and estimates are unchanged.
|
||||||
|
|
||||||
@@ -142,6 +146,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
|||||||
| 2026-10-18 | Exported and saved files can repeat their bytes: PNG, PDF, EPS, legacy `.ai`, packaged zips and `.vectorcraft` saves recorded the wall-clock time they were made in, so the same commands run twice gave different bytes though the pixels matched. `SOURCE_DATE_EPOCH` now stands in for the clock in everything a file records, as the reproducible-builds specification defines it; a date a command is given still wins, and a malformed value is ignored rather than refusing the export. The recovery heartbeat and the seed PDF encryption draws on keep the real clock, so a password-protected PDF still differs between runs (#2880). Headline numbers unchanged. |
|
| 2026-10-18 | Exported and saved files can repeat their bytes: PNG, PDF, EPS, legacy `.ai`, packaged zips and `.vectorcraft` saves recorded the wall-clock time they were made in, so the same commands run twice gave different bytes though the pixels matched. `SOURCE_DATE_EPOCH` now stands in for the clock in everything a file records, as the reproducible-builds specification defines it; a date a command is given still wins, and a malformed value is ignored rather than refusing the export. The recovery heartbeat and the seed PDF encryption draws on keep the real clock, so a password-protected PDF still differs between runs (#2880). Headline numbers unchanged. |
|
||||||
| 2026-10-18 | About 85 community PRs landed in two batches through the full gate, with our fixes on top. New: Word `.docx` files place as area type with their formatting (#2766), CCITT Group 3 TIFF files open (#2770), Paragraph panel menu › Roman Hanging Punctuation (#2775), the Anchor Point tool edits blend spine points and handles (#2643, fixes #2544), shadows and glows each draw from the art with the effects before them, without the old exponential cost (#2395), one to ten coloured lights in 3D effects (#2706), Artboard Options X and Y with a reference point (#2739), more Contextual Task Bar actions (#2471), Optical Margin Alignment for a whole thread (#2773), History panel Step Backward/Forward and Set History Limit (#2764), Edit › Undo and Redo name the step as the History panel does (#2774), bounding-box handles snap to the grid, pixels and guides (#2715), Ctrl/Cmd+A in the Artboards panel (#2761), Tabs panel menu items (#2742), Import Data Sets defines variables from header cells (#2736), a Linux option to use X11 when a pen is attached (#2717). Fidelity and robustness: variable fonts named by their default weight and fallback glyphs keep bold and italic (#2733, #2732), legacy `.ai` layer names in Windows Latin-1 (#2722), WMF window mapping (#2721), Magic Wand inside isolation (#2720), type values out of range in files are clamped (#2728, #2757), raster effects stay responsive in drags (#2745), exports and Save for Web report undecodable images (#2762, #2737, #2684), Windows-safe exported file names (#2738), a marquee skips a Shaper Group's originals (#2772). Second batch: an export progress dialog with Cancel on the shared cancel token (#2564), CMYK gradient meshes drawn several times faster within a 16 MB cap (#2802), Paragraph panel menu › Justification… with file values clamped (#2771), Edit › Clear History (#2843), SVG image links keep their spelling (#2837, fixes main's Windows test failures), the Linux save dialog through the XDG portal (#2783), RGB input stored as CMYK in CMYK documents (#2812), legacy `.ai` export of images and radial highlights (#2801, #2807), `.ai` import notes for damaged editing data, page sizes and variable libraries (#2799, #2800, #2842), PDF and `.ai` keep their creation date (#2838), undecodable images left out of PDF exports with a note (#2790), MCP input waits for queue room (#2776), slice, snapping, selection, type and accessibility fixes (#2794–#2797, #2805, #2806, #2809, #2815, #2844–#2846), dropdowns close after a keyboard pick (#2824), plug-in dialogs scroll (#2840). Languages: the Dutch catalog completed (#2730). Headline numbers unchanged (estimated). |
|
| 2026-10-18 | About 85 community PRs landed in two batches through the full gate, with our fixes on top. New: Word `.docx` files place as area type with their formatting (#2766), CCITT Group 3 TIFF files open (#2770), Paragraph panel menu › Roman Hanging Punctuation (#2775), the Anchor Point tool edits blend spine points and handles (#2643, fixes #2544), shadows and glows each draw from the art with the effects before them, without the old exponential cost (#2395), one to ten coloured lights in 3D effects (#2706), Artboard Options X and Y with a reference point (#2739), more Contextual Task Bar actions (#2471), Optical Margin Alignment for a whole thread (#2773), History panel Step Backward/Forward and Set History Limit (#2764), Edit › Undo and Redo name the step as the History panel does (#2774), bounding-box handles snap to the grid, pixels and guides (#2715), Ctrl/Cmd+A in the Artboards panel (#2761), Tabs panel menu items (#2742), Import Data Sets defines variables from header cells (#2736), a Linux option to use X11 when a pen is attached (#2717). Fidelity and robustness: variable fonts named by their default weight and fallback glyphs keep bold and italic (#2733, #2732), legacy `.ai` layer names in Windows Latin-1 (#2722), WMF window mapping (#2721), Magic Wand inside isolation (#2720), type values out of range in files are clamped (#2728, #2757), raster effects stay responsive in drags (#2745), exports and Save for Web report undecodable images (#2762, #2737, #2684), Windows-safe exported file names (#2738), a marquee skips a Shaper Group's originals (#2772). Second batch: an export progress dialog with Cancel on the shared cancel token (#2564), CMYK gradient meshes drawn several times faster within a 16 MB cap (#2802), Paragraph panel menu › Justification… with file values clamped (#2771), Edit › Clear History (#2843), SVG image links keep their spelling (#2837, fixes main's Windows test failures), the Linux save dialog through the XDG portal (#2783), RGB input stored as CMYK in CMYK documents (#2812), legacy `.ai` export of images and radial highlights (#2801, #2807), `.ai` import notes for damaged editing data, page sizes and variable libraries (#2799, #2800, #2842), PDF and `.ai` keep their creation date (#2838), undecodable images left out of PDF exports with a note (#2790), MCP input waits for queue room (#2776), slice, snapping, selection, type and accessibility fixes (#2794–#2797, #2805, #2806, #2809, #2815, #2844–#2846), dropdowns close after a keyboard pick (#2824), plug-in dialogs scroll (#2840). Languages: the Dutch catalog completed (#2730). Headline numbers unchanged (estimated). |
|
||||||
| 2026-10-19 | About 90 community PRs landed in batches through the full gate, with our fixes on top (formatting, catalog rows, small correctness fixes). New: Objects on Path (#3066), Object › Path › Simplify's slider with Auto-Simplify and More Options (#2555), Pencil Edit Selected Paths redraws the middle of a selected path (#2905), Pencil Tool Options' Alt key toggles to Smooth Tool (#2685), 3D solids built from each stroke's outline (#3049), Live Paint Selection Tool Options (#3052), Locate Selected Objects in the Layers panel (#3033), Snap to Unit for tab stops (#3039), DXF circles and arcs as native entities (#2962), Targa files (#2765), a Georgian interface (#2045), Traditional Chinese status and error messages (#2784), the web page's `window.vectorcraft` control channel (#2943), `select.color` (#2939), reproducible export bytes with `SOURCE_DATE_EPOCH` (#2947), Spacing Guides on resizes (#2966) and measurement labels on more tools. Fixed: number fields step to the next whole unit (#2916), a click away from edited type makes no new text (#2922), inch rulers in halves (#2951), Recent cards in New Document (#2904), Japanese W-weights in SVG (#2896), the point-type baseline marker (#2897) and the Reshape tool's falloff (#2900). Headline numbers unchanged. |
|
| 2026-10-19 | About 90 community PRs landed in batches through the full gate, with our fixes on top (formatting, catalog rows, small correctness fixes). New: Objects on Path (#3066), Object › Path › Simplify's slider with Auto-Simplify and More Options (#2555), Pencil Edit Selected Paths redraws the middle of a selected path (#2905), Pencil Tool Options' Alt key toggles to Smooth Tool (#2685), 3D solids built from each stroke's outline (#3049), Live Paint Selection Tool Options (#3052), Locate Selected Objects in the Layers panel (#3033), Snap to Unit for tab stops (#3039), DXF circles and arcs as native entities (#2962), Targa files (#2765), a Georgian interface (#2045), Traditional Chinese status and error messages (#2784), the web page's `window.vectorcraft` control channel (#2943), `select.color` (#2939), reproducible export bytes with `SOURCE_DATE_EPOCH` (#2947), Spacing Guides on resizes (#2966) and measurement labels on more tools. Fixed: number fields step to the next whole unit (#2916), a click away from edited type makes no new text (#2922), inch rulers in halves (#2951), Recent cards in New Document (#2904), Japanese W-weights in SVG (#2896), the point-type baseline marker (#2897) and the Reshape tool's falloff (#2900). Headline numbers unchanged. |
|
||||||
|
| 2026-10-21 | Affinity import (AF.5), brought over from DesignCraft's (storytold/designcraft#291): master pages show on the pages that use them (a locked layer at the bottom); tables open as a text frame per cell and a line per edge; each paragraph keeps its alignment, indents, spacing and leading; frame text takes its text scale, not its frame's; drop shadows and outer glows become live effects, outlines strokes, colour and gradient overlays are baked into the paint; embedded Affinity documents open as editable content, and their artboards no longer become artboards; large pixel layers come in at a reduced resolution; text placed as Affinity places it (lines of differently aligned paragraphs, a frame's first baseline at cap height, last lines whose descenders hang out of the frame), underlines and strikethrough, line breaks inside paragraphs, table cells taller than their row, no stroke on shapes whose stroke style is None, CMYK pixel layers (phone photos in CMYK documents) shown through the document's own CMYK profile instead of the profile-free formula and its blue cast, outlines on stroked shapes. Headline numbers unchanged. Hostile files stay bounded (one budget for the whole read, embedded documents included; overlay work; master page copies), covered by the import fuzzer and limit tests. |
|
||||||
| 2026-10-17 | Background Export shows a progress dialog: the file being exported, the artboard count and a bar, Cancel (Esc) and Continue in Background. It runs on the shared cancel token of #2546, so a cancelled export writes no file and keeps no half-drawn pattern tile or effect raster; translated in every language with complete menus (#2564). Headline numbers unchanged. |
|
| 2026-10-17 | Background Export shows a progress dialog: the file being exported, the artboard count and a bar, Cancel (Esc) and Continue in Background. It runs on the shared cancel token of #2546, so a cancelled export writes no file and keeps no half-drawn pattern tile or effect raster; translated in every language with complete menus (#2564). Headline numbers unchanged. |
|
||||||
| 2026-10-17 | About 145 community PRs landed in six batches, each through the full gate, plus our fixes on top. Crashes and hostile input: an image's RGBA size is checked from its header before decoding, without overflowing, in the renderer, sampling, recoloring and Crop Image (#2442, #2468, #2407), PDF Add Around and huge art are clamped (#2440), graph axis labels past i64, `+` in hex colours and XML ids are handled (#2610, #2470, #2447), Document Raster Effects resolutions open in range (#2579), Batch names stay in their folder (#2504). Regressions: floating panel groups are opaque again while the docked card keeps its rounded corners (#2499), the Wayland pointer returns over panels (#2502). New: MCP cancels long exports, answers while one runs and reports progress (#2546), `doc_inspect` takes ids, kind and paging (#2543), raster export of a page rectangle (#2511), `text.create` returns bounds (#2478), PDF presets and ICC profiles persist (#2547), screen-reader names for widgets (#2611), Expand on Intertwine (#2622, fixes #2540), Duplicate/Copy/Cut of several artboards (#2567), print presets by name (#2607), TSV data sets (#2608), planar TIFF (#2598), Alt-click a swatch onto a gradient stop (#2623), Shift-click ranges in Links, Graphic Styles and Libraries (#2612, #2613, #2618), Space+Shift rotates the view (#2601), Presentation Mode keys (#2583), Small Caps and super/subscript buttons (#2508). Fidelity: editable vector art from Illustrator EPS (#2571), `.ai` Drop Shadow Darkness (#2513), overprint preview with Darken (#2514), Generic CMYK black round-trips (#2503), PNG sRGB chunk (#2519), kerning at a caret (#2518), tab widths in justified area type (#2487), Transform copies keep their own gradients (#2486), smooth Zig Zag (#2475), Live Corners round up to their neighbours (#2484), kerning across style runs (#2554), SVG letter spacing on justified lines (#2574), Dimension tool snapping (#2482), gradient chips (#2495), Transform panel flips and percentages (#2400, #2404). Later the same day: PostScript `.ps` and `.epsf` files open (#2461), EPS images with CCITTFax compression draw (#2445), Illustrator 3 EPS without layers (#2436), `.ai` type opacity and blend modes (#2420), envelope meshes and compound holes in `.ai` (#2410, #2444), CMYK image soft masks in PDF export (#2448), Show Transparency Grid saved with the document (#2488), Save Selection in the Layers panel menu (#2483), Character and Paragraph Reset Panel (#2479, #2438), Cmd/Ctrl+Shift double-click releases a guide (#2467), Shift+Enter keeps a field active (#2431), Shift keeps reshaped handles perpendicular (#2425), emptied type keeps its style (#2464), Smart Blur radius (#2443), freeform gradients recolour (#2397), mesh slivers (#2456), Recolor wheel handles in CMYK (#2454), and six Image Trace fixes (#2387, #2584, #2586–#2589). Fifth batch: Optical Margin Alignment (#2563), Window › Blend panel (#2573), graph column designs (#2406), Live Paint Gap Options (#2424), the SVG Interactivity panel (#2437), RTF placed as area type (#2524), the Touch Type tool (#2469), pattern fills expand into clipped tiles (#2616), Save and Export ask before replacing a file an added extension names (#2619), the Artboard tool's Control bar and Contextual Task Bar (#2559, #2556), Alt-click New Artboard for its options (#2496), Shift-click in Brushes (#2562), the Move dialog remembers the last move (#2542), PDF export uses the document's assigned profiles (#2536), Printer Setup opens the selected printer's preferences on Windows (#2577). Sixth batch: Effect › Artistic › Cutout, Neon Glow and Poster Edges (#2453), View › Rulers › Change to Global / Artboard Rulers (#2416), Symbol Options 9-slice guides (#2512), Create Gradient Mesh on an embedded image (#2538), Rotate View cursors and the Mac trackpad rotation gesture (#2594, #2644), choice parameters as dropdowns in the inline effect editor (#2582), Group Selection picks and moves one path of a compound path (part of #2441), Shift-click ranges in the Artboards panel (#2626), Lock Variables (#2631), a layer dropped under the last row goes to the bottom (#2632), and robustness work: huge selections draw bounded overlays (#2635, #2645, part of #2460), values out of range in presets and files are clamped (#2656), whole-number checks for counts (#2638, #2659), exports warn about overset type (#2628), symbol instances measured by their art (#2614), colour-managed Eyedropper in CMYK documents (#2664). Later: Effect › Rasterize (#2409), variable-font axis sliders in the Character panel (#2710), Image Trace Create Strokes on an exact distance transform with Guo–Hall thinning and a skeleton graph (#2703–#2705), Auto Color keeps rare colours (#2702), large images trace with a warning (#2700), a 1e9 pt input bound on Reshape Segment, Convert Anchor Point and the Pencil (#2709), task-bar Connect, Corner and Smooth for selected anchors (#2697), PDF 0 w strokes import as hairlines (#2696), threading text from theLine truncated
|
| 2026-10-17 | About 145 community PRs landed in six batches, each through the full gate, plus our fixes on top. Crashes and hostile input: an image's RGBA size is checked from its header before decoding, without overflowing, in the renderer, sampling, recoloring and Crop Image (#2442, #2468, #2407), PDF Add Around and huge art are clamped (#2440), graph axis labels past i64, `+` in hex colours and XML ids are handled (#2610, #2470, #2447), Document Raster Effects resolutions open in range (#2579), Batch names stay in their folder (#2504). Regressions: floating panel groups are opaque again while the docked card keeps its rounded corners (#2499), the Wayland pointer returns over panels (#2502). New: MCP cancels long exports, answers while one runs and reports progress (#2546), `doc_inspect` takes ids, kind and paging (#2543), raster export of a page rectangle (#2511), `text.create` returns bounds (#2478), PDF presets and ICC profiles persist (#2547), screen-reader names for widgets (#2611), Expand on Intertwine (#2622, fixes #2540), Duplicate/Copy/Cut of several artboards (#2567), print presets by name (#2607), TSV data sets (#2608), planar TIFF (#2598), Alt-click a swatch onto a gradient stop (#2623), Shift-click ranges in Links, Graphic Styles and Libraries (#2612, #2613, #2618), Space+Shift rotates the view (#2601), Presentation Mode keys (#2583), Small Caps and super/subscript buttons (#2508). Fidelity: editable vector art from Illustrator EPS (#2571), `.ai` Drop Shadow Darkness (#2513), overprint preview with Darken (#2514), Generic CMYK black round-trips (#2503), PNG sRGB chunk (#2519), kerning at a caret (#2518), tab widths in justified area type (#2487), Transform copies keep their own gradients (#2486), smooth Zig Zag (#2475), Live Corners round up to their neighbours (#2484), kerning across style runs (#2554), SVG letter spacing on justified lines (#2574), Dimension tool snapping (#2482), gradient chips (#2495), Transform panel flips and percentages (#2400, #2404). Later the same day: PostScript `.ps` and `.epsf` files open (#2461), EPS images with CCITTFax compression draw (#2445), Illustrator 3 EPS without layers (#2436), `.ai` type opacity and blend modes (#2420), envelope meshes and compound holes in `.ai` (#2410, #2444), CMYK image soft masks in PDF export (#2448), Show Transparency Grid saved with the document (#2488), Save Selection in the Layers panel menu (#2483), Character and Paragraph Reset Panel (#2479, #2438), Cmd/Ctrl+Shift double-click releases a guide (#2467), Shift+Enter keeps a field active (#2431), Shift keeps reshaped handles perpendicular (#2425), emptied type keeps its style (#2464), Smart Blur radius (#2443), freeform gradients recolour (#2397), mesh slivers (#2456), Recolor wheel handles in CMYK (#2454), and six Image Trace fixes (#2387, #2584, #2586–#2589). Fifth batch: Optical Margin Alignment (#2563), Window › Blend panel (#2573), graph column designs (#2406), Live Paint Gap Options (#2424), the SVG Interactivity panel (#2437), RTF placed as area type (#2524), the Touch Type tool (#2469), pattern fills expand into clipped tiles (#2616), Save and Export ask before replacing a file an added extension names (#2619), the Artboard tool's Control bar and Contextual Task Bar (#2559, #2556), Alt-click New Artboard for its options (#2496), Shift-click in Brushes (#2562), the Move dialog remembers the last move (#2542), PDF export uses the document's assigned profiles (#2536), Printer Setup opens the selected printer's preferences on Windows (#2577). Sixth batch: Effect › Artistic › Cutout, Neon Glow and Poster Edges (#2453), View › Rulers › Change to Global / Artboard Rulers (#2416), Symbol Options 9-slice guides (#2512), Create Gradient Mesh on an embedded image (#2538), Rotate View cursors and the Mac trackpad rotation gesture (#2594, #2644), choice parameters as dropdowns in the inline effect editor (#2582), Group Selection picks and moves one path of a compound path (part of #2441), Shift-click ranges in the Artboards panel (#2626), Lock Variables (#2631), a layer dropped under the last row goes to the bottom (#2632), and robustness work: huge selections draw bounded overlays (#2635, #2645, part of #2460), values out of range in presets and files are clamped (#2656), whole-number checks for counts (#2638, #2659), exports warn about overset type (#2628), symbol instances measured by their art (#2614), colour-managed Eyedropper in CMYK documents (#2664). Later: Effect › Rasterize (#2409), variable-font axis sliders in the Character panel (#2710), Image Trace Create Strokes on an exact distance transform with Guo–Hall thinning and a skeleton graph (#2703–#2705), Auto Color keeps rare colours (#2702), large images trace with a warning (#2700), a 1e9 pt input bound on Reshape Segment, Convert Anchor Point and the Pencil (#2709), task-bar Connect, Corner and Smooth for selected anchors (#2697), PDF 0 w strokes import as hairlines (#2696), threading text from theLine truncated
|
||||||
| 2026-10-16 | Symbol Options › Export Type (Graphic or Movie Clip, default Movie Clip) with Registration; `symbol.options` / `symbol.list` (G5).
|
| 2026-10-16 | Symbol Options › Export Type (Graphic or Movie Clip, default Movie Clip) with Registration; `symbol.options` / `symbol.list` (G5).
|
||||||
@@ -189,6 +194,8 @@ The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/ro
|
|||||||
|
|
||||||
| Date | Change | Summary |
|
| Date | Change | Summary |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| 2026-10-22 | minor | Progress log row for colour emoji fonts with bitmap glyphs; headline numbers unchanged. |
|
||||||
|
| 2026-10-22 | minor | Effects: Sketch › Conté Crayon; measured Photoshop-style filters 57/57, the Effect Gallery remains (G2 estimate 4–6 h). |
|
||||||
| 2026-10-21 | minor | 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged |
|
| 2026-10-21 | minor | 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged |
|
||||||
| 2026-10-21 | minor | Print: Japanese paper sizes in the Print dialog's Media list (JIS B4/B5/B6, はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号, 四六判); ISO B4/B5 keep their sizes, and a paper is named by id or label, any case and spacing. Headline numbers unchanged. |
|
| 2026-10-21 | minor | Print: Japanese paper sizes in the Print dialog's Media list (JIS B4/B5/B6, はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号, 四六判); ISO B4/B5 keep their sizes, and a paper is named by id or label, any case and spacing. Headline numbers unchanged. |
|
||||||
| 2026-10-17 | minor | Colored Inflate: shared membrane normals and smooth RGB vector ramps replace flat polygon bands; channel groups are batched per solid-fill surface. White-light Inflate retains its original polygons, paint composition and seam joins; the 0.05 seam minimum is restored. Scores and hour estimates unchanged. |
|
| 2026-10-17 | minor | Colored Inflate: shared membrane normals and smooth RGB vector ramps replace flat polygon bands; channel groups are batched per solid-fill surface. White-light Inflate retains its original polygons, paint composition and seam joins; the 0.05 seam minimum is restored. Scores and hour estimates unchanged. |
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
//! `vectorcraft-cli`: VectorCraft from the command line.
|
//! `vectorcraft-cli`: VectorCraft from the command line.
|
||||||
//!
|
//!
|
||||||
//! ```text
|
//! ```text
|
||||||
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR] [--automation-write-root DIR]
|
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR]... [--automation-write-root DIR]...
|
||||||
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}']]... [--export out.svg]... [--scale 2]
|
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}'] | --commands FILE|-]... [--export out.svg]... [--scale 2]
|
||||||
//! vectorcraft-cli commands
|
//! vectorcraft-cli commands
|
||||||
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
|
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
|
||||||
//! vectorcraft-cli info FILE
|
//! vectorcraft-cli info FILE
|
||||||
@@ -68,19 +68,24 @@ const USAGE: &str = "\
|
|||||||
vectorcraft-cli — VectorCraft automation
|
vectorcraft-cli — VectorCraft automation
|
||||||
|
|
||||||
USAGE:
|
USAGE:
|
||||||
vectorcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR] [--automation-write-root DIR]
|
vectorcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR]... [--automation-write-root DIR]...
|
||||||
Run the MCP server on stdio. Default: connect to a running app at 127.0.0.1:7979
|
Run the MCP server on stdio. Default: connect to a running app at 127.0.0.1:7979
|
||||||
(vectorcraft --control 7979), falling back to a headless in-process session.
|
(vectorcraft --control 7979), falling back to a headless in-process session.
|
||||||
--automation-read-root / --automation-write-root confine the files the agent's commands read
|
--automation-read-root / --automation-write-root confine the files the agent's commands read
|
||||||
(open, place, relink, library loads…) and write (save, export, package, library saves…) to
|
(open, place, relink, library loads…) and write (save, export, package, library saves…) to
|
||||||
DIR, links followed; a root left out grants none of its access. They imply --headless: a
|
DIR, links followed; each may be given more than once, a path inside any of its folders being
|
||||||
running app enforces its own (vectorcraft --control PORT --automation-read-root DIR …).
|
allowed, and a kind left out grants none of its access. They imply --headless: a running app
|
||||||
|
enforces its own (vectorcraft --control PORT --automation-read-root DIR …).
|
||||||
|
|
||||||
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON]]... [--export FILE]... [--scale N]
|
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON] | --commands FILE|-]... [--export FILE]... [--scale N]
|
||||||
Headless batch: open FILE (any readable format) or start a new document, run commands in
|
Headless batch: open FILE (any readable format) or start a new document, run commands in
|
||||||
order, export each FILE in the format its extension picks (see Writable formats; a .ait FILE
|
order, export each FILE in the format its extension picks (see Writable formats; a .ait FILE
|
||||||
is a template, written as file.saveAsTemplate writes it). --scale sizes raster exports
|
is a template, written as file.saveAsTemplate writes it). --scale sizes raster exports
|
||||||
(pixels per point, default 1); other formats leave it out. Prints one JSON result per step.
|
(pixels per point, default 1); other formats leave it out. Prints one JSON result per step.
|
||||||
|
--commands reads steps from FILE, or from standard input for -, where it stands among the
|
||||||
|
--cmd and --export steps: a JSON array of {\"command\": ID, \"params\": {...}} (or \"id\" for
|
||||||
|
\"command\"), or one such object per line (JSON Lines), at most 64 MiB and 100000 steps.
|
||||||
|
Errors name the step, counting from 0 as \"$N\" references do.
|
||||||
|
|
||||||
vectorcraft-cli commands
|
vectorcraft-cli commands
|
||||||
Print the command catalogue as JSON.
|
Print the command catalogue as JSON.
|
||||||
@@ -177,7 +182,7 @@ fn with_stores(mut h: Headless) -> Headless {
|
|||||||
fn mcp(args: &[String]) -> Result<(), String> {
|
fn mcp(args: &[String]) -> Result<(), String> {
|
||||||
let mut connect: Option<String> = None;
|
let mut connect: Option<String> = None;
|
||||||
let mut headless = false;
|
let mut headless = false;
|
||||||
let (mut read_root, mut write_root): (Option<String>, Option<String>) = (None, None);
|
let (mut read_roots, mut write_roots): (Vec<String>, Vec<String>) = (vec![], vec![]);
|
||||||
let mut it = args.iter();
|
let mut it = args.iter();
|
||||||
while let Some(a) = it.next() {
|
while let Some(a) = it.next() {
|
||||||
// The roots also come as `--flag=DIR`, as the other Craft apps take them.
|
// The roots also come as `--flag=DIR`, as the other Craft apps take them.
|
||||||
@@ -190,10 +195,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
|||||||
"--headless" => headless = true,
|
"--headless" => headless = true,
|
||||||
"--automation-read-root" | "--automation-write-root" => {
|
"--automation-read-root" | "--automation-write-root" => {
|
||||||
let dir = inline.or_else(|| it.next().cloned()).ok_or_else(|| format!("{flag} needs a folder"))?;
|
let dir = inline.or_else(|| it.next().cloned()).ok_or_else(|| format!("{flag} needs a folder"))?;
|
||||||
let slot = if flag == "--automation-read-root" { &mut read_root } else { &mut write_root };
|
if flag == "--automation-read-root" { read_roots.push(dir) } else { write_roots.push(dir) }
|
||||||
if slot.replace(dir).is_some() {
|
|
||||||
return Err(format!("{flag} is given twice: it takes one folder"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
other => return Err(format!("unknown mcp option `{other}`")),
|
other => return Err(format!("unknown mcp option `{other}`")),
|
||||||
}
|
}
|
||||||
@@ -201,7 +203,9 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
|||||||
if headless && connect.is_some() {
|
if headless && connect.is_some() {
|
||||||
return Err("use either --connect or --headless".into());
|
return Err("use either --connect or --headless".into());
|
||||||
}
|
}
|
||||||
let roots = AutomationRoots::new(read_root.as_deref().map(Path::new), write_root.as_deref().map(Path::new))?;
|
let read: Vec<&Path> = read_roots.iter().map(Path::new).collect();
|
||||||
|
let write: Vec<&Path> = write_roots.iter().map(Path::new).collect();
|
||||||
|
let roots = AutomationRoots::new(&read, &write)?;
|
||||||
if roots.is_some() && connect.is_some() {
|
if roots.is_some() && connect.is_some() {
|
||||||
return Err("--automation-read-root and --automation-write-root confine the headless server; a running app enforces its own roots: start it with `vectorcraft --control <port> --automation-read-root <dir> --automation-write-root <dir>`"
|
return Err("--automation-read-root and --automation-write-root confine the headless server; a running app enforces its own roots: start it with `vectorcraft --control <port> --automation-read-root <dir> --automation-write-root <dir>`"
|
||||||
.into());
|
.into());
|
||||||
@@ -209,8 +213,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
|||||||
let backend: Box<dyn Backend> = if let Some(roots) = roots {
|
let backend: Box<dyn Backend> = if let Some(roots) = roots {
|
||||||
// Every thread of this process, not only the server's: nothing reaches past the roots.
|
// Every thread of this process, not only the server's: nothing reaches past the roots.
|
||||||
file_access::confine_process(roots.clone());
|
file_access::confine_process(roots.clone());
|
||||||
let show = |root: Option<&Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
|
eprintln!("vectorcraft-cli: files confined: {}", roots.describe());
|
||||||
eprintln!("vectorcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root()));
|
|
||||||
Box::new(Headless::with_document().with_automation_roots(Some(roots)))
|
Box::new(Headless::with_document().with_automation_roots(Some(roots)))
|
||||||
} else if headless {
|
} else if headless {
|
||||||
Box::new(with_stores(Headless::with_document()))
|
Box::new(with_stores(Headless::with_document()))
|
||||||
@@ -338,28 +341,127 @@ enum Step {
|
|||||||
Export(String),
|
Export(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Most bytes `run --commands` reads from a file or standard input: the steps are untrusted input.
|
||||||
|
const MAX_COMMANDS_BYTES: u64 = 64 * 1024 * 1024;
|
||||||
|
/// Most steps one `run --commands` takes.
|
||||||
|
const MAX_COMMANDS_STEPS: usize = 100_000;
|
||||||
|
|
||||||
|
/// The text of `--commands source`: the file `source`, or standard input for `-`, refused past
|
||||||
|
/// [`MAX_COMMANDS_BYTES`].
|
||||||
|
fn read_commands(source: &str) -> Result<String, String> {
|
||||||
|
use std::io::Read;
|
||||||
|
let mut bytes = vec![];
|
||||||
|
let read = if source == "-" {
|
||||||
|
std::io::stdin().lock().take(MAX_COMMANDS_BYTES + 1).read_to_end(&mut bytes)
|
||||||
|
} else {
|
||||||
|
std::fs::File::open(source).and_then(|f| f.take(MAX_COMMANDS_BYTES + 1).read_to_end(&mut bytes))
|
||||||
|
};
|
||||||
|
read.map_err(|e| format!("--commands {source}: {e}"))?;
|
||||||
|
if bytes.len() as u64 > MAX_COMMANDS_BYTES {
|
||||||
|
return Err(format!("--commands {source}: more than {} MiB of steps", MAX_COMMANDS_BYTES / (1024 * 1024)));
|
||||||
|
}
|
||||||
|
String::from_utf8(bytes).map_err(|_| format!("--commands {source}: not UTF-8 text"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The steps of `--commands source` from its `text`: a JSON array of `{"command": ID, "params":
|
||||||
|
/// {…}}` objects (`"id"` may stand for `"command"`, as in MCP's `command_batch`), or one such object
|
||||||
|
/// per line (JSON Lines, blank lines skipped). `first` is the run's index of the first of them
|
||||||
|
/// (the `N` of `"$N"`), which an error names with the entry's place in the text.
|
||||||
|
fn parse_commands(source: &str, text: &str, first: usize) -> Result<Vec<Step>, String> {
|
||||||
|
// Notepad on Windows starts UTF-8 files with a byte order mark.
|
||||||
|
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
|
||||||
|
let too_many = || format!("--commands {source}: more than {MAX_COMMANDS_STEPS} steps");
|
||||||
|
// Each entry with where it is in the text: its line for JSON Lines.
|
||||||
|
let mut entries: Vec<(Option<usize>, Value)> = vec![];
|
||||||
|
if text.trim_start().starts_with('[') {
|
||||||
|
let v: Value = serde_json::from_str(text).map_err(|e| format!("--commands {source}: {e}"))?;
|
||||||
|
let Value::Array(items) = v else { return Err(format!("--commands {source}: not a JSON array")) };
|
||||||
|
if items.len() > MAX_COMMANDS_STEPS {
|
||||||
|
return Err(too_many());
|
||||||
|
}
|
||||||
|
entries.extend(items.into_iter().map(|v| (None, v)));
|
||||||
|
} else {
|
||||||
|
for (n, line) in text.lines().enumerate().filter(|(_, l)| !l.trim().is_empty()) {
|
||||||
|
if entries.len() == MAX_COMMANDS_STEPS {
|
||||||
|
return Err(too_many());
|
||||||
|
}
|
||||||
|
let v = serde_json::from_str(line).map_err(|e| format!("--commands {source}: step {} (line {}): {e}", first + entries.len(), n + 1))?;
|
||||||
|
entries.push((Some(n + 1), v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(k, (line, v))| {
|
||||||
|
command_step(v).map_err(|e| {
|
||||||
|
let place = line.map_or_else(|| format!("entry {}", k + 1), |l| format!("line {l}"));
|
||||||
|
format!("--commands {source}: step {} ({place}): {e}", first + k)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The step of one `--commands` entry, `{"command": ID, "params": {…}}`.
|
||||||
|
fn command_step(v: Value) -> Result<Step, String> {
|
||||||
|
let Value::Object(mut o) = v else { return Err("a step must be an object such as {\"command\": \"select.all\", \"params\": {}}".into()) };
|
||||||
|
let id = match (o.remove("command"), o.remove("id")) {
|
||||||
|
(Some(Value::String(id)), None) | (None, Some(Value::String(id))) => id,
|
||||||
|
(Some(_), Some(_)) => return Err("give `command` or `id`, not both".into()),
|
||||||
|
(None, None) => return Err("no `command`".into()),
|
||||||
|
_ => return Err("`command` must be a string, a command id".into()),
|
||||||
|
};
|
||||||
|
let params = match o.remove("params") {
|
||||||
|
None | Some(Value::Null) => json!({}),
|
||||||
|
Some(p @ Value::Object(_)) => p,
|
||||||
|
Some(_) => return Err(format!("`params` of {id} must be a JSON object")),
|
||||||
|
};
|
||||||
|
if let Some(key) = o.keys().next() {
|
||||||
|
return Err(format!("unknown key `{key}`: a step has `command` (or `id`) and `params`"));
|
||||||
|
}
|
||||||
|
Ok(Step::Cmd(id, params))
|
||||||
|
}
|
||||||
|
|
||||||
fn run(args: &[String]) -> Result<(), String> {
|
fn run(args: &[String]) -> Result<(), String> {
|
||||||
let mut input: Option<String> = None;
|
let mut input: Option<String> = None;
|
||||||
let mut steps: Vec<Step> = vec![];
|
let mut steps: Vec<Step> = vec![];
|
||||||
|
// The step of the `--cmd` just given, which a `--params` sets.
|
||||||
|
let mut params_for: Option<usize> = None;
|
||||||
|
let mut stdin_read = false;
|
||||||
let mut scale = 1.0;
|
let mut scale = 1.0;
|
||||||
let mut it = args.iter();
|
let mut it = args.iter();
|
||||||
while let Some(a) = it.next() {
|
while let Some(a) = it.next() {
|
||||||
let mut val = |what: &str| it.next().cloned().ok_or_else(|| format!("{a} needs {what}"));
|
let mut val = |what: &str| it.next().cloned().ok_or_else(|| format!("{a} needs {what}"));
|
||||||
match a.as_str() {
|
match a.as_str() {
|
||||||
"--in" | "-i" => input = Some(val("a file")?),
|
"--in" | "-i" => input = Some(val("a file")?),
|
||||||
"--cmd" | "-c" => steps.push(Step::Cmd(val("a command id")?, json!({}))),
|
"--cmd" | "-c" => {
|
||||||
|
steps.push(Step::Cmd(val("a command id")?, json!({})));
|
||||||
|
params_for = Some(steps.len() - 1);
|
||||||
|
}
|
||||||
"--params" | "-p" => {
|
"--params" | "-p" => {
|
||||||
let raw = val("JSON")?;
|
let raw = val("JSON")?;
|
||||||
let p: Value = serde_json::from_str(&raw).map_err(|e| format!("--params {raw}: {e}"))?;
|
let p: Value = serde_json::from_str(&raw).map_err(|e| format!("--params {raw}: {e}"))?;
|
||||||
if !p.is_object() {
|
if !p.is_object() {
|
||||||
return Err(format!("--params must be a JSON object, got {raw}"));
|
return Err(format!("--params must be a JSON object, got {raw}"));
|
||||||
}
|
}
|
||||||
match steps.last_mut() {
|
match params_for.and_then(|i| steps.get_mut(i)) {
|
||||||
Some(Step::Cmd(_, params)) => *params = p,
|
Some(Step::Cmd(_, params)) => *params = p,
|
||||||
_ => return Err("--params must follow a --cmd".into()),
|
_ => return Err("--params must follow a --cmd".into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"--export" | "-o" => steps.push(Step::Export(val("a file")?)),
|
"--commands" => {
|
||||||
|
let source = val("a file, or - for standard input")?;
|
||||||
|
if source == "-" && std::mem::replace(&mut stdin_read, true) {
|
||||||
|
return Err("--commands - is given twice: standard input is read once".into());
|
||||||
|
}
|
||||||
|
let text = read_commands(&source)?;
|
||||||
|
let read = parse_commands(&source, &text, steps.len())?;
|
||||||
|
steps.extend(read);
|
||||||
|
params_for = None;
|
||||||
|
}
|
||||||
|
"--export" | "-o" => {
|
||||||
|
steps.push(Step::Export(val("a file")?));
|
||||||
|
params_for = None;
|
||||||
|
}
|
||||||
"--scale" | "-s" => {
|
"--scale" | "-s" => {
|
||||||
let raw = val("a number")?;
|
let raw = val("a number")?;
|
||||||
scale = raw.parse::<f64>().map_err(|_| format!("--scale {raw}: not a number"))?;
|
scale = raw.parse::<f64>().map_err(|_| format!("--scale {raw}: not a number"))?;
|
||||||
@@ -394,10 +496,12 @@ fn run(args: &[String]) -> Result<(), String> {
|
|||||||
// Each step's result, for later steps to refer to (`"$1.id"`, see `vectorcraft_engine::steps`).
|
// Each step's result, for later steps to refer to (`"$1.id"`, see `vectorcraft_engine::steps`).
|
||||||
let mut results = vec![];
|
let mut results = vec![];
|
||||||
for step in steps {
|
for step in steps {
|
||||||
|
// The step's index, as `"$N"` names its result.
|
||||||
|
let n = results.len();
|
||||||
let r = match step {
|
let r = match step {
|
||||||
Step::Cmd(id, params) => {
|
Step::Cmd(id, params) => {
|
||||||
let params = vectorcraft_engine::steps::resolve(¶ms, &results).map_err(|e| format!("{id}: {e}"))?;
|
let params = vectorcraft_engine::steps::resolve(¶ms, &results).map_err(|e| format!("step {n} ({id}): {e}"))?;
|
||||||
let r = h.call("engine.execute", json!({"command": id, "params": params})).map_err(|e| format!("{id}: {e}"))?;
|
let r = h.call("engine.execute", json!({"command": id, "params": params})).map_err(|e| format!("step {n} ({id}): {e}"))?;
|
||||||
emit(json!({"step": "cmd", "command": id, "result": r}))?;
|
emit(json!({"step": "cmd", "command": id, "result": r}))?;
|
||||||
r
|
r
|
||||||
}
|
}
|
||||||
@@ -407,7 +511,7 @@ fn run(args: &[String]) -> Result<(), String> {
|
|||||||
} else {
|
} else {
|
||||||
h.call("app.export", with_pixel_scale(json!({"path": path}), &path, scale))
|
h.call("app.export", with_pixel_scale(json!({"path": path}), &path, scale))
|
||||||
};
|
};
|
||||||
let r = r.map_err(|e| format!("export {path}: {e}"))?;
|
let r = r.map_err(|e| format!("step {n}: export {path}: {e}"))?;
|
||||||
emit(json!({"step": "export", "result": r}))?;
|
emit(json!({"step": "export", "result": r}))?;
|
||||||
r
|
r
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,13 +23,16 @@ impl Rng {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The synthetic document's page, in points.
|
||||||
|
const SYNTHETIC_PAGE: (f64, f64) = (1600.0, 1200.0);
|
||||||
|
|
||||||
/// `n` mixed paths (ellipses with strokes, translucent rectangles, stroked stars) on a 1600×1200 page.
|
/// `n` mixed paths (ellipses with strokes, translucent rectangles, stroked stars) on a 1600×1200 page.
|
||||||
pub fn synthetic(n: usize) -> Result<Document, String> {
|
pub fn synthetic(n: usize) -> Result<Document, String> {
|
||||||
let mut d = Document::new(1600.0, 1200.0);
|
let mut d = Document::new(SYNTHETIC_PAGE.0, SYNTHETIC_PAGE.1);
|
||||||
let l = d.layers.first().map(|l| l.id);
|
let l = d.layers.first().map(|l| l.id);
|
||||||
let mut r = Rng(42);
|
let mut r = Rng(42);
|
||||||
for i in 0..n {
|
for i in 0..n {
|
||||||
let (x, y, s) = (r.next() * 1600.0, r.next() * 1200.0, 3.0 + r.next() * 22.0);
|
let (x, y, s) = (r.next() * SYNTHETIC_PAGE.0, r.next() * SYNTHETIC_PAGE.1, 3.0 + r.next() * 22.0);
|
||||||
let c = Color::rgb(r.next() as f32, r.next() as f32, r.next() as f32);
|
let c = Color::rgb(r.next() as f32, r.next() as f32, r.next() as f32);
|
||||||
let id = d.alloc_id();
|
let id = d.alloc_id();
|
||||||
let mut node = match i % 3 {
|
let mut node = match i % 3 {
|
||||||
@@ -296,6 +299,27 @@ fn deep(n: usize) -> Result<(), String> {
|
|||||||
rows.push(("render 4 embedded 24 MP photos with 3 renderers (cold)".into(), ms));
|
rows.push(("render 4 embedded 24 MP photos with 3 renderers (cold)".into(), ms));
|
||||||
note("after rendering the photos with 3 renderers");
|
note("after rendering the photos with 3 renderers");
|
||||||
}
|
}
|
||||||
|
// An SVG with one embedded picture opened from its text, as picture-heavy SVGs come: the
|
||||||
|
// importer's passes over the text before usvg reads it, most of which is the data URL. The
|
||||||
|
// picture is the synthetic page drawn at 1.25x (about 2 MB as a PNG; the row names its size);
|
||||||
|
// the hidden rectangle is a marker, so the prepass walks the tree as it does for a file that
|
||||||
|
// has one, instead of returning at its first check.
|
||||||
|
{
|
||||||
|
let k = 1.25;
|
||||||
|
let (w, h) = ((SYNTHETIC_PAGE.0 * k).round() as u32, (SYNTHETIC_PAGE.1 * k).round() as u32);
|
||||||
|
let photo = Renderer::new().render(&synthetic(3000)?, w, h, Affine::scale(k), &opts).to_png()?;
|
||||||
|
let data = vectorcraft_svg::base64_encode(&photo);
|
||||||
|
let svg = format!(
|
||||||
|
r#"<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}"><image width="{w}" height="{h}" href="data:image/png;base64,{data}"/><rect width="100" height="100" style="display:none"/></svg>"#
|
||||||
|
);
|
||||||
|
// One import checked first, so a failure is reported rather than timed; the same text
|
||||||
|
// imports the same way each time. Its document is kept for the memory reading.
|
||||||
|
let imported = vectorcraft_svg::import_with_report(&svg).map_err(|e| e.to_string())?;
|
||||||
|
let ms = median_ms(5, || drop(vectorcraft_svg::import_with_report(&svg)));
|
||||||
|
rows.push((format!("import SVG, one embedded {} KB PNG", photo.len() / 1024), ms));
|
||||||
|
note("after the import of an embedded picture");
|
||||||
|
drop(imported);
|
||||||
|
}
|
||||||
// Output hashes, to compare builds: the SVG export and a save → open → save round trip.
|
// Output hashes, to compare builds: the SVG export and a save → open → save round trip.
|
||||||
let fnv = |b: &[u8]| b.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, &x| (h ^ u64::from(x)).wrapping_mul(0x100_0000_01b3));
|
let fnv = |b: &[u8]| b.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, &x| (h ^ u64::from(x)).wrapping_mul(0x100_0000_01b3));
|
||||||
let doc = synthetic(n)?;
|
let doc = synthetic(n)?;
|
||||||
|
|||||||
@@ -84,11 +84,125 @@ fn scale_sizes_raster_exports_only() {
|
|||||||
fn run_reports_errors() {
|
fn run_reports_errors() {
|
||||||
let out = Command::new(BIN).args(["run", "--cmd", "nope.nothing"]).output().unwrap();
|
let out = Command::new(BIN).args(["run", "--cmd", "nope.nothing"]).output().unwrap();
|
||||||
assert!(!out.status.success());
|
assert!(!out.status.success());
|
||||||
assert!(String::from_utf8_lossy(&out.stderr).contains("nope.nothing"));
|
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||||
|
assert!(stderr.contains("step 0 (nope.nothing)"), "{stderr}");
|
||||||
let out = Command::new(BIN).args(["run", "--params", "{}"]).output().unwrap();
|
let out = Command::new(BIN).args(["run", "--params", "{}"]).output().unwrap();
|
||||||
assert!(!out.status.success());
|
assert!(!out.status.success());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The JSON lines `run` printed.
|
||||||
|
fn json_lines(stdout: &[u8]) -> Vec<Value> {
|
||||||
|
String::from_utf8_lossy(stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #3147: `run --commands FILE` reads its steps from a JSON array (`command` or `id`), runs them
|
||||||
|
/// where the option stands among `--cmd` and `--export`, and they count with those for `"$N"`; a
|
||||||
|
/// placed file's `dataBase64` longer than a Windows command line holds fits.
|
||||||
|
#[test]
|
||||||
|
fn run_reads_steps_from_a_commands_file() {
|
||||||
|
let steps = tmp("steps.json");
|
||||||
|
let svg = tmp("steps.svg");
|
||||||
|
// Base64 of an SVG well past 32,767 characters, the most a Windows command line holds.
|
||||||
|
let padding = "x".repeat(40_000);
|
||||||
|
let placed = format!(
|
||||||
|
r##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><!-- {padding} --><rect width="40" height="20" fill="#118833"/></svg>"##
|
||||||
|
);
|
||||||
|
let data = vectorcraft_format::base64_encode(placed.as_bytes());
|
||||||
|
assert!(data.len() > 32_767);
|
||||||
|
let file = json!([
|
||||||
|
{"command": "shape.rectangle", "params": {"x": 10, "y": 10, "width": 50, "height": 30}},
|
||||||
|
{"command": "file.place", "params": {"name": "code.svg", "dataBase64": data}},
|
||||||
|
{"id": "object.setProps", "params": {"id": "$2.ids.0", "name": "barcode"}},
|
||||||
|
]);
|
||||||
|
std::fs::write(&steps, serde_json::to_string_pretty(&file).unwrap()).unwrap();
|
||||||
|
let out = Command::new(BIN)
|
||||||
|
.args(["run", "--cmd", "file.new", "--params", r#"{"width":200,"height":100}"#, "--commands", steps.to_str().unwrap()])
|
||||||
|
.args(["--cmd", "document.node", "--params", r#"{"id":"$2.ids.0"}"#, "--export", svg.to_str().unwrap()])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
let lines = json_lines(&out.stdout);
|
||||||
|
let ran: Vec<&str> = lines.iter().map(|l| l["command"].as_str().unwrap_or("export")).collect();
|
||||||
|
assert_eq!(ran, ["file.new", "shape.rectangle", "file.place", "object.setProps", "document.node", "export"]);
|
||||||
|
// `$2` in the file and in the `--cmd` after it is the placed art.
|
||||||
|
assert_eq!(lines[4]["result"]["name"], "barcode", "{}", lines[4]);
|
||||||
|
let written = std::fs::read_to_string(&svg).unwrap();
|
||||||
|
assert!(written.contains("118833") && written.contains("barcode"), "{written}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `run --commands -` reads JSON Lines from standard input: a byte order mark, blank lines and
|
||||||
|
/// CRLF line ends are fine, and the first step may be `file.new`.
|
||||||
|
#[test]
|
||||||
|
fn run_reads_json_lines_from_standard_input() {
|
||||||
|
let svg = tmp("stdin.svg");
|
||||||
|
let mut child = Command::new(BIN)
|
||||||
|
.args(["run", "--commands", "-", "--export", svg.to_str().unwrap()])
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.unwrap();
|
||||||
|
let text = "\u{feff}{\"command\":\"file.new\",\"params\":{\"width\":120,\"height\":80}}\n\n{\"command\":\"shape.ellipse\",\"params\":{\"x\":10,\"y\":10,\"width\":40,\"height\":30}}\r\n{\"id\":\"paint.setFill\",\"params\":{\"color\":\"#3366ff\"}}\n";
|
||||||
|
child.stdin.take().unwrap().write_all(text.as_bytes()).unwrap();
|
||||||
|
let out = child.wait_with_output().unwrap();
|
||||||
|
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
let lines = json_lines(&out.stdout);
|
||||||
|
assert_eq!(lines.len(), 4);
|
||||||
|
assert_eq!(lines[0]["command"], "file.new");
|
||||||
|
assert!(std::fs::read_to_string(&svg).unwrap().contains("3366ff"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Malformed `--commands` input stops the run before any step runs, naming the step (counted from
|
||||||
|
/// 0 with the `--cmd` before it) and the entry or line; input past the size and step limits is
|
||||||
|
/// refused; a step that fails names its number.
|
||||||
|
#[test]
|
||||||
|
fn run_refuses_malformed_commands_naming_the_step() {
|
||||||
|
let fails = |args: &[&str]| {
|
||||||
|
let out = Command::new(BIN).args(args).stdin(Stdio::null()).output().unwrap();
|
||||||
|
assert!(!out.status.success(), "{args:?}");
|
||||||
|
(String::from_utf8_lossy(&out.stderr).to_string(), json_lines(&out.stdout).len())
|
||||||
|
};
|
||||||
|
let bad = |name: &str, text: &[u8], wanted: &[&str]| {
|
||||||
|
let path = tmp(name);
|
||||||
|
std::fs::write(&path, text).unwrap();
|
||||||
|
let (e, printed) = fails(&["run", "--cmd", "file.new", "--commands", path.to_str().unwrap()]);
|
||||||
|
assert!(wanted.iter().all(|w| e.contains(w)), "{name}: {e}");
|
||||||
|
assert_eq!(printed, 0, "{name}: nothing runs");
|
||||||
|
};
|
||||||
|
bad("number.json", b"[1]", &["step 1 (entry 1)", "must be an object"]);
|
||||||
|
bad("both.json", br#"[{"command":"select.all"},{"command":"select.all","id":"select.none"}]"#, &["step 2 (entry 2)", "not both"]);
|
||||||
|
bad("no-command.json", br#"[{"params":{}}]"#, &["step 1 (entry 1)", "no `command`"]);
|
||||||
|
bad("id-number.json", br#"[{"command":3}]"#, &["`command` must be a string"]);
|
||||||
|
bad("params-array.json", br#"[{"command":"select.all","params":[1]}]"#, &["`params` of select.all must be a JSON object"]);
|
||||||
|
bad("typo.json", br#"[{"command":"select.all","param":{}}]"#, &["unknown key `param`"]);
|
||||||
|
bad("broken.json", b"[{\"command\":", &["--commands", "broken.json"]);
|
||||||
|
bad("lines.jsonl", b"{\"command\":\"select.all\"}\n\n{oops}\n", &["step 2 (line 3)"]);
|
||||||
|
bad("line-array.jsonl", b"{\"command\":\"select.all\"}\n[1]\n", &["step 2 (line 2)"]);
|
||||||
|
bad("latin1.json", b"[{\"command\":\"select.all\",\"params\":{\"name\":\"\xe9\"}}]", &["not UTF-8"]);
|
||||||
|
let many = "{\"command\":\"select.all\"}\n".repeat(100_001);
|
||||||
|
bad("many.jsonl", many.as_bytes(), &["more than 100000 steps"]);
|
||||||
|
// Past 64 MiB (a sparse file: nothing is written).
|
||||||
|
let huge = tmp("huge.json");
|
||||||
|
std::fs::File::create(&huge).unwrap().set_len(64 * 1024 * 1024 + 1).unwrap();
|
||||||
|
let (e, _) = fails(&["run", "--commands", huge.to_str().unwrap()]);
|
||||||
|
assert!(e.contains("more than 64 MiB"), "{e}");
|
||||||
|
let (e, _) = fails(&["run", "--commands", tmp("missing.json").to_str().unwrap()]);
|
||||||
|
assert!(e.contains("missing.json"), "{e}");
|
||||||
|
// `--params` sets the `--cmd` just before it, never a step of the file.
|
||||||
|
let ok = tmp("ok.json");
|
||||||
|
std::fs::write(&ok, br#"[{"command":"select.all"}]"#).unwrap();
|
||||||
|
let (e, _) = fails(&["run", "--cmd", "select.all", "--commands", ok.to_str().unwrap(), "--params", "{}"]);
|
||||||
|
assert!(e.contains("--params must follow a --cmd"), "{e}");
|
||||||
|
let (e, _) = fails(&["run", "--commands", "-", "--commands", "-"]);
|
||||||
|
assert!(e.contains("standard input is read once"), "{e}");
|
||||||
|
// A step that fails is named by its number, after the steps before it ran.
|
||||||
|
let failing = tmp("failing.json");
|
||||||
|
std::fs::write(&failing, br#"[{"command":"select.all"},{"command":"nope.nothing"}]"#).unwrap();
|
||||||
|
let (e, printed) = fails(&["run", "--cmd", "file.new", "--commands", failing.to_str().unwrap()]);
|
||||||
|
assert!(e.contains("step 2 (nope.nothing)"), "{e}");
|
||||||
|
assert_eq!(printed, 2);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mcp_headless_over_stdio() {
|
fn mcp_headless_over_stdio() {
|
||||||
let mut child = Command::new(BIN).args(["mcp", "--headless"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();
|
let mut child = Command::new(BIN).args(["mcp", "--headless"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();
|
||||||
@@ -130,6 +244,58 @@ fn mcp_automation_roots_in_a_folder_an_earlier_run_left() {
|
|||||||
automation_roots(&empty_dir(dir).join("roots"));
|
automation_roots(&empty_dir(dir).join("roots"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #3546: each root flag may be given more than once: a path inside any of the read roots opens,
|
||||||
|
/// one inside any of the write roots is written, and everything else, a write root's sibling read
|
||||||
|
/// root included, is refused.
|
||||||
|
#[test]
|
||||||
|
fn mcp_automation_roots_take_several_folders() {
|
||||||
|
let base = tmp("several-roots");
|
||||||
|
for d in ["in", "in2", "out", "out2", "outside"] {
|
||||||
|
std::fs::create_dir_all(base.join(d)).unwrap();
|
||||||
|
}
|
||||||
|
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="30"><rect width="20" height="10"/></svg>"#;
|
||||||
|
for f in ["in/a.svg", "in2/b.svg", "outside/c.svg"] {
|
||||||
|
std::fs::write(base.join(f), svg).unwrap();
|
||||||
|
}
|
||||||
|
let dir = |d: &str| base.join(d).to_string_lossy().into_owned();
|
||||||
|
let file = |d: &str, f: &str| base.join(d).join(f).to_string_lossy().into_owned();
|
||||||
|
let mut child = Command::new(BIN)
|
||||||
|
.args(["mcp", "--automation-read-root", &dir("in"), &format!("--automation-read-root={}", dir("in2"))])
|
||||||
|
.args(["--automation-write-root", &dir("out"), "--automation-write-root", &dir("out2")])
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.unwrap();
|
||||||
|
let call = |id: u64, name: &str, args: Value| json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":name,"arguments":args}});
|
||||||
|
let calls = [
|
||||||
|
(call(1, "open_file", json!({"path": file("in", "a.svg")})), false),
|
||||||
|
(call(2, "open_file", json!({"path": file("in2", "b.svg")})), false),
|
||||||
|
(call(3, "open_file", json!({"path": file("outside", "c.svg")})), true),
|
||||||
|
(call(4, "export", json!({"path": file("out", "a.png")})), false),
|
||||||
|
(call(5, "export", json!({"path": file("out2", "b.png")})), false),
|
||||||
|
(call(6, "export", json!({"path": file("outside", "c.png")})), true),
|
||||||
|
(call(7, "export", json!({"path": file("in2", "d.png")})), true),
|
||||||
|
(call(8, "open_file", json!({"path": file("out2", "b.png")})), true),
|
||||||
|
];
|
||||||
|
{
|
||||||
|
let mut stdin = child.stdin.take().unwrap();
|
||||||
|
for (m, _) in &calls {
|
||||||
|
writeln!(stdin, "{m}").unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let out = child.wait_with_output().unwrap();
|
||||||
|
assert!(out.status.success());
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
assert!(stderr.contains("files confined: read ") && stderr.contains("; write "), "{stderr}");
|
||||||
|
let replies: Vec<Value> = String::from_utf8(out.stdout).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||||
|
let errors: Vec<bool> = replies.iter().map(|r| r["result"]["isError"] == true).collect();
|
||||||
|
assert_eq!(errors, calls.iter().map(|(_, e)| *e).collect::<Vec<_>>(), "{replies:?}");
|
||||||
|
assert!(replies[2]["result"]["content"][0]["text"].as_str().unwrap().contains("outside the read roots"), "{}", replies[2]);
|
||||||
|
assert!(base.join("out/a.png").is_file() && base.join("out2/b.png").is_file());
|
||||||
|
assert!(!base.join("outside/c.png").exists() && !base.join("in2/d.png").exists());
|
||||||
|
}
|
||||||
|
|
||||||
fn automation_roots(base: &std::path::Path) {
|
fn automation_roots(base: &std::path::Path) {
|
||||||
for d in ["in", "out", "outside"] {
|
for d in ["in", "out", "outside"] {
|
||||||
std::fs::create_dir_all(base.join(d)).unwrap();
|
std::fs::create_dir_all(base.join(d)).unwrap();
|
||||||
@@ -153,7 +319,7 @@ fn automation_roots(base: &std::path::Path) {
|
|||||||
assert!(!out.status.success() && err.contains(why), "{args:?}: {err}");
|
assert!(!out.status.success() && err.contains(why), "{args:?}: {err}");
|
||||||
};
|
};
|
||||||
fails(&["mcp", "--automation-read-root", &dir("missing")], "cannot open the automation read root");
|
fails(&["mcp", "--automation-read-root", &dir("missing")], "cannot open the automation read root");
|
||||||
fails(&["mcp", "--automation-read-root", &dir("in"), "--automation-read-root", &dir("out")], "given twice");
|
fails(&["mcp", "--automation-read-root", &dir("in"), "--automation-read-root", &dir("missing")], "cannot open the automation read root");
|
||||||
fails(&["mcp", "--automation-write-root"], "needs a folder");
|
fails(&["mcp", "--automation-write-root"], "needs a folder");
|
||||||
fails(&["mcp", "--connect", "127.0.0.1:1", &format!("--automation-write-root={}", dir("out"))], "a running app enforces its own roots");
|
fails(&["mcp", "--connect", "127.0.0.1:1", &format!("--automation-write-root={}", dir("out"))], "a running app enforces its own roots");
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ eframe = { workspace = true, features = ["default_fonts", "wayland", "x11"] }
|
|||||||
egui = { workspace = true }
|
egui = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
|
||||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
rfd = { workspace = true }
|
rfd = { workspace = true }
|
||||||
|
|||||||
@@ -6,29 +6,29 @@ use std::ffi::OsString;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
pub struct AutomationArgs {
|
pub struct AutomationArgs {
|
||||||
pub read_root: Option<PathBuf>,
|
pub read_roots: Vec<PathBuf>,
|
||||||
pub write_root: Option<PathBuf>,
|
pub write_roots: Vec<PathBuf>,
|
||||||
read_given: bool,
|
read_given: bool,
|
||||||
write_given: bool,
|
write_given: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AutomationArgs {
|
impl AutomationArgs {
|
||||||
|
/// The roots the environment gives (`VECTORCRAFT_AUTOMATION_READ_ROOT` / `_WRITE_ROOT`), until
|
||||||
|
/// the command line gives its own.
|
||||||
pub fn new(read_root: Option<PathBuf>, write_root: Option<PathBuf>) -> Self {
|
pub fn new(read_root: Option<PathBuf>, write_root: Option<PathBuf>) -> Self {
|
||||||
Self { read_root, write_root, read_given: false, write_given: false }
|
Self { read_roots: read_root.into_iter().collect(), write_roots: write_root.into_iter().collect(), read_given: false, write_given: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns whether this argument is a root flag. CLI values override environment defaults
|
/// Returns whether this argument is a root flag. Each flag may be given more than once (#3546),
|
||||||
/// once; a second occurrence is an error, including when the two spellings are mixed.
|
/// in either spelling: the first replaces the environment's root of its kind, the others add
|
||||||
|
/// folders.
|
||||||
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
|
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
|
||||||
let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
|
let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
|
||||||
let (root, given) = match flag {
|
let (roots, given) = match flag {
|
||||||
"--automation-read-root" => (&mut self.read_root, &mut self.read_given),
|
"--automation-read-root" => (&mut self.read_roots, &mut self.read_given),
|
||||||
"--automation-write-root" => (&mut self.write_root, &mut self.write_given),
|
"--automation-write-root" => (&mut self.write_roots, &mut self.write_given),
|
||||||
_ => return Ok(false),
|
_ => return Ok(false),
|
||||||
};
|
};
|
||||||
if *given {
|
|
||||||
return Err(format!("{flag} given twice"));
|
|
||||||
}
|
|
||||||
let value = match inline {
|
let value = match inline {
|
||||||
Some(value) => value.to_owned(),
|
Some(value) => value.to_owned(),
|
||||||
None => {
|
None => {
|
||||||
@@ -42,8 +42,10 @@ impl AutomationArgs {
|
|||||||
if value.is_empty() {
|
if value.is_empty() {
|
||||||
return Err(format!("{flag} needs a non-empty folder"));
|
return Err(format!("{flag} needs a non-empty folder"));
|
||||||
}
|
}
|
||||||
*root = Some(value.into());
|
if !std::mem::replace(given, true) {
|
||||||
*given = true;
|
roots.clear();
|
||||||
|
}
|
||||||
|
roots.push(value.into());
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,17 +99,24 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn roots_override_environment_once_and_leave_other_arguments() {
|
fn roots_override_environment_and_add_up() {
|
||||||
let mut roots = AutomationArgs::new(Some("environment".into()), None);
|
let mut roots = AutomationArgs::new(Some("environment".into()), Some("environment out".into()));
|
||||||
let mut args = ["a folder".to_owned(), "drawing.svg".to_owned()].into_iter();
|
let mut args = ["a folder".to_owned(), "drawing.svg".to_owned()].into_iter();
|
||||||
assert!(roots.consume("--automation-read-root", &mut args).unwrap());
|
assert!(roots.consume("--automation-read-root", &mut args).unwrap());
|
||||||
assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
|
assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
|
||||||
assert!(!roots.consume("--in-window-menus", &mut args).unwrap());
|
assert!(!roots.consume("--in-window-menus", &mut args).unwrap());
|
||||||
assert_eq!(roots.read_root, Some("a folder".into()));
|
assert_eq!(roots.read_roots, [PathBuf::from("a folder")]);
|
||||||
assert_eq!(roots.write_root, Some("out".into()));
|
assert_eq!(roots.write_roots, [PathBuf::from("out")]);
|
||||||
assert_eq!(args.next().as_deref(), Some("drawing.svg"));
|
assert_eq!(args.next().as_deref(), Some("drawing.svg"));
|
||||||
assert!(roots.consume("--automation-read-root=other", &mut args).is_err());
|
// #3546: a flag given again adds a folder, in either spelling.
|
||||||
assert_eq!(roots.read_root, Some("a folder".into()));
|
assert!(roots.consume("--automation-read-root=other", &mut args).unwrap());
|
||||||
|
assert!(roots.consume("--automation-write-root", &mut ["out2".to_owned()].into_iter()).unwrap());
|
||||||
|
assert_eq!(roots.read_roots, [PathBuf::from("a folder"), PathBuf::from("other")]);
|
||||||
|
assert_eq!(roots.write_roots, [PathBuf::from("out"), PathBuf::from("out2")]);
|
||||||
|
// A kind the command line leaves alone keeps the environment's root.
|
||||||
|
let mut roots = AutomationArgs::new(Some("environment".into()), Some("environment out".into()));
|
||||||
|
assert!(roots.consume("--automation-read-root=a", &mut std::iter::empty()).unwrap());
|
||||||
|
assert_eq!(roots.write_roots, [PathBuf::from("environment out")]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -117,11 +126,15 @@ mod tests {
|
|||||||
let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into()));
|
let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into()));
|
||||||
let mut args = operand.map(str::to_owned).into_iter();
|
let mut args = operand.map(str::to_owned).into_iter();
|
||||||
assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
|
assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
|
||||||
assert_eq!(roots.read_root, Some("read".into()));
|
assert_eq!(roots.read_roots, [PathBuf::from("read")]);
|
||||||
assert_eq!(roots.write_root, Some("write".into()));
|
assert_eq!(roots.write_roots, [PathBuf::from("write")]);
|
||||||
}
|
}
|
||||||
let mut roots = AutomationArgs::new(None, None);
|
let mut roots = AutomationArgs::new(None, None);
|
||||||
assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
|
assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
|
||||||
|
// A bad second folder is an error too, and keeps the first.
|
||||||
|
assert!(roots.consume(&format!("{flag}=first"), &mut std::iter::empty()).unwrap());
|
||||||
|
assert!(roots.consume(flag, &mut std::iter::empty()).is_err());
|
||||||
|
assert_eq!([roots.read_roots.len(), roots.write_roots.len()].iter().sum::<usize>(), 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,8 +143,8 @@ mod tests {
|
|||||||
let mut roots = AutomationArgs::new(None, None);
|
let mut roots = AutomationArgs::new(None, None);
|
||||||
assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
|
assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
|
||||||
assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
|
assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
|
||||||
assert_eq!(roots.read_root, Some("-folder".into()));
|
assert_eq!(roots.read_roots, [PathBuf::from("-folder")]);
|
||||||
assert_eq!(roots.write_root, Some("./-folder".into()));
|
assert_eq!(roots.write_roots, [PathBuf::from("./-folder")]);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn control(args: &[&str]) -> Result<Option<u16>, String> {
|
fn control(args: &[&str]) -> Result<Option<u16>, String> {
|
||||||
|
|||||||
@@ -93,28 +93,36 @@ fn matches(name: &str, target: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
|
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
|
||||||
|
/// A clock far past chrono's year range still converts (the calendar repeats every 400 years), so
|
||||||
|
/// logging never panics and never silently resets to the epoch for a convertible instant.
|
||||||
pub fn timestamp(t: SystemTime) -> String {
|
pub fn timestamp(t: SystemTime) -> String {
|
||||||
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
||||||
let secs = since.as_secs();
|
// Pre-1970 is clamped to the epoch by `unwrap_or_default` above. `as_secs` is `u64`; a value
|
||||||
let (days, rem) = (secs / 86_400, secs % 86_400);
|
// past `i64::MAX` seconds saturates rather than falling back to the epoch.
|
||||||
let (y, m, d) = civil_from_days(days);
|
let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX);
|
||||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
|
let (y, t) = utc_parts(secs, since.subsec_nanos());
|
||||||
|
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
|
||||||
|
format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
|
/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
|
||||||
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). Saturating, so no clock
|
/// chrono's range by whole 400-year cycles). The calendar repeats every 400 years (146_097 days),
|
||||||
/// value can overflow it.
|
/// so month, day and time of day come from chrono and only the year is carried, which keeps every
|
||||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
/// `i64` exact. Replaces the old hand-rolled `civil_from_days`; chrono is the only calendar logic.
|
||||||
let z = days.saturating_add(719_468);
|
fn utc_parts(secs: i64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
|
||||||
let era = z / 146_097;
|
use chrono::Datelike;
|
||||||
let doe = z % 146_097;
|
let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
|
||||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
|
||||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
|
||||||
let mp = (5 * doy + 2) / 153;
|
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
|
||||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
|
||||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
match (date, time) {
|
||||||
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
|
(Some(d), Some(t)) => {
|
||||||
(y, m, d)
|
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||||
|
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t))
|
||||||
|
}
|
||||||
|
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||||
@@ -387,6 +395,20 @@ mod tests {
|
|||||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
|
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
|
||||||
// Before the epoch (a clock set wrong) clamps instead of panicking.
|
// Before the epoch (a clock set wrong) clamps instead of panicking.
|
||||||
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
|
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
|
||||||
|
// Past chrono's year range still converts via the 400-year cycle (the old code did too,
|
||||||
|
// through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows'
|
||||||
|
// SystemTime can't hold this instant, so it is only checked where it exists.
|
||||||
|
// `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output).
|
||||||
|
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
|
||||||
|
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
|
||||||
|
}
|
||||||
|
// Years past 9999 keep the plain zero-padded year (no `+`).
|
||||||
|
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
|
||||||
|
// 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857.
|
||||||
|
// Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
|
||||||
|
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
|
||||||
|
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! VectorCraft desktop app.
|
//! VectorCraft desktop app.
|
||||||
//!
|
//!
|
||||||
//! Usage: `vectorcraft [--control <port> [--automation-read-root <dir>] [--automation-write-root <dir>]]
|
//! Usage: `vectorcraft [--control <port> [--automation-read-root <dir>]... [--automation-write-root <dir>]...]
|
||||||
//! [--in-window-menus] [files…]`, `vectorcraft --version`, `vectorcraft --help` (or `-h`)
|
//! [--in-window-menus] [files…]`, `vectorcraft --version`, `vectorcraft --help` (or `-h`)
|
||||||
//!
|
//!
|
||||||
//! `--in-window-menus` (or `VECTORCRAFT_IN_WINDOW_MENUS=1`) keeps the menus inside the window on
|
//! `--in-window-menus` (or `VECTORCRAFT_IN_WINDOW_MENUS=1`) keeps the menus inside the window on
|
||||||
@@ -70,6 +70,12 @@ struct App {
|
|||||||
frames: u64,
|
frames: u64,
|
||||||
/// Keeps the custom title bar's window borderless (Windows, #1758).
|
/// Keeps the custom title bar's window borderless (Windows, #1758).
|
||||||
window_frame: Option<window_frame::WindowFrameFn>,
|
window_frame: Option<window_frame::WindowFrameFn>,
|
||||||
|
/// The main window; used to read and set maximized state the UI layer does not see on Linux (#3635).
|
||||||
|
winit_window: Option<std::sync::Arc<winit::window::Window>>,
|
||||||
|
/// A saved or fitted geometry asked for maximize once the window is mapped (Linux).
|
||||||
|
pending_maximize: bool,
|
||||||
|
/// Frames left to retry [`window::apply_pending_maximize`].
|
||||||
|
maximize_tries: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl eframe::App for App {
|
impl eframe::App for App {
|
||||||
@@ -147,7 +153,15 @@ impl App {
|
|||||||
open_files(&mut self.app, open_documents::take());
|
open_files(&mut self.app, open_documents::take());
|
||||||
}
|
}
|
||||||
self.app.logic(ctx);
|
self.app.logic(ctx);
|
||||||
window::track(ctx, &mut self.app.ui.window);
|
if self.pending_maximize {
|
||||||
|
if let Some(w) = &self.winit_window {
|
||||||
|
self.pending_maximize = window::apply_pending_maximize(w, true, &mut self.maximize_tries);
|
||||||
|
} else {
|
||||||
|
self.pending_maximize = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let host_maximized = self.winit_window.as_ref().map(|w| w.is_maximized());
|
||||||
|
window::track(ctx, &mut self.app.ui.window, host_maximized);
|
||||||
if self.app.ui.status == "quit" {
|
if self.app.ui.status == "quit" {
|
||||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||||
}
|
}
|
||||||
@@ -725,7 +739,9 @@ Options:
|
|||||||
--control <port> Start the localhost JSON-lines control server on <port>
|
--control <port> Start the localhost JSON-lines control server on <port>
|
||||||
(or VECTORCRAFT_CONTROL_PORT)
|
(or VECTORCRAFT_CONTROL_PORT)
|
||||||
--automation-read-root <dir> Confine the files control requests read to <dir>
|
--automation-read-root <dir> Confine the files control requests read to <dir>
|
||||||
|
(may be given more than once)
|
||||||
--automation-write-root <dir> Confine the files control requests write to <dir>
|
--automation-write-root <dir> Confine the files control requests write to <dir>
|
||||||
|
(may be given more than once)
|
||||||
--in-window-menus Keep the menus inside the window on macOS
|
--in-window-menus Keep the menus inside the window on macOS
|
||||||
(or VECTORCRAFT_IN_WINDOW_MENUS=1)
|
(or VECTORCRAFT_IN_WINDOW_MENUS=1)
|
||||||
--version Print the version and exit
|
--version Print the version and exit
|
||||||
@@ -774,7 +790,7 @@ fn main() -> std::process::ExitCode {
|
|||||||
_ => files.push(a),
|
_ => files.push(a),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let automation_args::AutomationArgs { read_root, write_root, .. } = roots_args;
|
let automation_args::AutomationArgs { read_roots, write_roots, .. } = roots_args;
|
||||||
// After the arguments: `--control` and `--version` win over a bad environment value.
|
// After the arguments: `--control` and `--version` win over a bad environment value.
|
||||||
let control_port = match control.port {
|
let control_port = match control.port {
|
||||||
Some(port) => Some(port),
|
Some(port) => Some(port),
|
||||||
@@ -813,12 +829,14 @@ fn main() -> std::process::ExitCode {
|
|||||||
// The roots confine the control channel: a bad one keeps the app from starting rather than
|
// The roots confine the control channel: a bad one keeps the app from starting rather than
|
||||||
// leaving agents unconfined. Without a control port there is nothing to confine.
|
// leaving agents unconfined. Without a control port there is nothing to confine.
|
||||||
let roots = if control_port.is_none() {
|
let roots = if control_port.is_none() {
|
||||||
if read_root.is_some() || write_root.is_some() {
|
if !read_roots.is_empty() || !write_roots.is_empty() {
|
||||||
log::warn!("--automation-read-root and --automation-write-root confine the control channel: ignored without --control");
|
log::warn!("--automation-read-root and --automation-write-root confine the control channel: ignored without --control");
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
match AutomationRoots::new(read_root.as_deref(), write_root.as_deref()) {
|
let read: Vec<&std::path::Path> = read_roots.iter().map(std::path::PathBuf::as_path).collect();
|
||||||
|
let write: Vec<&std::path::Path> = write_roots.iter().map(std::path::PathBuf::as_path).collect();
|
||||||
|
match AutomationRoots::new(&read, &write) {
|
||||||
Ok(roots) => roots,
|
Ok(roots) => roots,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("{e}");
|
log::error!("{e}");
|
||||||
@@ -949,11 +967,15 @@ fn main() -> std::process::ExitCode {
|
|||||||
// which takes over from the copy the preferences file used to hold.
|
// which takes over from the copy the preferences file used to hold.
|
||||||
prefs_dir::attach_pdf_preset_store(&mut app.session);
|
prefs_dir::attach_pdf_preset_store(&mut app.session);
|
||||||
// Fit the window to its monitor, or put it back where it was (still hidden).
|
// Fit the window to its monitor, or put it back where it was (still hidden).
|
||||||
if let Some(w) = cc.winit_window() {
|
let winit_window = cc.winit_window().cloned();
|
||||||
app.ui.window = Some(window::restore(w, saved_window));
|
if let Some(w) = &winit_window {
|
||||||
|
let g = window::restore(w, saved_window);
|
||||||
|
app.ui.window = Some(g);
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
set_taskbar_icon(w);
|
set_taskbar_icon(w);
|
||||||
}
|
}
|
||||||
|
// Linux may not map the window before the first frames, so a saved maximize is retried then (#3635).
|
||||||
|
let pending_maximize = cfg!(target_os = "linux") && app.ui.window.as_ref().is_some_and(|g| g.maximized);
|
||||||
// User Defined swatch and graphic style libraries live next to the preferences.
|
// User Defined swatch and graphic style libraries live next to the preferences.
|
||||||
let swatches = prefs_path().and_then(|p| Some(p.parent()?.join("Swatches").to_string_lossy().to_string()));
|
let swatches = prefs_path().and_then(|p| Some(p.parent()?.join("Swatches").to_string_lossy().to_string()));
|
||||||
app.session.swatch_libraries.set_user_dir(swatches);
|
app.session.swatch_libraries.set_user_dir(swatches);
|
||||||
@@ -1015,7 +1037,7 @@ fn main() -> std::process::ExitCode {
|
|||||||
// Everywhere the app doesn't say whose work it is (worker threads), the
|
// Everywhere the app doesn't say whose work it is (worker threads), the
|
||||||
// roots hold: unconfined is only what the person at the keyboard does.
|
// roots hold: unconfined is only what the person at the keyboard does.
|
||||||
file_access::confine_process(roots.clone());
|
file_access::confine_process(roots.clone());
|
||||||
log::info!("control channel confined to the automation roots: read {:?}, write {:?}", roots.read_root(), roots.write_root());
|
log::info!("control channel confined to the automation roots: {}", roots.describe());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
@@ -1034,7 +1056,16 @@ fn main() -> std::process::ExitCode {
|
|||||||
created.ready();
|
created.ready();
|
||||||
// The content and the pointer stay aligned on Windows (#1758).
|
// The content and the pointer stay aligned on Windows (#1758).
|
||||||
let window_frame = window_frame::service(cc, custom_titlebar);
|
let window_frame = window_frame::service(cc, custom_titlebar);
|
||||||
Ok(Box::new(App { app, graphics_loss, graphics_lost: false, frames: 0, window_frame }))
|
Ok(Box::new(App {
|
||||||
|
app,
|
||||||
|
graphics_loss,
|
||||||
|
graphics_lost: false,
|
||||||
|
frames: 0,
|
||||||
|
window_frame,
|
||||||
|
winit_window,
|
||||||
|
pending_maximize,
|
||||||
|
maximize_tries: 8,
|
||||||
|
}))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -110,7 +110,8 @@ fn fit(size: [f32; 2], room: [f32; 2]) -> [f32; 2] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Size and place the new window (eframe shows it after drawing the first frame) and return its
|
/// Size and place the new window (eframe shows it after drawing the first frame) and return its
|
||||||
/// geometry.
|
/// geometry. On Linux the window may not be mapped yet, so [`apply_pending_maximize`] must run
|
||||||
|
/// until the host reports maximized (fixes #3635).
|
||||||
pub fn restore(window: &winit::window::Window, saved: Option<WindowGeometry>) -> WindowGeometry {
|
pub fn restore(window: &winit::window::Window, saved: Option<WindowGeometry>) -> WindowGeometry {
|
||||||
let handles: Vec<_> = window.available_monitors().collect();
|
let handles: Vec<_> = window.available_monitors().collect();
|
||||||
let monitors: Vec<Monitor> = handles.iter().map(|m| Monitor::new(m.position(), m.size(), m.scale_factor())).collect();
|
let monitors: Vec<Monitor> = handles.iter().map(|m| Monitor::new(m.position(), m.size(), m.scale_factor())).collect();
|
||||||
@@ -121,22 +122,41 @@ pub fn restore(window: &winit::window::Window, saved: Option<WindowGeometry>) ->
|
|||||||
}
|
}
|
||||||
// Returns the new size when it applies at once; either way the window gets it.
|
// Returns the new size when it applies at once; either way the window gets it.
|
||||||
let _ = window.request_inner_size(LogicalSize::new(g.size[0], g.size[1]));
|
let _ = window.request_inner_size(LogicalSize::new(g.size[0], g.size[1]));
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
if g.maximized {
|
if g.maximized {
|
||||||
window.set_maximized(true);
|
window.set_maximized(true);
|
||||||
}
|
}
|
||||||
g
|
g
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keep asking the host to maximize until it sticks, or give up after a few frames.
|
||||||
|
pub fn apply_pending_maximize(window: &winit::window::Window, want: bool, tries: &mut u8) -> bool {
|
||||||
|
if !want {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if window.is_maximized() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if *tries == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
*tries -= 1;
|
||||||
|
window.set_maximized(true);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// Per frame: remember the window's geometry for the next launch. Minimized or full screen keeps
|
/// Per frame: remember the window's geometry for the next launch. Minimized or full screen keeps
|
||||||
/// the last one; maximized only sets its flag, so un-maximizing next time restores the last size.
|
/// the last one; maximized only sets its flag, so un-maximizing next time restores the last size.
|
||||||
pub fn track(ctx: &egui::Context, geometry: &mut Option<WindowGeometry>) {
|
/// `host_maximized` is the window manager's idea of maximized when egui does not report it (#3635).
|
||||||
|
pub fn track(ctx: &egui::Context, geometry: &mut Option<WindowGeometry>, host_maximized: Option<bool>) {
|
||||||
let zoom = ctx.zoom_factor();
|
let zoom = ctx.zoom_factor();
|
||||||
ctx.input(|i| {
|
ctx.input(|i| {
|
||||||
let v = i.viewport();
|
let v = i.viewport();
|
||||||
if v.minimized == Some(true) || v.fullscreen == Some(true) {
|
if v.minimized == Some(true) || v.fullscreen == Some(true) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if v.maximized == Some(true) {
|
let maximized = v.maximized == Some(true) || host_maximized == Some(true);
|
||||||
|
if maximized {
|
||||||
if let Some(g) = geometry {
|
if let Some(g) = geometry {
|
||||||
g.maximized = true;
|
g.maximized = true;
|
||||||
}
|
}
|
||||||
@@ -222,7 +242,7 @@ mod tests {
|
|||||||
let mut frame = |info: egui::ViewportInfo| {
|
let mut frame = |info: egui::ViewportInfo| {
|
||||||
let mut input = egui::RawInput::default();
|
let mut input = egui::RawInput::default();
|
||||||
input.viewports.insert(egui::ViewportId::ROOT, info);
|
input.viewports.insert(egui::ViewportId::ROOT, info);
|
||||||
ctx.run_ui(input, |ui| track(ui.ctx(), &mut geometry)).textures_delta.clear();
|
ctx.run_ui(input, |ui| track(ui.ctx(), &mut geometry, None)).textures_delta.clear();
|
||||||
geometry
|
geometry
|
||||||
};
|
};
|
||||||
let rect = egui::Rect::from_min_size(egui::pos2(20.0, 30.0), egui::vec2(1000.0, 600.0));
|
let rect = egui::Rect::from_min_size(egui::pos2(20.0, 30.0), egui::vec2(1000.0, 600.0));
|
||||||
@@ -253,4 +273,19 @@ mod tests {
|
|||||||
let back: vectorcraft_ui_egui::UiState = serde_json::from_value(json).unwrap();
|
let back: vectorcraft_ui_egui::UiState = serde_json::from_value(json).unwrap();
|
||||||
assert_eq!(back.window, None);
|
assert_eq!(back.window, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_host_maximized_flag_is_remembered_when_egui_does_not_report_it() {
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
let mut geometry = Some(WindowGeometry { pos: Some([10, 20]), size: [800.0, 600.0], maximized: false });
|
||||||
|
let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1920.0, 1080.0));
|
||||||
|
let mut input = egui::RawInput::default();
|
||||||
|
input.viewports.insert(
|
||||||
|
egui::ViewportId::ROOT,
|
||||||
|
egui::ViewportInfo { inner_rect: Some(rect), outer_rect: Some(rect), maximized: None, ..Default::default() },
|
||||||
|
);
|
||||||
|
let mut out = ctx.run_ui(input, |ui| track(ui.ctx(), &mut geometry, Some(true)));
|
||||||
|
out.textures_delta.clear();
|
||||||
|
assert_eq!(geometry, Some(WindowGeometry { pos: Some([10, 20]), size: [800.0, 600.0], maximized: true }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -60,11 +60,17 @@ fn missing_empty_or_option_operands_fail_before_startup() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #3546: a root flag may be given more than once, in either spelling; a bad later operand still
|
||||||
|
/// stops the app before it starts.
|
||||||
#[test]
|
#[test]
|
||||||
fn duplicate_roots_fail_for_both_spellings() {
|
fn repeated_roots_are_accepted_for_both_spellings() {
|
||||||
for flag in ["--automation-read-root", "--automation-write-root"] {
|
for flag in ["--automation-read-root", "--automation-write-root"] {
|
||||||
rejects(&[flag, ".", &format!("{flag}=.")], &format!("{flag} given twice"));
|
let out = Command::new(BIN).args([flag, ".", format!("{flag}=.").as_str(), flag, "./", "--version"]).output().unwrap();
|
||||||
rejects(&[&format!("{flag}=."), flag, "."], &format!("{flag} given twice"));
|
assert!(out.status.success(), "{flag}: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
assert_eq!(String::from_utf8(out.stdout).unwrap(), format!("vectorcraft {}\n", env!("CARGO_PKG_VERSION")));
|
||||||
|
assert!(out.stderr.is_empty());
|
||||||
|
rejects(&[flag, ".", flag, ""], &format!("{flag} needs a non-empty folder"));
|
||||||
|
rejects(&[&format!("{flag}=."), flag], &format!("{flag} needs a folder"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+50
-18
@@ -11,6 +11,7 @@ Affinity files.
|
|||||||
| Affinity | VectorCraft |
|
| Affinity | VectorCraft |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Pages, Publisher spreads (one or two pages) | artboards, spreads side by side |
|
| Pages, Publisher spreads (one or two pages) | artboards, spreads side by side |
|
||||||
|
| Master pages (`MpCh`) shown on pages through master page instances (`MPIN`: master, page offset, master page, count) | a locked "Master A, B…" layer at the bottom holding each master page's objects on the pages that show it |
|
||||||
| Artboards (legacy `ABEn` or Affinity 3 `phrp`/`aprp`), with name, paint and any transform | artboards plus a clip group with the actual vector outline, background and stroke |
|
| Artboards (legacy `ABEn` or Affinity 3 `phrp`/`aprp`), with name, paint and any transform | artboards plus a clip group with the actual vector outline, background and stroke |
|
||||||
| Layers (`Scop`), groups (`Grup`), pass-through or isolated | layers and sublayers, groups |
|
| Layers (`Scop`), groups (`Grup`), pass-through or isolated | layers and sublayers, groups |
|
||||||
| Curves (`PCrv`): cubic subpaths, closed flags, live corners (`CnrD`) | paths (multi-subpath curves fill even-odd) |
|
| Curves (`PCrv`): cubic subpaths, closed flags, live corners (`CnrD`) | paths (multi-subpath curves fill even-odd) |
|
||||||
@@ -21,22 +22,24 @@ Affinity files.
|
|||||||
| Pixel masks (`MRst`, attached or as a layer in a group) | opacity mask |
|
| Pixel masks (`MRst`, attached or as a layer in a group) | opacity mask |
|
||||||
| Solid fills: RGBA, HSLA, CMYK, grey, Lab (D50) | fill appearance (alpha as fill opacity) |
|
| Solid fills: RGBA, HSLA, CMYK, grey, Lab (D50) | fill appearance (alpha as fill opacity) |
|
||||||
| Linear, elliptical and radial gradients with Affinity's midpoint bias | gradients (the bias as the midpoint where the blend is half way) |
|
| Linear, elliptical and radial gradients with Affinity's midpoint bias | gradients (the bias as the midpoint where the blend is half way) |
|
||||||
| Strokes: weight, scale with object, dash pattern and phase, alignment, miter limit, behind the fill | stroke appearance |
|
| Strokes: weight, scale with object, dash pattern and phase, alignment, miter limit, behind the fill; a stroke whose style is None (every shape stores one, black and 1 px) is left out | stroke appearance |
|
||||||
| Fill layers (`FRst`) | filled rectangles |
|
| Fill layers (`FRst`) | filled rectangles |
|
||||||
| Artistic and frame text: Unicode scalar-indexed runs, stored fallback font, family/weight/style, boolean OpenType features, all caps, size, tracking, fixed leading, colour, paragraph alignment, first baseline | point type and area type |
|
| Artistic and frame text: Unicode scalar-indexed runs, stored fallback font, family/weight/style, boolean OpenType features, all caps, underline, strikethrough, size, tracking, horizontal scale, fixed leading, colour, outlined characters, first baseline; each paragraph's alignment, indents, space before and after, fixed and automatic leading; frame text sized by its text scale (`FTxS`), not its frame's transform | point type and area type, one paragraph style per paragraph; artistic text whose paragraphs align differently keeps them aligned within its longest line (by indents), as Affinity draws it; a frame's first line hangs from its capitals' tops, and a frame whose last lines' descenders hang out of it is made just tall enough to show them; line breaks inside a paragraph become paragraphs without spacing between them; table cells place their text as Affinity does (centred from the capitals' tops to the last baseline, reaching out of a cell too short for it) |
|
||||||
|
| Tables (`TxtT`): grid, cell text, merged cells, cell insets and vertical alignment, the line on every cell edge | a group of area type per cell and a stroked line per edge (VectorCraft has no tables) |
|
||||||
| Placed images (`ImgN`): the original JPEG/PNG embedded in the file | embedded images |
|
| Placed images (`ImgN`): the original JPEG/PNG embedded in the file | embedded images |
|
||||||
| Pixel layers (`Rstr`): RGBA 8/16-bit and CMYK 8-bit tiles, cropped to their content; source-backed RGBA8 JPEG/PNG tiles with matching dimensions and zero origin | embedded PNG images |
|
| Pixel layers (`Rstr`): RGBA 8/16-bit and CMYK 8-bit tiles, cropped to their content; source-backed RGBA8 JPEG/PNG tiles with matching dimensions and zero origin; the document's CMYK colour profile (`CMYP`) | embedded PNG images; CMYK layers through the document's profile (relative colorimetric, black-point compensation, as Affinity converts them), or, without one, CMYK images that keep their inks |
|
||||||
| Embedded documents and symbols (`EmbN`) | the picture Affinity cached of them |
|
| Embedded Affinity documents (`EmbN` with an `EmDc` entry), four deep at most | their first page's content, editable (their artboards only clip, they don't become artboards; their master pages are left out); other embedded documents and symbols as the picture Affinity cached of them |
|
||||||
|
| Layer effects: drop shadows, outer glows, inner shadows; outlines; colour and gradient overlays in any blend mode | Stylize › Drop Shadow and Outer Glow, Inner Glow at the edge (approximated); one more stroke on shapes (around their own stroke), the characters' stroke on text; overlays baked into fill, stroke, text colours and pixels (a group's over its children's) |
|
||||||
| Opacity, visibility, lock, names, the blend modes VectorCraft has | the same |
|
| Opacity, visibility, lock, names, the blend modes VectorCraft has | the same |
|
||||||
|
|
||||||
Everything else is reported in the import warnings, one line per kind with a count, never
|
Everything else is reported in the import warnings, one line per kind with a count, never
|
||||||
dropped silently: layer effects, adjustment layers and live filters, brush and pressure strokes,
|
dropped silently: bevel, blur and other layer effects, adjustment layers and live filters, brush and pressure strokes,
|
||||||
transparency gradients, fill opacity, bitmap fills, master pages, conical gradients (as radial),
|
transparency gradients, fill opacity, bitmap fills, master pages based on other master pages, conical gradients (as radial),
|
||||||
special shapes (cloud, heart, cog, callouts, arrows…, imported as their bounding ellipse), corner
|
special shapes (cloud, heart, cog, callouts, arrows…, imported as their bounding ellipse), corner
|
||||||
types other than round or straight, stars with rounded points, several fills or strokes on one object (the
|
types other than round or straight, stars with rounded points, several fills or strokes on one object (the
|
||||||
active one is used), outlined or scaled text, text fields such as page numbers, frames with
|
active one is used), skewed or flipped text, characters of different transparency in one text, text fields such as page numbers, text threading, frames with
|
||||||
columns or a curved outline, paragraph indents/spacing, mixed paragraph alignments, unsupported font selectors, grey/Lab/32-bit pixels, CMYK pixels (converted to RGB without the
|
columns or a curved outline, table cell fills, unsupported font selectors, grey/Lab/32-bit pixels, CMYK pixels with transparency in a document
|
||||||
document's profile) and Affinity-only blend modes (Add, Linear Light… as Normal).
|
without a CMYK profile (converted through VectorCraft's working CMYK profile) and Affinity-only blend modes (Add, Linear Light… as Normal).
|
||||||
|
|
||||||
When the native document can't be read at all, the engine opens the embedded PNG preview instead,
|
When the native document can't be read at all, the engine opens the embedded PNG preview instead,
|
||||||
with a warning that names the reason; that preview can't be placed, used as a template or mined
|
with a warning that names the reason; that preview can't be placed, used as a template or mined
|
||||||
@@ -78,10 +81,22 @@ of CC0 sample files.
|
|||||||
* **Read-only.** Nothing is written in Affinity's format; export waits until someone can check
|
* **Read-only.** Nothing is written in Affinity's format; export waits until someone can check
|
||||||
written files in Affinity itself.
|
written files in Affinity itself.
|
||||||
|
|
||||||
The reader also has an independent copy in PhotoCraft's `photocraft-affinity`.
|
The reader also has independent copies in PhotoCraft's `photocraft-affinity` and DesignCraft's
|
||||||
|
`designcraft-affinity`. Tables, paragraph formats, text scale, master pages, layer effects,
|
||||||
|
overlays, embedded documents, large pixel layers and HEIC photos were added in DesignCraft
|
||||||
|
([storytold/designcraft#291](https://github.com/storytold/designcraft/pull/291)) and brought back
|
||||||
|
here, from the same public fixtures and the thumbnails of documents their owners made, used only
|
||||||
|
locally. Automatic leading (the font size plus the font's line gap, for artistic and frame text)
|
||||||
|
was fitted on the thumbnails of one such local document with 56 artboards and on the public Patchy
|
||||||
|
text-frame fixture.
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
|
* `engine/tests/affinity_features.rs` covers pages, several artboards per canvas and per spread,
|
||||||
|
master pages, tables, paragraph formats, text scale, layer effects, overlays and embedded
|
||||||
|
documents (whose artboards don't become artboards) on synthetic documents, and mutates them.
|
||||||
|
`engine/tests/affinity_local.rs` compares every artboard of local documents with their thumbnail
|
||||||
|
when `AFFINITY_SAMPLES` points at a folder (never in CI; nothing is committed).
|
||||||
* Unit tests on synthetic containers built by the `synth` feature (stored, zlib and zstd entries,
|
* Unit tests on synthetic containers built by the `synth` feature (stored, zlib and zstd entries,
|
||||||
checksums, budgets, cycles, hostile streams, every truncation) and property tests of random
|
checksums, budgets, cycles, hostile streams, every truncation) and property tests of random
|
||||||
mutations.
|
mutations.
|
||||||
@@ -104,9 +119,14 @@ The reader also has an independent copy in PhotoCraft's `photocraft-affinity`.
|
|||||||
pass CI. Run locally with `AFFINITY_CORPUS_REQUIRED=1` for the same strict gate.
|
pass CI. Run locally with `AFFINITY_CORPUS_REQUIRED=1` for the same strict gate.
|
||||||
* Synthetic engine regressions cover nested, rotated and curved artboards, negative origins,
|
* Synthetic engine regressions cover nested, rotated and curved artboards, negative origins,
|
||||||
multiple spreads and resolution conversion, alongside text recovery and native save/export.
|
multiple spreads and resolution conversion, alongside text recovery and native save/export.
|
||||||
* `engine/tests/import_fuzz.rs` mutates a synthetic native document's stream and archive and checks
|
* `engine/tests/import_fuzz.rs` mutates a synthetic native document's stream and archive, and
|
||||||
that whatever opens also renders and exports; `fuzz/` has `cargo-fuzz` targets for the whole
|
documents with layer effects, colour overlays (also over tables), line styles, paragraph and
|
||||||
reader (`container`) and the object stream (`stream`):
|
character formats, tables, master pages, CMYK pixel layers and embedded documents (the embedding
|
||||||
|
and the embedded stream), and checks that whatever opens also renders and exports.
|
||||||
|
`tests/import_limits.rs` builds hostile documents against the limits below: grids of 16 million
|
||||||
|
cells, grids and stories shared by many layers, 200 000 paragraphs, a document embedded hundreds
|
||||||
|
of times. `fuzz/` has `cargo-fuzz` targets for the whole reader (`container`) and the object
|
||||||
|
stream (`stream`):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd crates/affinity
|
cd crates/affinity
|
||||||
@@ -132,11 +152,23 @@ anything is allocated: a value takes about 40 bytes in memory however few it too
|
|||||||
those 189 public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve
|
those 189 public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve
|
||||||
nodes per curve; compound outlines also cap recursion at 128 levels, operand visits at 100 000
|
nodes per curve; compound outlines also cap recursion at 128 levels, operand visits at 100 000
|
||||||
and cumulative geometry work at four million, rejecting cycles and partial results on limits.
|
and cumulative geometry work at four million, rejecting cycles and partial results on limits.
|
||||||
Pie sweeps normalize finite angles in constant time. Pixel layers are limited to 64 megapixels
|
Pie sweeps normalize finite angles in constant time. Pixel layers are decoded at no more than 64
|
||||||
(cropped to their content first); gradients to 1024 stops. Text collection is bounded to a
|
megapixels each (cropped to their content first; larger ones are read at a half, a quarter… of
|
||||||
million characters per node before allocation, with a cumulative 16 MiB budget for owned font
|
their resolution) and 512 megapixels for the whole document; pixel masks are read only over what
|
||||||
names, features and gradient stops. Exhaustion warns and uses default attributes for remaining
|
they mask (all of it under artistic text, whose glyphs only the layout places). One budget covers
|
||||||
text, preserving its characters.
|
the whole read: layers, pixels, 16 million characters of stories and 262 144 table cells, so
|
||||||
|
content that many layers link, or a document embedded many times, can't multiply them. Embedded
|
||||||
|
documents nest four deep at most; both of their readings are charged to that budget and to the
|
||||||
|
importing document's archive limits, also when they fail part of the way. Tables have at most
|
||||||
|
4096 rows and columns and 65 536 cells, and 64 layer effects per node are read. Gradients are
|
||||||
|
limited to 1024 stops. Text collection is bounded to a million characters per node before
|
||||||
|
allocation, with a cumulative 16 MiB budget for owned font names (256 bytes at most each),
|
||||||
|
features and gradient stops; paragraph formats are matched to runs by binary search. Exhaustion
|
||||||
|
warns and uses default attributes for remaining text, preserving its characters. Colour and
|
||||||
|
gradient overlays share one work budget per import (`overlay::MAX_WORK`: the nodes and pixels
|
||||||
|
each is baked into); past it they are left out, and said. In VectorCraft, master pages are
|
||||||
|
copied onto at most as many objects, characters and pixels as a document may hold, a few times
|
||||||
|
over, and objects beyond `MAX_COORD` of the origin are left out.
|
||||||
Every archive entry's CRC-32 and size must match. Malformed input returns an `Error`; the
|
Every archive entry's CRC-32 and size must match. Malformed input returns an `Error`; the
|
||||||
crate has no panics outside tests.
|
crate has no panics outside tests.
|
||||||
|
|
||||||
|
|||||||
@@ -263,6 +263,21 @@ impl<'a> Archive<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Extract an entry by name: decompress, undo the byte predictor and verify the CRC.
|
/// Extract an entry by name: decompress, undo the byte predictor and verify the CRC.
|
||||||
|
/// What is left of the limits for documents embedded in this one.
|
||||||
|
pub(crate) fn remaining(&self) -> Limits {
|
||||||
|
Limits { max_entry: self.limits.max_entry, max_total: self.limits.max_total.saturating_sub(self.extracted) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count `n` bytes extracted elsewhere (from embedded documents) against this import.
|
||||||
|
pub(crate) fn charge(&mut self, n: usize) -> Result<(), Error> {
|
||||||
|
let total = self.extracted.checked_add(n).ok_or(Error::Limit("document too large"))?;
|
||||||
|
if total > self.limits.max_total {
|
||||||
|
return Err(Error::Limit("document too large"));
|
||||||
|
}
|
||||||
|
self.extracted = total;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn read(&mut self, name: &str) -> Result<Vec<u8>, Error> {
|
pub fn read(&mut self, name: &str) -> Result<Vec<u8>, Error> {
|
||||||
let e = self.entries.get(name).ok_or(Error::Malformed("missing archive entry"))?.clone();
|
let e = self.entries.get(name).ok_or(Error::Malformed("missing archive entry"))?.clone();
|
||||||
let size = usize::try_from(e.size).map_err(|_| Error::Limit("archive entry too large"))?;
|
let size = usize::try_from(e.size).map_err(|_| Error::Limit("archive entry too large"))?;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ mod access;
|
|||||||
pub mod container;
|
pub mod container;
|
||||||
mod geometry;
|
mod geometry;
|
||||||
pub mod model;
|
pub mod model;
|
||||||
|
pub mod overlay;
|
||||||
pub mod paint;
|
pub mod paint;
|
||||||
pub mod preview;
|
pub mod preview;
|
||||||
mod raster;
|
mod raster;
|
||||||
|
|||||||
+625
-59
@@ -1,4 +1,4 @@
|
|||||||
//! The document model VectorCraft imports: spreads, artboards, layers, groups, curves, shapes,
|
//! The document model the Craft apps import: spreads, artboards, layers, groups, curves, shapes,
|
||||||
//! text and images, with transforms composed into document space. Anything else is reported
|
//! text and images, with transforms composed into document space. Anything else is reported
|
||||||
//! by [`Document::warnings`] instead of being dropped silently.
|
//! by [`Document::warnings`] instead of being dropped silently.
|
||||||
|
|
||||||
@@ -175,17 +175,54 @@ pub struct TextRun {
|
|||||||
/// Boolean OpenType feature overrides, as tags or `-tag` for disabled features.
|
/// Boolean OpenType feature overrides, as tags or `-tag` for disabled features.
|
||||||
pub features: Vec<String>,
|
pub features: Vec<String>,
|
||||||
pub all_caps: bool,
|
pub all_caps: bool,
|
||||||
|
/// Underlined characters (`Ints` slot 0 is 1; identified on underlined words in a document its
|
||||||
|
/// owner made, all other characters there and in the public corpus have 0).
|
||||||
|
pub underline: bool,
|
||||||
|
/// Struck-through characters (`Ints` slot 1 is 1; identified the same way, on prices struck
|
||||||
|
/// through in another document its owner made).
|
||||||
|
pub strikethrough: bool,
|
||||||
/// Font size in document pixels, before the node's transform.
|
/// Font size in document pixels, before the node's transform.
|
||||||
pub size: f64,
|
pub size: f64,
|
||||||
/// Tracking in em.
|
/// Tracking in em.
|
||||||
pub tracking: f64,
|
pub tracking: f64,
|
||||||
|
/// Horizontal scale of the glyphs (1 = as designed).
|
||||||
|
pub h_scale: f64,
|
||||||
/// Fixed line pitch in document pixels, when the run overrides automatic leading.
|
/// Fixed line pitch in document pixels, when the run overrides automatic leading.
|
||||||
pub leading: Option<f64>,
|
pub leading: Option<f64>,
|
||||||
pub fill: Paint,
|
pub fill: Paint,
|
||||||
|
/// Outlined characters: the outline's paint and width (node space).
|
||||||
|
pub stroke: Option<crate::paint::Stroke>,
|
||||||
|
/// The run's paragraph: alignment, indents and spacing.
|
||||||
|
pub paragraph: Paragraph,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
/// Paragraph alignment, indents, spacing and leading, in node space (document pixels before the
|
||||||
|
/// node's text scale).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct Paragraph {
|
||||||
|
pub align: Align,
|
||||||
|
/// Indent of every line but the first, from the left.
|
||||||
|
pub left: f64,
|
||||||
|
pub right: f64,
|
||||||
|
/// Indent of the first line, from the left.
|
||||||
|
pub first: f64,
|
||||||
|
pub space_before: f64,
|
||||||
|
pub space_after: f64,
|
||||||
|
/// Fixed distance between baselines, when the paragraph sets one.
|
||||||
|
pub leading: Option<f64>,
|
||||||
|
/// Automatic leading as a multiple of the font's own line height (ascent plus descent).
|
||||||
|
pub auto_leading: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Paragraph {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { align: Align::Left, left: 0.0, right: 0.0, first: 0.0, space_before: 0.0, space_after: 0.0, leading: None, auto_leading: 1.0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
pub enum Align {
|
pub enum Align {
|
||||||
|
#[default]
|
||||||
Left,
|
Left,
|
||||||
Center,
|
Center,
|
||||||
Right,
|
Right,
|
||||||
@@ -202,12 +239,69 @@ pub struct Text {
|
|||||||
pub frame: Option<Rect>,
|
pub frame: Option<Rect>,
|
||||||
/// Node space to document pixels.
|
/// Node space to document pixels.
|
||||||
pub transform: Affine,
|
pub transform: Affine,
|
||||||
|
/// Node space to document pixels for the type (its linear part matters): artistic text scales
|
||||||
|
/// with its node, frame text only with its frame's text scale.
|
||||||
|
pub text_transform: Affine,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vertical alignment of a table cell's text.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum VAlign {
|
||||||
|
Top,
|
||||||
|
Center,
|
||||||
|
Bottom,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct TableCell {
|
||||||
|
pub runs: Vec<TextRun>,
|
||||||
|
/// Space between the cell edges and its text, in node space: left, top, right, bottom.
|
||||||
|
pub inset: [f64; 4],
|
||||||
|
pub valign: VAlign,
|
||||||
|
/// Part of the cell to its left (merged): it has no text of its own.
|
||||||
|
pub merged_left: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A table: a grid of cells with text, and a line on every cell edge.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct Table {
|
||||||
|
/// Column boundaries (one more than the columns), increasing, in node space.
|
||||||
|
pub columns: Vec<f64>,
|
||||||
|
/// Row boundaries (one more than the rows), increasing, in node space.
|
||||||
|
pub rows: Vec<f64>,
|
||||||
|
/// Row by row.
|
||||||
|
pub cells: Vec<TableCell>,
|
||||||
|
/// Vertical edges row by row, one more per row than the columns; `None` draws no line, and
|
||||||
|
/// the list is empty when the file's didn't match the grid.
|
||||||
|
pub vertical: Vec<Option<Stroke>>,
|
||||||
|
/// Horizontal edges boundary by boundary (one more than the rows), one per column (empty
|
||||||
|
/// as `vertical` can be).
|
||||||
|
pub horizontal: Vec<Option<Stroke>>,
|
||||||
|
/// Node space to document pixels.
|
||||||
|
pub transform: Affine,
|
||||||
|
/// Node space to document pixels for the cells' type: resizing a table moves its grid, not
|
||||||
|
/// its type.
|
||||||
|
pub text_transform: Affine,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Table {
|
||||||
|
pub fn ncols(&self) -> usize {
|
||||||
|
self.columns.len().saturating_sub(1)
|
||||||
|
}
|
||||||
|
pub fn nrows(&self) -> usize {
|
||||||
|
self.rows.len().saturating_sub(1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum Pixels {
|
pub enum Pixels {
|
||||||
/// Straight-alpha RGBA, 8 bits per channel, row by row.
|
/// Straight-alpha RGBA, 8 bits per channel, row by row.
|
||||||
Rgba8(Vec<u8>),
|
Rgba8(Vec<u8>),
|
||||||
|
/// A CMYK pixel layer: cyan, magenta, yellow and black ink amounts (0 none, 255 full) and
|
||||||
|
/// straight alpha, 8 bits each, row by row. Left in CMYK for the importer to show through a
|
||||||
|
/// colour profile: the profile-free formula turns the extra cyan of a separation's neutrals
|
||||||
|
/// into a blue cast.
|
||||||
|
Cmyka8(Vec<u8>),
|
||||||
/// The original file a placed image was made from (PNG, JPEG…), as embedded.
|
/// The original file a placed image was made from (PNG, JPEG…), as embedded.
|
||||||
Encoded(Vec<u8>),
|
Encoded(Vec<u8>),
|
||||||
}
|
}
|
||||||
@@ -246,11 +340,78 @@ pub enum Kind {
|
|||||||
even_odd: bool,
|
even_odd: bool,
|
||||||
},
|
},
|
||||||
Text(Text),
|
Text(Text),
|
||||||
|
Table(Table),
|
||||||
Image(Image),
|
Image(Image),
|
||||||
|
/// A master page shown on this spread's pages: pages `page..page + count` show the master
|
||||||
|
/// spread [`Document::masters`]`[master]`'s pages from `master_page` on.
|
||||||
|
MasterInstance {
|
||||||
|
master: usize,
|
||||||
|
page: usize,
|
||||||
|
master_page: usize,
|
||||||
|
count: usize,
|
||||||
|
},
|
||||||
/// Something this reader does not import; its children are still imported.
|
/// Something this reader does not import; its children are still imported.
|
||||||
Unsupported,
|
Unsupported,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A layer effect. Lengths are document pixels; `angle` is the direction the shadow is offset in
|
||||||
|
/// (radians, clockwise from +x as y points down: π/2 puts it below).
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum Effect {
|
||||||
|
DropShadow {
|
||||||
|
color: crate::paint::Color,
|
||||||
|
opacity: f64,
|
||||||
|
radius: f64,
|
||||||
|
distance: f64,
|
||||||
|
angle: f64,
|
||||||
|
},
|
||||||
|
InnerShadow {
|
||||||
|
color: crate::paint::Color,
|
||||||
|
opacity: f64,
|
||||||
|
radius: f64,
|
||||||
|
distance: f64,
|
||||||
|
angle: f64,
|
||||||
|
},
|
||||||
|
OuterGlow {
|
||||||
|
color: crate::paint::Color,
|
||||||
|
opacity: f64,
|
||||||
|
radius: f64,
|
||||||
|
},
|
||||||
|
/// An outline of `width` around the node's shape: `align` 0 outside, 1 inside, 2 centred.
|
||||||
|
Outline {
|
||||||
|
color: crate::paint::Color,
|
||||||
|
opacity: f64,
|
||||||
|
width: f64,
|
||||||
|
align: u16,
|
||||||
|
},
|
||||||
|
/// Bevel and emboss: lit and shaded edges `size` wide. `angle` is the light's azimuth
|
||||||
|
/// (radians, counter-clockwise from +x with y up), `depth` Affinity's depth (5 by default);
|
||||||
|
/// `inner` is false for the outer, emboss and pillow kinds.
|
||||||
|
Bevel {
|
||||||
|
size: f64,
|
||||||
|
depth: f64,
|
||||||
|
angle: f64,
|
||||||
|
highlight: crate::paint::Color,
|
||||||
|
highlight_opacity: f64,
|
||||||
|
shadow: crate::paint::Color,
|
||||||
|
shadow_opacity: f64,
|
||||||
|
inner: bool,
|
||||||
|
},
|
||||||
|
/// Colour overlay: everything the node paints takes `color`, laid over it in `blend` mode at
|
||||||
|
/// `opacity` (the node's own alpha is kept).
|
||||||
|
ColorOverlay {
|
||||||
|
color: crate::paint::Color,
|
||||||
|
opacity: f64,
|
||||||
|
blend: Blend,
|
||||||
|
},
|
||||||
|
/// Gradient overlay: a colour overlay whose colour comes from `gradient` (document pixels).
|
||||||
|
GradientOverlay {
|
||||||
|
gradient: crate::paint::Gradient,
|
||||||
|
opacity: f64,
|
||||||
|
blend: Blend,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct Node {
|
pub struct Node {
|
||||||
pub class: Tag,
|
pub class: Tag,
|
||||||
@@ -264,6 +425,8 @@ pub struct Node {
|
|||||||
pub mask: Option<Path>,
|
pub mask: Option<Path>,
|
||||||
/// Pixel mask: grey levels (white shows the node, black hides it); outside it the node is hidden.
|
/// Pixel mask: grey levels (white shows the node, black hides it); outside it the node is hidden.
|
||||||
pub pixel_mask: Option<Image>,
|
pub pixel_mask: Option<Image>,
|
||||||
|
/// Layer effects the importer can show or bake in (others are reported).
|
||||||
|
pub effects: Vec<Effect>,
|
||||||
/// Bottom to top.
|
/// Bottom to top.
|
||||||
pub children: Vec<Node>,
|
pub children: Vec<Node>,
|
||||||
}
|
}
|
||||||
@@ -285,12 +448,21 @@ pub struct Document {
|
|||||||
/// Pixels per inch: all geometry is in document pixels at this resolution.
|
/// Pixels per inch: all geometry is in document pixels at this resolution.
|
||||||
pub dpi: f64,
|
pub dpi: f64,
|
||||||
pub spreads: Vec<Spread>,
|
pub spreads: Vec<Spread>,
|
||||||
|
/// Master spreads, which [`Kind::MasterInstance`] nodes show on pages.
|
||||||
|
pub masters: Vec<Spread>,
|
||||||
/// Application that last saved the file, as stored (`Affinity 3.0.2`), when present.
|
/// Application that last saved the file, as stored (`Affinity 3.0.2`), when present.
|
||||||
pub saved_by: Option<String>,
|
pub saved_by: Option<String>,
|
||||||
/// Content that was not imported or was approximated, one line per kind, deduplicated.
|
/// Content that was not imported or was approximated, one line per kind, deduplicated.
|
||||||
pub warnings: Vec<String>,
|
pub warnings: Vec<String>,
|
||||||
|
/// The document's CMYK colour profile (an ICC file), when it embeds one: the root's `CMYP`,
|
||||||
|
/// which each CMYK pixel layer's colour settings (`Prof`) also name, with relative
|
||||||
|
/// colorimetric intent and black-point compensation in every document checked.
|
||||||
|
pub cmyk_profile: Option<Vec<u8>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Largest embedded colour profile kept (real CMYK profiles are a few megabytes).
|
||||||
|
const MAX_PROFILE_BYTES: usize = 32 << 20;
|
||||||
|
|
||||||
/// Deepest layer nesting followed; deeper content is reported, not imported.
|
/// Deepest layer nesting followed; deeper content is reported, not imported.
|
||||||
const MAX_TREE_DEPTH: usize = 128;
|
const MAX_TREE_DEPTH: usize = 128;
|
||||||
/// Total layer nodes imported.
|
/// Total layer nodes imported.
|
||||||
@@ -300,6 +472,32 @@ const MAX_NODES: usize = 500_000;
|
|||||||
/// count the bytes extracted from it, and a layer of one flat colour needs none (#1704).
|
/// count the bytes extracted from it, and a layer of one flat colour needs none (#1704).
|
||||||
const MAX_LAYER_PIXELS: u64 = 512 << 20;
|
const MAX_LAYER_PIXELS: u64 = 512 << 20;
|
||||||
|
|
||||||
|
/// Embedded documents inside embedded documents are followed this deep.
|
||||||
|
const MAX_EMBED_DEPTH: usize = 4;
|
||||||
|
/// Characters of all stories together (a story may be shared by many text layers).
|
||||||
|
const MAX_DOCUMENT_CHARS: usize = 16 << 20;
|
||||||
|
/// Table cells of all tables together (each becomes a text object with its edge lines).
|
||||||
|
const MAX_DOCUMENT_CELLS: usize = 1 << 18;
|
||||||
|
|
||||||
|
/// What a whole read may still use. Shared objects can be linked from many layers, and embedded
|
||||||
|
/// documents are read with what their embedding document left, so linking or embedding the
|
||||||
|
/// same content many times can't multiply the work.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub(crate) struct Budget {
|
||||||
|
/// Layer nodes ([`MAX_NODES`] in all).
|
||||||
|
pub(crate) nodes: usize,
|
||||||
|
/// Pixels the pixel layers may decode ([`MAX_LAYER_PIXELS`] in all).
|
||||||
|
pub(crate) pixels: u64,
|
||||||
|
/// Characters of stories ([`MAX_DOCUMENT_CHARS`] in all).
|
||||||
|
pub(crate) chars: usize,
|
||||||
|
/// Table cells ([`MAX_DOCUMENT_CELLS`] in all).
|
||||||
|
pub(crate) cells: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Budget {
|
||||||
|
pub(crate) const FULL: Budget = Budget { nodes: MAX_NODES, pixels: MAX_LAYER_PIXELS, chars: MAX_DOCUMENT_CHARS, cells: MAX_DOCUMENT_CELLS };
|
||||||
|
}
|
||||||
|
|
||||||
/// Read a document: archive, `doc.dat` and the model.
|
/// Read a document: archive, `doc.dat` and the model.
|
||||||
pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
|
pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
|
||||||
read_within(bytes, limits, MAX_LAYER_PIXELS)
|
read_within(bytes, limits, MAX_LAYER_PIXELS)
|
||||||
@@ -307,35 +505,81 @@ pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
|
|||||||
|
|
||||||
/// [`read`], its pixel layers holding at most `pixels` pixels together.
|
/// [`read`], its pixel layers holding at most `pixels` pixels together.
|
||||||
pub(crate) fn read_within(bytes: &[u8], limits: Limits, pixels: u64) -> Result<Document, Error> {
|
pub(crate) fn read_within(bytes: &[u8], limits: Limits, pixels: u64) -> Result<Document, Error> {
|
||||||
let mut archive = Archive::open(bytes, limits)?;
|
read_at(bytes, limits, &mut Budget { pixels, ..Budget::FULL }, Affine::IDENTITY, 0).document
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A document read by [`read_at`] (or why it couldn't be), with the bytes its archive extracted
|
||||||
|
/// either way.
|
||||||
|
struct ReadAt {
|
||||||
|
document: Result<Document, Error>,
|
||||||
|
extracted: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a document whose spreads are placed by `base` (for embedded documents, nested `depth`
|
||||||
|
/// deep). What it uses is taken from `budget`, also when it fails part of the way.
|
||||||
|
fn read_at(bytes: &[u8], limits: Limits, budget: &mut Budget, base: Affine, depth: usize) -> ReadAt {
|
||||||
|
let mut archive = match Archive::open(bytes, limits) {
|
||||||
|
Ok(a) => a,
|
||||||
|
Err(e) => return ReadAt { document: Err(e), extracted: 0 },
|
||||||
|
};
|
||||||
|
let document = read_archive(&mut archive, budget, base, depth);
|
||||||
|
ReadAt { document, extracted: archive.extracted() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_archive(archive: &mut Archive, budget: &mut Budget, base: Affine, depth: usize) -> Result<Document, Error> {
|
||||||
let doc = archive.read("doc.dat")?;
|
let doc = archive.read("doc.dat")?;
|
||||||
let s = stream::parse(&doc)?;
|
let s = stream::parse(&doc)?;
|
||||||
let mut reader = Reader::new(&s, &mut archive);
|
let mut reader = Reader::new(&s, archive, base, depth);
|
||||||
reader.pixels_left = pixels;
|
reader.budget = *budget;
|
||||||
reader.document()
|
let document = reader.document();
|
||||||
|
*budget = reader.budget;
|
||||||
|
document
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct Reader<'s, 'a, 'b> {
|
pub(crate) struct Reader<'s, 'a, 'b> {
|
||||||
pub(crate) s: &'s Stream,
|
pub(crate) s: &'s Stream,
|
||||||
pub(crate) archive: &'b mut Archive<'a>,
|
pub(crate) archive: &'b mut Archive<'a>,
|
||||||
pub(crate) dpi: f64,
|
pub(crate) dpi: f64,
|
||||||
warnings: BTreeMap<String, usize>,
|
pub(crate) warnings: BTreeMap<String, usize>,
|
||||||
nodes: usize,
|
|
||||||
active: HashSet<ObjId>,
|
active: HashSet<ObjId>,
|
||||||
/// Pixels the pixel layers still to come may decode ([`MAX_LAYER_PIXELS`] in all).
|
/// Where the spreads go: the identity, or an embedding document's placement.
|
||||||
pub(crate) pixels_left: u64,
|
base: Affine,
|
||||||
|
/// The linear part of `base`, which also scales frame text.
|
||||||
|
pub(crate) type_base: Affine,
|
||||||
|
/// How deep inside embedded documents this one is.
|
||||||
|
depth: usize,
|
||||||
|
/// An embedded document's content, waiting to become its node's children.
|
||||||
|
pending: Option<Vec<Node>>,
|
||||||
|
/// The master spreads, in the order of [`Document::masters`].
|
||||||
|
master_ids: Vec<ObjId>,
|
||||||
|
/// What the rest of the read may use.
|
||||||
|
pub(crate) budget: Budget,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||||
fn new(s: &'s Stream, archive: &'b mut Archive<'a>) -> Self {
|
pub(crate) fn new(s: &'s Stream, archive: &'b mut Archive<'a>, base: Affine, depth: usize) -> Self {
|
||||||
Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new(), pixels_left: MAX_LAYER_PIXELS }
|
let [a, b, c, d, ..] = base.0;
|
||||||
|
Self {
|
||||||
|
s,
|
||||||
|
archive,
|
||||||
|
dpi: 72.0,
|
||||||
|
warnings: BTreeMap::new(),
|
||||||
|
active: HashSet::new(),
|
||||||
|
base,
|
||||||
|
type_base: Affine([a, b, c, d, 0.0, 0.0]),
|
||||||
|
depth,
|
||||||
|
pending: None,
|
||||||
|
master_ids: Vec::new(),
|
||||||
|
budget: Budget::FULL,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn warn(&mut self, what: impl Into<String>) {
|
pub(crate) fn warn(&mut self, what: impl Into<String>) {
|
||||||
*self.warnings.entry(what.into()).or_default() += 1;
|
*self.warnings.entry(what.into()).or_default() += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn document(mut self) -> Result<Document, Error> {
|
/// The document.
|
||||||
|
fn document(&mut self) -> Result<Document, Error> {
|
||||||
let s = self.s;
|
let s = self.s;
|
||||||
let root = s.root;
|
let root = s.root;
|
||||||
self.dpi = s.obj(root, b"UVCn").and_then(|u| s.f64(u, b"UPPI")).filter(|d| *d > 0.0 && *d < 100_000.0).unwrap_or(72.0);
|
self.dpi = s.obj(root, b"UVCn").and_then(|u| s.f64(u, b"UPPI")).filter(|d| *d > 0.0 && *d < 100_000.0).unwrap_or(72.0);
|
||||||
@@ -347,40 +591,65 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
let doc = s.obj(root, b"DocR").ok_or(Error::Malformed("document has no root node"))?;
|
let doc = s.obj(root, b"DocR").ok_or(Error::Malformed("document has no root node"))?;
|
||||||
|
self.master_ids = s.objs(doc, b"MpCh").into_iter().filter(|m| s.is(*m, b"Sprd")).collect();
|
||||||
let mut spreads = Vec::new();
|
let mut spreads = Vec::new();
|
||||||
for spread in s.objs(doc, b"Chld") {
|
for spread in s.objs(doc, b"Chld") {
|
||||||
if !s.is(spread, b"Sprd") {
|
if let Some(sp) = self.spread(doc, spread)? {
|
||||||
self.warn("an unknown kind of page");
|
spreads.push(sp);
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
let pages = pages(s, spread);
|
|
||||||
let default_size =
|
|
||||||
s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h });
|
|
||||||
let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect {
|
|
||||||
x0: 0.0,
|
|
||||||
y0: 0.0,
|
|
||||||
x1: 0.0,
|
|
||||||
y1: 0.0,
|
|
||||||
});
|
|
||||||
let transparent = s.bool(spread, b"SprT").unwrap_or(false);
|
|
||||||
let (nodes, mask) = self.children(spread, Affine::IDENTITY, 0)?;
|
|
||||||
if mask.is_some() {
|
|
||||||
self.warn("mask layers directly on a page (imported without them)");
|
|
||||||
}
|
|
||||||
spreads.push(Spread { bounds, pages, transparent, nodes });
|
|
||||||
}
|
}
|
||||||
if spreads.is_empty() {
|
if spreads.is_empty() {
|
||||||
return Err(Error::Malformed("document has no pages"));
|
return Err(Error::Malformed("document has no pages"));
|
||||||
}
|
}
|
||||||
if !s.objs(doc, b"MpCh").is_empty() {
|
let mut masters = Vec::new();
|
||||||
self.warn("master pages (their content is not placed on the pages that use them)");
|
for master in self.master_ids.clone() {
|
||||||
|
match self.spread(doc, master)? {
|
||||||
|
Some(sp) => masters.push(sp),
|
||||||
|
// Keep the indices of `MasterInstance::master` valid.
|
||||||
|
None => masters.push(Spread {
|
||||||
|
bounds: Rect { x0: 0.0, y0: 0.0, x1: 0.0, y1: 0.0 },
|
||||||
|
pages: Vec::new(),
|
||||||
|
transparent: true,
|
||||||
|
nodes: Vec::new(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let warnings = self.warnings.into_iter().map(|(w, n)| if n > 1 { format!("{w} ({n}×)") } else { w }).collect();
|
let cmyk_profile = s
|
||||||
Ok(Document { dpi: self.dpi, spreads, saved_by, warnings })
|
.obj(root, b"CMYP")
|
||||||
|
.and_then(|p| s.bytes(p, b"Data"))
|
||||||
|
.filter(|b| b.len() <= MAX_PROFILE_BYTES && b.get(16..20) == Some(b"CMYK".as_slice()))
|
||||||
|
.map(<[u8]>::to_vec);
|
||||||
|
let warnings = std::mem::take(&mut self.warnings).into_iter().map(|(w, n)| if n > 1 { format!("{w} ({n}×)") } else { w }).collect();
|
||||||
|
Ok(Document { dpi: self.dpi, spreads, masters, saved_by, warnings, cmyk_profile })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The children of `parent`, and the pixel mask a mask layer among them puts on the parent.
|
/// A spread (`Sprd`) of the document `doc`, or of its master spreads.
|
||||||
fn children(&mut self, parent: ObjId, world: Affine, depth: usize) -> Result<(Vec<Node>, Option<Image>), Error> {
|
fn spread(&mut self, doc: ObjId, spread: ObjId) -> Result<Option<Spread>, Error> {
|
||||||
|
let s = self.s;
|
||||||
|
if !s.is(spread, b"Sprd") {
|
||||||
|
self.warn("an unknown kind of page");
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let pages = pages(s, spread);
|
||||||
|
let default_size = s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h });
|
||||||
|
let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect {
|
||||||
|
x0: 0.0,
|
||||||
|
y0: 0.0,
|
||||||
|
x1: 0.0,
|
||||||
|
y1: 0.0,
|
||||||
|
});
|
||||||
|
let transparent = s.bool(spread, b"SprT").unwrap_or(false);
|
||||||
|
let (nodes, mask) = self.children(spread, self.base, 0)?;
|
||||||
|
if mask.is_some() {
|
||||||
|
self.warn("mask layers directly on a page (imported without them)");
|
||||||
|
}
|
||||||
|
Ok(Some(Spread { bounds, pages, transparent, nodes }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The children of `parent`, and the mask layer among them that masks the parent (with its
|
||||||
|
/// placement), read later over just what it masks.
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
fn children(&mut self, parent: ObjId, world: Affine, depth: usize) -> Result<(Vec<Node>, Option<(ObjId, Affine)>), Error> {
|
||||||
let s = self.s;
|
let s = self.s;
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let mut mask = None;
|
let mut mask = None;
|
||||||
@@ -391,10 +660,10 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
|
let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
|
||||||
match crate::raster::mask(self, c, local.then(world))? {
|
if mask.is_none() {
|
||||||
Some(m) if mask.is_none() => mask = Some(m),
|
mask = Some((c, local.then(world)));
|
||||||
Some(_) => self.warn("several pixel masks on one layer (only the first is used)"),
|
} else {
|
||||||
None => {}
|
self.warn("several pixel masks on one layer (only the first is used)");
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -411,10 +680,10 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
|||||||
self.warn("layers nested deeper than 128 levels");
|
self.warn("layers nested deeper than 128 levels");
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
self.nodes += 1;
|
let Some(left) = self.budget.nodes.checked_sub(1) else {
|
||||||
if self.nodes > MAX_NODES {
|
|
||||||
return Err(Error::Limit("too many layers"));
|
return Err(Error::Limit("too many layers"));
|
||||||
}
|
};
|
||||||
|
self.budget.nodes = left;
|
||||||
// A node that contains itself (a shared object linked back into its own subtree).
|
// A node that contains itself (a shared object linked back into its own subtree).
|
||||||
if !self.active.insert(id) {
|
if !self.active.insert(id) {
|
||||||
self.warn("a layer that contains itself");
|
self.warn("a layer that contains itself");
|
||||||
@@ -441,15 +710,28 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
|||||||
} else {
|
} else {
|
||||||
self.kind(id, class, world, parent)?
|
self.kind(id, class, world, parent)?
|
||||||
};
|
};
|
||||||
|
// This node's embedded content, before its children's `node` calls can take it.
|
||||||
|
let embedded = self.pending.take();
|
||||||
// A compound's children are its operands, already merged into its outline.
|
// A compound's children are its operands, already merged into its outline.
|
||||||
let (children, child_mask) = if class == Tag::of(b"Comp") { (Vec::new(), None) } else { self.children(id, world, depth)? };
|
let (mut children, child_mask) = if class == Tag::of(b"Comp") { (Vec::new(), None) } else { self.children(id, world, depth)? };
|
||||||
let (mask, attached_mask) = self.attached_masks(id, world)?;
|
if let Some(embedded) = embedded {
|
||||||
|
children.extend(embedded);
|
||||||
|
}
|
||||||
|
// Pixel masks are read only over what they mask (they can span a whole spread at print
|
||||||
|
// resolution, a gigapixel).
|
||||||
|
let over = mask_extent(&kind, &children);
|
||||||
|
let child_mask = match child_mask {
|
||||||
|
Some((c, at)) => crate::raster::mask(self, c, at, over)?,
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let (mask, attached_mask) = self.attached_masks(id, world, over)?;
|
||||||
let pixel_mask = attached_mask.or(child_mask);
|
let pixel_mask = attached_mask.or(child_mask);
|
||||||
self.active.remove(&id);
|
self.active.remove(&id);
|
||||||
if s.objs(id, b"FiEf").iter().any(|e| s.bool(*e, b"Enab") != Some(false)) {
|
let mut node = Node { class, name, visible, locked, opacity, blend, kind, mask, pixel_mask, effects: Vec::new(), children };
|
||||||
self.warn("layer effects (shadows, glows, outlines, bevels…)");
|
// Gradient overlays are laid out over the node's bounds, also while it is hidden.
|
||||||
}
|
let bounds = tree_bounds(&node);
|
||||||
Ok(Some(Node { class, name, visible, locked, opacity, blend, kind, mask, pixel_mask, children }))
|
node.effects = self.effects(id, world, bounds);
|
||||||
|
Ok(Some(node))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn kind(&mut self, id: ObjId, class: Tag, world: Affine, parent: Affine) -> Result<Kind, Error> {
|
fn kind(&mut self, id: ObjId, class: Tag, world: Affine, parent: Affine) -> Result<Kind, Error> {
|
||||||
@@ -471,6 +753,13 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
|||||||
let (fills, strokes) = paint::node_paint(self, id, world);
|
let (fills, strokes) = paint::node_paint(self, id, world);
|
||||||
Kind::Shape { path: local.transformed(world), fills, strokes, even_odd }
|
Kind::Shape { path: local.transformed(world), fills, strokes, even_odd }
|
||||||
}
|
}
|
||||||
|
b"TxtT" => match crate::text::table(self, id, world) {
|
||||||
|
Some(t) => Kind::Table(t),
|
||||||
|
None => {
|
||||||
|
self.warn("tables that could not be read");
|
||||||
|
Kind::Unsupported
|
||||||
|
}
|
||||||
|
},
|
||||||
b"TxtA" | b"TxtF" | b"TxtC" => match crate::text::read(self, id, world) {
|
b"TxtA" | b"TxtF" | b"TxtC" => match crate::text::read(self, id, world) {
|
||||||
Some(t) => Kind::Text(t),
|
Some(t) => Kind::Text(t),
|
||||||
None => {
|
None => {
|
||||||
@@ -482,15 +771,35 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
|||||||
Some(img) => Kind::Image(img),
|
Some(img) => Kind::Image(img),
|
||||||
None => Kind::Unsupported,
|
None => Kind::Unsupported,
|
||||||
},
|
},
|
||||||
b"EmbN" => match crate::raster::embedded(self, id, world)? {
|
b"MPIN" => {
|
||||||
Some(img) => {
|
// `SLnk` links the instance with the master spread it shows.
|
||||||
self.warn("embedded documents and symbols (imported as pictures of them)");
|
let linked = s.obj(id, b"SLnk").map(|l| s.objs(l, b"ILOb")).unwrap_or_default();
|
||||||
Kind::Image(img)
|
let master = linked.iter().find_map(|o| self.master_ids.iter().position(|m| m == o));
|
||||||
|
let at = |t: &[u8; 4], default: usize| s.int(id, t).and_then(|v| usize::try_from(v).ok()).unwrap_or(default).min(4096);
|
||||||
|
match master {
|
||||||
|
Some(master) => Kind::MasterInstance { master, page: at(b"PgOf", 0), master_page: at(b"MPOf", 0), count: at(b"PgCt", 1) },
|
||||||
|
None => {
|
||||||
|
self.warn("master page instances without their master");
|
||||||
|
Kind::Unsupported
|
||||||
|
}
|
||||||
}
|
}
|
||||||
None => {
|
}
|
||||||
self.warn("embedded documents and symbols without a cached picture");
|
b"EmbN" => match self.embedded_document(id, world) {
|
||||||
Kind::Unsupported
|
// The embedded document itself, as editable content.
|
||||||
|
Some(nodes) => {
|
||||||
|
self.pending = Some(nodes);
|
||||||
|
Kind::Group
|
||||||
}
|
}
|
||||||
|
None => match crate::raster::embedded(self, id, world)? {
|
||||||
|
Some(img) => {
|
||||||
|
self.warn("embedded documents and symbols (imported as pictures of them)");
|
||||||
|
Kind::Image(img)
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
self.warn("embedded documents and symbols without a cached picture");
|
||||||
|
Kind::Unsupported
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
b"FRst" => {
|
b"FRst" => {
|
||||||
// A fill layer paints its whole bitmap area; its gradient lives in page space.
|
// A fill layer paints its whole bitmap area; its gradient lives in page space.
|
||||||
@@ -515,9 +824,89 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The enabled layer effects (`FiEf`) of node `id`. The angle convention was measured on the
|
||||||
|
/// public MIT fx fixture (Patchy): an angle of π/2 with offset 10 shadows 10 below.
|
||||||
|
fn effects(&mut self, id: ObjId, world: Affine, bounds: Option<Rect>) -> Vec<Effect> {
|
||||||
|
let s = self.s;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for e in s.objs(id, b"FiEf").into_iter().take(64) {
|
||||||
|
if s.bool(e, b"Enab") == Some(false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// "Scale with object": lengths follow the node's transform; otherwise they are absolute.
|
||||||
|
let k = if s.bool(e, b"SclO") == Some(true) { world.scale() } else { 1.0 };
|
||||||
|
let len = |t: &[u8; 4]| s.f64(e, t).filter(|v| v.is_finite() && *v >= 0.0 && *v < 1e6).unwrap_or(0.0) * k;
|
||||||
|
let opacity = s.f64(e, b"Opac").filter(|v| v.is_finite()).unwrap_or(1.0).clamp(0.0, 1.0);
|
||||||
|
let angle = s.f64(e, b"Angl").filter(|v| v.is_finite()).unwrap_or(0.0);
|
||||||
|
let color = s.obj(e, b"Colr").and_then(|c| crate::paint::color(self, c));
|
||||||
|
let class = s.class(e).map(|c| c.0.to_be_bytes());
|
||||||
|
match (class.as_ref(), color) {
|
||||||
|
(Some(b"Shad"), Some(color)) => out.push(Effect::DropShadow { color, opacity, radius: len(b"Radi"), distance: len(b"Offs"), angle }),
|
||||||
|
(Some(b"InnS"), Some(color)) => out.push(Effect::InnerShadow { color, opacity, radius: len(b"Radi"), distance: len(b"Offs"), angle }),
|
||||||
|
(Some(b"OutG"), Some(color)) => out.push(Effect::OuterGlow { color, opacity, radius: len(b"Radi") }),
|
||||||
|
(Some(b"Strk"), Some(color)) if s.enumeration(e, b"Ftyp").is_none_or(|(t, _)| t == 0) => {
|
||||||
|
let align = s.enumeration(e, b"Alig").map_or(0, |(a, _)| a);
|
||||||
|
out.push(Effect::Outline { color, opacity, width: len(b"Radi"), align });
|
||||||
|
}
|
||||||
|
(Some(b"Strk"), _) => self.warn("gradient outline effects (left out)"),
|
||||||
|
(Some(b"BevE" | b"EmbE"), _) => {
|
||||||
|
// No radius, no bevel (as Affinity draws it).
|
||||||
|
let size = len(b"Radi");
|
||||||
|
if size > 0.0 {
|
||||||
|
let white = crate::paint::Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 };
|
||||||
|
let black = crate::paint::Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 };
|
||||||
|
let highlight = s.obj(e, b"HiCl").and_then(|c| crate::paint::color(self, c)).unwrap_or(white);
|
||||||
|
let shadow = s.obj(e, b"ShCl").and_then(|c| crate::paint::color(self, c)).unwrap_or(black);
|
||||||
|
let shadow_opacity = s.f64(e, b"ShOp").filter(|v| v.is_finite()).unwrap_or(0.75).clamp(0.0, 1.0);
|
||||||
|
let depth = s.f64(e, b"Dept").filter(|v| v.is_finite()).unwrap_or(5.0);
|
||||||
|
let azimuth = s.f64(e, b"Azim").filter(|v| v.is_finite()).unwrap_or(std::f64::consts::FRAC_PI_4 * 3.0);
|
||||||
|
let inner = s.enumeration(e, b"Beve").is_none_or(|(k, _)| k == 0);
|
||||||
|
out.push(Effect::Bevel { size, depth, angle: azimuth, highlight, highlight_opacity: opacity, shadow, shadow_opacity, inner });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Some(b"ColO"), Some(color)) => {
|
||||||
|
let blend = self.effect_blend(e);
|
||||||
|
out.push(Effect::ColorOverlay { color, opacity, blend });
|
||||||
|
}
|
||||||
|
(Some(b"GrdO"), _) => {
|
||||||
|
let blend = self.effect_blend(e);
|
||||||
|
// The gradient's unit space spans the node's bounding box from -1 to 1, centred
|
||||||
|
// on it (y down): checked against the stored previews of real documents.
|
||||||
|
let space =
|
||||||
|
bounds.map(|b| Affine([(b.x1 - b.x0) / 2.0, 0.0, 0.0, (b.y1 - b.y0) / 2.0, (b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0]));
|
||||||
|
match (s.obj(e, b"GrFl"), space) {
|
||||||
|
(Some(fill), Some(space)) => match paint::descriptor(self, fill, space) {
|
||||||
|
Some(Paint::Gradient(gradient)) => out.push(Effect::GradientOverlay { gradient, opacity, blend }),
|
||||||
|
Some(Paint::Solid(color)) => out.push(Effect::ColorOverlay { color, opacity, blend }),
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
// Nothing visible to lay it over.
|
||||||
|
(Some(_), None) => {}
|
||||||
|
(None, _) => self.warn("gradient overlay effects that could not be read (left out)"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Some(b"ColO"), None) => self.warn("colour overlay effects that could not be read (left out)"),
|
||||||
|
(Some(b"Gaus"), _) => self.warn("blur effects (left out)"),
|
||||||
|
_ => self.warn("layer effects of other kinds (left out)"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A layer effect's blend mode (`BlnM`); unknown modes are Normal (warned).
|
||||||
|
fn effect_blend(&mut self, e: ObjId) -> Blend {
|
||||||
|
match self.s.enumeration(e, b"BlnM") {
|
||||||
|
None => Blend::Normal,
|
||||||
|
Some((i, v)) => Blend::from_enum(i, v).unwrap_or_else(|| {
|
||||||
|
self.warn("an unknown blend mode (imported as Normal)");
|
||||||
|
Blend::Normal
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Vector shapes attached to a node (`AdCh`) mask it: it shows only inside their outlines;
|
/// Vector shapes attached to a node (`AdCh`) mask it: it shows only inside their outlines;
|
||||||
/// an attached mask layer is a pixel mask. Adjustments attached the same way are reported.
|
/// an attached mask layer is a pixel mask. Adjustments attached the same way are reported.
|
||||||
fn attached_masks(&mut self, id: ObjId, world: Affine) -> Result<(Option<Path>, Option<Image>), Error> {
|
fn attached_masks(&mut self, id: ObjId, world: Affine, over: Option<Rect>) -> Result<(Option<Path>, Option<Image>), Error> {
|
||||||
let s = self.s;
|
let s = self.s;
|
||||||
let attached = s.objs(id, b"AdCh");
|
let attached = s.objs(id, b"AdCh");
|
||||||
let mut mask: Option<Path> = None;
|
let mut mask: Option<Path> = None;
|
||||||
@@ -534,7 +923,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
|||||||
}
|
}
|
||||||
} else if class == Tag::of(b"MRst") {
|
} else if class == Tag::of(b"MRst") {
|
||||||
let local = s.floats::<6>(a, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY).then(world);
|
let local = s.floats::<6>(a, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY).then(world);
|
||||||
match crate::raster::mask(self, a, local)? {
|
match crate::raster::mask(self, a, local, over)? {
|
||||||
Some(m) if pixels.is_none() => pixels = Some(m),
|
Some(m) if pixels.is_none() => pixels = Some(m),
|
||||||
Some(_) => self.warn("several pixel masks on one layer (only the first is used)"),
|
Some(_) => self.warn("several pixel masks on one layer (only the first is used)"),
|
||||||
None => {}
|
None => {}
|
||||||
@@ -546,6 +935,93 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
|||||||
Ok((mask, pixels))
|
Ok((mask, pixels))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The content of an Affinity document embedded in this one (`EmbN` whose `EmbC` entry holds
|
||||||
|
/// `EmDc`, four more bytes and a complete Affinity document): its first spread's nodes,
|
||||||
|
/// placed so the centre of its page, first artboard or content is the node's origin.
|
||||||
|
fn embedded_document(&mut self, id: ObjId, world: Affine) -> Option<Vec<Node>> {
|
||||||
|
let s = self.s;
|
||||||
|
let entry = s.obj(id, b"Bitm").and_then(|b| s.obj(b, b"EmCn")).and_then(|c| s.entry(c, b"EmbC"))?.to_string();
|
||||||
|
if entry.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if self.depth >= MAX_EMBED_DEPTH {
|
||||||
|
self.warn("embedded documents nested more than four deep");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let data = self.archive.read(&entry).ok()?;
|
||||||
|
let inner = data.strip_prefix(b"EmDc").and_then(|d| d.get(4..))?;
|
||||||
|
if !crate::is_affinity(inner) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// A first reading finds its page and resolution, the second places it.
|
||||||
|
// Both readings are charged to the budget and the archive's limits, also when they fail:
|
||||||
|
// each does the work.
|
||||||
|
let mut budget = self.budget;
|
||||||
|
let probe = read_at(inner, self.archive.remaining(), &mut budget, Affine::IDENTITY, self.depth + 1);
|
||||||
|
self.budget = budget;
|
||||||
|
if self.archive.charge(probe.extracted).is_err() {
|
||||||
|
self.warn("embedded documents beyond the size limit (left out)");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let probe = match probe.document {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => {
|
||||||
|
self.warn(format!("embedded documents that could not be read ({e})"));
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let spread = probe.spreads.first()?;
|
||||||
|
// `PBBx` 2 places the content's own bounds (as with a cached picture); otherwise its page.
|
||||||
|
let content = s.obj(id, b"Bitm").and_then(|b| s.enumeration(b, b"PBBx")).is_some_and(|(k, _)| k == 2);
|
||||||
|
let first_board = spread.nodes.iter().find_map(|n| if let Kind::Artboard { rect, .. } = &n.kind { Some(*rect) } else { None });
|
||||||
|
let page = if content { None } else { spread.pages.first().copied().or(first_board) };
|
||||||
|
let page = page.or_else(|| nodes_bounds(&spread.nodes)).unwrap_or(spread.bounds);
|
||||||
|
// Its pixels aren't needed any more: never hold both readings' at once.
|
||||||
|
drop(probe);
|
||||||
|
// Its document pixels are the node's units, whatever either document's resolution.
|
||||||
|
let k = 1.0;
|
||||||
|
let (cx, cy) = ((page.x0 + page.x1) / 2.0, (page.y0 + page.y1) / 2.0);
|
||||||
|
let base = Affine([k, 0.0, 0.0, k, -cx * k, -cy * k]).then(world);
|
||||||
|
if !base.is_finite() {
|
||||||
|
self.warn("embedded documents with an invalid placement");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut budget = self.budget;
|
||||||
|
let placed = read_at(inner, self.archive.remaining(), &mut budget, base, self.depth + 1);
|
||||||
|
self.budget = budget;
|
||||||
|
if self.archive.charge(placed.extracted).is_err() {
|
||||||
|
self.warn("embedded documents beyond the size limit (left out)");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let doc = placed.document.ok()?;
|
||||||
|
for w in doc.warnings {
|
||||||
|
self.warn(format!("{w} [in embedded documents]"));
|
||||||
|
}
|
||||||
|
if doc.spreads.len() > 1 {
|
||||||
|
self.warn("embedded documents with several pages (the first is used)");
|
||||||
|
}
|
||||||
|
let mut nodes = doc.spreads.into_iter().next()?.nodes;
|
||||||
|
// Its master page instances count its own masters, which aren't kept: in this document
|
||||||
|
// they would show other masters.
|
||||||
|
fn strip_masters(nodes: &mut [Node], depth: usize) -> bool {
|
||||||
|
let mut found = false;
|
||||||
|
for n in nodes {
|
||||||
|
if matches!(n.kind, Kind::MasterInstance { .. }) {
|
||||||
|
n.kind = Kind::Unsupported;
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
if depth < MAX_TREE_DEPTH {
|
||||||
|
found |= strip_masters(&mut n.children, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
found
|
||||||
|
}
|
||||||
|
if strip_masters(&mut nodes, 0) {
|
||||||
|
self.warn("master pages of embedded documents (left out)");
|
||||||
|
}
|
||||||
|
Some(nodes)
|
||||||
|
}
|
||||||
|
|
||||||
fn artboard(&mut self, id: ObjId, world: Affine) -> Kind {
|
fn artboard(&mut self, id: ObjId, world: Affine) -> Kind {
|
||||||
let s = self.s;
|
let s = self.s;
|
||||||
let Some(b) = s.floats::<4>(id, b"ShpB").map(rect).filter(|b| b.x1 > b.x0 && b.y1 > b.y0) else {
|
let Some(b) = s.floats::<4>(id, b"ShpB").map(rect).filter(|b| b.x1 > b.x0 && b.y1 > b.y0) else {
|
||||||
@@ -580,6 +1056,96 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The bounds of what visible `nodes` draw (control points of outlines, corners of images, frames
|
||||||
|
/// and tables), in document pixels.
|
||||||
|
pub fn nodes_bounds(nodes: &[Node]) -> Option<Rect> {
|
||||||
|
let mut out = None;
|
||||||
|
walk_bounds(nodes, true, 0, &mut out);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bounds of `node` and everything in it, hidden or not.
|
||||||
|
fn tree_bounds(node: &Node) -> Option<Rect> {
|
||||||
|
let mut out = None;
|
||||||
|
walk_bounds(std::slice::from_ref(node), false, 0, &mut out);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walk_bounds(nodes: &[Node], visible_only: bool, depth: usize, out: &mut Option<Rect>) {
|
||||||
|
if depth > MAX_TREE_DEPTH {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for n in nodes.iter().filter(|n| n.visible || !visible_only) {
|
||||||
|
kind_points(&n.kind, &mut |p| add_point(out, p));
|
||||||
|
walk_bounds(&n.children, visible_only, depth + 1, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a pixel mask over a node of `kind` with `children` is read over: the bounds of what they
|
||||||
|
/// draw, strokes included, or `None` (all of the mask) when some of it has no bounds known here:
|
||||||
|
/// artistic text, whose glyphs only the layout places.
|
||||||
|
fn mask_extent(kind: &Kind, children: &[Node]) -> Option<Rect> {
|
||||||
|
fn visit(kind: &Kind, children: &[Node], depth: usize, out: &mut Option<Rect>) -> bool {
|
||||||
|
if depth > MAX_TREE_DEPTH || matches!(kind, Kind::Text(t) if t.frame.is_none()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut own = kind_bounds(kind);
|
||||||
|
if let (Some(r), Kind::Shape { strokes, .. } | Kind::Artboard { strokes, .. }) = (own, kind) {
|
||||||
|
// As far as a stroke can reach out of its path, miter joins included.
|
||||||
|
let reach = strokes.iter().map(|s| s.width * s.miter_limit.clamp(1.0, 10.0)).filter(|w| w.is_finite()).fold(0.0, f64::max);
|
||||||
|
own = Some(Rect { x0: r.x0 - reach, y0: r.y0 - reach, x1: r.x1 + reach, y1: r.y1 + reach });
|
||||||
|
}
|
||||||
|
if let Some(r) = own {
|
||||||
|
*out = Some(out.map_or(r, |o| union(o, r)));
|
||||||
|
}
|
||||||
|
children.iter().filter(|n| n.visible).all(|n| visit(&n.kind, &n.children, depth + 1, out))
|
||||||
|
}
|
||||||
|
let mut out = None;
|
||||||
|
if visit(kind, children, 0, &mut out) { out } else { None }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bounds of what one node's own kind draws (not its children): its outline's points, its
|
||||||
|
/// image's or frame's corners, or artistic text's anchor.
|
||||||
|
pub fn kind_bounds(kind: &Kind) -> Option<Rect> {
|
||||||
|
let mut out = None;
|
||||||
|
kind_points(kind, &mut |p| add_point(&mut out, p));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_point(out: &mut Option<Rect>, p: Point) {
|
||||||
|
if p.x.is_finite() && p.y.is_finite() {
|
||||||
|
let r = Rect { x0: p.x, y0: p.y, x1: p.x, y1: p.y };
|
||||||
|
*out = Some(out.map_or(r, |o| union(o, r)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kind_points(kind: &Kind, add: &mut dyn FnMut(Point)) {
|
||||||
|
let corners = |r: Rect, m: Affine| [(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)].map(|(x, y)| m.apply(Point { x, y }));
|
||||||
|
match kind {
|
||||||
|
Kind::Shape { path, .. } | Kind::Artboard { path, .. } => {
|
||||||
|
for sp in &path.subpaths {
|
||||||
|
add(sp.start);
|
||||||
|
for seg in &sp.segments {
|
||||||
|
seg.iter().for_each(|p| add(*p));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Kind::Image(img) => {
|
||||||
|
corners(Rect { x0: 0.0, y0: 0.0, x1: f64::from(img.width), y1: f64::from(img.height) }, img.transform).into_iter().for_each(add)
|
||||||
|
}
|
||||||
|
Kind::Text(t) => match t.frame {
|
||||||
|
Some(f) => corners(f, t.transform).into_iter().for_each(add),
|
||||||
|
None => add(t.transform.apply(t.anchor)),
|
||||||
|
},
|
||||||
|
Kind::Table(t) => {
|
||||||
|
if let (Some(&x0), Some(&x1), Some(&y0), Some(&y1)) = (t.columns.first(), t.columns.last(), t.rows.first(), t.rows.last()) {
|
||||||
|
corners(Rect { x0, y0, x1, y1 }, t.transform).into_iter().for_each(add);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Kind::Layer | Kind::Group | Kind::Unsupported | Kind::MasterInstance { .. } => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Page rectangles: `SpMd.PagR[].rctp` (Publisher 2, Affinity 3) or `SprB` split into `PagC`
|
/// Page rectangles: `SpMd.PagR[].rctp` (Publisher 2, Affinity 3) or `SprB` split into `PagC`
|
||||||
/// equal pages (Publisher 1 facing spreads).
|
/// equal pages (Publisher 1 facing spreads).
|
||||||
fn pages(s: &Stream, spread: ObjId) -> Vec<Rect> {
|
fn pages(s: &Stream, spread: ObjId) -> Vec<Rect> {
|
||||||
|
|||||||
@@ -0,0 +1,663 @@
|
|||||||
|
//! Colour and gradient overlays. VectorCraft has no such effect, so an overlay is baked into what
|
||||||
|
//! its node paints: fill, stroke and text colours, and pixels. The overlay lies over the node's
|
||||||
|
//! own paint in its blend mode at its opacity, inside the node's alpha (which it keeps).
|
||||||
|
//!
|
||||||
|
//! Blend modes follow the W3C Compositing and Blending formulas; the modes PDF lacks (Vivid
|
||||||
|
//! Light, Linear Light, Pin Light, Hard Mix, Add, Subtract, Average, Negation, Reflect, Glow,
|
||||||
|
//! Darker/Lighter Color) use their usual definitions.
|
||||||
|
|
||||||
|
use std::borrow::Cow;
|
||||||
|
|
||||||
|
use crate::model::{Affine, Blend, Effect, Image, Kind, Node, Pixels, Point};
|
||||||
|
use crate::paint::{Color, Gradient, GradientKind, Paint, Stop, Stroke};
|
||||||
|
|
||||||
|
/// Deepest subtree an overlay is baked into (the reader bounds trees well below this).
|
||||||
|
const MAX_DEPTH: usize = 256;
|
||||||
|
|
||||||
|
/// Most pixels an overlay is baked into per image.
|
||||||
|
const MAX_PIXELS: usize = 64 << 20;
|
||||||
|
|
||||||
|
/// Work the overlays of one import may take together: each overlay counts the nodes and pixels
|
||||||
|
/// of the subtree it is baked into (which is copied once more for them all). Nested overlays
|
||||||
|
/// and shared content would otherwise multiply it; past it, overlays are left out and said.
|
||||||
|
pub const MAX_WORK: u64 = 1 << 29;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum Source<'a> {
|
||||||
|
Solid(Color),
|
||||||
|
Gradient(&'a Gradient),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct Overlay<'a> {
|
||||||
|
source: Source<'a>,
|
||||||
|
opacity: f64,
|
||||||
|
blend: Blend,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `node` with its colour and gradient overlays baked in, and those of everything in it
|
||||||
|
/// (borrowed unchanged when `node` has none, or when what is left of `work`, see [`MAX_WORK`],
|
||||||
|
/// can't pay for copying it). `warn` hears what could only be approximated or was left out.
|
||||||
|
pub fn apply<'a>(node: &'a Node, work: &mut u64, warn: &mut dyn FnMut(&str)) -> Cow<'a, Node> {
|
||||||
|
if overlays(&node.effects).is_empty() {
|
||||||
|
return Cow::Borrowed(node);
|
||||||
|
}
|
||||||
|
let Some(left) = work.checked_sub(subtree_cost(node, *work, 0)) else {
|
||||||
|
warn("overlay effects beyond the work one import may take (left out)");
|
||||||
|
return Cow::Borrowed(node);
|
||||||
|
};
|
||||||
|
*work = left;
|
||||||
|
let mut out = node.clone();
|
||||||
|
bake_tree(&mut out, work, 0, warn);
|
||||||
|
Cow::Owned(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The visible colour and gradient overlays among `effects`.
|
||||||
|
fn overlays(effects: &[Effect]) -> Vec<Overlay<'_>> {
|
||||||
|
effects
|
||||||
|
.iter()
|
||||||
|
.filter_map(|e| match e {
|
||||||
|
Effect::ColorOverlay { color, opacity, blend } => Some(Overlay { source: Source::Solid(*color), opacity: *opacity, blend: *blend }),
|
||||||
|
Effect::GradientOverlay { gradient, opacity, blend } => {
|
||||||
|
Some(Overlay { source: Source::Gradient(gradient), opacity: *opacity, blend: *blend })
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.filter(|o| o.opacity > 0.0)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_overlay(e: &Effect) -> bool {
|
||||||
|
matches!(e, Effect::ColorOverlay { .. } | Effect::GradientOverlay { .. })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bakes the overlays of `node` and its subtree into their paint, innermost first: an overlay
|
||||||
|
/// lies over what its node shows, its children's overlays included. Each overlay pays for the
|
||||||
|
/// subtree it is baked into out of `work`; past it, it is left out (and said).
|
||||||
|
fn bake_tree(node: &mut Node, work: &mut u64, depth: usize, warn: &mut dyn FnMut(&str)) {
|
||||||
|
if depth > MAX_DEPTH {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for c in &mut node.children {
|
||||||
|
bake_tree(c, work, depth + 1, warn);
|
||||||
|
}
|
||||||
|
if !node.effects.iter().any(is_overlay) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (effects, rest): (Vec<Effect>, Vec<Effect>) = std::mem::take(&mut node.effects).into_iter().partition(is_overlay);
|
||||||
|
node.effects = rest;
|
||||||
|
let overlays = overlays(&effects);
|
||||||
|
if overlays.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let count = u64::try_from(overlays.len()).unwrap_or(u64::MAX);
|
||||||
|
let cost = subtree_cost(node, *work / count, 0).saturating_mul(count);
|
||||||
|
let Some(left) = work.checked_sub(cost) else {
|
||||||
|
warn("overlay effects beyond the work one import may take (left out)");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
*work = left;
|
||||||
|
for o in &overlays {
|
||||||
|
if matches!(o.blend, Blend::Erase | Blend::PassThrough) {
|
||||||
|
warn("overlay effects in a blend mode VectorCraft doesn't have (imported as Normal)");
|
||||||
|
}
|
||||||
|
bake_node(node, o, 0, warn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nodes and pixels in `node`'s subtree, counted only until they pass `limit`.
|
||||||
|
fn subtree_cost(node: &Node, limit: u64, depth: usize) -> u64 {
|
||||||
|
if depth > MAX_DEPTH {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let mut cost: u64 = match &node.kind {
|
||||||
|
Kind::Image(img) => (u64::from(img.width) * u64::from(img.height)).saturating_add(1),
|
||||||
|
Kind::Text(t) => u64::try_from(t.runs.len()).unwrap_or(u64::MAX).saturating_add(1),
|
||||||
|
Kind::Table(t) => u64::try_from(t.cells.len()).unwrap_or(u64::MAX).saturating_add(1),
|
||||||
|
_ => 1,
|
||||||
|
};
|
||||||
|
for c in &node.children {
|
||||||
|
if cost > limit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cost = cost.saturating_add(subtree_cost(c, limit - cost, depth + 1));
|
||||||
|
}
|
||||||
|
cost
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bake_node(node: &mut Node, o: &Overlay, depth: usize, warn: &mut dyn FnMut(&str)) {
|
||||||
|
if depth > MAX_DEPTH {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match &mut node.kind {
|
||||||
|
Kind::Shape { fills, strokes, .. } => {
|
||||||
|
for f in fills.iter_mut() {
|
||||||
|
*f = paint(f, o, warn);
|
||||||
|
}
|
||||||
|
strokes.iter_mut().for_each(|s| stroke(s, o, warn));
|
||||||
|
}
|
||||||
|
Kind::Artboard { background, strokes, .. } => {
|
||||||
|
for f in background.iter_mut() {
|
||||||
|
*f = paint(f, o, warn);
|
||||||
|
}
|
||||||
|
strokes.iter_mut().for_each(|s| stroke(s, o, warn));
|
||||||
|
}
|
||||||
|
Kind::Text(t) => {
|
||||||
|
for r in &mut t.runs {
|
||||||
|
r.fill = paint(&r.fill, o, warn);
|
||||||
|
if let Some(s) = &mut r.stroke {
|
||||||
|
stroke(s, o, warn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Kind::Table(t) => {
|
||||||
|
for c in &mut t.cells {
|
||||||
|
for r in &mut c.runs {
|
||||||
|
r.fill = paint(&r.fill, o, warn);
|
||||||
|
if let Some(s) = &mut r.stroke {
|
||||||
|
stroke(s, o, warn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for s in t.vertical.iter_mut().chain(t.horizontal.iter_mut()).flatten() {
|
||||||
|
stroke(s, o, warn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Kind::Image(img) => {
|
||||||
|
if !pixels(img, o) {
|
||||||
|
warn("overlay effects on images too large to recolour (left out)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Kind::Layer | Kind::Group | Kind::Unsupported | Kind::MasterInstance { .. } => {}
|
||||||
|
}
|
||||||
|
for e in &mut node.effects {
|
||||||
|
match e {
|
||||||
|
Effect::Outline { color, .. } => *color = over(*color, o.source.at_solid(), o),
|
||||||
|
Effect::DropShadow { .. } | Effect::InnerShadow { .. } | Effect::OuterGlow { .. } | Effect::Bevel { .. } => {}
|
||||||
|
Effect::ColorOverlay { .. } | Effect::GradientOverlay { .. } => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for c in &mut node.children {
|
||||||
|
bake_node(c, o, depth + 1, warn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stroke(s: &mut Stroke, o: &Overlay, warn: &mut dyn FnMut(&str)) {
|
||||||
|
s.paint = paint(&s.paint, o, warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A paint with the overlay over it.
|
||||||
|
fn paint(p: &Paint, o: &Overlay, warn: &mut dyn FnMut(&str)) -> Paint {
|
||||||
|
match (p, o.source) {
|
||||||
|
(Paint::None, _) => Paint::None,
|
||||||
|
(Paint::Solid(c), Source::Solid(s)) => Paint::Solid(over(*c, s, o)),
|
||||||
|
(Paint::Gradient(g), Source::Solid(s)) => {
|
||||||
|
Paint::Gradient(Gradient { stops: g.stops.iter().map(|st| Stop { color: over(st.color, s, o), ..*st }).collect(), ..g.clone() })
|
||||||
|
}
|
||||||
|
// Over one colour, the result is the overlay's gradient with each stop laid over it.
|
||||||
|
(Paint::Solid(c), Source::Gradient(g)) => {
|
||||||
|
Paint::Gradient(Gradient { stops: g.stops.iter().map(|st| Stop { color: over(*c, st.color, o), ..*st }).collect(), ..g.clone() })
|
||||||
|
}
|
||||||
|
(Paint::Gradient(base), Source::Gradient(g)) => {
|
||||||
|
if !(o.blend == Blend::Normal && o.opacity >= 1.0) {
|
||||||
|
warn("gradient overlays on gradients (approximated)");
|
||||||
|
}
|
||||||
|
// Exact when the overlay covers it; otherwise over the base gradient's average colour.
|
||||||
|
let avg = average(base);
|
||||||
|
Paint::Gradient(Gradient { stops: g.stops.iter().map(|st| Stop { color: over(avg, st.color, o), ..*st }).collect(), ..g.clone() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Source<'_> {
|
||||||
|
/// One colour for the source (a gradient's average) where only one fits.
|
||||||
|
fn at_solid(&self) -> Color {
|
||||||
|
match self {
|
||||||
|
Source::Solid(c) => *c,
|
||||||
|
Source::Gradient(g) => average(g),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn average(g: &Gradient) -> Color {
|
||||||
|
let n = g.stops.len().max(1) as f64;
|
||||||
|
let (mut r, mut gr, mut b, mut a) = (0.0, 0.0, 0.0, 0.0);
|
||||||
|
for s in &g.stops {
|
||||||
|
let [sr, sg, sb] = rgb(s.color);
|
||||||
|
r += sr;
|
||||||
|
gr += sg;
|
||||||
|
b += sb;
|
||||||
|
a += s.color.alpha();
|
||||||
|
}
|
||||||
|
Color::Rgb { r: r / n, g: gr / n, b: b / n, a: a / n }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `source` laid over `base` in the overlay's mode and opacity; `base`'s alpha is kept.
|
||||||
|
fn over(base: Color, source: Color, o: &Overlay) -> Color {
|
||||||
|
let k = (o.opacity * source.alpha()).clamp(0.0, 1.0);
|
||||||
|
let alpha = base.alpha();
|
||||||
|
// Normal mixes within the colour model when both share it, so CMYK stays CMYK.
|
||||||
|
if matches!(o.blend, Blend::Normal | Blend::PassThrough | Blend::Erase) {
|
||||||
|
let mix = |a: f64, b: f64| a + (b - a) * k;
|
||||||
|
match (base, source) {
|
||||||
|
(Color::Cmyk { c, m, y, k: kk, .. }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2, .. }) => {
|
||||||
|
return Color::Cmyk { c: mix(c, c2), m: mix(m, m2), y: mix(y, y2), k: mix(kk, k2), a: alpha };
|
||||||
|
}
|
||||||
|
(Color::Gray { v, .. }, Color::Gray { v: v2, .. }) => return Color::Gray { v: mix(v, v2), a: alpha },
|
||||||
|
(_, s) if k >= 1.0 => return with_alpha(s, alpha),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let b = rgb(base);
|
||||||
|
let s = rgb(source);
|
||||||
|
let blended = blend(o.blend, b, s);
|
||||||
|
let [r, g, bl] = [0, 1, 2].map(|i| b[i] + (blended[i] - b[i]) * k);
|
||||||
|
Color::Rgb { r, g, b: bl, a: alpha }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_alpha(c: Color, a: f64) -> Color {
|
||||||
|
match c {
|
||||||
|
Color::Rgb { r, g, b, .. } => Color::Rgb { r, g, b, a },
|
||||||
|
Color::Cmyk { c, m, y, k, .. } => Color::Cmyk { c, m, y, k, a },
|
||||||
|
Color::Gray { v, .. } => Color::Gray { v, a },
|
||||||
|
Color::Lab { l, a: la, b, .. } => Color::Lab { l, a: la, b, alpha: a },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// sRGB components (0..1) of a colour. CMYK uses the profile-free formula (the engine shows the
|
||||||
|
/// node's other CMYK colours through the document's profile), Lab its D50 sRGB colour.
|
||||||
|
fn rgb(c: Color) -> [f64; 3] {
|
||||||
|
let f = |v: f64| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
|
||||||
|
match c {
|
||||||
|
Color::Rgb { r, g, b, .. } => [f(r), f(g), f(b)],
|
||||||
|
Color::Gray { v, .. } => [f(v); 3],
|
||||||
|
Color::Cmyk { c, m, y, k, .. } => {
|
||||||
|
let w = 1.0 - f(k);
|
||||||
|
[(1.0 - f(c)) * w, (1.0 - f(m)) * w, (1.0 - f(y)) * w]
|
||||||
|
}
|
||||||
|
Color::Lab { l, a, b, .. } => lab_to_srgb(l, a, b).map(f),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CIE L*a*b* (D50) to sRGB, through XYZ and the Bradford adaptation to D65.
|
||||||
|
fn lab_to_srgb(l: f64, a: f64, b: f64) -> [f64; 3] {
|
||||||
|
let finite = |v: f64| if v.is_finite() { v } else { 0.0 };
|
||||||
|
let (l, a, b) = (finite(l), finite(a), finite(b));
|
||||||
|
let fy = (l + 16.0) / 116.0;
|
||||||
|
let (fx, fz) = (fy + a / 500.0, fy - b / 200.0);
|
||||||
|
let inv = |t: f64| if t > 6.0 / 29.0 { t * t * t } else { 3.0 * (6.0f64 / 29.0).powi(2) * (t - 4.0 / 29.0) };
|
||||||
|
// D50 white.
|
||||||
|
let (x, y, z) = (0.964_22 * inv(fx), inv(fy), 0.825_21 * inv(fz));
|
||||||
|
// XYZ (D50) to linear sRGB, Bradford-adapted.
|
||||||
|
let r = 3.133_856 * x - 1.616_867 * y - 0.490_615 * z;
|
||||||
|
let g = -0.978_768 * x + 1.916_142 * y + 0.033_454 * z;
|
||||||
|
let bl = 0.071_945 * x - 0.228_991 * y + 1.405_243 * z;
|
||||||
|
let gamma = |v: f64| if v <= 0.003_130_8 { 12.92 * v } else { 1.055 * v.max(0.0).powf(1.0 / 2.4) - 0.055 };
|
||||||
|
[gamma(r), gamma(g), gamma(bl)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bake the overlay into an image's pixels. False if they couldn't be read or are too many.
|
||||||
|
fn pixels(img: &mut Image, o: &Overlay) -> bool {
|
||||||
|
let Some(n) = (img.width as usize).checked_mul(img.height as usize).filter(|n| *n <= MAX_PIXELS) else { return false };
|
||||||
|
let Some(len) = n.checked_mul(4) else { return false };
|
||||||
|
let mut px = match &img.pixels {
|
||||||
|
Pixels::Rgba8(p) => match p.get(..len) {
|
||||||
|
Some(p) => p.to_vec(),
|
||||||
|
None => return false,
|
||||||
|
},
|
||||||
|
Pixels::Encoded(bytes) => match decode(bytes, img.width, img.height) {
|
||||||
|
Some(p) => p,
|
||||||
|
None => return false,
|
||||||
|
},
|
||||||
|
// An overlay is blended in RGB: CMYK pixels take the profile-free formula here.
|
||||||
|
Pixels::Cmyka8(p) => match p.get(..n.saturating_mul(5)) {
|
||||||
|
Some(p) if p.len() == n * 5 => p
|
||||||
|
.as_chunks::<5>()
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.flat_map(|[c, m, y, k, a]| {
|
||||||
|
let w = 255 - u16::from(*k);
|
||||||
|
let ch = |v: u8| ((u16::from(255 - v) * w + 127) / 255) as u8;
|
||||||
|
[ch(*c), ch(*m), ch(*y), *a]
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
_ => return false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let width = img.width.max(1) as usize;
|
||||||
|
// A solid source is converted once; a gradient is sampled at each pixel's centre.
|
||||||
|
let solid = match o.source {
|
||||||
|
Source::Solid(c) => Some((rgb(c), c.alpha())),
|
||||||
|
Source::Gradient(_) => None,
|
||||||
|
};
|
||||||
|
let to_unit = match o.source {
|
||||||
|
Source::Gradient(g) => match invert(g.transform) {
|
||||||
|
Some(m) => Some((img.transform.then(m), g)),
|
||||||
|
None => return false,
|
||||||
|
},
|
||||||
|
Source::Solid(_) => None,
|
||||||
|
};
|
||||||
|
for (i, p) in px.as_chunks_mut::<4>().0.iter_mut().enumerate() {
|
||||||
|
if p[3] == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (s, sa) = match (solid, &to_unit) {
|
||||||
|
(Some(s), _) => s,
|
||||||
|
(None, Some((m, g))) => {
|
||||||
|
let q = m.apply(Point { x: (i % width) as f64 + 0.5, y: (i / width) as f64 + 0.5 });
|
||||||
|
let c = sample(g, q);
|
||||||
|
(rgb(c), c.alpha())
|
||||||
|
}
|
||||||
|
(None, None) => continue,
|
||||||
|
};
|
||||||
|
let k = (o.opacity * sa).clamp(0.0, 1.0);
|
||||||
|
let b = [p[0], p[1], p[2]].map(|v| f64::from(v) / 255.0);
|
||||||
|
let m = blend(o.blend, b, s);
|
||||||
|
for c in 0..3 {
|
||||||
|
p[c] = ((b[c] + (m[c] - b[c]) * k).clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
img.pixels = Pixels::Rgba8(px);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An encoded original's RGBA pixels, decoded only if it is `width` × `height` as its layer says.
|
||||||
|
fn decode(bytes: &[u8], width: u32, height: u32) -> Option<Vec<u8>> {
|
||||||
|
let probe = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
|
||||||
|
if probe.into_dimensions().ok()? != (width, height) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
|
||||||
|
let mut limits = image::Limits::default();
|
||||||
|
limits.max_image_width = Some(width);
|
||||||
|
limits.max_image_height = Some(height);
|
||||||
|
limits.max_alloc = u64::try_from(MAX_PIXELS).ok().map(|n| n * 8);
|
||||||
|
reader.limits(limits);
|
||||||
|
Some(reader.decode().ok()?.into_rgba8().into_raw())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The gradient's colour at unit-space point `q`.
|
||||||
|
fn sample(g: &Gradient, q: Point) -> Color {
|
||||||
|
let t = match g.kind {
|
||||||
|
GradientKind::Linear => q.x,
|
||||||
|
GradientKind::Radial => q.x.hypot(q.y),
|
||||||
|
GradientKind::Conical => q.y.atan2(q.x).rem_euclid(std::f64::consts::TAU) / std::f64::consts::TAU,
|
||||||
|
};
|
||||||
|
let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 };
|
||||||
|
let (Some(first), Some(last)) = (g.stops.first(), g.stops.last()) else { return Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 0.0 } };
|
||||||
|
if t <= first.offset {
|
||||||
|
return first.color;
|
||||||
|
}
|
||||||
|
for w in g.stops.windows(2) {
|
||||||
|
let [a, b] = [&w[0], &w[1]];
|
||||||
|
if t <= b.offset {
|
||||||
|
let span = b.offset - a.offset;
|
||||||
|
let u = if span > 1e-9 { (t - a.offset) / span } else { 1.0 };
|
||||||
|
// Affinity's midpoint bias (see `Stop::half_point`).
|
||||||
|
let k = 1.0 + 8.0 * (a.bias - 0.5).abs();
|
||||||
|
let u = if a.bias >= 0.5 { u.powf(k) } else { 1.0 - (1.0 - u).powf(k) };
|
||||||
|
let (x, y) = (rgb(a.color), rgb(b.color));
|
||||||
|
let [r, g, bl] = [0, 1, 2].map(|i| x[i] + (y[i] - x[i]) * u);
|
||||||
|
return Color::Rgb { r, g, b: bl, a: a.color.alpha() + (b.color.alpha() - a.color.alpha()) * u };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
last.color
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invert(m: Affine) -> Option<Affine> {
|
||||||
|
let [a, b, c, d, e, f] = m.0;
|
||||||
|
let det = a * d - b * c;
|
||||||
|
if !(det.is_finite() && det.abs() > 1e-12) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (ia, ib, ic, id) = (d / det, -b / det, -c / det, a / det);
|
||||||
|
Some(Affine([ia, ib, ic, id, -(ia * e + ic * f), -(ib * e + id * f)]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The blend of backdrop `b` and source `s` (sRGB, 0..1).
|
||||||
|
fn blend(mode: Blend, b: [f64; 3], s: [f64; 3]) -> [f64; 3] {
|
||||||
|
use Blend::*;
|
||||||
|
let sep = |f: fn(f64, f64) -> f64| [0, 1, 2].map(|i| f(b[i], s[i]).clamp(0.0, 1.0));
|
||||||
|
match mode {
|
||||||
|
Normal | PassThrough | Erase => s,
|
||||||
|
Darken => sep(f64::min),
|
||||||
|
Lighten => sep(f64::max),
|
||||||
|
Multiply => sep(|b, s| b * s),
|
||||||
|
Screen => sep(screen),
|
||||||
|
ColorBurn => sep(burn),
|
||||||
|
ColorDodge => sep(dodge),
|
||||||
|
Blend::Overlay => sep(|b, s| hard_light(s, b)),
|
||||||
|
HardLight => sep(hard_light),
|
||||||
|
SoftLight => sep(soft_light),
|
||||||
|
Difference => sep(|b, s| (b - s).abs()),
|
||||||
|
Exclusion => sep(|b, s| b + s - 2.0 * b * s),
|
||||||
|
Add => sep(|b, s| b + s),
|
||||||
|
Subtract => sep(|b, s| b - s),
|
||||||
|
VividLight => sep(|b, s| if s <= 0.5 { burn(b, 2.0 * s) } else { dodge(b, 2.0 * s - 1.0) }),
|
||||||
|
LinearLight => sep(|b, s| b + 2.0 * s - 1.0),
|
||||||
|
PinLight => sep(|b, s| if s <= 0.5 { b.min(2.0 * s) } else { b.max(2.0 * s - 1.0) }),
|
||||||
|
HardMix => sep(|b, s| if b + s >= 1.0 { 1.0 } else { 0.0 }),
|
||||||
|
Average => sep(|b, s| (b + s) / 2.0),
|
||||||
|
Negation => sep(|b, s| 1.0 - (1.0 - b - s).abs()),
|
||||||
|
Reflect => sep(reflect),
|
||||||
|
Glow => sep(|b, s| reflect(s, b)),
|
||||||
|
DarkerColor => {
|
||||||
|
if lum(s) < lum(b) {
|
||||||
|
s
|
||||||
|
} else {
|
||||||
|
b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LighterColor => {
|
||||||
|
if lum(s) > lum(b) {
|
||||||
|
s
|
||||||
|
} else {
|
||||||
|
b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Hue => set_lum(set_sat(s, sat(b)), lum(b)),
|
||||||
|
Saturation => set_lum(set_sat(b, sat(s)), lum(b)),
|
||||||
|
Blend::Color => set_lum(s, lum(b)),
|
||||||
|
Luminosity => set_lum(b, lum(s)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn screen(b: f64, s: f64) -> f64 {
|
||||||
|
b + s - b * s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn burn(b: f64, s: f64) -> f64 {
|
||||||
|
if b >= 1.0 {
|
||||||
|
1.0
|
||||||
|
} else if s <= 0.0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
1.0 - ((1.0 - b) / s).min(1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dodge(b: f64, s: f64) -> f64 {
|
||||||
|
if b <= 0.0 {
|
||||||
|
0.0
|
||||||
|
} else if s >= 1.0 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
(b / (1.0 - s)).min(1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hard_light(b: f64, s: f64) -> f64 {
|
||||||
|
if s <= 0.5 { b * 2.0 * s } else { screen(b, 2.0 * s - 1.0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn soft_light(b: f64, s: f64) -> f64 {
|
||||||
|
if s <= 0.5 {
|
||||||
|
b - (1.0 - 2.0 * s) * b * (1.0 - b)
|
||||||
|
} else {
|
||||||
|
let d = if b <= 0.25 { ((16.0 * b - 12.0) * b + 4.0) * b } else { b.sqrt() };
|
||||||
|
b + (2.0 * s - 1.0) * (d - b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reflect(b: f64, s: f64) -> f64 {
|
||||||
|
if s >= 1.0 { 1.0 } else { (b * b / (1.0 - s)).min(1.0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lum(c: [f64; 3]) -> f64 {
|
||||||
|
0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clip_color(c: [f64; 3]) -> [f64; 3] {
|
||||||
|
let l = lum(c);
|
||||||
|
let n = c[0].min(c[1]).min(c[2]);
|
||||||
|
let x = c[0].max(c[1]).max(c[2]);
|
||||||
|
c.map(|v| {
|
||||||
|
let mut v = v;
|
||||||
|
if n < 0.0 && l - n > 1e-12 {
|
||||||
|
v = l + (v - l) * l / (l - n);
|
||||||
|
}
|
||||||
|
if x > 1.0 && x - l > 1e-12 {
|
||||||
|
v = l + (v - l) * (1.0 - l) / (x - l);
|
||||||
|
}
|
||||||
|
v.clamp(0.0, 1.0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_lum(c: [f64; 3], l: f64) -> [f64; 3] {
|
||||||
|
let d = l - lum(c);
|
||||||
|
clip_color(c.map(|v| v + d))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sat(c: [f64; 3]) -> f64 {
|
||||||
|
c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_sat(c: [f64; 3], s: f64) -> [f64; 3] {
|
||||||
|
let max = c[0].max(c[1]).max(c[2]);
|
||||||
|
let min = c[0].min(c[1]).min(c[2]);
|
||||||
|
if max - min <= 1e-12 {
|
||||||
|
return [0.0; 3];
|
||||||
|
}
|
||||||
|
c.map(|v| (v - min) * s / (max - min))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn solid(c: Color, opacity: f64, blend: Blend) -> Node {
|
||||||
|
Node {
|
||||||
|
class: crate::stream::Tag::of(b"ShpN"),
|
||||||
|
name: String::new(),
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
opacity: 1.0,
|
||||||
|
blend: Blend::Normal,
|
||||||
|
kind: Kind::Shape {
|
||||||
|
path: Default::default(),
|
||||||
|
fills: vec![Paint::Solid(Color::Rgb { r: 1.0, g: 0.5, b: 0.0, a: 1.0 })],
|
||||||
|
strokes: vec![],
|
||||||
|
even_odd: false,
|
||||||
|
},
|
||||||
|
mask: None,
|
||||||
|
pixel_mask: None,
|
||||||
|
effects: vec![Effect::ColorOverlay { color: c, opacity, blend }],
|
||||||
|
children: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fill(n: &Node) -> Color {
|
||||||
|
match &n.kind {
|
||||||
|
Kind::Shape { fills, .. } => match fills.first() {
|
||||||
|
Some(Paint::Solid(c)) => *c,
|
||||||
|
other => panic!("{other:?}"),
|
||||||
|
},
|
||||||
|
other => panic!("{other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_full_colour_overlay_replaces_the_fill_and_keeps_its_alpha() {
|
||||||
|
let white = Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 };
|
||||||
|
let n = apply(&solid(white, 1.0, Blend::Normal), &mut { MAX_WORK }, &mut |_| {}).into_owned();
|
||||||
|
assert_eq!(fill(&n), white);
|
||||||
|
assert!(n.effects.is_empty(), "the overlay is baked, not kept");
|
||||||
|
// Half way, mixed in RGB.
|
||||||
|
let n = apply(&solid(white, 0.5, Blend::Normal), &mut { MAX_WORK }, &mut |_| {}).into_owned();
|
||||||
|
assert_eq!(fill(&n), Color::Rgb { r: 1.0, g: 0.75, b: 0.5, a: 1.0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blend_modes_follow_their_formulas() {
|
||||||
|
let b = [0.25, 0.5, 1.0];
|
||||||
|
assert_eq!(blend(Blend::Multiply, b, [0.5; 3]), [0.125, 0.25, 0.5]);
|
||||||
|
assert_eq!(blend(Blend::Screen, b, [0.5; 3]), [0.625, 0.75, 1.0]);
|
||||||
|
assert_eq!(blend(Blend::VividLight, [0.5; 3], [0.75; 3]), [1.0; 3]);
|
||||||
|
assert_eq!(blend(Blend::HardMix, [0.5; 3], [0.6; 3]), [1.0; 3]);
|
||||||
|
// Color keeps the backdrop's luminosity with the source's hue and saturation.
|
||||||
|
let c = blend(Blend::Color, [0.5; 3], [1.0, 0.0, 0.0]);
|
||||||
|
assert!((lum(c) - 0.5).abs() < 1e-9 && c[0] > c[1] && (c[1] - c[2]).abs() < 1e-9, "{c:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overlays_recolour_pixels_inside_their_alpha() {
|
||||||
|
let mut n = solid(Color::Rgb { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }, 1.0, Blend::Normal);
|
||||||
|
n.kind = Kind::Image(Image { width: 2, height: 1, pixels: Pixels::Rgba8(vec![255, 0, 0, 255, 255, 0, 0, 0]), transform: Affine::IDENTITY });
|
||||||
|
let out = apply(&n, &mut { MAX_WORK }, &mut |_| {}).into_owned();
|
||||||
|
let Kind::Image(img) = &out.kind else { panic!() };
|
||||||
|
assert_eq!(img.pixels, Pixels::Rgba8(vec![0, 0, 255, 255, 255, 0, 0, 0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_gradient_overlay_over_one_colour_becomes_that_gradient() {
|
||||||
|
let g = Gradient {
|
||||||
|
kind: GradientKind::Linear,
|
||||||
|
stops: vec![
|
||||||
|
Stop { offset: 0.0, color: Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, bias: 0.5 },
|
||||||
|
Stop { offset: 1.0, color: Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }, bias: 0.5 },
|
||||||
|
],
|
||||||
|
transform: Affine([10.0, 0.0, 0.0, 10.0, 0.0, 0.0]),
|
||||||
|
};
|
||||||
|
let mut n = solid(Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, 1.0, Blend::Normal);
|
||||||
|
n.effects = vec![Effect::GradientOverlay { gradient: g.clone(), opacity: 1.0, blend: Blend::Normal }];
|
||||||
|
let out = apply(&n, &mut { MAX_WORK }, &mut |_| {}).into_owned();
|
||||||
|
let Kind::Shape { fills, .. } = &out.kind else { panic!() };
|
||||||
|
assert_eq!(fills.first(), Some(&Paint::Gradient(g)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_group_overlay_lies_over_its_childrens_overlays() {
|
||||||
|
let red = Color::Rgb { r: 1.0, g: 0.0, b: 0.0, a: 1.0 };
|
||||||
|
let blue = Color::Rgb { r: 0.0, g: 0.0, b: 1.0, a: 1.0 };
|
||||||
|
let mut group = solid(red, 0.5, Blend::Normal);
|
||||||
|
group.kind = Kind::Group;
|
||||||
|
group.children = vec![solid(blue, 1.0, Blend::Normal)];
|
||||||
|
// The copy (2 nodes), the child's overlay (1) and the group's (2).
|
||||||
|
let mut work = 5;
|
||||||
|
let out = apply(&group, &mut work, &mut |_| {}).into_owned();
|
||||||
|
assert_eq!(work, 0);
|
||||||
|
let child = out.children.first().unwrap();
|
||||||
|
// Blue first, then half of the group's red over it.
|
||||||
|
assert_eq!(fill(child), Color::Rgb { r: 0.5, g: 0.0, b: 0.5, a: 1.0 });
|
||||||
|
assert!(out.effects.is_empty() && child.effects.is_empty(), "both overlays are baked");
|
||||||
|
// With no work left for the group's overlay, the child's still applies.
|
||||||
|
let mut warnings = Vec::new();
|
||||||
|
let out = apply(&group, &mut { 4 }, &mut |w| warnings.push(w.to_string())).into_owned();
|
||||||
|
assert_eq!(fill(out.children.first().unwrap()), blue);
|
||||||
|
assert!(warnings.iter().any(|w| w.contains("beyond the work")), "{warnings:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overlays_stop_at_the_work_one_import_may_take() {
|
||||||
|
let mut n = solid(Color::Rgb { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }, 1.0, Blend::Normal);
|
||||||
|
n.kind = Kind::Image(Image { width: 4, height: 4, pixels: Pixels::Rgba8(vec![255; 64]), transform: Affine::IDENTITY });
|
||||||
|
// 16 pixels and the node, for the copy and for the overlay: 34.
|
||||||
|
let mut work = 40;
|
||||||
|
let mut warnings = Vec::new();
|
||||||
|
assert!(matches!(apply(&n, &mut work, &mut |w| warnings.push(w.to_string())), Cow::Owned(_)));
|
||||||
|
assert_eq!((work, warnings.len()), (6, 0));
|
||||||
|
assert!(matches!(apply(&n, &mut work, &mut |w| warnings.push(w.to_string())), Cow::Borrowed(_)));
|
||||||
|
assert_eq!(work, 6);
|
||||||
|
assert!(warnings.iter().any(|w| w.contains("beyond the work")), "{warnings:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -126,6 +126,9 @@ pub struct Stroke {
|
|||||||
/// Gradient stops per gradient.
|
/// Gradient stops per gradient.
|
||||||
const MAX_STOPS: usize = 1024;
|
const MAX_STOPS: usize = 1024;
|
||||||
|
|
||||||
|
/// Dash and gap lengths per dash pattern (a pattern has a few; strokes and text runs copy theirs).
|
||||||
|
const MAX_DASHES: usize = 64;
|
||||||
|
|
||||||
fn floats(v: &Value) -> Option<Vec<f64>> {
|
fn floats(v: &Value) -> Option<Vec<f64>> {
|
||||||
match v {
|
match v {
|
||||||
Value::Floats(f) => Some(f.clone()),
|
Value::Floats(f) => Some(f.clone()),
|
||||||
@@ -283,7 +286,7 @@ pub(crate) fn node_paint(r: &mut Reader, id: ObjId, local: Affine) -> (Vec<Paint
|
|||||||
(fills, stroke.into_iter().collect())
|
(fills, stroke.into_iter().collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool) -> Option<Stroke> {
|
pub(crate) fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool) -> Option<Stroke> {
|
||||||
let s = r.s;
|
let s = r.s;
|
||||||
let paint = descriptor(r, pen, local)?;
|
let paint = descriptor(r, pen, local)?;
|
||||||
if paint == Paint::None {
|
if paint == Paint::None {
|
||||||
@@ -295,6 +298,13 @@ fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool)
|
|||||||
let scales = s.bool(ldsc, b"LDSc").unwrap_or(true);
|
let scales = s.bool(ldsc, b"LDSc").unwrap_or(true);
|
||||||
let width = if scales { weight * local.scale() } else { weight };
|
let width = if scales { weight * local.scale() } else { weight };
|
||||||
let data = s.bytes(lsty, b"Data").unwrap_or_default();
|
let data = s.bytes(lsty, b"Data").unwrap_or_default();
|
||||||
|
// Byte 10 is the stroke's style: 0 none, 1 solid, 2 dashed, 3 a brush. A shape without a
|
||||||
|
// stroke still stores one (black, 1 px wide), with style 0. Identified on two documents their
|
||||||
|
// owner made, where every such stroke is invisible in Affinity's own PDF export, and checked
|
||||||
|
// against the public corpus's thumbnails.
|
||||||
|
if data.get(10) == Some(&0) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let miter_limit = data.get(0..8).and_then(|b| b.try_into().ok()).map(f64::from_le_bytes).filter(|m| m.is_finite() && *m >= 1.0).unwrap_or(4.0);
|
let miter_limit = data.get(0..8).and_then(|b| b.try_into().ok()).map(f64::from_le_bytes).filter(|m| m.is_finite() && *m >= 1.0).unwrap_or(4.0);
|
||||||
// Cap and join bytes are only partly understood: (0, 0) is butt/miter in every clean sample;
|
// Cap and join bytes are only partly understood: (0, 0) is butt/miter in every clean sample;
|
||||||
// (2, 2) ends plain lines square and brush lines round; joins import round, which never
|
// (2, 2) ends plain lines square and brush lines round; joins import round, which never
|
||||||
@@ -312,6 +322,10 @@ fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool)
|
|||||||
_ => Align::Center,
|
_ => Align::Center,
|
||||||
};
|
};
|
||||||
let dash = match s.field(lsty, b"Patn") {
|
let dash = match s.field(lsty, b"Patn") {
|
||||||
|
Some(Value::Array(a)) if a.len() > MAX_DASHES => {
|
||||||
|
r.warn("dash patterns of more than 64 lengths (imported as solid lines)");
|
||||||
|
None
|
||||||
|
}
|
||||||
Some(Value::Array(a)) if data.get(10) == Some(&2) || !a.is_empty() => {
|
Some(Value::Array(a)) if data.get(10) == Some(&2) || !a.is_empty() => {
|
||||||
let mut v: Vec<f64> = a.iter().filter_map(|x| if let Value::Float(f) = x { Some(*f) } else { None }).collect();
|
let mut v: Vec<f64> = a.iter().filter_map(|x| if let Value::Float(f) = x { Some(*f) } else { None }).collect();
|
||||||
while v.len() >= 2 && v.iter().rev().take(2).all(|x| *x == 0.0) {
|
while v.len() >= 2 && v.iter().rev().take(2).all(|x| *x == 0.0) {
|
||||||
|
|||||||
+246
-59
@@ -39,6 +39,9 @@ pub(crate) fn node_image(r: &mut Reader, id: ObjId, world: Affine) -> Result<Opt
|
|||||||
&& (s.is(id, b"ImgN") || !has_tiles)
|
&& (s.is(id, b"ImgN") || !has_tiles)
|
||||||
{
|
{
|
||||||
match original(r, name) {
|
match original(r, name) {
|
||||||
|
// HEIF/HEIC (phone photos) and AVIF originals can't be decoded here: the same pixels
|
||||||
|
// stored as tiles are used instead (see `decode` when the tiles point back at the original).
|
||||||
|
Ok(bytes) if is_heif(&bytes) && has_tiles => {}
|
||||||
Ok(bytes) => return Ok(Some(Image { width, height, pixels: Pixels::Encoded(bytes), transform: world })),
|
Ok(bytes) => return Ok(Some(Image { width, height, pixels: Pixels::Encoded(bytes), transform: world })),
|
||||||
Err(Error::Limit(e)) => return Err(Error::Limit(e)),
|
Err(Error::Limit(e)) => return Err(Error::Limit(e)),
|
||||||
Err(_) => r.warn("an embedded image file that could not be read (its cached pixels were used)"),
|
Err(_) => r.warn("an embedded image file that could not be read (its cached pixels were used)"),
|
||||||
@@ -66,8 +69,17 @@ pub(crate) fn node_image(r: &mut Reader, id: ObjId, world: Affine) -> Result<Opt
|
|||||||
decode(r, bitmap, crop, world)
|
decode(r, bitmap, crop, world)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A mask layer (`MRst`): one channel of coverage as grey pixels (white shows, black hides).
|
/// An ISO base media file holding a HEIF image (HEIC, AVIF): `ftyp` with an image brand.
|
||||||
pub(crate) fn mask(r: &mut Reader, id: ObjId, world: Affine) -> Result<Option<Image>, Error> {
|
fn is_heif(bytes: &[u8]) -> bool {
|
||||||
|
bytes.get(4..8) == Some(b"ftyp".as_slice())
|
||||||
|
&& bytes.get(8..12).is_some_and(|brand| {
|
||||||
|
[b"heic", b"heix", b"hevc", b"hevx", b"heim", b"heis", b"mif1", b"msf1", b"avif", b"avis"].iter().any(|b| brand == b.as_slice())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A mask layer (`MRst`): one channel of coverage as grey pixels (white shows, black hides), read
|
||||||
|
/// over `over` (document pixels) when given; outside its pixels a mask hides everything.
|
||||||
|
pub(crate) fn mask(r: &mut Reader, id: ObjId, world: Affine, over: Option<crate::model::Rect>) -> Result<Option<Image>, Error> {
|
||||||
let s = r.s;
|
let s = r.s;
|
||||||
let Some(bitmap) = s.obj(id, b"Bitm") else { return Ok(None) };
|
let Some(bitmap) = s.obj(id, b"Bitm") else { return Ok(None) };
|
||||||
let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
|
let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
|
||||||
@@ -76,7 +88,41 @@ pub(crate) fn mask(r: &mut Reader, id: ObjId, world: Affine) -> Result<Option<Im
|
|||||||
r.warn("pixel masks in an unknown format (imported without them)");
|
r.warn("pixel masks in an unknown format (imported without them)");
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
decode(r, bitmap, (0, 0, width, height), world)
|
let crop = match over.map(|o| crop_to(o, world, width, height)) {
|
||||||
|
Some(Some(c)) => c,
|
||||||
|
// What it masks lies outside it: all hidden, which one black pixel says as well.
|
||||||
|
Some(None) => return Ok(Some(Image { width: 1, height: 1, pixels: Pixels::Rgba8(vec![0, 0, 0, 255]), transform: world })),
|
||||||
|
None => (0, 0, width, height),
|
||||||
|
};
|
||||||
|
decode(r, bitmap, crop, world)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bitmap pixels (x0, y0, x1, y1) of a `width` × `height` bitmap placed by `world` that cover
|
||||||
|
/// document rectangle `over`, one pixel wider all round; `None` if they don't overlap.
|
||||||
|
fn crop_to(over: crate::model::Rect, world: Affine, width: u32, height: u32) -> Option<(u32, u32, u32, u32)> {
|
||||||
|
let inv = invert(world)?;
|
||||||
|
let pts = [(over.x0, over.y0), (over.x1, over.y0), (over.x1, over.y1), (over.x0, over.y1)].map(|(x, y)| inv.apply(crate::model::Point { x, y }));
|
||||||
|
let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
|
||||||
|
for p in pts {
|
||||||
|
if !(p.x.is_finite() && p.y.is_finite()) {
|
||||||
|
return Some((0, 0, width, height));
|
||||||
|
}
|
||||||
|
(x0, y0, x1, y1) = (x0.min(p.x), y0.min(p.y), x1.max(p.x), y1.max(p.y));
|
||||||
|
}
|
||||||
|
let clamp = |v: f64, max: u32| v.clamp(0.0, f64::from(max)) as u32;
|
||||||
|
let (cx0, cy0, cx1, cy1) =
|
||||||
|
(clamp(x0.floor() - 1.0, width), clamp(y0.floor() - 1.0, height), clamp(x1.ceil() + 1.0, width), clamp(y1.ceil() + 1.0, height));
|
||||||
|
(cx1 > cx0 && cy1 > cy0).then_some((cx0, cy0, cx1, cy1))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invert(m: Affine) -> Option<Affine> {
|
||||||
|
let [a, b, c, d, e, f] = m.0;
|
||||||
|
let det = a * d - b * c;
|
||||||
|
if !(det.is_finite() && det.abs() > 1e-12) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (ia, ib, ic, id) = (d / det, -b / det, -c / det, a / det);
|
||||||
|
Some(Affine([ia, ib, ic, id, -(ia * e + ic * f), -(ib * e + id * f)]))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An embedded document or symbol instance (`EmbN`), as the picture of it Affinity caches.
|
/// An embedded document or symbol instance (`EmbN`), as the picture of it Affinity caches.
|
||||||
@@ -99,33 +145,41 @@ pub(crate) fn embedded(r: &mut Reader, id: ObjId, world: Affine) -> Result<Optio
|
|||||||
|
|
||||||
/// Decode the tiles of a bitmap inside `crop` (pixels), placed by `world` (bitmap pixels to document).
|
/// Decode the tiles of a bitmap inside `crop` (pixels), placed by `world` (bitmap pixels to document).
|
||||||
fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affine) -> Result<Option<Image>, Error> {
|
fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affine) -> Result<Option<Image>, Error> {
|
||||||
|
decode_within(r, bitmap, crop, world, MAX_PIXELS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`decode`] with at most `max_pixels` decoded pixels.
|
||||||
|
fn decode_within(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affine, max_pixels: u64) -> Result<Option<Image>, Error> {
|
||||||
let s = r.s;
|
let s = r.s;
|
||||||
let (x0, y0, x1, y1) = crop;
|
let (x0, y0, x1, y1) = crop;
|
||||||
if x1 <= x0 || y1 <= y0 {
|
if x1 <= x0 || y1 <= y0 {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let (w, h) = (x1 - x0, y1 - y0);
|
let (w, h) = (x1 - x0, y1 - y0);
|
||||||
let pixels = u64::from(w) * u64::from(h);
|
// Larger layers (mostly masks of whole spreads) are read at a half, a quarter… of their
|
||||||
if pixels > MAX_PIXELS {
|
// resolution: every `step`th pixel of every `step`th row.
|
||||||
r.warn("pixel layers larger than 64 megapixels");
|
let mut step = 1u32;
|
||||||
return Ok(None);
|
while u64::from(w.div_ceil(step)) * u64::from(h.div_ceil(step)) > max_pixels {
|
||||||
|
step *= 2;
|
||||||
|
if step > 1 << 12 {
|
||||||
|
r.warn("pixel layers too large to read (left out)");
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Before anything is allocated: a layer's tiles can be flat colour, with nothing to extract.
|
// Before anything is allocated: a layer's tiles can be flat colour, with nothing to extract.
|
||||||
let Some(left) = r.pixels_left.checked_sub(pixels) else {
|
let pixels = u64::from(w.div_ceil(step)) * u64::from(h.div_ceil(step));
|
||||||
|
let Some(left) = r.budget.pixels.checked_sub(pixels) else {
|
||||||
r.warn("pixel layers beyond the pixels one document may hold");
|
r.warn("pixel layers beyond the pixels one document may hold");
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
r.pixels_left = left;
|
r.budget.pixels = left;
|
||||||
let format = s.enumeration(bitmap, b"Frmt").map(|(f, _)| f);
|
let format = s.enumeration(bitmap, b"Frmt").map(|(f, _)| f);
|
||||||
let (channels, bps) = match format {
|
let (channels, bps) = match format {
|
||||||
Some(0) => (4, 1),
|
Some(0) => (4, 1),
|
||||||
Some(1) => (4, 2),
|
Some(1) => (4, 2),
|
||||||
Some(6) => (1, 1),
|
Some(6) => (1, 1),
|
||||||
Some(7) => (1, 2),
|
Some(7) => (1, 2),
|
||||||
Some(4) => {
|
Some(4) => (5, 1),
|
||||||
r.warn("CMYK pixel layers (converted to RGB without the document's colour profile)");
|
|
||||||
(5, 1)
|
|
||||||
}
|
|
||||||
_ => {
|
_ => {
|
||||||
r.warn("pixel layers in grey, Lab or 32-bit formats");
|
r.warn("pixel layers in grey, Lab or 32-bit formats");
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -152,11 +206,48 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
// Tiles kept only in an original that can't be decoded here are left empty (transparent). Where
|
||||||
|
// a tile's colour is in the original but its alpha is stored, as in CMYK layers, it would come
|
||||||
|
// out opaque white instead: such a layer (a HEIC photo in a CMYK document) is left out, and said.
|
||||||
|
if needs_source && source.is_none() && channels > 1 {
|
||||||
|
let states = |c: usize| match s.field(bitmap, &tag(b"Sta", c)) {
|
||||||
|
Some(Value::Array(a)) => a.clone(),
|
||||||
|
_ => Vec::new(),
|
||||||
|
};
|
||||||
|
let alpha = states(channels);
|
||||||
|
let blank = (1..channels).any(|c| states(c).iter().enumerate().any(|(i, v)| *v == Value::UInt(5) && alpha.get(i) != Some(&Value::UInt(5))));
|
||||||
|
if blank {
|
||||||
|
r.warn("placed images whose pixels are only in an original the reader can't decode, such as HEIC photos (left out)");
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if step > 1 {
|
||||||
|
// Its stored tiles must fit what is left of the import's extraction budget.
|
||||||
|
let stored: usize = (1..=channels).map(|c| tiles_in(s, bitmap, c, bps, (x0, y0, w, h)).len()).sum();
|
||||||
|
if stored.saturating_mul(TILE_BYTES) > r.archive.remaining().max_total {
|
||||||
|
r.warn("pixel layers too large to read (left out)");
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
r.warn("pixel layers larger than 64 megapixels (read at a lower resolution)");
|
||||||
|
}
|
||||||
let mut cache = CachedPixels { tiles: HashMap::new(), source };
|
let mut cache = CachedPixels { tiles: HashMap::new(), source };
|
||||||
for c in 1..=channels {
|
for c in 1..=channels {
|
||||||
planes.push(plane(r, bitmap, c, bps, (x0, y0, w, h), &mut cache)?);
|
planes.push(plane(r, bitmap, c, bps, (x0, y0, w, h), step, &mut cache)?);
|
||||||
}
|
}
|
||||||
|
let (w, h) = (w.div_ceil(step), h.div_ceil(step));
|
||||||
let n = (w as usize) * (h as usize);
|
let n = (w as usize) * (h as usize);
|
||||||
|
let k = f64::from(step);
|
||||||
|
let transform = Affine([k, 0.0, 0.0, k, f64::from(x0), f64::from(y0)]).then(world);
|
||||||
|
if channels == 5 {
|
||||||
|
// Ink amounts and alpha as stored (8 bits), interleaved.
|
||||||
|
let mut cmyka = vec![0u8; n * 5];
|
||||||
|
for (i, px) in cmyka.as_chunks_mut::<5>().0.iter_mut().enumerate() {
|
||||||
|
for (c, v) in px.iter_mut().enumerate() {
|
||||||
|
*v = planes.get(c).and_then(|p| p.get(i)).copied().unwrap_or(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(Some(Image { width: w, height: h, pixels: Pixels::Cmyka8(cmyka), transform }));
|
||||||
|
}
|
||||||
let mut rgba = vec![0u8; n * 4];
|
let mut rgba = vec![0u8; n * 4];
|
||||||
let sample = |p: &Vec<u8>, i: usize| -> f64 {
|
let sample = |p: &Vec<u8>, i: usize| -> f64 {
|
||||||
if bps == 2 {
|
if bps == 2 {
|
||||||
@@ -167,17 +258,9 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi
|
|||||||
};
|
};
|
||||||
for (i, px) in rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() {
|
for (i, px) in rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() {
|
||||||
let v: Vec<f64> = planes.iter().map(|p| sample(p, i)).collect();
|
let v: Vec<f64> = planes.iter().map(|p| sample(p, i)).collect();
|
||||||
let (r2, g, b, a) = if channels == 1 {
|
let (r2, g, b, a) = if channels == 1 { (v[0], v[0], v[0], 1.0) } else { (v[0], v[1], v[2], v[3]) };
|
||||||
(v[0], v[0], v[0], 1.0)
|
|
||||||
} else if channels == 5 {
|
|
||||||
let k = 1.0 - v[3];
|
|
||||||
((1.0 - v[0]) * k, (1.0 - v[1]) * k, (1.0 - v[2]) * k, v[4])
|
|
||||||
} else {
|
|
||||||
(v[0], v[1], v[2], v[3])
|
|
||||||
};
|
|
||||||
px.copy_from_slice(&[r2, g, b, a].map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8));
|
px.copy_from_slice(&[r2, g, b, a].map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8));
|
||||||
}
|
}
|
||||||
let transform = Affine([1.0, 0.0, 0.0, 1.0, f64::from(x0), f64::from(y0)]).then(world);
|
|
||||||
Ok(Some(Image { width: w, height: h, pixels: Pixels::Rgba8(rgba), transform }))
|
Ok(Some(Image { width: w, height: h, pixels: Pixels::Rgba8(rgba), transform }))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,28 +312,57 @@ fn tag(prefix: &[u8; 3], c: usize) -> [u8; 4] {
|
|||||||
[prefix[0], prefix[1], prefix[2], b'0' + c as u8]
|
[prefix[0], prefix[1], prefix[2], b'0' + c as u8]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One channel of level 0, cropped to (x0, y0, w, h) pixels; `bps` bytes per sample.
|
/// Channel `c`'s tile grid: (tiles across, tiles down, each tile's state).
|
||||||
|
fn grid(s: &stream::Stream, bitmap: ObjId, c: usize, bps: usize) -> (usize, usize, Vec<u8>) {
|
||||||
|
let width = s.int(bitmap, b"BmpW").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
|
||||||
|
let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
|
||||||
|
let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| width.saturating_mul(bps).div_ceil(TILE));
|
||||||
|
let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE));
|
||||||
|
let states: Vec<u8> = match s.field(bitmap, &tag(b"Sta", c)) {
|
||||||
|
Some(Value::Array(a)) => a.iter().map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(),
|
||||||
|
_ => Vec::new(),
|
||||||
|
};
|
||||||
|
(tw, th, states)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stored tiles (state 4) of channel `c` that overlap the crop, by their index in `Idx`.
|
||||||
|
fn tiles_in(s: &stream::Stream, bitmap: ObjId, c: usize, bps: usize, (x0, y0, w, h): (u32, u32, u32, u32)) -> Vec<usize> {
|
||||||
|
let (tw, th, states) = grid(s, bitmap, c, bps);
|
||||||
|
let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps);
|
||||||
|
let (py0, py1) = (y0 as usize, (y0 + h) as usize);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut next = 0usize;
|
||||||
|
for (t, state) in states.iter().enumerate().take(tw.saturating_mul(th)) {
|
||||||
|
if *state != 4 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (ox, oy) = ((t % tw.max(1)) * TILE, (t / tw.max(1)) * TILE);
|
||||||
|
if !(ox >= bx1 || ox + TILE <= bx0 || oy >= py1 || oy + TILE <= py0) {
|
||||||
|
out.push(next);
|
||||||
|
}
|
||||||
|
next += 1;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One channel of level 0, cropped to (x0, y0, w, h) pixels and taking every `step`th pixel of
|
||||||
|
/// every `step`th row; `bps` bytes per sample.
|
||||||
fn plane(
|
fn plane(
|
||||||
r: &mut Reader,
|
r: &mut Reader,
|
||||||
bitmap: ObjId,
|
bitmap: ObjId,
|
||||||
c: usize,
|
c: usize,
|
||||||
bps: usize,
|
bps: usize,
|
||||||
(x0, y0, w, h): (u32, u32, u32, u32),
|
(x0, y0, w, h): (u32, u32, u32, u32),
|
||||||
|
step: u32,
|
||||||
cache: &mut CachedPixels,
|
cache: &mut CachedPixels,
|
||||||
) -> Result<Vec<u8>, Error> {
|
) -> Result<Vec<u8>, Error> {
|
||||||
let CachedPixels { tiles: cache, source } = cache;
|
let CachedPixels { tiles: cache, source } = cache;
|
||||||
let s = r.s;
|
let s = r.s;
|
||||||
let width = s.int(bitmap, b"BmpW").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
|
let (tw, th, states) = grid(s, bitmap, c, bps);
|
||||||
let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
|
|
||||||
let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| (width * bps).div_ceil(TILE));
|
|
||||||
let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE));
|
|
||||||
let states: Vec<u8> = match s.field(bitmap, &tag(b"Sta", c)) {
|
|
||||||
Some(Value::Array(a)) => a.iter().map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(),
|
|
||||||
_ => Vec::new(),
|
|
||||||
};
|
|
||||||
let tiles = s.objs(bitmap, &tag(b"Idx", c));
|
let tiles = s.objs(bitmap, &tag(b"Idx", c));
|
||||||
let row = (w as usize) * bps;
|
let at = Crop { bps, x0: x0 as usize, y0: y0 as usize, w: w as usize, h: h as usize, step: step.max(1) as usize };
|
||||||
let mut out = vec![0u8; row * h as usize];
|
let row = at.w.div_ceil(at.step) * bps;
|
||||||
|
let mut out = vec![0u8; row * at.h.div_ceil(at.step)];
|
||||||
let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps);
|
let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps);
|
||||||
let (py0, py1) = (y0 as usize, (y0 + h) as usize);
|
let (py0, py1) = (y0 as usize, (y0 + h) as usize);
|
||||||
let mut next = 0usize;
|
let mut next = 0usize;
|
||||||
@@ -267,7 +379,7 @@ fn plane(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let name = s.entry(blck, b"Data").ok_or(Error::Malformed("pixel tile without data"))?.to_string();
|
let name = s.entry(blck, b"Data").ok_or(Error::Malformed("pixel tile without data"))?.to_string();
|
||||||
if !cache.contains_key(&name) {
|
let read = |r: &mut Reader| -> Result<Vec<u8>, Error> {
|
||||||
let mut data = r.archive.read(&name)?;
|
let mut data = r.archive.read(&name)?;
|
||||||
// Older files wrap a tile in a one-field stream: header, then a 64 KiB blob.
|
// Older files wrap a tile in a one-field stream: header, then a 64 KiB blob.
|
||||||
if data.len() == TILE_BYTES + 22 && data.starts_with(b"\x00\xffKS") {
|
if data.len() == TILE_BYTES + 22 && data.starts_with(b"\x00\xffKS") {
|
||||||
@@ -276,10 +388,20 @@ fn plane(
|
|||||||
if data.len() != TILE_BYTES {
|
if data.len() != TILE_BYTES {
|
||||||
return Err(Error::Malformed("pixel tile size"));
|
return Err(Error::Malformed("pixel tile size"));
|
||||||
}
|
}
|
||||||
|
Ok(data)
|
||||||
|
};
|
||||||
|
// A layer read at a lower resolution is too large to keep its tiles around.
|
||||||
|
if at.step > 1 {
|
||||||
|
let tile = read(r)?;
|
||||||
|
copy(&mut out, &at, (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !cache.contains_key(&name) {
|
||||||
|
let data = read(r)?;
|
||||||
cache.insert(name.clone(), data);
|
cache.insert(name.clone(), data);
|
||||||
}
|
}
|
||||||
let tile = cache.get(&name).ok_or(Error::Malformed("pixel tile"))?;
|
let tile = cache.get(&name).ok_or(Error::Malformed("pixel tile"))?;
|
||||||
copy(&mut out, row, (bx0, py0, bx1, py1), (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0));
|
copy(&mut out, &at, (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
3 => {
|
3 => {
|
||||||
@@ -289,9 +411,7 @@ fn plane(
|
|||||||
5 => {
|
5 => {
|
||||||
if let Some((source_width, pixels)) = source.as_ref() {
|
if let Some((source_width, pixels)) = source.as_ref() {
|
||||||
let (ox, oy) = (tx * TILE, ty * TILE);
|
let (ox, oy) = (tx * TILE, ty * TILE);
|
||||||
copy(&mut out, row, (bx0, py0, bx1, py1), (ox, oy), |x, y| {
|
copy(&mut out, &at, (ox, oy), |x, y| pixels.get(((oy + y) * source_width + ox + x) * 4 + c - 1).copied().unwrap_or(0));
|
||||||
pixels.get(((oy + y) * source_width + ox + x) * 4 + c - 1).copied().unwrap_or(0)
|
|
||||||
});
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
r.warn("pixel layers drawn from an embedded image (left empty)");
|
r.warn("pixel layers drawn from an embedded image (left empty)");
|
||||||
@@ -300,17 +420,38 @@ fn plane(
|
|||||||
_ => return Err(Error::Malformed("unknown pixel tile state")),
|
_ => return Err(Error::Malformed("unknown pixel tile state")),
|
||||||
};
|
};
|
||||||
if let Some(v) = fill {
|
if let Some(v) = fill {
|
||||||
copy(&mut out, row, (bx0, py0, bx1, py1), (tx * TILE, ty * TILE), |_, _| v);
|
copy(&mut out, &at, (tx * TILE, ty * TILE), |_, _| v);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Copy the overlap of a tile at byte column `ox`, row `oy` into the cropped plane.
|
/// Where a plane's pixels come from: the crop (pixels) and the sampling step.
|
||||||
fn copy(out: &mut [u8], row: usize, (bx0, py0, bx1, py1): (usize, usize, usize, usize), (ox, oy): (usize, usize), at: impl Fn(usize, usize) -> u8) {
|
struct Crop {
|
||||||
for y in oy.max(py0)..(oy + TILE).min(py1) {
|
bps: usize,
|
||||||
|
x0: usize,
|
||||||
|
y0: usize,
|
||||||
|
w: usize,
|
||||||
|
h: usize,
|
||||||
|
step: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy the overlap of a tile at byte column `ox`, row `oy` into the cropped plane, keeping every
|
||||||
|
/// `step`th pixel of every `step`th row.
|
||||||
|
fn copy(out: &mut [u8], c: &Crop, (ox, oy): (usize, usize), at: impl Fn(usize, usize) -> u8) {
|
||||||
|
let row = c.w.div_ceil(c.step) * c.bps;
|
||||||
|
let (bx0, bx1) = (c.x0 * c.bps, (c.x0 + c.w) * c.bps);
|
||||||
|
for y in oy.max(c.y0)..(oy + TILE).min(c.y0 + c.h) {
|
||||||
|
if !(y - c.y0).is_multiple_of(c.step) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let out_row = (y - c.y0) / c.step * row;
|
||||||
for x in ox.max(bx0)..(ox + TILE).min(bx1) {
|
for x in ox.max(bx0)..(ox + TILE).min(bx1) {
|
||||||
if let Some(b) = out.get_mut((y - py0) * row + (x - bx0)) {
|
let px = x / c.bps - c.x0;
|
||||||
|
if !px.is_multiple_of(c.step) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(b) = out.get_mut(out_row + px / c.step * c.bps + x % c.bps) {
|
||||||
*b = at(x - ox, y - oy);
|
*b = at(x - ox, y - oy);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -323,6 +464,15 @@ mod tests {
|
|||||||
use crate::model::Kind;
|
use crate::model::Kind;
|
||||||
use crate::synth::{self, F, Method, tag};
|
use crate::synth::{self, F, Method, tag};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn heif_originals_are_recognised_by_their_brand() {
|
||||||
|
assert!(is_heif(b"\0\0\0\x18ftypheic\0\0\0\0"));
|
||||||
|
assert!(is_heif(b"\0\0\0\x1cftypavif"));
|
||||||
|
assert!(!is_heif(b"\0\0\0\x18ftypisom"), "a video is not a photo");
|
||||||
|
assert!(!is_heif(&png()));
|
||||||
|
assert!(!is_heif(b"ftyp"));
|
||||||
|
}
|
||||||
|
|
||||||
fn png() -> Vec<u8> {
|
fn png() -> Vec<u8> {
|
||||||
let pixels = image::RgbaImage::from_fn(300, 2, |x, y| image::Rgba([(x % 251) as u8, (y * 31) as u8, 77, 255]));
|
let pixels = image::RgbaImage::from_fn(300, 2, |x, y| image::Rgba([(x % 251) as u8, (y * 31) as u8, 77, 255]));
|
||||||
let mut bytes = std::io::Cursor::new(Vec::new());
|
let mut bytes = std::io::Cursor::new(Vec::new());
|
||||||
@@ -333,18 +483,29 @@ mod tests {
|
|||||||
/// An original synthetic mixed source/cache bitmap. It exercises reader recovery,
|
/// An original synthetic mixed source/cache bitmap. It exercises reader recovery,
|
||||||
/// not a claim that Affinity itself accepts the synthetic container.
|
/// not a claim that Affinity itself accepts the synthetic container.
|
||||||
fn fixture(source: &[u8], origin: i32) -> Vec<u8> {
|
fn fixture(source: &[u8], origin: i32) -> Vec<u8> {
|
||||||
|
fixture_in(source, origin, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `cmyk`: a CMYK bitmap whose colour tiles are all in the source and whose alpha is stored.
|
||||||
|
fn fixture_in(source: &[u8], origin: i32, cmyk: bool) -> Vec<u8> {
|
||||||
|
let channels = if cmyk { 5 } else { 4 };
|
||||||
let mut bitmap = vec![
|
let mut bitmap = vec![
|
||||||
(tag(b"Frmt"), F::Enum(0, 0)),
|
(tag(b"Frmt"), F::Enum(if cmyk { 4 } else { 0 }, 0)),
|
||||||
(tag(b"BmpW"), F::I32(300)),
|
(tag(b"BmpW"), F::I32(300)),
|
||||||
(tag(b"BmpH"), F::I32(2)),
|
(tag(b"BmpH"), F::I32(2)),
|
||||||
(tag(b"LInf"), F::I32(origin)),
|
(tag(b"LInf"), F::I32(origin)),
|
||||||
(tag(b"TInf"), F::I32(0)),
|
(tag(b"TInf"), F::I32(0)),
|
||||||
(tag(b"Bckg"), F::Entry("c/1".into())),
|
(tag(b"Bckg"), F::Entry("c/1".into())),
|
||||||
];
|
];
|
||||||
for c in 1..=4 {
|
for c in 1..=channels {
|
||||||
|
let states = match (cmyk, c) {
|
||||||
|
(false, _) => vec![5, 4],
|
||||||
|
(true, 5) => vec![4, 4],
|
||||||
|
(true, _) => vec![5, 5],
|
||||||
|
};
|
||||||
bitmap.push((tag(&super::tag(b"TWi", c)), F::I32(2)));
|
bitmap.push((tag(&super::tag(b"TWi", c)), F::I32(2)));
|
||||||
bitmap.push((tag(&super::tag(b"THi", c)), F::I32(1)));
|
bitmap.push((tag(&super::tag(b"THi", c)), F::I32(1)));
|
||||||
bitmap.push((tag(&super::tag(b"Sta", c)), F::U8s(vec![5, 4])));
|
bitmap.push((tag(&super::tag(b"Sta", c)), F::U8s(states)));
|
||||||
bitmap.push((
|
bitmap.push((
|
||||||
tag(&super::tag(b"Idx", c)),
|
tag(&super::tag(b"Idx", c)),
|
||||||
F::Shared(vec![F::Def(c as u32, vec![tag(b"Blck")], vec![(tag(b"Data"), F::Entry(format!("d/{c}")))])]),
|
F::Shared(vec![F::Def(c as u32, vec![tag(b"Blck")], vec![(tag(b"Data"), F::Entry(format!("d/{c}")))])]),
|
||||||
@@ -369,18 +530,44 @@ mod tests {
|
|||||||
blob.extend((source.len() as u32).to_le_bytes());
|
blob.extend((source.len() as u32).to_le_bytes());
|
||||||
blob.extend(source);
|
blob.extend(source);
|
||||||
let block = synth::stream(&[(tag(b"Data"), F::Raw(blob))]);
|
let block = synth::stream(&[(tag(b"Data"), F::Raw(blob))]);
|
||||||
let tiles: Vec<_> = [200u8, 10, 30, 255].into_iter().map(|v| vec![v; TILE_BYTES]).collect();
|
let tiles: Vec<_> = [200u8, 10, 30, 255, 255].into_iter().map(|v| vec![v; TILE_BYTES]).collect();
|
||||||
synth::container(
|
let names: Vec<String> = (1..=channels).map(|c| format!("d/{c}")).collect();
|
||||||
&[
|
let mut entries = vec![("doc.dat", doc.as_slice(), Method::Zlib), ("c/1", block.as_slice(), Method::Zlib)];
|
||||||
("doc.dat", &doc, Method::Zlib),
|
for (name, tile) in names.iter().zip(&tiles) {
|
||||||
("c/1", &block, Method::Zlib),
|
entries.push((name.as_str(), tile.as_slice(), Method::Zlib));
|
||||||
("d/1", &tiles[0], Method::Zlib),
|
}
|
||||||
("d/2", &tiles[1], Method::Zlib),
|
synth::container(&entries, None)
|
||||||
("d/3", &tiles[2], Method::Zlib),
|
}
|
||||||
("d/4", &tiles[3], Method::Zlib),
|
|
||||||
],
|
#[test]
|
||||||
None,
|
fn a_cmyk_layer_whose_colours_are_only_in_an_unreadable_original_is_left_out_not_white() {
|
||||||
)
|
let doc = crate::read(&fixture_in(b"\0\0\0\x18ftypheic not decodable", 0, true), crate::Limits::default()).unwrap();
|
||||||
|
assert!(doc.warnings.iter().any(|w| w.starts_with("placed images whose pixels are only in an original")), "{:?}", doc.warnings);
|
||||||
|
assert!(matches!(doc.spreads[0].nodes[0].kind, Kind::Unsupported), "{:?}", doc.spreads[0].nodes[0].kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn layers_over_the_pixel_budget_are_read_at_a_lower_resolution() {
|
||||||
|
let bytes = fixture(&png(), 0);
|
||||||
|
let mut archive = crate::Archive::open(&bytes, crate::Limits::default()).unwrap();
|
||||||
|
let doc = archive.read("doc.dat").unwrap();
|
||||||
|
let s = stream::parse(&doc).unwrap();
|
||||||
|
let bitmap = s.objects.iter().position(|o| o.class == Tag::of(b"DyBm")).unwrap();
|
||||||
|
let mut r = Reader::new(&s, &mut archive, Affine::IDENTITY, 0);
|
||||||
|
// 20 × 2 pixels over a budget of 10: every other pixel of every other row.
|
||||||
|
let image = decode_within(&mut r, bitmap, (250, 0, 270, 2), Affine::IDENTITY, 10).unwrap().unwrap();
|
||||||
|
assert_eq!((image.width, image.height), (10, 1));
|
||||||
|
assert_eq!(image.transform, Affine([2.0, 0.0, 0.0, 2.0, 250.0, 0.0]), "each pixel covers two");
|
||||||
|
let Pixels::Rgba8(pixels) = &image.pixels else { panic!() };
|
||||||
|
for x in 0..10 {
|
||||||
|
let sx = 2 * x;
|
||||||
|
let expected = if sx + 250 < 256 { [((sx + 250) % 251) as u8, 0, 77, 255] } else { [200, 10, 30, 255] };
|
||||||
|
assert_eq!(&pixels[x * 4..x * 4 + 4], &expected, "pixel {x}");
|
||||||
|
}
|
||||||
|
assert!(r.warnings.contains_key("pixel layers larger than 64 megapixels (read at a lower resolution)"));
|
||||||
|
// Within budget, nothing changes.
|
||||||
|
let image = decode_within(&mut r, bitmap, (250, 0, 270, 2), Affine::IDENTITY, 40).unwrap().unwrap();
|
||||||
|
assert_eq!((image.width, image.height), (20, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+330
-99
@@ -2,7 +2,7 @@
|
|||||||
//! leading, the paragraph alignment and the first baseline. Layout (line breaks in frames, kerning,
|
//! leading, the paragraph alignment and the first baseline. Layout (line breaks in frames, kerning,
|
||||||
//! OpenType features) is left to the importing application.
|
//! OpenType features) is left to the importing application.
|
||||||
|
|
||||||
use crate::model::{Affine, Align, Point, Reader, Text, TextRun, rect};
|
use crate::model::{Affine, Align, Paragraph, Point, Reader, Table, TableCell, Text, TextRun, VAlign, rect};
|
||||||
use crate::paint::{self, Color, Paint};
|
use crate::paint::{self, Color, Paint};
|
||||||
use crate::stream::{ObjId, Value};
|
use crate::stream::{ObjId, Value};
|
||||||
|
|
||||||
@@ -11,92 +11,16 @@ const MAX_CHARS: usize = 1 << 20;
|
|||||||
/// Shared character attributes must not amplify into unbounded font/feature/gradient data.
|
/// Shared character attributes must not amplify into unbounded font/feature/gradient data.
|
||||||
const MAX_ATTRIBUTE_BYTES: usize = 16 << 20;
|
const MAX_ATTRIBUTE_BYTES: usize = 16 << 20;
|
||||||
|
|
||||||
|
/// Ends a table cell in a table's story (Affinity's break glyph of kind 4).
|
||||||
|
pub const CELL_BREAK: char = '\u{1F}';
|
||||||
|
|
||||||
pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option<Text> {
|
pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option<Text> {
|
||||||
let s = r.s;
|
let s = r.s;
|
||||||
let story = s.obj(id, b"StSt")?;
|
let story = s.obj(id, b"StSt")?;
|
||||||
let frame = s.obj(id, b"TxtH")?;
|
let frame = s.obj(id, b"TxtH")?;
|
||||||
let artistic = s.is(frame, b"ArFr");
|
let artistic = s.is(frame, b"ArFr");
|
||||||
let bounds = s.floats::<4>(frame, b"FrmB").map(rect)?;
|
let bounds = s.floats::<4>(frame, b"FrmB").map(rect)?;
|
||||||
let mut runs = Vec::new();
|
let (runs, align) = story_runs(r, story, world);
|
||||||
let mut align = None;
|
|
||||||
let mut total = 0usize;
|
|
||||||
let mut placeholders = false;
|
|
||||||
let mut attribute_budget = Some(MAX_ATTRIBUTE_BYTES);
|
|
||||||
for block in s.objs(story, b"Blok") {
|
|
||||||
let Some(glyphs) = s.obj(block, b"Glyp") else {
|
|
||||||
r.warn("a text block without readable characters (left out)");
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
// Bound collection itself, including placeholders, before allocating the character buffer.
|
|
||||||
let budget = MAX_CHARS - total;
|
|
||||||
|
|
||||||
let mut chars: Vec<char> = match s.str(glyphs, b"Utf8") {
|
|
||||||
Some(t) => t.chars().take(budget + 1).collect(),
|
|
||||||
None => {
|
|
||||||
// Inline objects (page numbers, anchors) each take one index before the segment text.
|
|
||||||
let segments = s.objs(glyphs, b"Mixd");
|
|
||||||
if segments.is_empty() {
|
|
||||||
r.warn("a text block without readable characters (left out)");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let mut v = Vec::new();
|
|
||||||
for seg in segments {
|
|
||||||
let inline = s.objs(seg, b"Glys").len();
|
|
||||||
v.extend(std::iter::repeat_n('\u{FFFC}', inline.min(budget + 1 - v.len())));
|
|
||||||
placeholders |= inline > 0;
|
|
||||||
v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars().take(budget + 1 - v.len()));
|
|
||||||
if v.len() > budget {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
v
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let truncated = chars.len() > budget;
|
|
||||||
if truncated {
|
|
||||||
chars.truncate(budget);
|
|
||||||
r.warn("text longer than a million characters (truncated)");
|
|
||||||
}
|
|
||||||
total += chars.len();
|
|
||||||
let mut start = 0usize;
|
|
||||||
for run in s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default() {
|
|
||||||
let Some(index) = s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) else {
|
|
||||||
r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let end = index.min(chars.len());
|
|
||||||
if index > chars.len() || end < start {
|
|
||||||
r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
|
|
||||||
}
|
|
||||||
if end <= start {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let text = characters(&chars, start, end);
|
|
||||||
start = end;
|
|
||||||
push_run(r, &mut runs, s.obj(run, b"Item"), text, world, &mut attribute_budget);
|
|
||||||
}
|
|
||||||
if start < chars.len() {
|
|
||||||
let text = characters(&chars, start, chars.len());
|
|
||||||
push_run(r, &mut runs, None, text, world, &mut attribute_budget);
|
|
||||||
}
|
|
||||||
for paragraph in s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default() {
|
|
||||||
if let Some(attrs) = s.obj(paragraph, b"Item") {
|
|
||||||
let paragraph = paragraph_align(r, attrs);
|
|
||||||
match align {
|
|
||||||
None => align = Some(paragraph),
|
|
||||||
Some(first) if first != paragraph => r.warn("mixed paragraph alignments (the first alignment is used)"),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if truncated {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if placeholders {
|
|
||||||
r.warn("text fields such as page numbers (left out of the text)");
|
|
||||||
}
|
|
||||||
let align = align.unwrap_or(Align::Left);
|
|
||||||
let first_baseline = bounds.y0 + s.f64(frame, b"ArtV").unwrap_or(0.0);
|
let first_baseline = bounds.y0 + s.f64(frame, b"ArtV").unwrap_or(0.0);
|
||||||
let anchor_x = match align {
|
let anchor_x = match align {
|
||||||
Align::Center => (bounds.x0 + bounds.x1) / 2.0,
|
Align::Center => (bounds.x0 + bounds.x1) / 2.0,
|
||||||
@@ -111,19 +35,256 @@ pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option<Text> {
|
|||||||
r.warn("text frames with a curved outline (imported as rectangular frames)");
|
r.warn("text frames with a curved outline (imported as rectangular frames)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(Text { runs, align, anchor: Point { x: anchor_x, y: first_baseline }, frame: if artistic { None } else { Some(bounds) }, transform: world })
|
let text_transform = if artistic { world } else { text_scale(r, id).then(r.type_base) };
|
||||||
|
Some(Text {
|
||||||
|
runs,
|
||||||
|
align,
|
||||||
|
anchor: Point { x: anchor_x, y: first_baseline },
|
||||||
|
frame: if artistic { None } else { Some(bounds) },
|
||||||
|
transform: world,
|
||||||
|
text_transform,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rows and columns a table may have.
|
||||||
|
const MAX_TABLE_LINES: usize = 4096;
|
||||||
|
/// Cells a table may have (rows × columns): each becomes a text object with its edge lines.
|
||||||
|
const MAX_TABLE_CELLS: usize = 1 << 16;
|
||||||
|
|
||||||
|
/// A table node (`TxtT`): the grid, the cells' text and the lines on the cell edges.
|
||||||
|
pub(crate) fn table(r: &mut Reader, id: ObjId, world: Affine) -> Option<Table> {
|
||||||
|
let s = r.s;
|
||||||
|
let story = s.obj(id, b"StSt")?;
|
||||||
|
let tabl = s.obj(id, b"TxtH").and_then(|h| s.obj(h, b"Tabl"))?;
|
||||||
|
let bounds = |pos: Option<ObjId>| -> Option<Vec<f64>> {
|
||||||
|
let Some(Value::Array(a)) = pos.and_then(|p| s.field(p, b"Posn")) else { return None };
|
||||||
|
let v: Vec<f64> = a.iter().take(MAX_TABLE_LINES + 2).map(float).collect::<Option<_>>()?;
|
||||||
|
(v.len() >= 2 && v.len() <= MAX_TABLE_LINES + 1 && v.windows(2).all(|w| w[1] >= w[0])).then_some(v)
|
||||||
|
};
|
||||||
|
let columns = bounds(s.obj(tabl, b"CPos"))?;
|
||||||
|
let rows = bounds(s.obj(tabl, b"RPos"))?;
|
||||||
|
let (ncols, nrows) = (columns.len() - 1, rows.len() - 1);
|
||||||
|
let count = ncols.checked_mul(nrows)?;
|
||||||
|
if count > MAX_TABLE_CELLS {
|
||||||
|
r.warn("tables with more than 65536 cells (left out)");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let Some(left) = r.budget.cells.checked_sub(count) else {
|
||||||
|
r.warn("tables beyond the cells one document may hold (left out)");
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
r.budget.cells = left;
|
||||||
|
let cell_objs = s.obj(tabl, b"Cell").map(|c| s.objs(c, b"Cell")).unwrap_or_default();
|
||||||
|
// Formatting that doesn't match the grid would land on the wrong cells (and merging them,
|
||||||
|
// move text between cells): only text then.
|
||||||
|
let cell_objs = if cell_objs.len() == count {
|
||||||
|
cell_objs
|
||||||
|
} else {
|
||||||
|
r.warn("tables whose cells don't match their grid (cell formatting left out)");
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
let blank = || TableCell { runs: Vec::new(), inset: [0.0; 4], valign: VAlign::Top, merged_left: false };
|
||||||
|
let mut cells: Vec<TableCell> = (0..count).map(|_| blank()).collect();
|
||||||
|
for (i, (cell, obj)) in cells.iter_mut().zip(&cell_objs).enumerate() {
|
||||||
|
if let Some(inset) = s.floats::<4>(*obj, b"Inse").filter(|v| v.iter().all(|x| x.is_finite() && *x >= 0.0)) {
|
||||||
|
cell.inset = inset;
|
||||||
|
}
|
||||||
|
cell.valign = match s.enumeration(*obj, b"AliY").map(|(i, _)| i) {
|
||||||
|
Some(1) => VAlign::Center,
|
||||||
|
Some(2) => VAlign::Bottom,
|
||||||
|
_ => VAlign::Top,
|
||||||
|
};
|
||||||
|
// A cell merged into the one on its left; the first column has nothing to its left.
|
||||||
|
cell.merged_left = i % ncols != 0 && s.bool(*obj, b"BrLf") == Some(true);
|
||||||
|
}
|
||||||
|
// The story holds the text of every cell that isn't merged away, row by row, each ended by a
|
||||||
|
// cell break.
|
||||||
|
let owner_list: Vec<usize> = (0..count).filter(|i| cells.get(*i).is_some_and(|c| !c.merged_left)).collect();
|
||||||
|
let mut owners = owner_list.into_iter();
|
||||||
|
let mut target = owners.next();
|
||||||
|
let mut extra = false;
|
||||||
|
let (runs, _) = story_runs(r, story, world);
|
||||||
|
for run in runs {
|
||||||
|
let mut parts = run.text.split(CELL_BREAK).peekable();
|
||||||
|
while let Some(part) = parts.next() {
|
||||||
|
if !part.is_empty() {
|
||||||
|
match target.and_then(|i| cells.get_mut(i)) {
|
||||||
|
Some(cell) => cell.runs.push(TextRun { text: part.to_string(), ..run.clone() }),
|
||||||
|
None => extra = true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if parts.peek().is_some() {
|
||||||
|
target = owners.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if extra {
|
||||||
|
r.warn("table text beyond its last cell (left out)");
|
||||||
|
}
|
||||||
|
let mut edges = |tag: &[u8; 4], expected: usize| -> Vec<Option<crate::paint::Stroke>> {
|
||||||
|
let objs = s.obj(tabl, tag).map(|e| s.objs(e, b"Edge")).unwrap_or_default();
|
||||||
|
if objs.len() != expected {
|
||||||
|
r.warn("tables whose lines don't match their grid (lines left out)");
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
objs.into_iter()
|
||||||
|
.map(|e| match (s.obj(e, b"Fill"), s.obj(e, b"Line")) {
|
||||||
|
(Some(pen), Some(line)) => paint::stroke(r, pen, line, world, false),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let vertical = edges(b"CEdg", (ncols + 1) * nrows);
|
||||||
|
let horizontal = edges(b"REdg", ncols * (nrows + 1));
|
||||||
|
if let Some(format) = s.obj(tabl, b"TFmt") {
|
||||||
|
let filled =
|
||||||
|
s.objs(format, b"Clls").into_iter().filter_map(|c| s.obj(c, b"Fill")).any(|f| s.obj(f, b"FDeF").is_some_and(|d| !s.is(d, b"FilN")));
|
||||||
|
if filled {
|
||||||
|
r.warn("table cell fills (left out)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let text_transform = text_scale(r, id).then(r.type_base);
|
||||||
|
Some(Table { columns, rows, cells, vertical, horizontal, transform: world, text_transform })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A story's character runs, each with its paragraph's format, and the first paragraph's
|
||||||
|
/// alignment. Table cells end with [`CELL_BREAK`].
|
||||||
|
pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec<TextRun>, Align) {
|
||||||
|
let s = r.s;
|
||||||
|
let mut runs = Vec::new();
|
||||||
|
let mut first_align = None;
|
||||||
|
let mut total = 0usize;
|
||||||
|
let mut placeholders = false;
|
||||||
|
let mut attribute_budget = Some(MAX_ATTRIBUTE_BYTES);
|
||||||
|
for block in s.objs(story, b"Blok") {
|
||||||
|
let Some(glyphs) = s.obj(block, b"Glyp") else {
|
||||||
|
r.warn("a text block without readable characters (left out)");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// Bound collection itself, including placeholders, before allocating the character buffer:
|
||||||
|
// by the story's limit and by what the document's other stories left.
|
||||||
|
let budget = (MAX_CHARS - total).min(r.budget.chars.saturating_sub(total));
|
||||||
|
|
||||||
|
let mut chars: Vec<char> = match s.str(glyphs, b"Utf8") {
|
||||||
|
Some(t) => t.chars().take(budget + 1).collect(),
|
||||||
|
None => {
|
||||||
|
// Inline objects (page numbers, anchors, cell ends) each take one index before
|
||||||
|
// the segment text.
|
||||||
|
let segments = s.objs(glyphs, b"Mixd");
|
||||||
|
if segments.is_empty() {
|
||||||
|
r.warn("a text block without readable characters (left out)");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut v = Vec::new();
|
||||||
|
for seg in segments {
|
||||||
|
for inline in s.objs(seg, b"Glys") {
|
||||||
|
if v.len() > budget {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if s.is(inline, b"BrGl") && s.enumeration(inline, b"HdBk").is_some_and(|(kind, _)| kind == 4) {
|
||||||
|
v.push(CELL_BREAK);
|
||||||
|
} else {
|
||||||
|
v.push('\u{FFFC}');
|
||||||
|
placeholders = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars().take((budget + 1).saturating_sub(v.len())));
|
||||||
|
if v.len() > budget {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let truncated = chars.len() > budget;
|
||||||
|
if truncated {
|
||||||
|
chars.truncate(budget);
|
||||||
|
if budget < MAX_CHARS - total {
|
||||||
|
r.warn("text beyond the characters one document may hold (truncated)");
|
||||||
|
} else {
|
||||||
|
r.warn("text longer than a million characters (truncated)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
total += chars.len();
|
||||||
|
// Paragraph formats by end index; a run without an index covers the rest of the block.
|
||||||
|
let mut paragraphs: Vec<(usize, Paragraph)> = Vec::new();
|
||||||
|
let mut open: Option<Paragraph> = None;
|
||||||
|
for run in s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default() {
|
||||||
|
let Some(attrs) = s.obj(run, b"Item") else { continue };
|
||||||
|
let a = paragraph(r, attrs);
|
||||||
|
first_align.get_or_insert(a.align);
|
||||||
|
match s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) {
|
||||||
|
Some(end) => paragraphs.push((end, a)),
|
||||||
|
None => match open {
|
||||||
|
None => open = Some(a),
|
||||||
|
Some(first) if first != a => r.warn("mixed paragraph formats in one block (the first is used)"),
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sorted by end (they are in order in every file), so both lookups below are binary
|
||||||
|
// searches: a block can have as many paragraphs and runs as characters.
|
||||||
|
paragraphs.sort_by_key(|(end, _)| *end);
|
||||||
|
let align_at = |i: usize| paragraphs.get(paragraphs.partition_point(|(end, _)| *end <= i)).map(|(_, a)| *a).or(open).unwrap_or_default();
|
||||||
|
let mut cuts: Vec<usize> = paragraphs.iter().map(|(end, _)| *end).filter(|e| *e < chars.len()).collect();
|
||||||
|
cuts.dedup();
|
||||||
|
let push = |r: &mut Reader, runs: &mut Vec<TextRun>, attrs: Option<ObjId>, from: usize, to: usize, budget: &mut Option<usize>| {
|
||||||
|
// Split at paragraph ends so every run has one paragraph format.
|
||||||
|
let inside = cuts.get(cuts.partition_point(|c| *c <= from)..cuts.partition_point(|c| *c < to)).unwrap_or_default();
|
||||||
|
let mut a = from;
|
||||||
|
for &c in inside.iter().chain(std::iter::once(&to)) {
|
||||||
|
push_run(r, runs, attrs, characters(&chars, a, c), world, budget, align_at(a));
|
||||||
|
a = c;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut start = 0usize;
|
||||||
|
for run in s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default() {
|
||||||
|
let Some(index) = s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) else {
|
||||||
|
r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let end = index.min(chars.len());
|
||||||
|
if index > chars.len() || end < start {
|
||||||
|
r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
|
||||||
|
}
|
||||||
|
if end <= start {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
push(r, &mut runs, s.obj(run, b"Item"), start, end, &mut attribute_budget);
|
||||||
|
start = end;
|
||||||
|
}
|
||||||
|
if start < chars.len() {
|
||||||
|
push(r, &mut runs, None, start, chars.len(), &mut attribute_budget);
|
||||||
|
}
|
||||||
|
if truncated {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.budget.chars = r.budget.chars.saturating_sub(total);
|
||||||
|
if placeholders {
|
||||||
|
r.warn("text fields such as page numbers (left out of the text)");
|
||||||
|
}
|
||||||
|
(runs, first_align.unwrap_or(Align::Left))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn characters(chars: &[char], start: usize, end: usize) -> String {
|
fn characters(chars: &[char], start: usize, end: usize) -> String {
|
||||||
chars.get(start..end).unwrap_or_default().iter().filter(|c| **c != '\0' && **c != '\u{FFFC}').collect()
|
chars.get(start..end).unwrap_or_default().iter().filter(|c| **c != '\0' && **c != '\u{FFFC}').collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn push_run(r: &mut Reader, runs: &mut Vec<TextRun>, attrs: Option<ObjId>, text: String, world: Affine, attribute_budget: &mut Option<usize>) {
|
fn push_run(
|
||||||
|
r: &mut Reader,
|
||||||
|
runs: &mut Vec<TextRun>,
|
||||||
|
attrs: Option<ObjId>,
|
||||||
|
text: String,
|
||||||
|
world: Affine,
|
||||||
|
attribute_budget: &mut Option<usize>,
|
||||||
|
paragraph: Paragraph,
|
||||||
|
) {
|
||||||
if text.is_empty() {
|
if text.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let run = match attrs {
|
let run = match attrs {
|
||||||
Some(attrs) if attribute_budget.is_some() => run_attrs(r, attrs, text, world, attribute_budget),
|
Some(attrs) if attribute_budget.is_some() => TextRun { paragraph, ..run_attrs(r, attrs, text, world, attribute_budget) },
|
||||||
_ => {
|
_ => {
|
||||||
// This is a recovery policy, not an assertion about unspecified Affinity attributes.
|
// This is a recovery policy, not an assertion about unspecified Affinity attributes.
|
||||||
if attrs.is_none() {
|
if attrs.is_none() {
|
||||||
@@ -137,10 +298,15 @@ fn push_run(r: &mut Reader, runs: &mut Vec<TextRun>, attrs: Option<ObjId>, text:
|
|||||||
italic: false,
|
italic: false,
|
||||||
features: Vec::new(),
|
features: Vec::new(),
|
||||||
all_caps: false,
|
all_caps: false,
|
||||||
|
underline: false,
|
||||||
|
strikethrough: false,
|
||||||
size: 12.0 * r.dpi / 72.0,
|
size: 12.0 * r.dpi / 72.0,
|
||||||
tracking: 0.0,
|
tracking: 0.0,
|
||||||
|
h_scale: 1.0,
|
||||||
leading: None,
|
leading: None,
|
||||||
fill: Paint::Solid(Color::Gray { v: 0.0, a: 1.0 }),
|
fill: Paint::Solid(Color::Gray { v: 0.0, a: 1.0 }),
|
||||||
|
stroke: None,
|
||||||
|
paragraph,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -195,13 +361,33 @@ fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine, attribut
|
|||||||
fill = Paint::Solid(Color::Gray { v: 0.0, a: 1.0 });
|
fill = Paint::Solid(Color::Gray { v: 0.0, a: 1.0 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if attribute_budget.is_some() && objects.get(1).copied().flatten().and_then(|f| paint::descriptor(r, f, world)).is_some_and(|p| p != Paint::None)
|
// Outlined characters: `Objs` 1 is the outline's paint, 2 its line style (as for shapes).
|
||||||
{
|
let mut stroke = match (attribute_budget.is_some(), objects.get(1).copied().flatten(), objects.get(2).copied().flatten()) {
|
||||||
r.warn("outlined (stroked) text");
|
(true, Some(pen), Some(style)) => paint::stroke(r, pen, style, Affine::IDENTITY, false),
|
||||||
}
|
_ => None,
|
||||||
if slot(doubles, 10, float).is_some_and(|h| (h - 1.0).abs() > 1e-3) {
|
};
|
||||||
r.warn("horizontally scaled text");
|
// Its gradient stops and dashes are owned data too, charged like the fill's.
|
||||||
|
if let Some(st) = &stroke {
|
||||||
|
let stops = match &st.paint {
|
||||||
|
Paint::Gradient(g) => g.stops.len().saturating_mul(std::mem::size_of::<paint::Stop>()),
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
let dash = st.dash.as_ref().map_or(0, |(d, _)| d.len().saturating_mul(std::mem::size_of::<f64>()));
|
||||||
|
if !charge_attributes(r, attribute_budget, stops.saturating_add(dash)) {
|
||||||
|
stroke = None;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// Underline (slot 0) and strikethrough (slot 1): 1 is a single line.
|
||||||
|
let mut decoration = |i: usize| match slot(ints, i, int) {
|
||||||
|
Some(1) => true,
|
||||||
|
Some(0) | None => false,
|
||||||
|
Some(_) => {
|
||||||
|
r.warn("text decorations of an unknown kind (left out)");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (underline, strikethrough) = (decoration(0), decoration(1));
|
||||||
|
let h_scale = slot(doubles, 10, float).filter(|h| *h > 0.0 && *h < 100.0).unwrap_or(1.0);
|
||||||
// A resolved font is populated on the emoji run of the public Affinity 3 text-runs file
|
// A resolved font is populated on the emoji run of the public Affinity 3 text-runs file
|
||||||
// (Courier New requested, Segoe UI Emoji stored in RFnt). Empty RFnt records are common.
|
// (Courier New requested, Segoe UI Emoji stored in RFnt). Empty RFnt records are common.
|
||||||
// Source: https://github.com/SethRobinson/Patchy/tree/de84eab550758b30fa062e479f5778cce7693b73/test-fixtures/af
|
// Source: https://github.com/SethRobinson/Patchy/tree/de84eab550758b30fa062e479f5778cce7693b73/test-fixtures/af
|
||||||
@@ -220,8 +406,8 @@ fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine, attribut
|
|||||||
if font.and_then(|f| s.int(f, b"Widh")).is_some_and(|w| w != 5) {
|
if font.and_then(|f| s.int(f, b"Widh")).is_some_and(|w| w != 5) {
|
||||||
r.warn("font width variants (exact face depends on the installed PostScript font)");
|
r.warn("font width variants (exact face depends on the installed PostScript font)");
|
||||||
}
|
}
|
||||||
let postscript = metadata_string(r, attribute_budget, font.and_then(|f| s.str(f, b"Post")).unwrap_or_default());
|
let postscript = font_name(r, attribute_budget, font.and_then(|f| s.str(f, b"Post")).unwrap_or_default());
|
||||||
let family = metadata_string(r, attribute_budget, font.and_then(|f| s.str(f, b"Famy")).unwrap_or_default());
|
let family = font_name(r, attribute_budget, font.and_then(|f| s.str(f, b"Famy")).unwrap_or_default());
|
||||||
let (features, all_caps) = font_features(r, objects.get(7).copied().flatten(), attribute_budget);
|
let (features, all_caps) = font_features(r, objects.get(7).copied().flatten(), attribute_budget);
|
||||||
let weight = font.and_then(|f| s.int(f, b"Wegt")).unwrap_or(400);
|
let weight = font.and_then(|f| s.int(f, b"Wegt")).unwrap_or(400);
|
||||||
let weight = if (1..=1000).contains(&weight) {
|
let weight = if (1..=1000).contains(&weight) {
|
||||||
@@ -238,10 +424,15 @@ fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine, attribut
|
|||||||
italic: font.and_then(|f| s.bool(f, b"Ital")).unwrap_or(false),
|
italic: font.and_then(|f| s.bool(f, b"Ital")).unwrap_or(false),
|
||||||
features,
|
features,
|
||||||
all_caps,
|
all_caps,
|
||||||
|
underline,
|
||||||
|
strikethrough,
|
||||||
size,
|
size,
|
||||||
tracking,
|
tracking,
|
||||||
|
h_scale,
|
||||||
leading,
|
leading,
|
||||||
fill,
|
fill,
|
||||||
|
stroke,
|
||||||
|
paragraph: Paragraph::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,6 +451,17 @@ fn charge_attributes(r: &mut Reader, budget: &mut Option<usize>, bytes: usize) -
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Longest font name kept (OpenType names are far shorter); every run and paragraph carries it.
|
||||||
|
const MAX_FONT_NAME: usize = 256;
|
||||||
|
|
||||||
|
fn font_name(r: &mut Reader, budget: &mut Option<usize>, value: &str) -> String {
|
||||||
|
if value.len() > MAX_FONT_NAME {
|
||||||
|
r.warn("font names longer than 256 bytes (the default font is used)");
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
metadata_string(r, budget, value)
|
||||||
|
}
|
||||||
|
|
||||||
fn metadata_string(r: &mut Reader, budget: &mut Option<usize>, value: &str) -> String {
|
fn metadata_string(r: &mut Reader, budget: &mut Option<usize>, value: &str) -> String {
|
||||||
let bytes = value.len().saturating_add(std::mem::size_of::<String>());
|
let bytes = value.len().saturating_add(std::mem::size_of::<String>());
|
||||||
if value.is_empty() || !charge_attributes(r, budget, bytes) { String::new() } else { value.to_owned() }
|
if value.is_empty() || !charge_attributes(r, budget, bytes) { String::new() } else { value.to_owned() }
|
||||||
@@ -303,13 +505,18 @@ fn font_features(r: &mut Reader, attrs: Option<ObjId>, attribute_budget: &mut Op
|
|||||||
(features, all_caps)
|
(features, all_caps)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn paragraph_align(r: &mut Reader, p: ObjId) -> Align {
|
/// A paragraph's alignment, indents, spacing and leading. The `Doub` slots: 0 the automatic
|
||||||
// Nonzero offsets in these slots are present in the public text-indent and text-para-spacing
|
/// leading as a multiple of the font's line height (1 in every document checked), 1 fixed leading
|
||||||
// fixtures. Their layout is not represented by this importer; retain text and report the gap.
|
/// (when `Ints` slot 1 is 2; in every paragraph checked the two go together), 2 indent of the
|
||||||
if (1..=6).any(|i| slot(r.s.field(p, b"Doub"), i, float).is_some_and(|v| v.abs() > 1e-9)) {
|
/// lines after the first, 3 right indent, 4 indent of the first line, 5 space before, 6 space
|
||||||
r.warn("paragraph indents or spacing (imported with defaults)");
|
/// after. Slots 2, 4, 5 and 6 were identified on the public MIT text-indent and text-para-spacing
|
||||||
}
|
/// fixtures (Patchy) by measuring their thumbnails, slot 1 by measuring the baseline pitch in the
|
||||||
match slot(r.s.field(p, b"Ints"), 0, int) {
|
/// thumbnail of a document whose owner made it earlier.
|
||||||
|
fn paragraph(r: &mut Reader, p: ObjId) -> Paragraph {
|
||||||
|
let doubles = r.s.field(p, b"Doub");
|
||||||
|
let ints = r.s.field(p, b"Ints");
|
||||||
|
let at = |i: usize| slot(doubles, i, float).filter(|v| v.abs() < 1e6).unwrap_or(0.0);
|
||||||
|
let align = match slot(ints, 0, int) {
|
||||||
Some(1) => Align::Center,
|
Some(1) => Align::Center,
|
||||||
Some(2) => Align::Right,
|
Some(2) => Align::Right,
|
||||||
Some(3) => Align::Justify,
|
Some(3) => Align::Justify,
|
||||||
@@ -318,5 +525,29 @@ fn paragraph_align(r: &mut Reader, p: ObjId) -> Align {
|
|||||||
r.warn("an unknown paragraph alignment (imported as left)");
|
r.warn("an unknown paragraph alignment (imported as left)");
|
||||||
Align::Left
|
Align::Left
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
let leading = match slot(ints, 1, int) {
|
||||||
|
Some(2) => Some(at(1)).filter(|l| *l > 0.0),
|
||||||
|
Some(0) | None => None,
|
||||||
|
Some(_) => {
|
||||||
|
r.warn("paragraph leading of an unknown kind (imported as automatic)");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let auto_leading = slot(doubles, 0, float).filter(|v| *v > 0.0 && *v < 100.0).unwrap_or(1.0);
|
||||||
|
Paragraph { align, left: at(2), right: at(3), first: at(4), space_before: at(5).max(0.0), space_after: at(6).max(0.0), leading, auto_leading }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The scale a text frame or table gives its type (`FTxS`, stored like `Xfrm`): none unless set.
|
||||||
|
/// Neither the frame's own transform nor its groups' scale its type (checked against the
|
||||||
|
/// thumbnails of tables and frames inside scaled groups).
|
||||||
|
fn text_scale(r: &mut Reader, id: ObjId) -> Affine {
|
||||||
|
match r.s.floats::<6>(id, b"FTxS").map(Affine::from_xfrm) {
|
||||||
|
Some(m) if m.is_finite() && m.scale() > 1e-9 => Affine([m.0[0], m.0[1], m.0[2], m.0[3], 0.0, 0.0]),
|
||||||
|
Some(_) => {
|
||||||
|
r.warn("text frames with an invalid text scale (imported unscaled)");
|
||||||
|
Affine::IDENTITY
|
||||||
|
}
|
||||||
|
None => Affine::IDENTITY,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
//! Crafted documents can't make the reader allocate or work without bound: tables, shared
|
||||||
|
//! stories, paragraphs and embedded documents stay within the whole read's budget, however often
|
||||||
|
//! a file links or embeds the same content. Original synthetic documents in the reader's
|
||||||
|
//! accepted schema; no Affinity software or files were used.
|
||||||
|
#![cfg(feature = "synth")]
|
||||||
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||||
|
|
||||||
|
mod support {
|
||||||
|
pub mod text_fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
use support::text_fixture::*;
|
||||||
|
use vectorcraft_affinity::{
|
||||||
|
Document, Error, Limits,
|
||||||
|
model::{Kind, Node},
|
||||||
|
read,
|
||||||
|
synth::{self, F, Method, tag},
|
||||||
|
};
|
||||||
|
|
||||||
|
type Field = (vectorcraft_affinity::stream::Tag, F);
|
||||||
|
|
||||||
|
/// A document of one spread holding `nodes`, plus these other archive entries.
|
||||||
|
fn file(nodes: Vec<F>, entries: &[(&str, &[u8])]) -> Vec<u8> {
|
||||||
|
let spread = F::Obj(tag(b"Sprd"), vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(nodes))]);
|
||||||
|
let mut root = vec![(tag(b"DocR"), F::Obj(tag(b"DocN"), vec![(tag(b"Chld"), F::Shared(vec![spread]))]))];
|
||||||
|
let mut next_id = 1000;
|
||||||
|
for (_, value) in &mut root {
|
||||||
|
shared_objects(value, &mut next_id);
|
||||||
|
}
|
||||||
|
let data = synth::stream(&root);
|
||||||
|
let mut all = vec![("doc.dat", data.as_slice(), Method::Zlib)];
|
||||||
|
all.extend(entries.iter().map(|(name, bytes)| (*name, *bytes, Method::Stored)));
|
||||||
|
synth::container(&all, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open(nodes: Vec<F>) -> Document {
|
||||||
|
read(&file(nodes, &[]), Limits::default()).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn all(nodes: &[Node]) -> Vec<&Node> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for n in nodes {
|
||||||
|
out.push(n);
|
||||||
|
out.extend(all(&n.children));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn warned(d: &Document, needle: &str) -> bool {
|
||||||
|
d.warnings.iter().any(|w| w.contains(needle))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn story(text: &str) -> F {
|
||||||
|
F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![block(utf8(text), vec![run(text.chars().count() as i32, None)], &[])]))])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn artistic(story: F) -> F {
|
||||||
|
F::Obj(
|
||||||
|
tag(b"TxtA"),
|
||||||
|
vec![
|
||||||
|
(tag(b"StSt"), story),
|
||||||
|
(tag(b"TxtH"), F::Obj(tag(b"ArFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 100.0, 20.0])), (tag(b"ArtV"), F::F64(16.0))])),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A table frame (`TbFr`) with a grid of `cols` × `rows` cells and no cell objects or lines.
|
||||||
|
fn grid(cols: usize, rows: usize) -> Vec<Field> {
|
||||||
|
let pos = |n: usize| {
|
||||||
|
let v: Vec<f64> = (0..=n).map(|i| i as f64).collect();
|
||||||
|
F::Obj(tag(b"TPos"), vec![(tag(b"Posn"), array_f64(b"Posn", &v))])
|
||||||
|
};
|
||||||
|
let table = F::Obj(tag(b"Tabl"), vec![(tag(b"CPos"), pos(cols)), (tag(b"RPos"), pos(rows))]);
|
||||||
|
vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, cols as f64, rows as f64])), (tag(b"Tabl"), table)]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn table(frame: F) -> F {
|
||||||
|
F::Obj(tag(b"TxtT"), vec![(tag(b"StSt"), story("A")), (tag(b"TxtH"), frame)])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tables(d: &Document) -> Vec<&vectorcraft_affinity::model::Table> {
|
||||||
|
all(&d.spreads[0].nodes).into_iter().filter_map(|n| if let Kind::Table(t) = &n.kind { Some(t) } else { None }).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_table_of_more_than_65536_cells_is_left_out_before_its_cells_are_made() {
|
||||||
|
// 4096 × 4096 is within each dimension's limit but 16.7 million cells.
|
||||||
|
let d = open(vec![table(F::Obj(tag(b"TbFr"), grid(4096, 4096))), table(F::Obj(tag(b"TbFr"), grid(256, 256)))]);
|
||||||
|
let t = tables(&d);
|
||||||
|
assert_eq!(t.len(), 1);
|
||||||
|
assert_eq!((t[0].cells.len(), t[0].vertical.len(), t[0].horizontal.len()), (65536, 0, 0), "no lines when they don't match");
|
||||||
|
assert!(warned(&d, "tables with more than 65536 cells"), "{:?}", d.warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tables_sharing_one_grid_stay_within_the_cells_of_one_document() {
|
||||||
|
// One 65536-cell grid linked from five tables: four fill the document's 262144 cells.
|
||||||
|
let shared = F::Def(2_000_000, vec![tag(b"TbFr")], grid(256, 256));
|
||||||
|
let d = open((0..5).map(|i| table(if i == 0 { shared.clone() } else { F::Ref(2_000_000) })).collect());
|
||||||
|
assert_eq!(tables(&d).len(), 4);
|
||||||
|
assert!(warned(&d, "tables beyond the cells one document may hold"), "{:?}", d.warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_shared_story_is_read_within_the_characters_of_one_document() {
|
||||||
|
// A story of a million characters linked from twenty text layers: 16 million in all.
|
||||||
|
let text = "x".repeat(1 << 20);
|
||||||
|
let shared = F::Def(2_000_000, vec![tag(b"Stry")], vec![(tag(b"Blok"), F::Shared(vec![block(utf8(&text), vec![run(1 << 20, None)], &[])]))]);
|
||||||
|
let d = open((0..20).map(|i| artistic(if i == 0 { shared.clone() } else { F::Ref(2_000_000) })).collect());
|
||||||
|
let chars: usize = all(&d.spreads[0].nodes)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|n| if let Kind::Text(t) = &n.kind { Some(t.runs.iter().map(|r| r.text.len()).sum::<usize>()) } else { None })
|
||||||
|
.sum();
|
||||||
|
assert_eq!(chars, 16 << 20);
|
||||||
|
assert!(warned(&d, "text beyond the characters one document may hold"), "{:?}", d.warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn many_paragraphs_and_runs_are_matched_in_about_linear_time() {
|
||||||
|
// 200 000 paragraphs, each with its own run and format.
|
||||||
|
let n = 200_000;
|
||||||
|
let text = "a\u{2029}".repeat(n);
|
||||||
|
let fmt =
|
||||||
|
|end: usize| vec![(tag(b"Indx"), F::I32(end as i32)), (tag(b"Item"), F::Obj(tag(b"PAtt"), vec![(tag(b"Ints"), array_i32(b"Ints", &[1]))]))];
|
||||||
|
let blk = F::Obj(
|
||||||
|
tag(b"StBl"),
|
||||||
|
vec![
|
||||||
|
(tag(b"Glyp"), utf8(&text)),
|
||||||
|
(tag(b"GAtt"), F::Obj(tag(b"GlAS"), vec![(tag(b"Runs"), F::Objs(tag(b"GlAR"), (1..=n).map(|i| run(2 * i as i32, None)).collect()))])),
|
||||||
|
(tag(b"PAtt"), F::Obj(tag(b"PaAS"), vec![(tag(b"Runs"), F::Objs(tag(b"PaAR"), (1..=n).map(|i| fmt(2 * i)).collect()))])),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let d = open(vec![artistic(F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![blk]))]))]);
|
||||||
|
let Kind::Text(t) = &d.spreads[0].nodes[0].kind else { panic!("text") };
|
||||||
|
assert_eq!(t.runs.len(), n);
|
||||||
|
// (About half a second; quadratic matching took over half a minute on the same machine.)
|
||||||
|
assert!(started.elapsed().as_secs() < 10, "{:?}", started.elapsed());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A shape node of `size` px.
|
||||||
|
fn rect(size: f64) -> F {
|
||||||
|
F::Obj(tag(b"ShpN"), vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, size, size])), (tag(b"Shpe"), F::Obj(tag(b"ShNR"), vec![]))])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An instance of the embedded document in archive entry `edc/1`, with `children` of its own.
|
||||||
|
fn embedded(children: Vec<F>) -> F {
|
||||||
|
let content = F::Obj(tag(b"EmbC"), vec![(tag(b"EmbC"), F::Entry("edc/1".into()))]);
|
||||||
|
let mut fields = vec![(tag(b"Bitm"), F::Obj(tag(b"EmbR"), vec![(tag(b"EmCn"), content)]))];
|
||||||
|
if !children.is_empty() {
|
||||||
|
fields.push((tag(b"Chld"), F::Shared(children)));
|
||||||
|
}
|
||||||
|
F::Obj(tag(b"EmbN"), fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn embedding(inner: &[u8], nodes: Vec<F>) -> Vec<u8> {
|
||||||
|
let mut entry = b"EmDc\0\0\0\0".to_vec();
|
||||||
|
entry.extend_from_slice(inner);
|
||||||
|
file(nodes, &[("edc/1", &entry)])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_embedded_documents_content_stays_with_its_node() {
|
||||||
|
// Read before the node's own children, which must not take it.
|
||||||
|
let inner = file(vec![rect(10.0), rect(20.0)], &[]);
|
||||||
|
let d = read(&embedding(&inner, vec![embedded(vec![rect(5.0)])]), Limits::default()).unwrap();
|
||||||
|
let node = &d.spreads[0].nodes[0];
|
||||||
|
assert_eq!(node.children.len(), 3, "its own child and the embedded document's two shapes");
|
||||||
|
assert!(node.children.iter().all(|c| c.children.is_empty()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn embedding_one_document_many_times_shares_one_budget() {
|
||||||
|
// 2000 shapes, embedded 300 times and read twice each: 1.2 million nodes, past the 500 000
|
||||||
|
// a whole read may take (each embedding used to have an allowance of its own).
|
||||||
|
let inner = file((0..2000).map(|_| rect(10.0)).collect(), &[]);
|
||||||
|
let outer = embedding(&inner, (0..300).map(|_| embedded(Vec::new())).collect());
|
||||||
|
assert!(matches!(read(&outer, Limits::default()), Err(Error::Limit(_))));
|
||||||
|
// Embedded fewer times, it opens.
|
||||||
|
let d = read(&embedding(&inner, (0..50).map(|_| embedded(Vec::new())).collect()), Limits::default()).unwrap();
|
||||||
|
assert_eq!(all(&d.spreads[0].nodes).len(), 50 + 50 * 2000);
|
||||||
|
}
|
||||||
@@ -103,13 +103,16 @@ fn rotated_scaled_pixels_keep_the_full_affine_transform() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cmyk_and_lab_losses_are_reported() {
|
fn cmyk_pixels_stay_cmyk_and_lab_losses_are_reported() {
|
||||||
|
// CMYK layers keep their inks for the importer to convert through a colour profile.
|
||||||
let Some((_, cmyk, images)) = read_fixture("patchy-cmyk.af") else { return };
|
let Some((_, cmyk, images)) = read_fixture("patchy-cmyk.af") else { return };
|
||||||
assert_eq!(images.len(), 1);
|
assert_eq!(images.len(), 1);
|
||||||
assert_eq!((images[0].width, images[0].height), (64, 48));
|
assert_eq!((images[0].width, images[0].height), (64, 48));
|
||||||
let Pixels::Rgba8(pixels) = &images[0].pixels else { panic!() };
|
let Pixels::Cmyka8(pixels) = &images[0].pixels else { panic!() };
|
||||||
assert_eq!(crc32fast::hash(pixels), 0x5872bb76);
|
assert_eq!(pixels.len(), 64 * 48 * 5);
|
||||||
assert_eq!(cmyk.warnings, ["CMYK pixel layers (converted to RGB without the document's colour profile)"]);
|
assert_eq!(crc32fast::hash(pixels), 0x56b1f659);
|
||||||
|
assert!(cmyk.warnings.is_empty(), "{:?}", cmyk.warnings);
|
||||||
|
assert_eq!(cmyk.cmyk_profile, None, "this file embeds no profile");
|
||||||
let (_, lab, images) = read_fixture("patchy-lab.af").unwrap();
|
let (_, lab, images) = read_fixture("patchy-lab.af").unwrap();
|
||||||
assert!(images.is_empty());
|
assert!(images.is_empty());
|
||||||
assert_eq!(lab.warnings, ["pixel layers in grey, Lab or 32-bit formats"]);
|
assert_eq!(lab.warnings, ["pixel layers in grey, Lab or 32-bit formats"]);
|
||||||
|
|||||||
@@ -1,33 +1,33 @@
|
|||||||
affinity3-mexican-guy.af dpi=300;spreads=1;model_crc32=df3c417b;counts={"depth": 8, "fills": 169, "gradients": 0, "groups": 23, "nodes": 283, "opacity": 1, "segments": 1491, "shapes": 260, "strokes": 114, "subpaths": 264};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes) (114×)", "layer effects (shadows, glows, outlines, bevels…) (156×)"]
|
affinity3-mexican-guy.af dpi=300;spreads=1;model_crc32=d23a6900;counts={"depth": 8, "fills": 169, "gradients": 0, "groups": 23, "nodes": 283, "opacity": 1, "segments": 1491, "shapes": 260, "strokes": 114, "subpaths": 264};boards=[];fonts={};warnings=["blur effects (left out) (156×)", "brush and pressure strokes (imported as plain strokes) (114×)"]
|
||||||
affinity3-mexican-man.af dpi=300;spreads=1;model_crc32=d0387de4;counts={"depth": 7, "fills": 277, "gradients": 2, "groups": 29, "nodes": 903, "segments": 3225, "shapes": 874, "strokes": 559, "subpaths": 881};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes) (545×)", "layer effects (shadows, glows, outlines, bevels…) (245×)"]
|
affinity3-mexican-man.af dpi=300;spreads=1;model_crc32=b8cecb1c;counts={"depth": 7, "fills": 277, "gradients": 2, "groups": 29, "nodes": 903, "segments": 3225, "shapes": 874, "strokes": 540, "subpaths": 881};boards=[];fonts={};warnings=["blur effects (left out) (244×)", "brush and pressure strokes (imported as plain strokes) (534×)"]
|
||||||
affinity3-mexican-woman.af dpi=300;spreads=1;model_crc32=b966f693;counts={"depth": 9, "fills": 527, "gradients": 0, "groups": 28, "nodes": 595, "segments": 3562, "shapes": 567, "strokes": 60, "subpaths": 570};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes) (54×)", "layer effects (shadows, glows, outlines, bevels…) (278×)"]
|
affinity3-mexican-woman.af dpi=300;spreads=1;model_crc32=23d1e39c;counts={"depth": 9, "fills": 527, "gradients": 0, "groups": 28, "nodes": 595, "segments": 3562, "shapes": 567, "strokes": 60, "subpaths": 570};boards=[];fonts={};warnings=["blur effects (left out) (278×)", "brush and pressure strokes (imported as plain strokes) (54×)"]
|
||||||
affinity3-playing-cards.af dpi=300;spreads=1;model_crc32=ac448123;counts={"depth": 5, "fills": 363, "gradients": 0, "groups": 758, "nodes": 1350, "segments": 4864, "shapes": 592, "strokes": 288, "subpaths": 656};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes)", "layer effects (shadows, glows, outlines, bevels…) (339×)"]
|
affinity3-playing-cards.af dpi=300;spreads=1;model_crc32=cafb7572;counts={"depth": 5, "fills": 363, "gradients": 0, "groups": 758, "nodes": 1350, "segments": 4864, "shapes": 592, "strokes": 17, "subpaths": 656};boards=[];fonts={};warnings=["blur effects (left out) (309×)", "brush and pressure strokes (imported as plain strokes)"]
|
||||||
patchy-adjust-curves.af dpi=72;spreads=1;model_crc32=10a37502;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"]
|
patchy-adjust-curves.af dpi=72;spreads=1;model_crc32=36ab962f;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"]
|
||||||
patchy-adjust-hsl.af dpi=72;spreads=1;model_crc32=c65409b4;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"]
|
patchy-adjust-hsl.af dpi=72;spreads=1;model_crc32=e2eac05e;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"]
|
||||||
patchy-artboards.af dpi=72;spreads=1;model_crc32=f1ae5644;counts={"artboards": 2, "depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2};boards=["board-a:0.0000,0.0000,80.0000,60.0000", "board-b:100.0000,10.0000,160.0000,70.0000"];fonts={};warnings=[]
|
patchy-artboards.af dpi=72;spreads=1;model_crc32=0037a28e;counts={"artboards": 2, "depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2};boards=["board-a:0.0000,0.0000,80.0000,60.0000", "board-b:100.0000,10.0000,160.0000,70.0000"];fonts={};warnings=[]
|
||||||
patchy-blend-affinity.af dpi=72;spreads=1;model_crc32=f6691ff9;counts={"depth": 2, "fills": 8, "gradients": 0, "images": 1, "layers": 1, "nodes": 10, "segments": 24, "shapes": 8, "strokes": 0, "subpaths": 8};boards=[];fonts={};warnings=["an unknown blend mode (imported as Normal) (2×)"]
|
patchy-blend-affinity.af dpi=72;spreads=1;model_crc32=8497c917;counts={"depth": 2, "fills": 8, "gradients": 0, "images": 1, "layers": 1, "nodes": 10, "segments": 24, "shapes": 8, "strokes": 0, "subpaths": 8};boards=[];fonts={};warnings=["an unknown blend mode (imported as Normal) (2×)"]
|
||||||
patchy-cmyk.af dpi=72;spreads=1;model_crc32=5145b5a1;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=["CMYK pixel layers (converted to RGB without the document's colour profile)"]
|
patchy-cmyk.af dpi=72;spreads=1;model_crc32=56754ce9;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
|
||||||
patchy-dpi300.af dpi=300;spreads=1;model_crc32=0c23e7c8;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
|
patchy-dpi300.af dpi=300;spreads=1;model_crc32=2561f9d3;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
|
||||||
patchy-embedded-jpeg.af dpi=72;spreads=1;model_crc32=090c674b;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
|
patchy-embedded-jpeg.af dpi=72;spreads=1;model_crc32=ab4b6fbf;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
|
||||||
patchy-fx-blur.af dpi=72;spreads=1;model_crc32=3f1b8b44;counts={"depth": 1, "images": 2, "nodes": 2};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…) (2×)"]
|
patchy-fx-blur.af dpi=72;spreads=1;model_crc32=29b5083b;counts={"depth": 1, "images": 2, "nodes": 2};boards=[];fonts={};warnings=["blur effects (left out) (2×)"]
|
||||||
patchy-fx-gradient.af dpi=72;spreads=1;model_crc32=e868c992;counts={"depth": 1, "images": 3, "nodes": 3};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…) (3×)"]
|
patchy-fx-gradient.af dpi=72;spreads=1;model_crc32=96cd10be;counts={"depth": 1, "images": 3, "nodes": 3};boards=[];fonts={};warnings=[]
|
||||||
patchy-fx.af dpi=72;spreads=1;model_crc32=5a1c34ac;counts={"depth": 1, "images": 5, "nodes": 5};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…) (5×)"]
|
patchy-fx.af dpi=72;spreads=1;model_crc32=7c8d4d20;counts={"depth": 1, "images": 5, "nodes": 5};boards=[];fonts={};warnings=[]
|
||||||
patchy-group.af dpi=72;spreads=1;model_crc32=c217ab38;counts={"depth": 2, "images": 3, "layers": 1, "nodes": 4};boards=[];fonts={};warnings=[]
|
patchy-group.af dpi=72;spreads=1;model_crc32=dc37ab6c;counts={"depth": 2, "images": 3, "layers": 1, "nodes": 4};boards=[];fonts={};warnings=[]
|
||||||
patchy-incremental-chain.af dpi=72;spreads=1;model_crc32=63ad778e;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…)"]
|
patchy-incremental-chain.af dpi=72;spreads=1;model_crc32=38c9c5f4;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
|
||||||
patchy-lab.af dpi=72;spreads=1;model_crc32=01e8a926;counts={"depth": 1, "nodes": 1, "unsupported": 1};boards=[];fonts={};warnings=["pixel layers in grey, Lab or 32-bit formats"]
|
patchy-lab.af dpi=72;spreads=1;model_crc32=df3a4e40;counts={"depth": 1, "nodes": 1, "unsupported": 1};boards=[];fonts={};warnings=["pixel layers in grey, Lab or 32-bit formats"]
|
||||||
patchy-lazy-placed.af dpi=72;spreads=1;model_crc32=9ce9937e;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
|
patchy-lazy-placed.af dpi=72;spreads=1;model_crc32=8776a8a0;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
|
||||||
patchy-live-filter.af dpi=72;spreads=1;model_crc32=30c5f8ee;counts={"depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 6, "shapes": 2, "strokes": 0, "subpaths": 2, "unsupported": 2};boards=[];fonts={};warnings=["adjustment layers", "unknown layers"]
|
patchy-live-filter.af dpi=72;spreads=1;model_crc32=12235fe9;counts={"depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 6, "shapes": 2, "strokes": 0, "subpaths": 2, "unsupported": 2};boards=[];fonts={};warnings=["adjustment layers", "unknown layers"]
|
||||||
patchy-rgba16.af dpi=72;spreads=1;model_crc32=1bb23571;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
|
patchy-rgba16.af dpi=72;spreads=1;model_crc32=688b2475;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
|
||||||
patchy-rgba8.af dpi=72;spreads=1;model_crc32=1bb23571;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
|
patchy-rgba8.af dpi=72;spreads=1;model_crc32=688b2475;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
|
||||||
patchy-shapes-2.af dpi=72;spreads=1;model_crc32=7828d3cd;counts={"depth": 1, "fills": 12, "gradients": 0, "nodes": 12, "segments": 62, "shapes": 12, "strokes": 0, "subpaths": 12};boards=[];fonts={};warnings=["cloud, heart, cog, callout, arrow, tear and other special shapes (imported as their bounding ellipse) (9×)"]
|
patchy-shapes-2.af dpi=72;spreads=1;model_crc32=886e9852;counts={"depth": 1, "fills": 12, "gradients": 0, "nodes": 12, "segments": 62, "shapes": 12, "strokes": 0, "subpaths": 12};boards=[];fonts={};warnings=["cloud, heart, cog, callout, arrow, tear and other special shapes (imported as their bounding ellipse) (9×)"]
|
||||||
patchy-shapes.af dpi=72;spreads=1;model_crc32=7519f5fe;counts={"depth": 1, "fills": 4, "gradients": 0, "nodes": 4, "segments": 28, "shapes": 4, "strokes": 0, "subpaths": 4};boards=[];fonts={};warnings=[]
|
patchy-shapes.af dpi=72;spreads=1;model_crc32=c1790e97;counts={"depth": 1, "fills": 4, "gradients": 0, "nodes": 4, "segments": 28, "shapes": 4, "strokes": 0, "subpaths": 4};boards=[];fonts={};warnings=[]
|
||||||
patchy-text-artistic.af dpi=72;spreads=1;model_crc32=b6137f6e;counts={"depth": 1, "nodes": 1, "text_chars": 5, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=[]
|
patchy-text-artistic.af dpi=72;spreads=1;model_crc32=1d33192c;counts={"depth": 1, "nodes": 1, "text_chars": 5, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=[]
|
||||||
patchy-text-caps.af dpi=72;spreads=1;model_crc32=b5527e72;counts={"depth": 1, "nodes": 2, "text_chars": 21, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)"]
|
patchy-text-caps.af dpi=72;spreads=1;model_crc32=24e52518;counts={"depth": 1, "nodes": 2, "text_chars": 21, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)"]
|
||||||
patchy-text-frame.af dpi=72;spreads=1;model_crc32=7a2cd32d;counts={"depth": 1, "nodes": 1, "text_chars": 11, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=["paragraph indents or spacing (imported with defaults)"]
|
patchy-text-frame.af dpi=72;spreads=1;model_crc32=3bff8ef9;counts={"depth": 1, "nodes": 1, "text_chars": 11, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=[]
|
||||||
patchy-text-indent.af dpi=72;spreads=1;model_crc32=30ee834c;counts={"depth": 1, "nodes": 2, "text_chars": 104, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)", "paragraph indents or spacing (imported with defaults) (2×)"]
|
patchy-text-indent.af dpi=72;spreads=1;model_crc32=dc36dfc5;counts={"depth": 1, "nodes": 2, "text_chars": 104, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)"]
|
||||||
patchy-text-para-spacing.af dpi=72;spreads=1;model_crc32=3d580ad2;counts={"depth": 1, "nodes": 1, "text_chars": 13, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)", "paragraph indents or spacing (imported with defaults)"]
|
patchy-text-para-spacing.af dpi=72;spreads=1;model_crc32=6408f39b;counts={"depth": 1, "nodes": 1, "text_chars": 13, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)"]
|
||||||
patchy-text-rotated.af dpi=72;spreads=1;model_crc32=ad77772a;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)"]
|
patchy-text-rotated.af dpi=72;spreads=1;model_crc32=3d1de559;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)"]
|
||||||
patchy-text-runs.af dpi=72;spreads=1;model_crc32=a8da3052;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 5, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false", "Courier New:CourierNewPSMT:400:false", "Segoe UI Emoji:SegoeUIEmoji:400:false", "Times New Roman:TimesNewRomanPSMT:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)", "paragraph indents or spacing (imported with defaults)", "text uses a stored fallback font (imported with that font)"]
|
patchy-text-runs.af dpi=72;spreads=1;model_crc32=7820b881;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 5, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false", "Courier New:CourierNewPSMT:400:false", "Segoe UI Emoji:SegoeUIEmoji:400:false", "Times New Roman:TimesNewRomanPSMT:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)", "text uses a stored fallback font (imported with that font)"]
|
||||||
patchy-transform.af dpi=72;spreads=1;model_crc32=f8807611;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
|
patchy-transform.af dpi=72;spreads=1;model_crc32=8d56df84;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
|
||||||
patchy-vector-mask.af dpi=72;spreads=1;model_crc32=1851fd90;counts={"depth": 1, "fills": 2, "gradients": 0, "nodes": 2, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2, "vector_masks": 1};boards=[];fonts={};warnings=["unknown layers attached to other layers (imported without them)"]
|
patchy-vector-mask.af dpi=72;spreads=1;model_crc32=80ca946a;counts={"depth": 1, "fills": 2, "gradients": 0, "nodes": 2, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2, "vector_masks": 1};boards=[];fonts={};warnings=["unknown layers attached to other layers (imported without them)"]
|
||||||
patchy-vector.af dpi=72;spreads=1;model_crc32=b89a91d3;counts={"depth": 1, "fills": 3, "gradients": 0, "nodes": 4, "segments": 17, "shapes": 4, "strokes": 2, "subpaths": 5};boards=[];fonts={};warnings=[]
|
patchy-vector.af dpi=72;spreads=1;model_crc32=b36a9767;counts={"depth": 1, "fills": 3, "gradients": 0, "nodes": 4, "segments": 17, "shapes": 4, "strokes": 2, "subpaths": 5};boards=[];fonts={};warnings=[]
|
||||||
@@ -35,7 +35,12 @@ pub fn signature(doc: &Document) -> String {
|
|||||||
}
|
}
|
||||||
"texts"
|
"texts"
|
||||||
}
|
}
|
||||||
|
Kind::Table(t) => {
|
||||||
|
*counts.entry("table_cells").or_default() += t.cells.len();
|
||||||
|
"tables"
|
||||||
|
}
|
||||||
Kind::Image(_) => "images",
|
Kind::Image(_) => "images",
|
||||||
|
Kind::MasterInstance { .. } => "master_instances",
|
||||||
Kind::Unsupported => "unsupported",
|
Kind::Unsupported => "unsupported",
|
||||||
};
|
};
|
||||||
*counts.entry(key).or_default() += 1;
|
*counts.entry(key).or_default() += 1;
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ pub const IDENTITY: [f64; 6] = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0];
|
|||||||
|
|
||||||
// Shared arrays require definitions/references, whereas inline fields use Obj. Give the original
|
// Shared arrays require definitions/references, whereas inline fields use Obj. Give the original
|
||||||
// fixture objects independent shared IDs without making the fixture descriptions noisy.
|
// fixture objects independent shared IDs without making the fixture descriptions noisy.
|
||||||
fn shared_objects(value: &mut F, next_id: &mut u32) {
|
pub fn shared_objects(value: &mut F, next_id: &mut u32) {
|
||||||
match value {
|
match value {
|
||||||
F::Shared(items) => {
|
F::Shared(items) => {
|
||||||
for item in items {
|
for item in items {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ mod support {
|
|||||||
use support::text_fixture::*;
|
use support::text_fixture::*;
|
||||||
use vectorcraft_affinity::{
|
use vectorcraft_affinity::{
|
||||||
Document, Limits,
|
Document, Limits,
|
||||||
model::{Align, Kind, Text},
|
model::{Align, Kind, Paragraph, Text},
|
||||||
paint::{Color, Paint},
|
paint::{Color, Paint},
|
||||||
synth::{F, tag},
|
synth::{F, tag},
|
||||||
};
|
};
|
||||||
@@ -108,7 +108,7 @@ fn mixed_paragraph_alignments_are_explicitly_reported() {
|
|||||||
let d = read(vec![block(utf8("one\u{2029}"), vec![run(4, None)], &[2]), block(utf8("two"), vec![run(3, None)], &[0, 1])]);
|
let d = read(vec![block(utf8("one\u{2029}"), vec![run(4, None)], &[2]), block(utf8("two"), vec![run(3, None)], &[0, 1])]);
|
||||||
assert_eq!(content(text(&d)), "one\u{2029}two");
|
assert_eq!(content(text(&d)), "one\u{2029}two");
|
||||||
assert_eq!(text(&d).align, Align::Right);
|
assert_eq!(text(&d).align, Align::Right);
|
||||||
assert!(warning(&d, "mixed paragraph alignments"));
|
assert!(warning(&d, "mixed paragraph formats"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -194,10 +194,22 @@ fn real_affinity3_text_fixtures_confirm_unicode_and_caps_fields() {
|
|||||||
assert_eq!(texts.len(), 2);
|
assert_eq!(texts.len(), 2);
|
||||||
assert!(texts[0].runs[0].all_caps);
|
assert!(texts[0].runs[0].all_caps);
|
||||||
assert_eq!(texts[1].runs[0].features, ["smcp"]);
|
assert_eq!(texts[1].runs[0].features, ["smcp"]);
|
||||||
for name in ["patchy-text-indent.af", "patchy-text-para-spacing.af"] {
|
// Indents and paragraph spacing are read (values in document pixels, as these fixtures set
|
||||||
|
// them in Affinity's paragraph panel).
|
||||||
|
let paragraphs = |name: &str| -> Vec<Paragraph> {
|
||||||
let d = load(name);
|
let d = load(name);
|
||||||
assert!(warning(&d, "paragraph indents or spacing"), "{name}: {:?}", d.warnings);
|
assert!(!warning(&d, "paragraph"), "{name}: {:?}", d.warnings);
|
||||||
}
|
d.spreads[0]
|
||||||
|
.nodes
|
||||||
|
.iter()
|
||||||
|
.filter_map(|n| if let Kind::Text(t) = &n.kind { Some(t) } else { None })
|
||||||
|
.flat_map(|t| t.runs.iter().map(|r| r.paragraph))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let p = paragraphs("patchy-text-indent.af");
|
||||||
|
assert_eq!(p.iter().map(|p| (p.left, p.first, p.space_after)).collect::<Vec<_>>(), [(40.0, 0.0, 12.0), (0.0, 25.0, 12.0)]);
|
||||||
|
let p = paragraphs("patchy-text-para-spacing.af");
|
||||||
|
assert_eq!(p.iter().map(|p| (p.space_before, p.space_after)).collect::<Vec<_>>(), [(9.0, 17.0)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
proptest::proptest! {
|
proptest::proptest! {
|
||||||
@@ -247,9 +259,19 @@ fn excessive_feature_settings_are_bounded_and_reported() {
|
|||||||
assert!(warning(&d, "more than 256 text feature settings"));
|
assert!(warning(&d, "more than 256 text feature settings"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn font_names_longer_than_256_bytes_are_dropped() {
|
||||||
|
// Each paragraph and run carries its font's name: a huge one would be copied with each.
|
||||||
|
let d = read(vec![block(utf8("x"), vec![run(1, Some(attrs(&"F".repeat(257), "Inter-Regular", 400, false, 12.0)))], &[])]);
|
||||||
|
let r = &text(&d).runs[0];
|
||||||
|
assert!(r.family.is_empty() && r.postscript == "Inter-Regular");
|
||||||
|
assert!(warning(&d, "font names longer than 256 bytes"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shared_font_metadata_amplification_is_bounded_without_losing_characters() {
|
fn shared_font_metadata_amplification_is_bounded_without_losing_characters() {
|
||||||
let mut a = attrs(&"F".repeat(16 * 1024), &"P".repeat(16 * 1024), 400, false, 12.0);
|
// The longest font names kept (see `font_names_longer_than_256_bytes_are_dropped`).
|
||||||
|
let mut a = attrs(&"F".repeat(256), &"P".repeat(256), 400, false, 12.0);
|
||||||
if let F::Obj(_, fields) = &mut a {
|
if let F::Obj(_, fields) = &mut a {
|
||||||
let settings = F::Obj(
|
let settings = F::Obj(
|
||||||
tag(b"OtAt"),
|
tag(b"OtAt"),
|
||||||
@@ -266,11 +288,12 @@ fn shared_font_metadata_amplification_is_bounded_without_losing_characters() {
|
|||||||
}
|
}
|
||||||
let F::Obj(class, fields) = a else { panic!("attributes must be an object") };
|
let F::Obj(class, fields) = a else { panic!("attributes must be an object") };
|
||||||
let mut runs = vec![run(1, Some(F::Def(42, vec![class], fields)))];
|
let mut runs = vec![run(1, Some(F::Def(42, vec![class], fields)))];
|
||||||
runs.extend((2..=1000).map(|end| run(end, Some(F::Ref(42)))));
|
// About 7.7 kB of names and features per run: the budget runs out after about 2200 runs.
|
||||||
let d = read(vec![block(utf8(&"x".repeat(1000)), runs, &[])]);
|
runs.extend((2..=4000).map(|end| run(end, Some(F::Ref(42)))));
|
||||||
|
let d = read(vec![block(utf8(&"x".repeat(4000)), runs, &[])]);
|
||||||
let t = text(&d);
|
let t = text(&d);
|
||||||
assert_eq!(content(t), "x".repeat(1000));
|
assert_eq!(content(t), "x".repeat(4000));
|
||||||
assert_eq!(t.runs.len(), 1000);
|
assert_eq!(t.runs.len(), 4000);
|
||||||
let metadata: usize = t
|
let metadata: usize = t
|
||||||
.runs
|
.runs
|
||||||
.iter()
|
.iter()
|
||||||
@@ -279,7 +302,7 @@ fn shared_font_metadata_amplification_is_bounded_without_losing_characters() {
|
|||||||
.map(|s| s.len() + std::mem::size_of::<String>())
|
.map(|s| s.len() + std::mem::size_of::<String>())
|
||||||
.sum();
|
.sum();
|
||||||
assert!(metadata <= 16 << 20, "shared metadata was amplified to {metadata} bytes");
|
assert!(metadata <= 16 << 20, "shared metadata was amplified to {metadata} bytes");
|
||||||
assert_eq!(t.runs[0].family.len(), 16 * 1024);
|
assert_eq!(t.runs[0].family.len(), 256);
|
||||||
assert_eq!(t.runs[0].features.len(), 256);
|
assert_eq!(t.runs[0].features.len(), 256);
|
||||||
assert!(t.runs.last().unwrap().family.is_empty());
|
assert!(t.runs.last().unwrap().family.is_empty());
|
||||||
assert!(t.runs.last().unwrap().features.is_empty());
|
assert!(t.runs.last().unwrap().features.is_empty());
|
||||||
@@ -320,3 +343,39 @@ fn shared_gradient_attributes_are_bounded_without_losing_characters() {
|
|||||||
assert!(warning(&d, "text attribute budget exceeded"));
|
assert!(warning(&d, "text attribute budget exceeded"));
|
||||||
assert!(!warning(&d, "text without character attributes"), "budget recovery must not claim source attributes are absent");
|
assert!(!warning(&d, "text without character attributes"), "budget recovery must not claim source attributes are absent");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shared_gradient_outlines_are_bounded_without_losing_characters() {
|
||||||
|
let color =
|
||||||
|
F::Def(43, vec![tag(b"RGBA")], vec![(tag(b"_col"), F::Struct([0.2f32, 0.4, 0.6, 1.0].iter().flat_map(|v| v.to_le_bytes()).collect()))]);
|
||||||
|
let mut colors = vec![color];
|
||||||
|
colors.extend((1..1024).map(|_| F::Ref(43)));
|
||||||
|
let positions = (0..1024).map(|i| [(i as f32) / 1023.0, 0.5].iter().flat_map(|v| v.to_le_bytes()).collect()).collect();
|
||||||
|
let pen = F::Obj(
|
||||||
|
tag(b"FilG"),
|
||||||
|
vec![(tag(b"Grad"), F::Obj(tag(b"Grad"), vec![(tag(b"Cols"), F::Shared(colors)), (tag(b"Posn"), F::Records(8, positions))]))],
|
||||||
|
);
|
||||||
|
let style = F::Obj(tag(b"LSty"), vec![(tag(b"Wght"), F::F64(2.0))]);
|
||||||
|
// `Objs` 1 and 2: the outline's paint and line style, shared by every run.
|
||||||
|
let attrs = F::Def(42, vec![tag(b"GAtt")], vec![(tag(b"Objs"), F::Shared(vec![solid(1.0), pen, style]))]);
|
||||||
|
let mut runs = vec![run(1, Some(attrs))];
|
||||||
|
runs.extend((2..=1000).map(|end| run(end, Some(F::Ref(42)))));
|
||||||
|
let d = read(vec![block(utf8(&"x".repeat(1000)), runs, &[])]);
|
||||||
|
let t = text(&d);
|
||||||
|
assert_eq!(content(t), "x".repeat(1000));
|
||||||
|
assert_eq!(t.runs.len(), 1000);
|
||||||
|
let stop_count: usize = t
|
||||||
|
.runs
|
||||||
|
.iter()
|
||||||
|
.filter_map(|r| r.stroke.as_ref())
|
||||||
|
.map(|s| match &s.paint {
|
||||||
|
Paint::Gradient(g) => g.stops.len(),
|
||||||
|
_ => 0,
|
||||||
|
})
|
||||||
|
.sum();
|
||||||
|
assert!(stop_count * std::mem::size_of::<vectorcraft_affinity::paint::Stop>() <= 16 << 20);
|
||||||
|
let Some(Paint::Gradient(first)) = t.runs[0].stroke.as_ref().map(|s| &s.paint) else { panic!("the first outline must fit") };
|
||||||
|
assert_eq!(first.stops.len(), 1024);
|
||||||
|
assert!(t.runs.last().unwrap().stroke.is_none());
|
||||||
|
assert!(warning(&d, "text attribute budget exceeded"));
|
||||||
|
}
|
||||||
@@ -33,6 +33,9 @@ const WIDE_LINES: &str =
|
|||||||
const STROKE_EXTRAS: &str = "stroke alignment, width profiles, arrowheads and brushes are left out (Maximize Editability writes strokes as lines)";
|
const STROKE_EXTRAS: &str = "stroke alignment, width profiles, arrowheads and brushes are left out (Maximize Editability writes strokes as lines)";
|
||||||
const CLIPPING: &str = "clipping masks are not applied: the art they clip is written whole";
|
const CLIPPING: &str = "clipping masks are not applied: the art they clip is written whole";
|
||||||
const TYPE_ITEMS: &str = "fills and strokes added to whole type objects are left out of text entities (outline the text to keep them)";
|
const TYPE_ITEMS: &str = "fills and strokes added to whole type objects are left out of text entities (outline the text to keep them)";
|
||||||
|
/// The warning for an image left out because it can't be decoded: the note raster exports give
|
||||||
|
/// (`vectorcraft-engine`'s `rasterfx::undecoded_warning`).
|
||||||
|
const UNDECODED: &str = "an image couldn't be decoded and is missing from the picture";
|
||||||
|
|
||||||
/// One segment of a subpath, cubics for every curve.
|
/// One segment of a subpath, cubics for every curve.
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
@@ -973,8 +976,8 @@ impl<'a> Scene<'a> {
|
|||||||
|
|
||||||
/// Image `key` as a file in the chosen format, and its size in pixels.
|
/// Image `key` as a file in the chosen format, and its size in pixels.
|
||||||
fn encode_image(&self, key: &str) -> Result<(DxfImage, (u32, u32)), String> {
|
fn encode_image(&self, key: &str) -> Result<(DxfImage, (u32, u32)), String> {
|
||||||
let blob = self.doc.images.get(key).filter(|b| !b.bytes.is_empty()).ok_or_else(|| format!("image '{key}' has no pixels and was left out"))?;
|
let blob = self.doc.images.get(key).filter(|b| !b.bytes.is_empty()).ok_or_else(|| "an image has no pixels and was left out".to_string())?;
|
||||||
let img = image::load_from_memory(&blob.bytes).map_err(|_| format!("image '{key}' could not be decoded and was left out"))?;
|
let img = image::load_from_memory(&blob.bytes).map_err(|_| UNDECODED.to_string())?;
|
||||||
let size = (img.width(), img.height());
|
let size = (img.width(), img.height());
|
||||||
let mut bytes = vec![];
|
let mut bytes = vec![];
|
||||||
let written = match self.o.raster {
|
let written = match self.o.raster {
|
||||||
@@ -992,7 +995,7 @@ impl<'a> Scene<'a> {
|
|||||||
rgb.write_with_encoder(image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, 90))
|
rgb.write_with_encoder(image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, 90))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
written.map_err(|e| format!("image '{key}' could not be written: {e}"))?;
|
written.map_err(|e| format!("an image could not be written: {e}"))?;
|
||||||
Ok((DxfImage { name: format!("{}.{}", blob.content_key(), self.o.raster.extension()), bytes }, size))
|
Ok((DxfImage { name: format!("{}.{}", blob.content_key(), self.o.raster.extension()), bytes }, size))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -526,6 +526,24 @@ fn images_link_png_or_jpeg_files() {
|
|||||||
assert!(r12.warnings.iter().any(|w| w.contains("no images")));
|
assert!(r12.warnings.iter().any(|w| w.contains("no images")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn images_without_pixels_are_left_out_with_one_note() {
|
||||||
|
// One image has no data in the document, and the other image's data is empty.
|
||||||
|
let mut d = doc_with(|d| {
|
||||||
|
["raster-1", "raster-2"]
|
||||||
|
.into_iter()
|
||||||
|
.map(|key| {
|
||||||
|
let im = ImageObject { key: key.into(), width: 4, height: 2, xf: Affine::IDENTITY, link: None, placement: Default::default() };
|
||||||
|
Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
d.images.insert("raster-2".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(vec![]), proxy: None });
|
||||||
|
let out = export(&d, &opts(&d)).unwrap();
|
||||||
|
assert!(entities(&out).is_empty() && out.images.is_empty());
|
||||||
|
assert_eq!(out.warnings, ["an image has no pixels and was left out"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn crop_leaves_out_art_outside_the_region() {
|
fn crop_leaves_out_art_outside_the_region() {
|
||||||
let d = shapes_doc();
|
let d = shapes_doc();
|
||||||
|
|||||||
+394
-11
@@ -31,6 +31,75 @@ impl GradientKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How a gradient mixes the colours between its stops (the Gradient panel's Method).
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum GradientMethod {
|
||||||
|
/// Mixed in display RGB, as gradients always were.
|
||||||
|
#[default]
|
||||||
|
Classic,
|
||||||
|
/// Mixed in a perceptual colour space (OKLab), so the steps look even to the eye: blue to
|
||||||
|
/// yellow passes through a light blue-grey rather than a dull mid grey.
|
||||||
|
Perceptual,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GradientMethod {
|
||||||
|
/// Parse a method name (`classic`, `perceptual`; any case).
|
||||||
|
pub fn parse(s: &str) -> Option<Self> {
|
||||||
|
[Self::Classic, Self::Perceptual].into_iter().find(|m| m.label().eq_ignore_ascii_case(s))
|
||||||
|
}
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Classic => "Classic",
|
||||||
|
Self::Perceptual => "Perceptual",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn is_classic(&self) -> bool {
|
||||||
|
*self == Self::Classic
|
||||||
|
}
|
||||||
|
/// `a` and `b` mixed at `t` (0..1): in display RGB (Classic), or in OKLab (Perceptual: an
|
||||||
|
/// RGB colour between the two).
|
||||||
|
pub fn mix(self, a: &Color, b: &Color, t: f32) -> Color {
|
||||||
|
match self {
|
||||||
|
Self::Classic => Color::lerp(a, b, t),
|
||||||
|
Self::Perceptual => {
|
||||||
|
use crate::libraries::{oklab_to_srgb, srgb_to_oklab};
|
||||||
|
let (p, q, t) = (srgb_to_oklab(a.to_rgb()), srgb_to_oklab(b.to_rgb()), f64::from(t));
|
||||||
|
let [r, g, bl] = oklab_to_srgb(p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t, p[2] + (q[2] - p[2]) * t);
|
||||||
|
let c = |v: f64| if v.is_finite() { v.clamp(0.0, 1.0) as f32 } else { 0.0 };
|
||||||
|
Color::rgb(c(r), c(g), c(bl))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How far (display RGB, 0..1) the straight line between a Perceptual gradient's neighbouring
|
||||||
|
/// expanded stops may stray from the perceptual mix: under half a colour level.
|
||||||
|
const PERCEPTUAL_TOLERANCE: f32 = 0.4 / 255.0;
|
||||||
|
/// Most times each half of a Perceptual gradient's segment (either side of its midpoint) is
|
||||||
|
/// halved to keep within [`PERCEPTUAL_TOLERANCE`].
|
||||||
|
const PERCEPTUAL_DEPTH: u32 = 10;
|
||||||
|
|
||||||
|
/// The mix positions (0..1, after `v0`, before `v1`) between which display RGB mixed linearly
|
||||||
|
/// stays within [`PERCEPTUAL_TOLERANCE`] of the perceptual mix of `a` and `b`: the span halved
|
||||||
|
/// where a straight line strays, at most [`PERCEPTUAL_DEPTH`] times, in order.
|
||||||
|
fn perceptual_steps(a: &Color, b: &Color, (v0, v1): (f32, f32), depth: u32, out: &mut Vec<f32>) {
|
||||||
|
let at = |v: f32| GradientMethod::Perceptual.mix(a, b, v).to_rgb();
|
||||||
|
let (c0, c1) = (at(v0), at(v1));
|
||||||
|
let stray = [0.25, 0.5, 0.75]
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|u: f32| {
|
||||||
|
let c = at(v0 + (v1 - v0) * u);
|
||||||
|
(0..3).map(move |k| (c0[k] + (c1[k] - c0[k]) * u - c[k]).abs())
|
||||||
|
})
|
||||||
|
.fold(0.0, f32::max);
|
||||||
|
if depth < PERCEPTUAL_DEPTH && stray > PERCEPTUAL_TOLERANCE {
|
||||||
|
let m = (v0 + v1) / 2.0;
|
||||||
|
perceptual_steps(a, b, (v0, m), depth + 1, out);
|
||||||
|
out.push(m);
|
||||||
|
perceptual_steps(a, b, (m, v1), depth + 1, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct GradientStop {
|
pub struct GradientStop {
|
||||||
/// 0..=1 along the gradient.
|
/// 0..=1 along the gradient.
|
||||||
@@ -75,19 +144,39 @@ fn half() -> f32 {
|
|||||||
pub struct Gradient {
|
pub struct Gradient {
|
||||||
pub kind: GradientKind,
|
pub kind: GradientKind,
|
||||||
pub stops: Vec<GradientStop>,
|
pub stops: Vec<GradientStop>,
|
||||||
|
/// Dither: a fill of this gradient is drawn as pixels with fine noise of up to a colour level
|
||||||
|
/// added, which hides the steps (banding) between the levels 8-bit colour has.
|
||||||
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||||
|
pub dither: bool,
|
||||||
|
/// How the colours between the stops are mixed (Classic unless set; written only when not).
|
||||||
|
#[serde(default, skip_serializing_if = "GradientMethod::is_classic")]
|
||||||
|
pub method: GradientMethod,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Gradient {
|
impl Default for Gradient {
|
||||||
/// Illustrator's default "White, Black" gradient.
|
/// Illustrator's default "White, Black" gradient.
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self { kind: GradientKind::Linear, stops: vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)] }
|
Self::new(GradientKind::Linear, vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Gradient {
|
impl Gradient {
|
||||||
/// Colour and opacity at `t` (honours midpoints).
|
/// A gradient of `kind` through `stops`, with the default options.
|
||||||
|
pub fn new(kind: GradientKind, stops: Vec<GradientStop>) -> Self {
|
||||||
|
Self { kind, stops, dither: false, method: GradientMethod::Classic }
|
||||||
|
}
|
||||||
|
/// Colour and opacity at `t` (honours midpoints and the method).
|
||||||
pub fn sample(&self, t: f32) -> (Color, f32) {
|
pub fn sample(&self, t: f32) -> (Color, f32) {
|
||||||
self.sample_with(t, Color::lerp)
|
self.sample_with(t, |a, b, v| self.method.mix(a, b, v))
|
||||||
|
}
|
||||||
|
/// [`Self::sample`], a Classic gradient's neighbouring stop colours of one model mixed in it
|
||||||
|
/// (CMYK stays CMYK; [`Color::lerp_keep_model`]) rather than in display RGB, a Perceptual
|
||||||
|
/// gradient's as [`Self::sample`] mixes them.
|
||||||
|
pub fn sample_keep_model(&self, t: f32) -> (Color, f32) {
|
||||||
|
match self.method {
|
||||||
|
GradientMethod::Classic => self.sample_with(t, Color::lerp_keep_model),
|
||||||
|
m => self.sample_with(t, |a, b, v| m.mix(a, b, v)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
/// [`Gradient::sample`] with neighbouring stop colours mixed by `mix` (e.g. in their own colour
|
/// [`Gradient::sample`] with neighbouring stop colours mixed by `mix` (e.g. in their own colour
|
||||||
/// model rather than display RGB).
|
/// model rather than display RGB).
|
||||||
@@ -115,20 +204,47 @@ impl Gradient {
|
|||||||
None => (Color::BLACK, 1.0),
|
None => (Color::BLACK, 1.0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// Stops expanded so that midpoints are represented as explicit stops (for renderers without midpoints).
|
/// Stops expanded so that midpoints are represented as explicit stops (for renderers without
|
||||||
|
/// midpoints), and a Perceptual gradient's segments as stops on its curve (for renderers that
|
||||||
|
/// mix linearly; within [`PERCEPTUAL_TOLERANCE`] of it).
|
||||||
pub fn expanded_stops(&self) -> Vec<(f32, Color, f32)> {
|
pub fn expanded_stops(&self) -> Vec<(f32, Color, f32)> {
|
||||||
self.expanded().map(|(t, c, o, _)| (t, c, o)).collect()
|
self.expanded().map(|(t, c, o, _)| (t, c, o)).collect()
|
||||||
}
|
}
|
||||||
/// [`Self::expanded_stops`], each stop that stands for a midpoint with that midpoint (`None`
|
/// [`Self::expanded_stops`], each stop that stands for a midpoint with that midpoint (`None`
|
||||||
/// on the gradient's own stops).
|
/// on the gradient's own stops and a Perceptual gradient's other in-between stops).
|
||||||
pub fn expanded(&self) -> impl Iterator<Item = (f32, Color, f32, Option<f32>)> + '_ {
|
pub fn expanded(&self) -> impl Iterator<Item = (f32, Color, f32, Option<f32>)> + '_ {
|
||||||
self.stops.iter().enumerate().flat_map(move |(i, s)| {
|
self.stops.iter().enumerate().flat_map(move |(i, s)| {
|
||||||
let mid = self.stops.get(i + 1).filter(|_| (s.midpoint - 0.5).abs() > 1e-3).map(|n| {
|
let next = self.stops.get(i + 1);
|
||||||
|
let off_centre = (s.midpoint - 0.5).abs() > 1e-3;
|
||||||
|
// Classic: a stop at the midpoint, when it is off-centre.
|
||||||
|
let classic = next.filter(|_| off_centre && self.method.is_classic()).map(|n| {
|
||||||
let t = s.offset + (n.offset - s.offset) * s.midpoint;
|
let t = s.offset + (n.offset - s.offset) * s.midpoint;
|
||||||
let (c, o) = self.sample(t);
|
let (c, o) = self.sample(t);
|
||||||
(t, c, o, Some(s.midpoint))
|
(t, c, o, Some(s.midpoint))
|
||||||
});
|
});
|
||||||
std::iter::once((s.offset, s.color, s.opacity, None)).chain(mid)
|
// Perceptual: stops along the mix (`v`) where a straight line would stray from it, the
|
||||||
|
// midpoint (v = ½) among them, placed through the midpoint as [`Self::sample`] maps them.
|
||||||
|
let perceptual = next.filter(|_| !self.method.is_classic()).map(|n| {
|
||||||
|
let mut vs = Vec::new();
|
||||||
|
perceptual_steps(&s.color, &n.color, (0.0, 0.5), 1, &mut vs);
|
||||||
|
vs.push(0.5);
|
||||||
|
perceptual_steps(&s.color, &n.color, (0.5, 1.0), 1, &mut vs);
|
||||||
|
let m = s.midpoint.clamp(0.01, 0.99);
|
||||||
|
vs.into_iter().map(move |v| {
|
||||||
|
let mid = v == 0.5;
|
||||||
|
let u = if mid {
|
||||||
|
s.midpoint
|
||||||
|
} else if v < 0.5 {
|
||||||
|
2.0 * v * m
|
||||||
|
} else {
|
||||||
|
m + (v - 0.5) * 2.0 * (1.0 - m)
|
||||||
|
};
|
||||||
|
let t = s.offset + (n.offset - s.offset) * u;
|
||||||
|
let o = s.opacity + (n.opacity - s.opacity) * v;
|
||||||
|
(t, self.method.mix(&s.color, &n.color, v), o, (mid && off_centre).then_some(s.midpoint))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
std::iter::once((s.offset, s.color, s.opacity, None)).chain(classic).chain(perceptual.into_iter().flatten())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/// The same ramp the other way round: the stops in reverse order at mirrored offsets, each
|
/// The same ramp the other way round: the stops in reverse order at mirrored offsets, each
|
||||||
@@ -172,7 +288,7 @@ impl Gradient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
stops.push(stop(1.0, to));
|
stops.push(stop(1.0, to));
|
||||||
Gradient { kind: GradientKind::Linear, stops }
|
Gradient::new(GradientKind::Linear, stops)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -534,6 +650,117 @@ impl GradientPaint {
|
|||||||
f.transform(a);
|
f.transform(a);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/// Is this gradient drawn as pixels rather than as a vector gradient when it paints a fill
|
||||||
|
/// (`fill`) or a stroke (SVG and PDF draw such gradients as images): a freeform gradient, a
|
||||||
|
/// dithered fill (dither applies to fills only), or a Perceptual one (its colours don't mix
|
||||||
|
/// linearly between the stops, as SVG's and PDF's gradients do)?
|
||||||
|
pub fn draws_as_pixels(&self, fill: bool) -> bool {
|
||||||
|
self.gradient.kind == GradientKind::Freeform || (fill && self.gradient.dither) || !self.gradient.method.is_classic()
|
||||||
|
}
|
||||||
|
/// A linear or radial gradient placed on box `bounds` as a `cols` × `rows` grid of
|
||||||
|
/// unpremultiplied RGBA8 pixels over `area`, row by row, each sampled at its centre (colours
|
||||||
|
/// as `rgb` gives them, mixed as the renderer mixes them: [`Ramp`]). Dithered and painting a
|
||||||
|
/// fill (`fill`; dither applies to fills only), each colour channel of pixel (column, row) gets
|
||||||
|
/// [`dither_noise`] before it is rounded to a level.
|
||||||
|
pub fn pixels(
|
||||||
|
&self,
|
||||||
|
bounds: Rect,
|
||||||
|
area: Rect,
|
||||||
|
cols: u16,
|
||||||
|
rows: u16,
|
||||||
|
fill: bool,
|
||||||
|
rgb: &dyn Fn(&Color) -> [f32; 3],
|
||||||
|
) -> impl Iterator<Item = [u8; 4]> + use<> {
|
||||||
|
self.pixels_within(bounds, area, cols, rows, [0..cols, 0..rows], fill, rgb)
|
||||||
|
}
|
||||||
|
/// [`Self::pixels`] for the cells in columns `cells[0]` and rows `cells[1]` of the grid only,
|
||||||
|
/// row by row: the same pixels, noise and all, the whole grid has there.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn pixels_within(
|
||||||
|
&self,
|
||||||
|
bounds: Rect,
|
||||||
|
area: Rect,
|
||||||
|
cols: u16,
|
||||||
|
rows: u16,
|
||||||
|
cells: [std::ops::Range<u16>; 2],
|
||||||
|
fill: bool,
|
||||||
|
rgb: &dyn Fn(&Color) -> [f32; 3],
|
||||||
|
) -> impl Iterator<Item = [u8; 4]> + use<> {
|
||||||
|
let (kind, geom, dither) = (self.gradient.kind, self.resolve(bounds), fill && self.gradient.dither);
|
||||||
|
let ramp = Ramp::new(&self.gradient, rgb);
|
||||||
|
let (cw, ch) = (area.width() / f64::from(cols.max(1)), area.height() / f64::from(rows.max(1)));
|
||||||
|
let (x0, y0) = (area.x0, area.y0);
|
||||||
|
let [across, down] = cells.map(|r| u32::from(r.start)..u32::from(r.end));
|
||||||
|
down.flat_map(move |j| across.clone().map(move |i| (i, j))).map(move |(i, j)| {
|
||||||
|
let p = Point::new(x0 + (f64::from(i) + 0.5) * cw, y0 + (f64::from(j) + 0.5) * ch);
|
||||||
|
let c = ramp.at(geom.param_at(kind, p) as f32);
|
||||||
|
let level = |k: usize| {
|
||||||
|
let noise = if dither { dither_noise(i, j, k as u32) } else { 0.0 };
|
||||||
|
(c[k].clamp(0.0, 1.0) * 255.0 + noise).round().clamp(0.0, 255.0) as u8
|
||||||
|
};
|
||||||
|
[level(0), level(1), level(2), (c[3].clamp(0.0, 1.0) * 255.0).round() as u8]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A gradient's colours ready to be sampled pixel by pixel, in a colour space of the caller's
|
||||||
|
/// (display RGB, or an ink's coverage): its stops, midpoints and a Perceptual gradient's
|
||||||
|
/// in-between colours as stops (as [`Gradient::expanded_stops`]), each turned into that space
|
||||||
|
/// once, with colour and opacity mixed linearly between them, each on its own (as the renderer's
|
||||||
|
/// vector gradients mix them).
|
||||||
|
pub struct Ramp {
|
||||||
|
stops: Vec<(f32, [f32; 4])>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ramp {
|
||||||
|
pub fn new(g: &Gradient, rgb: &dyn Fn(&Color) -> [f32; 3]) -> Self {
|
||||||
|
let stops = g
|
||||||
|
.expanded()
|
||||||
|
.map(|(t, c, o, _)| {
|
||||||
|
let [r, gr, b] = rgb(&c);
|
||||||
|
(t, [r, gr, b, o])
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Self { stops }
|
||||||
|
}
|
||||||
|
/// Unpremultiplied colour and opacity at `t`, padded past the ends (transparent black
|
||||||
|
/// without stops).
|
||||||
|
pub fn at(&self, t: f32) -> [f32; 4] {
|
||||||
|
let Some(&(t0, first)) = self.stops.first() else { return [0.0; 4] };
|
||||||
|
// Not after the first stop (or not a number): its colour.
|
||||||
|
if t.partial_cmp(&t0) != Some(std::cmp::Ordering::Greater) {
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
for w in self.stops.windows(2) {
|
||||||
|
let ((a, ca), (b, cb)) = (w[0], w[1]);
|
||||||
|
if t <= b {
|
||||||
|
let u = if b - a > 1e-9 { (t - a) / (b - a) } else { 1.0 };
|
||||||
|
return std::array::from_fn(|k| ca[k] + (cb[k] - ca[k]) * u);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.stops.last().map_or([0.0; 4], |s| s.1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seed of [`dither_noise`]: fixed, so a dithered gradient draws and exports the same pixels on
|
||||||
|
/// every run.
|
||||||
|
const DITHER_SEED: u32 = 0x5eed_d17e;
|
||||||
|
|
||||||
|
/// An integer hash that mixes every input bit into every output bit (the "lowbias32" mix).
|
||||||
|
fn mix32(mut v: u32) -> u32 {
|
||||||
|
v ^= v >> 16;
|
||||||
|
v = v.wrapping_mul(0x7feb_352d);
|
||||||
|
v ^= v >> 15;
|
||||||
|
v = v.wrapping_mul(0x846c_a68b);
|
||||||
|
v ^ (v >> 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dither noise for colour channel `channel` of pixel (`x`, `y`), in colour levels: -1..1,
|
||||||
|
/// densest around 0 (the sum of two uniform values: noise that hides steps between levels without
|
||||||
|
/// a pattern of its own), the same for the same pixel on every run.
|
||||||
|
pub fn dither_noise(x: u32, y: u32, channel: u32) -> f32 {
|
||||||
|
let h = mix32(x ^ mix32(y ^ mix32(channel ^ DITHER_SEED)));
|
||||||
|
((h & 0xffff) as f32 + (h >> 16) as f32) / 65535.0 - 1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -582,9 +809,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn reverse_mirrors_midpoints() {
|
fn reverse_mirrors_midpoints() {
|
||||||
let stop = |offset, hex, midpoint| GradientStop { midpoint, ..GradientStop::new(offset, Color::from_hex(hex).unwrap()) };
|
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 two = Gradient::new(GradientKind::Linear, vec![stop(0.0, "#ff0000", 0.2), stop(1.0, "#0000ff", 0.5)]);
|
||||||
let three =
|
let three = Gradient::new(GradientKind::Linear, vec![stop(0.0, "#ff0000", 0.2), stop(0.3, "#00ff00", 0.8), stop(1.0, "#0000ff", 0.5)]);
|
||||||
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] {
|
for g in [two, three] {
|
||||||
let mut r = g.clone();
|
let mut r = g.clone();
|
||||||
r.reverse();
|
r.reverse();
|
||||||
@@ -825,4 +1051,161 @@ mod tests {
|
|||||||
h.keep_focal_from(&before);
|
h.keep_focal_from(&before);
|
||||||
assert!(close(h.focal.unwrap(), Point::new(0.0, -20.0)), "{h:?}");
|
assert!(close(h.focal.unwrap(), Point::new(0.0, -20.0)), "{h:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pixels_sample_the_ramp_at_each_cell_centre() {
|
||||||
|
// White → black left to right over a 10 × 2 box, a midpoint at 30% and a half-opaque end.
|
||||||
|
let mut g = GradientPaint::new(Gradient::default());
|
||||||
|
g.gradient.stops[0].midpoint = 0.3;
|
||||||
|
g.gradient.stops[1].opacity = 0.5;
|
||||||
|
let b = Rect::new(0.0, 0.0, 10.0, 2.0);
|
||||||
|
let px: Vec<[u8; 4]> = g.pixels(b, b, 10, 2, true, &Color::to_rgb).collect();
|
||||||
|
assert_eq!(px.len(), 20);
|
||||||
|
let ramp = Ramp::new(&g.gradient, &Color::to_rgb);
|
||||||
|
for (i, p) in px.iter().take(10).enumerate() {
|
||||||
|
let c = ramp.at((i as f32 + 0.5) / 10.0).map(|v| (v * 255.0).round() as u8);
|
||||||
|
assert_eq!(*p, c, "pixel {i}");
|
||||||
|
}
|
||||||
|
assert_eq!(px[3], px[13], "rows alike");
|
||||||
|
// The midpoint stop: grey at 30%; padded past the ends.
|
||||||
|
assert!((ramp.at(0.3)[0] - 0.5).abs() < 0.02, "{:?}", ramp.at(0.3));
|
||||||
|
assert_eq!(ramp.at(-1.0), [1.0, 1.0, 1.0, 1.0]);
|
||||||
|
assert_eq!(ramp.at(2.0), [0.0, 0.0, 0.0, 0.5]);
|
||||||
|
assert_eq!(ramp.at(f32::NAN), [1.0, 1.0, 1.0, 1.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blue_yellow(method: GradientMethod) -> Gradient {
|
||||||
|
let mut g = Gradient::new(
|
||||||
|
GradientKind::Linear,
|
||||||
|
vec![GradientStop::new(0.0, Color::rgb(0.0, 0.0, 1.0)), GradientStop::new(1.0, Color::rgb(1.0, 1.0, 0.0))],
|
||||||
|
);
|
||||||
|
g.method = method;
|
||||||
|
g
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Illustrator 30's Perceptual method mixes colours in a perceptually even way
|
||||||
|
/// (https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-perceptual-interpolation-in-gradients.html).
|
||||||
|
/// Here that is OKLab: blue → yellow passes through a light blue-grey, not the flat mid grey
|
||||||
|
/// a display RGB mix gives, and its lightness climbs evenly.
|
||||||
|
#[test]
|
||||||
|
fn perceptual_blue_to_yellow_is_not_the_grey_rgb_mix() {
|
||||||
|
let classic = blue_yellow(GradientMethod::Classic).sample(0.5).0.to_rgb();
|
||||||
|
assert!(classic.iter().all(|v| (v - 0.5).abs() < 1e-5), "classic: the RGB mix {classic:?}");
|
||||||
|
let p = blue_yellow(GradientMethod::Perceptual);
|
||||||
|
let [r, g, b] = p.sample(0.5).0.to_rgb();
|
||||||
|
assert!(b > r + 0.1 && b > g + 0.05, "a blue-grey, not grey: {:?}", [r, g, b]);
|
||||||
|
assert!(r + g + b > 1.5 + 0.2, "lighter than the RGB mix: {:?}", [r, g, b]);
|
||||||
|
let lightness = |t: f32| crate::libraries::to_oklab(&p.sample(t).0)[0];
|
||||||
|
let steps: Vec<f64> = (0..4).map(|k| lightness((k + 1) as f32 / 4.0) - lightness(k as f32 / 4.0)).collect();
|
||||||
|
assert!(steps.iter().all(|d| (d - steps[0]).abs() < 1e-3), "even lightness steps: {steps:?}");
|
||||||
|
// Ends and opacity as before.
|
||||||
|
assert_eq!(p.sample(0.0).0.to_hex(), "#0000ff");
|
||||||
|
assert_eq!(p.sample(1.0).0.to_hex(), "#ffff00");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renderers mix linearly between stops: a Perceptual segment becomes stops on the perceptual
|
||||||
|
/// curve (the midpoint diamond among them), more where it bends, close enough that the linear
|
||||||
|
/// ramp between them stays within half a colour level of the curve; a segment that hardly
|
||||||
|
/// bends needs few.
|
||||||
|
#[test]
|
||||||
|
fn perceptual_segments_expand_into_stops_on_the_curve() {
|
||||||
|
let pairs = [
|
||||||
|
(Color::rgb(0.0, 0.0, 1.0), Color::rgb(1.0, 1.0, 0.0)),
|
||||||
|
(Color::BLACK, Color::rgb(1.0, 0.0, 0.0)),
|
||||||
|
(Color::rgb(0.0, 1.0, 0.0), Color::rgb(1.0, 0.0, 1.0)),
|
||||||
|
(Color::cmyk(0.0, 0.0, 0.0, 1.0), Color::WHITE),
|
||||||
|
];
|
||||||
|
for (a, b) in pairs {
|
||||||
|
let mut p = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, a), GradientStop::new(1.0, b)]);
|
||||||
|
p.method = GradientMethod::Perceptual;
|
||||||
|
p.stops[0].midpoint = 0.3;
|
||||||
|
p.stops[1].opacity = 0.5;
|
||||||
|
let ex: Vec<_> = p.expanded().collect();
|
||||||
|
assert!((3..=100).contains(&ex.len()), "{a:?} → {b:?}: {} stops", ex.len());
|
||||||
|
assert!(ex.windows(2).all(|w| w[0].0 < w[1].0), "in order");
|
||||||
|
assert_eq!(ex.iter().filter(|s| s.3.is_some()).count(), 1, "one stop stands for the midpoint");
|
||||||
|
assert!(ex.iter().any(|s| s.3 == Some(0.3) && (s.0 - 0.3).abs() < 1e-6));
|
||||||
|
let ramp = Ramp::new(&p, &Color::to_rgb);
|
||||||
|
for k in 0..=2000 {
|
||||||
|
let t = k as f32 / 2000.0;
|
||||||
|
let (c, o) = p.sample(t);
|
||||||
|
let [r, g, b] = c.to_rgb();
|
||||||
|
let off = [r, g, b, o].iter().zip(ramp.at(t)).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max);
|
||||||
|
assert!(off < 0.5 / 255.0, "{a:?} → {b:?} at {t}: {off}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut flat = Gradient::new(
|
||||||
|
GradientKind::Linear,
|
||||||
|
vec![GradientStop::new(0.0, Color::rgb(0.2, 0.4, 0.6)), GradientStop::new(1.0, Color::rgb(0.25, 0.45, 0.6))],
|
||||||
|
);
|
||||||
|
flat.method = GradientMethod::Perceptual;
|
||||||
|
assert_eq!(flat.expanded().count(), 3, "the ends and the midpoint");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classic gradients expand as they always did: their stops, and a stop at each off-centre
|
||||||
|
/// midpoint, sampled in display RGB (so the renderer draws the same pixels as before).
|
||||||
|
#[test]
|
||||||
|
fn classic_gradients_expand_as_before() {
|
||||||
|
let mut g = Gradient::new(
|
||||||
|
GradientKind::Linear,
|
||||||
|
vec![
|
||||||
|
GradientStop { midpoint: 0.2, ..GradientStop::new(0.0, Color::rgb(0.0, 0.0, 1.0)) },
|
||||||
|
GradientStop { opacity: 0.4, ..GradientStop::new(0.45, Color::cmyk(0.1, 0.8, 0.0, 0.2)) },
|
||||||
|
GradientStop { midpoint: 0.87, ..GradientStop::new(0.6, Color::rgb(1.0, 1.0, 0.0)) },
|
||||||
|
GradientStop::new(1.0, Color::gray(0.3)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
// The expansion gradients had before they had a method.
|
||||||
|
let before: Vec<_> = g
|
||||||
|
.stops
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.flat_map(|(i, s)| {
|
||||||
|
let mid = g.stops.get(i + 1).filter(|_| (s.midpoint - 0.5).abs() > 1e-3).map(|n| {
|
||||||
|
let t = s.offset + (n.offset - s.offset) * s.midpoint;
|
||||||
|
let (c, o) = g.sample_with(t, Color::lerp);
|
||||||
|
(t, c, o, Some(s.midpoint))
|
||||||
|
});
|
||||||
|
std::iter::once((s.offset, s.color, s.opacity, None)).chain(mid)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(g.expanded().collect::<Vec<_>>(), before);
|
||||||
|
assert_eq!(before.len(), 6);
|
||||||
|
assert_eq!(g.sample(0.3), g.sample_with(0.3, Color::lerp));
|
||||||
|
assert_eq!(g.sample_keep_model(0.5), g.sample_with(0.5, Color::lerp_keep_model));
|
||||||
|
g.method = GradientMethod::Perceptual;
|
||||||
|
assert_eq!(g.sample_keep_model(0.5), g.sample(0.5), "perceptual: one mix");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The method is written only when Perceptual, so files from before it (and Classic
|
||||||
|
/// gradients) read as Classic and new Classic files stay as they were.
|
||||||
|
#[test]
|
||||||
|
fn method_is_saved_only_when_perceptual() {
|
||||||
|
let classic = serde_json::to_value(Gradient::default()).unwrap();
|
||||||
|
assert!(classic.get("method").is_none(), "{classic}");
|
||||||
|
let old: Gradient = serde_json::from_value(serde_json::json!({"kind": "Linear", "stops": []})).unwrap();
|
||||||
|
assert_eq!(old.method, GradientMethod::Classic);
|
||||||
|
let p = blue_yellow(GradientMethod::Perceptual);
|
||||||
|
let v = serde_json::to_value(&p).unwrap();
|
||||||
|
assert_eq!(v["method"], "Perceptual");
|
||||||
|
assert_eq!(serde_json::from_value::<Gradient>(v).unwrap(), p);
|
||||||
|
assert_eq!(GradientMethod::parse("perceptual"), Some(GradientMethod::Perceptual));
|
||||||
|
assert_eq!(GradientMethod::parse("CLASSIC"), Some(GradientMethod::Classic));
|
||||||
|
assert_eq!(GradientMethod::parse("smooth"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SVG and PDF can't mix colours perceptually: a Perceptual gradient is drawn as pixels there,
|
||||||
|
/// as Illustrator rasterises perceptual gradients saved as PDF or SVG, as a fill or a stroke,
|
||||||
|
/// dithered or not.
|
||||||
|
#[test]
|
||||||
|
fn perceptual_gradients_draw_as_pixels() {
|
||||||
|
for fill in [true, false] {
|
||||||
|
assert!(!GradientPaint::new(blue_yellow(GradientMethod::Classic)).draws_as_pixels(fill));
|
||||||
|
assert!(GradientPaint::new(blue_yellow(GradientMethod::Perceptual)).draws_as_pixels(fill));
|
||||||
|
let mut radial = blue_yellow(GradientMethod::Perceptual);
|
||||||
|
radial.kind = GradientKind::Radial;
|
||||||
|
radial.dither = true;
|
||||||
|
assert!(GradientPaint::new(radial).draws_as_pixels(fill));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+21
-1
@@ -16,7 +16,7 @@ pub mod swatch;
|
|||||||
|
|
||||||
pub use blend::BlendMode;
|
pub use blend::BlendMode;
|
||||||
pub use freeform::{Freeform, FreeformMode, FreeformPoint};
|
pub use freeform::{Freeform, FreeformMode, FreeformPoint};
|
||||||
pub use gradient::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
pub use gradient::{Gradient, GradientGeom, GradientKind, GradientMethod, GradientPaint, GradientStop};
|
||||||
pub use libraries::SwatchLibrary;
|
pub use libraries::SwatchLibrary;
|
||||||
pub use swatch::{Swatch, SwatchGroup, default_swatches};
|
pub use swatch::{Swatch, SwatchGroup, default_swatches};
|
||||||
|
|
||||||
@@ -146,6 +146,8 @@ impl Color {
|
|||||||
} else {
|
} else {
|
||||||
60.0 * ((r - g) / d + 4.0)
|
60.0 * ((r - g) / d + 4.0)
|
||||||
};
|
};
|
||||||
|
// f32 rounding in rem_euclid can yield exactly 360; wrap back to 0.
|
||||||
|
let h = if h >= 360.0 { 0.0 } else { h };
|
||||||
let s = if max <= 0.0 { 0.0 } else { d / max };
|
let s = if max <= 0.0 { 0.0 } else { d / max };
|
||||||
[h, s, max]
|
[h, s, max]
|
||||||
}
|
}
|
||||||
@@ -378,6 +380,18 @@ impl Paint {
|
|||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/// This stroke paint as the fill of the stroke's outline, so the outline looks as the stroke
|
||||||
|
/// did: without dither, which strokes ignore ([`GradientPaint::draws_as_pixels`]).
|
||||||
|
pub fn stroke_as_fill(&self) -> Paint {
|
||||||
|
match self {
|
||||||
|
Paint::Gradient(g) if g.gradient.dither => {
|
||||||
|
let mut g = g.clone();
|
||||||
|
g.gradient.dither = false;
|
||||||
|
Paint::Gradient(g)
|
||||||
|
}
|
||||||
|
p => p.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
/// The Registration colour: 100% of every ink, linked to [`swatch::REGISTRATION`].
|
/// The Registration colour: 100% of every ink, linked to [`swatch::REGISTRATION`].
|
||||||
pub fn registration() -> Self {
|
pub fn registration() -> Self {
|
||||||
Paint::Solid { color: Color::cmyk(1.0, 1.0, 1.0, 1.0), swatch: Some(swatch::REGISTRATION.into()), tint: 1.0 }
|
Paint::Solid { color: Color::cmyk(1.0, 1.0, 1.0, 1.0), swatch: Some(swatch::REGISTRATION.into()), tint: 1.0 }
|
||||||
@@ -426,6 +440,12 @@ impl Paint {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hue_below_360() {
|
||||||
|
let h = Color::Rgb { r: 1.0, g: 0.0, b: 1e-7 }.to_hsb()[0];
|
||||||
|
assert!((0.0..360.0).contains(&h), "hue {h}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hex_roundtrip() {
|
fn hex_roundtrip() {
|
||||||
let c = Color::from_hex("#ff8000").unwrap();
|
let c = Color::from_hex("#ff8000").unwrap();
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ pub fn builtin_library(id: &str) -> Option<Arc<SwatchLibrary>> {
|
|||||||
// ---------- colour construction ----------
|
// ---------- colour construction ----------
|
||||||
|
|
||||||
/// sRGB (0..=1, gamma encoded) of OKLab `(l, a, b)`, unclamped.
|
/// sRGB (0..=1, gamma encoded) of OKLab `(l, a, b)`, unclamped.
|
||||||
fn oklab_to_srgb(l: f64, a: f64, b: f64) -> [f64; 3] {
|
pub(crate) fn oklab_to_srgb(l: f64, a: f64, b: f64) -> [f64; 3] {
|
||||||
let l_ = l + 0.396_337_777_4 * a + 0.215_803_757_3 * b;
|
let l_ = l + 0.396_337_777_4 * a + 0.215_803_757_3 * b;
|
||||||
let m_ = l - 0.105_561_345_8 * a - 0.063_854_172_8 * b;
|
let m_ = l - 0.105_561_345_8 * a - 0.063_854_172_8 * b;
|
||||||
let s_ = l - 0.089_484_177_5 * a - 1.291_485_548_0 * b;
|
let s_ = l - 0.089_484_177_5 * a - 1.291_485_548_0 * b;
|
||||||
@@ -113,7 +113,12 @@ fn oklab_to_srgb(l: f64, a: f64, b: f64) -> [f64; 3] {
|
|||||||
|
|
||||||
/// OKLab `[l, a, b]` of a colour's display RGB.
|
/// OKLab `[l, a, b]` of a colour's display RGB.
|
||||||
pub fn to_oklab(c: &Color) -> [f64; 3] {
|
pub fn to_oklab(c: &Color) -> [f64; 3] {
|
||||||
let [r, g, b] = c.to_rgb_uncalibrated().map(|v| {
|
srgb_to_oklab(c.to_rgb_uncalibrated())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OKLab `[l, a, b]` of sRGB `rgb` (0..=1, gamma encoded).
|
||||||
|
pub(crate) fn srgb_to_oklab(rgb: [f32; 3]) -> [f64; 3] {
|
||||||
|
let [r, g, b] = rgb.map(|v| {
|
||||||
let v = f64::from(v);
|
let v = f64::from(v);
|
||||||
if v <= 0.040_45 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
|
if v <= 0.040_45 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
|
||||||
});
|
});
|
||||||
@@ -171,7 +176,7 @@ fn group(name: &str, swatches: Vec<Swatch>) -> SwatchGroup {
|
|||||||
/// A gradient swatch through `stops` of (offset, colour, opacity).
|
/// A gradient swatch through `stops` of (offset, colour, opacity).
|
||||||
pub(crate) fn gradient(name: &str, kind: GradientKind, stops: &[(f32, Color, f32)]) -> Swatch {
|
pub(crate) fn gradient(name: &str, kind: GradientKind, stops: &[(f32, Color, f32)]) -> Swatch {
|
||||||
let stops = stops.iter().map(|&(offset, color, opacity)| GradientStop { opacity, ..GradientStop::new(offset, color) }).collect();
|
let stops = stops.iter().map(|&(offset, color, opacity)| GradientStop { opacity, ..GradientStop::new(offset, color) }).collect();
|
||||||
Swatch { name: name.into(), paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind, stops }))), global: false, spot: false }
|
Swatch { name: name.into(), paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(kind, stops)))), global: false, spot: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `n` steps of OKLCH lightness from `l0` to `l1`, chroma `c(t)` and hue `h(t)` (`t` in 0..=1),
|
/// `n` steps of OKLCH lightness from `l0` to `l1`, chroma `c(t)` and hue `h(t)` (`t` in 0..=1),
|
||||||
|
|||||||
@@ -721,13 +721,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn sample() -> SwatchLibrary {
|
fn sample() -> SwatchLibrary {
|
||||||
let grad = Paint::Gradient(Box::new(GradientPaint::new(Gradient {
|
let grad = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(
|
||||||
kind: GradientKind::Linear,
|
GradientKind::Linear,
|
||||||
stops: vec![
|
vec![
|
||||||
GradientStop { midpoint: 0.4, ..GradientStop::new(0.0, Color::cmyk(0.1, 0.2, 0.3, 0.0)) },
|
GradientStop { midpoint: 0.4, ..GradientStop::new(0.0, Color::cmyk(0.1, 0.2, 0.3, 0.0)) },
|
||||||
GradientStop { opacity: 0.5, ..GradientStop::new(1.0, Color::rgb8(255, 0, 0)) },
|
GradientStop { opacity: 0.5, ..GradientStop::new(1.0, Color::rgb8(255, 0, 0)) },
|
||||||
],
|
],
|
||||||
})));
|
))));
|
||||||
SwatchLibrary {
|
SwatchLibrary {
|
||||||
name: "Brand */ Colours".into(),
|
name: "Brand */ Colours".into(),
|
||||||
swatches: vec![
|
swatches: vec![
|
||||||
@@ -822,15 +822,15 @@ mod tests {
|
|||||||
name: "Positions".into(),
|
name: "Positions".into(),
|
||||||
swatches: vec![Swatch {
|
swatches: vec![Swatch {
|
||||||
name: "Ramp".into(),
|
name: "Ramp".into(),
|
||||||
paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient {
|
paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(
|
||||||
kind,
|
kind,
|
||||||
stops: vec![
|
vec![
|
||||||
GradientStop::new(0.0, Color::BLACK),
|
GradientStop::new(0.0, Color::BLACK),
|
||||||
GradientStop::new(0.125, Color::rgb8(255, 0, 0)),
|
GradientStop::new(0.125, Color::rgb8(255, 0, 0)),
|
||||||
GradientStop::new(0.12890625, Color::WHITE),
|
GradientStop::new(0.12890625, Color::WHITE),
|
||||||
GradientStop::new(1.0, Color::rgb8(0, 0, 255)),
|
GradientStop::new(1.0, Color::rgb8(0, 0, 255)),
|
||||||
],
|
],
|
||||||
}))),
|
)))),
|
||||||
global: false,
|
global: false,
|
||||||
spot: false,
|
spot: false,
|
||||||
}],
|
}],
|
||||||
|
|||||||
@@ -123,13 +123,13 @@ fn base_swatches() -> (Vec<Swatch>, Vec<SwatchGroup>) {
|
|||||||
.collect();
|
.collect();
|
||||||
let grad = |name: &str, a: &str, b: &str, kind: GradientKind| Swatch {
|
let grad = |name: &str, a: &str, b: &str, kind: GradientKind| Swatch {
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
paint: Paint::Gradient(Box::new(crate::GradientPaint::new(Gradient {
|
paint: Paint::Gradient(Box::new(crate::GradientPaint::new(Gradient::new(
|
||||||
kind,
|
kind,
|
||||||
stops: vec![
|
vec![
|
||||||
GradientStop::new(0.0, Color::from_hex(a).unwrap_or(Color::WHITE)),
|
GradientStop::new(0.0, Color::from_hex(a).unwrap_or(Color::WHITE)),
|
||||||
GradientStop::new(1.0, Color::from_hex(b).unwrap_or(Color::BLACK)),
|
GradientStop::new(1.0, Color::from_hex(b).unwrap_or(Color::BLACK)),
|
||||||
],
|
],
|
||||||
}))),
|
)))),
|
||||||
global: false,
|
global: false,
|
||||||
spot: false,
|
spot: false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ zune-jpeg = { workspace = true }
|
|||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
jpeg-encoder = { workspace = true }
|
jpeg-encoder = { workspace = true }
|
||||||
|
|||||||
+69
-11
@@ -89,7 +89,7 @@ fn resample_stops(g: &Gradient, offsets: &[f32]) -> Gradient {
|
|||||||
out.stops = offsets
|
out.stops = offsets
|
||||||
.iter()
|
.iter()
|
||||||
.map(|&o| {
|
.map(|&o| {
|
||||||
let (color, opacity) = g.sample_with(o, lerp_color);
|
let (color, opacity) = g.sample_keep_model(o);
|
||||||
let (swatch, tint) = link_at(g, o);
|
let (swatch, tint) = link_at(g, o);
|
||||||
GradientStop { opacity, swatch, tint, ..GradientStop::new(o, color) }
|
GradientStop { opacity, swatch, tint, ..GradientStop::new(o, color) }
|
||||||
})
|
})
|
||||||
@@ -579,11 +579,18 @@ impl Lerp {
|
|||||||
|
|
||||||
/// The object at `t` (0 = `a`, 1 = `b`), with id 0.
|
/// The object at `t` (0 = `a`, 1 = `b`), with id 0.
|
||||||
pub fn at(&self, t: f64) -> Node {
|
pub fn at(&self, t: f64) -> Node {
|
||||||
|
self.at_eased(t, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The object whose shape is at `t` and whose colours (fills, strokes, opacity, mesh and text
|
||||||
|
/// colours) are at `tc`: a blend with its colours eased apart from its spacing.
|
||||||
|
pub fn at_eased(&self, t: f64, tc: f64) -> Node {
|
||||||
let (a, b) = (&self.a, &self.b);
|
let (a, b) = (&self.a, &self.b);
|
||||||
let base = if t < 0.5 { a } else { b };
|
let base = if t < 0.5 { a } else { b };
|
||||||
let mut n = match &self.shape {
|
let mut n = match &self.shape {
|
||||||
Shape::Group { children, clip } => {
|
Shape::Group { children, clip } => {
|
||||||
let mut g = Node::new(NodeId(0), NodeKind::Group { children: children.iter().map(|c| Arc::new(c.at(t))).collect(), clip: *clip });
|
let mut g =
|
||||||
|
Node::new(NodeId(0), NodeKind::Group { children: children.iter().map(|c| Arc::new(c.at_eased(t, tc))).collect(), clip: *clip });
|
||||||
g.isolate = base.isolate;
|
g.isolate = base.isolate;
|
||||||
g.knockout = base.knockout;
|
g.knockout = base.knockout;
|
||||||
g
|
g
|
||||||
@@ -593,8 +600,8 @@ impl Lerp {
|
|||||||
if let (NodeKind::Mesh(m), NodeKind::Mesh(ma), NodeKind::Mesh(mb)) = (&mut n.kind, &a.kind, &b.kind) {
|
if let (NodeKind::Mesh(m), NodeKind::Mesh(ma), NodeKind::Mesh(mb)) = (&mut n.kind, &a.kind, &b.kind) {
|
||||||
for ((p, x), y) in m.points.iter_mut().zip(&ma.points).zip(&mb.points) {
|
for ((p, x), y) in m.points.iter_mut().zip(&ma.points).zip(&mb.points) {
|
||||||
p.p = x.p.lerp(y.p, t);
|
p.p = x.p.lerp(y.p, t);
|
||||||
p.color = lerp_color(&x.color, &y.color, t as f32);
|
p.color = lerp_color(&x.color, &y.color, tc as f32);
|
||||||
p.opacity = lerp_f32(x.opacity, y.opacity, t as f32);
|
p.opacity = lerp_f32(x.opacity, y.opacity, tc as f32);
|
||||||
for h in 0..4 {
|
for h in 0..4 {
|
||||||
p.handles[h] = x.handles[h].lerp(y.handles[h], t);
|
p.handles[h] = x.handles[h].lerp(y.handles[h], t);
|
||||||
}
|
}
|
||||||
@@ -620,7 +627,7 @@ impl Lerp {
|
|||||||
Shape::Placed => {
|
Shape::Placed => {
|
||||||
let mut n = base.clone();
|
let mut n = base.clone();
|
||||||
match (&mut n.kind, &a.kind, &b.kind) {
|
match (&mut n.kind, &a.kind, &b.kind) {
|
||||||
(NodeKind::Text(o), NodeKind::Text(x), NodeKind::Text(y)) => lerp_text(o, x, y, t),
|
(NodeKind::Text(o), NodeKind::Text(x), NodeKind::Text(y)) => lerp_text(o, x, y, t, tc),
|
||||||
(NodeKind::SymbolInstance { xf: o, .. }, NodeKind::SymbolInstance { xf: x, .. }, NodeKind::SymbolInstance { xf: y, .. }) => {
|
(NodeKind::SymbolInstance { xf: o, .. }, NodeKind::SymbolInstance { xf: x, .. }, NodeKind::SymbolInstance { xf: y, .. }) => {
|
||||||
*o = lerp_affine(*x, *y, t)
|
*o = lerp_affine(*x, *y, t)
|
||||||
}
|
}
|
||||||
@@ -657,8 +664,8 @@ impl Lerp {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
n.id = NodeId(0);
|
n.id = NodeId(0);
|
||||||
n.appearance = lerp_appearance(&a.appearance, &b.appearance, t).into();
|
n.appearance = lerp_appearance(&a.appearance, &b.appearance, tc).into();
|
||||||
n.opacity = lerp_f32(a.opacity, b.opacity, t as f32);
|
n.opacity = lerp_f32(a.opacity, b.opacity, tc as f32);
|
||||||
n.blend = base.blend;
|
n.blend = base.blend;
|
||||||
n.visible = true;
|
n.visible = true;
|
||||||
n.locked = false;
|
n.locked = false;
|
||||||
@@ -688,7 +695,7 @@ fn compound(path: PathData, rule: FillRule, counts: &[usize], total: usize) -> N
|
|||||||
/// Interpolate text `x` → `y` into `o` (a copy of the nearer): the transform by its parts, an
|
/// Interpolate text `x` → `y` into `o` (a copy of the nearer): the transform by its parts, an
|
||||||
/// area frame anchor by anchor, and each run's colours, stroke weight and size from the runs at
|
/// area frame anchor by anchor, and each run's colours, stroke weight and size from the runs at
|
||||||
/// the same place (by character) of both.
|
/// the same place (by character) of both.
|
||||||
fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64) {
|
fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64, tc: f64) {
|
||||||
o.xf = lerp_affine(x.xf, y.xf, t);
|
o.xf = lerp_affine(x.xf, y.xf, t);
|
||||||
if let (crate::text::TextKind::Area { frame: f }, crate::text::TextKind::Area { frame: fx }, crate::text::TextKind::Area { frame: fy }) =
|
if let (crate::text::TextKind::Area { frame: f }, crate::text::TextKind::Area { frame: fx }, crate::text::TextKind::Area { frame: fy }) =
|
||||||
(&mut o.kind, &x.kind, &y.kind)
|
(&mut o.kind, &x.kind, &y.kind)
|
||||||
@@ -710,7 +717,7 @@ fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64) {
|
|||||||
}
|
}
|
||||||
let total: usize = o.runs.iter().map(|r| r.text.chars().count()).sum::<usize>().max(1);
|
let total: usize = o.runs.iter().map(|r| r.text.chars().count()).sum::<usize>().max(1);
|
||||||
let mut start = 0usize;
|
let mut start = 0usize;
|
||||||
let tf = t as f32;
|
let tf = tc as f32;
|
||||||
let mut resized = false;
|
let mut resized = false;
|
||||||
for r in &mut o.runs {
|
for r in &mut o.runs {
|
||||||
let len = r.text.chars().count();
|
let len = r.text.chars().count();
|
||||||
@@ -1071,8 +1078,10 @@ pub fn blend_expand(keys: &[Arc<Node>], spec: &BlendSpec) -> Vec<Node> {
|
|||||||
let n = blend_step_count(a, b, spec.spacing, len);
|
let n = blend_step_count(a, b, spec.spacing, len);
|
||||||
let lerp = Lerp::new(a, b, (spec.start(i), spec.start(i + 1)));
|
let lerp = Lerp::new(a, b, (spec.start(i), spec.start(i + 1)));
|
||||||
for j in 1..=n {
|
for j in 1..=n {
|
||||||
let t = j as f64 / (n + 1) as f64;
|
// Even position of the step, then where the easing puts its shape and its colours.
|
||||||
let mut s = lerp.at(t);
|
let u = j as f64 / (n + 1) as f64;
|
||||||
|
let (t, tc) = (if rail.is_some() { spec.ease.apply(u) } else { u }, spec.color_position(u));
|
||||||
|
let mut s = lerp.at_eased(t, tc);
|
||||||
if let Some(r) = &rail {
|
if let Some(r) = &rail {
|
||||||
r.place(&mut s, i, t);
|
r.place(&mut s, i, t);
|
||||||
}
|
}
|
||||||
@@ -1134,6 +1143,55 @@ mod tests {
|
|||||||
assert_eq!(out[0].geometric_bounds().unwrap().center(), Point::new(5.0, 5.0), "keys stay put");
|
assert_eq!(out[0].geometric_bounds().unwrap().center(), Point::new(5.0, 5.0), "keys stay put");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn easing_keeps_the_ends_and_stays_monotone() {
|
||||||
|
use crate::live::{BlendEase, BlendEaseType as K};
|
||||||
|
for kind in [K::In, K::Out, K::InOut, K::OutIn] {
|
||||||
|
for ramp in [0.0, 0.5, 1.0, f64::NAN, f64::INFINITY] {
|
||||||
|
let e = BlendEase { kind, ramp };
|
||||||
|
assert_eq!((e.apply(0.0), e.apply(1.0)), (0.0, 1.0));
|
||||||
|
let ys: Vec<f64> = (0..=100).map(|i| e.apply(i as f64 / 100.0)).collect();
|
||||||
|
assert!(ys.windows(2).all(|w| w[0] <= w[1]));
|
||||||
|
assert_eq!(e.apply(f64::NAN), 0.0);
|
||||||
|
if ramp == 0.0 {
|
||||||
|
assert_eq!(e.apply(0.25), 0.25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let e = |kind| BlendEase { kind, ramp: 1.0 };
|
||||||
|
assert!(e(K::In).apply(0.25) < 0.25);
|
||||||
|
assert!(e(K::Out).apply(0.25) > 0.25);
|
||||||
|
assert!(e(K::InOut).apply(0.25) < 0.25 && e(K::InOut).apply(0.75) > 0.75);
|
||||||
|
assert!(e(K::OutIn).apply(0.25) > 0.25 && e(K::OutIn).apply(0.75) < 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn colour_easing_moves_colours_apart_from_positions() {
|
||||||
|
let rect = |x: f64, c: Color| {
|
||||||
|
let mut n = Node::path(NodeId(1), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::default());
|
||||||
|
n.appearance = Appearance::basic(Paint::solid(c), Paint::None, 0.0).into();
|
||||||
|
Arc::new(n)
|
||||||
|
};
|
||||||
|
let keys = vec![rect(0.0, Color::BLACK), rect(100.0, Color::WHITE)];
|
||||||
|
let mid = |spec: &BlendSpec| {
|
||||||
|
let out = blend_expand(&keys, spec);
|
||||||
|
let n = &out[1];
|
||||||
|
(n.geometric_bounds().unwrap().x0, n.appearance.fill_paint())
|
||||||
|
};
|
||||||
|
// Position easing runs along a spine: Align to Path gives the blend one.
|
||||||
|
let base = BlendSpec { spacing: BlendSpacing::Steps(1), orientation: BlendOrientation::AlignToPath, ..Default::default() };
|
||||||
|
let (x0, p0) = mid(&base);
|
||||||
|
// Colour easing alone leaves the position and moves the colour.
|
||||||
|
let colour = BlendSpec { color_shift: 50.0, ..base.clone() };
|
||||||
|
let (x1, p1) = mid(&colour);
|
||||||
|
assert!((x0 - x1).abs() < 1e-9 && p0 != p1);
|
||||||
|
// Position easing alone leaves the colour and moves the position.
|
||||||
|
let pos = BlendSpec { ease: crate::live::BlendEase { kind: crate::live::BlendEaseType::In, ramp: 1.0 }, ..base.clone() };
|
||||||
|
let (x2, p2) = mid(&pos);
|
||||||
|
assert!((x0 - x2).abs() > 1e-3 && p0 == p2);
|
||||||
|
assert_eq!(mid(&pos).0, x2, "deterministic");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn affine_lerp_turns_the_short_way() {
|
fn affine_lerp_turns_the_short_way() {
|
||||||
let x = Affine::rotate(170f64.to_radians());
|
let x = Affine::rotate(170f64.to_radians());
|
||||||
|
|||||||
+132
-2
@@ -99,7 +99,7 @@ pub use graph::{
|
|||||||
};
|
};
|
||||||
pub use hit::{Hit, HitKind};
|
pub use hit::{Hit, HitKind};
|
||||||
pub use links::{LinkInfo, PlacementOptions};
|
pub use links::{LinkInfo, PlacementOptions};
|
||||||
pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};
|
pub use live::{BlendEase, BlendEaseType, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};
|
||||||
pub use live_paint::{GapOptions, GapSize};
|
pub use live_paint::{GapOptions, GapSize};
|
||||||
pub use metadata::{CopyrightStatus, DocMetadata};
|
pub use metadata::{CopyrightStatus, DocMetadata};
|
||||||
pub use node::Knockout;
|
pub use node::Knockout;
|
||||||
@@ -109,7 +109,7 @@ pub use node::{ImageMap, MAX_SVG_SCRIPTS, ObjectAttributes, SVG_EVENTS};
|
|||||||
pub use orient::OrientedBox;
|
pub use orient::OrientedBox;
|
||||||
pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType};
|
pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType};
|
||||||
pub use perspective::PerspectiveAttachment;
|
pub use perspective::PerspectiveAttachment;
|
||||||
pub use placed_document::PlacedDocument;
|
pub use placed_document::{PageBox, PlacedDocument};
|
||||||
pub use profiles::ColorProfiles;
|
pub use profiles::ColorProfiles;
|
||||||
pub use puppet::{PuppetPin, PuppetPins};
|
pub use puppet::{PuppetPin, PuppetPins};
|
||||||
pub use rastersettings::{RasterColorModel, RasterEffectsSettings};
|
pub use rastersettings::{RasterColorModel, RasterEffectsSettings};
|
||||||
@@ -935,6 +935,136 @@ type Places = BTreeMap<Vec<usize>, Vec<(usize, usize)>>;
|
|||||||
pub const FORMAT_VERSION: u32 = 1;
|
pub const FORMAT_VERSION: u32 = 1;
|
||||||
|
|
||||||
impl Document {
|
impl Document {
|
||||||
|
/// Is everything but the layers the same in `other`, as the next version of the document after
|
||||||
|
/// an edit of its art? The id counter and the id index are left out, and art outside the layers
|
||||||
|
/// (symbols, patterns, graph designs) compares by identity, which an edit keeps for what it
|
||||||
|
/// doesn't change: quick for every frame of an animation.
|
||||||
|
pub fn same_outside_layers(&self, other: &Self) -> bool {
|
||||||
|
let same_art = |a: &[Arc<Node>], b: &[Arc<Node>]| a.len() == b.len() && a.iter().zip(b).all(|(a, b)| Arc::ptr_eq(a, b));
|
||||||
|
let same_pattern = |a: &PatternDef, b: &PatternDef| {
|
||||||
|
same_art(&a.art, &b.art) && PatternDef { art: Vec::new(), ..a.clone() } == PatternDef { art: Vec::new(), ..b.clone() }
|
||||||
|
};
|
||||||
|
let Self {
|
||||||
|
version,
|
||||||
|
title,
|
||||||
|
template,
|
||||||
|
units,
|
||||||
|
ruler_origin,
|
||||||
|
rulers,
|
||||||
|
artboard_ruler_origins,
|
||||||
|
color_mode,
|
||||||
|
artboards,
|
||||||
|
layers: _,
|
||||||
|
swatches,
|
||||||
|
swatch_groups,
|
||||||
|
graphic_styles,
|
||||||
|
char_styles,
|
||||||
|
para_styles,
|
||||||
|
text_threads,
|
||||||
|
symbols,
|
||||||
|
graph_designs,
|
||||||
|
guides,
|
||||||
|
views,
|
||||||
|
saved_selections,
|
||||||
|
variables,
|
||||||
|
grid,
|
||||||
|
raster_effects_ppi,
|
||||||
|
images,
|
||||||
|
patterns,
|
||||||
|
pattern_edit,
|
||||||
|
mask_edit,
|
||||||
|
next_id: _,
|
||||||
|
unknown,
|
||||||
|
page_isolate,
|
||||||
|
page_knockout,
|
||||||
|
spot_use_lab,
|
||||||
|
setup,
|
||||||
|
paste_remembers_layers,
|
||||||
|
metadata,
|
||||||
|
raster_effects,
|
||||||
|
last_view,
|
||||||
|
transparency_grid,
|
||||||
|
layers_open,
|
||||||
|
color_profiles,
|
||||||
|
export_settings,
|
||||||
|
extra,
|
||||||
|
slices,
|
||||||
|
slices_clip_to_artboard,
|
||||||
|
print_setup,
|
||||||
|
assets,
|
||||||
|
svg_scripts,
|
||||||
|
puppet,
|
||||||
|
new_art,
|
||||||
|
dimensions,
|
||||||
|
index: _,
|
||||||
|
} = self;
|
||||||
|
*version == other.version
|
||||||
|
&& *title == other.title
|
||||||
|
&& *template == other.template
|
||||||
|
&& *units == other.units
|
||||||
|
&& *ruler_origin == other.ruler_origin
|
||||||
|
&& *rulers == other.rulers
|
||||||
|
&& *artboard_ruler_origins == other.artboard_ruler_origins
|
||||||
|
&& *color_mode == other.color_mode
|
||||||
|
&& *artboards == other.artboards
|
||||||
|
&& *swatches == other.swatches
|
||||||
|
&& *swatch_groups == other.swatch_groups
|
||||||
|
&& *graphic_styles == other.graphic_styles
|
||||||
|
&& *char_styles == other.char_styles
|
||||||
|
&& *para_styles == other.para_styles
|
||||||
|
&& *text_threads == other.text_threads
|
||||||
|
&& symbols.len() == other.symbols.len()
|
||||||
|
&& symbols.iter().zip(&other.symbols).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art))
|
||||||
|
&& graph_designs.len() == other.graph_designs.len()
|
||||||
|
&& graph_designs.iter().zip(&other.graph_designs).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art))
|
||||||
|
&& *guides == other.guides
|
||||||
|
&& *views == other.views
|
||||||
|
&& *saved_selections == other.saved_selections
|
||||||
|
&& *variables == other.variables
|
||||||
|
&& *grid == other.grid
|
||||||
|
&& *raster_effects_ppi == other.raster_effects_ppi
|
||||||
|
&& *images == other.images
|
||||||
|
&& patterns.len() == other.patterns.len()
|
||||||
|
&& patterns.iter().zip(&other.patterns).all(|(a, b)| same_pattern(a, b))
|
||||||
|
&& match (pattern_edit, &other.pattern_edit) {
|
||||||
|
(Some(a), Some(b)) => {
|
||||||
|
a.pattern == b.pattern
|
||||||
|
&& a.layer == b.layer
|
||||||
|
&& match (&a.original, &b.original) {
|
||||||
|
(Some(a), Some(b)) => same_pattern(a, b),
|
||||||
|
(a, b) => a.is_none() && b.is_none(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(a, b) => a.is_none() && b.is_none(),
|
||||||
|
}
|
||||||
|
&& *mask_edit == other.mask_edit
|
||||||
|
&& *unknown == other.unknown
|
||||||
|
&& *page_isolate == other.page_isolate
|
||||||
|
&& *page_knockout == other.page_knockout
|
||||||
|
&& *spot_use_lab == other.spot_use_lab
|
||||||
|
&& *setup == other.setup
|
||||||
|
&& *paste_remembers_layers == other.paste_remembers_layers
|
||||||
|
&& *metadata == other.metadata
|
||||||
|
&& *raster_effects == other.raster_effects
|
||||||
|
&& *last_view == other.last_view
|
||||||
|
&& *transparency_grid == other.transparency_grid
|
||||||
|
&& *layers_open == other.layers_open
|
||||||
|
&& *color_profiles == other.color_profiles
|
||||||
|
&& *export_settings == other.export_settings
|
||||||
|
&& *extra == other.extra
|
||||||
|
&& *slices == other.slices
|
||||||
|
&& *slices_clip_to_artboard == other.slices_clip_to_artboard
|
||||||
|
&& *print_setup == other.print_setup
|
||||||
|
&& *assets == other.assets
|
||||||
|
&& *svg_scripts == other.svg_scripts
|
||||||
|
&& match (puppet, &other.puppet) {
|
||||||
|
(Some(a), Some(b)) => Arc::ptr_eq(a, b),
|
||||||
|
(a, b) => a.is_none() && b.is_none(),
|
||||||
|
}
|
||||||
|
&& *new_art == other.new_art
|
||||||
|
&& *dimensions == other.dimensions
|
||||||
|
}
|
||||||
|
|
||||||
/// Document Raster Effects Settings › Resolution in ppi, 1 to 2400 (72 when the stored value
|
/// Document Raster Effects Settings › Resolution in ppi, 1 to 2400 (72 when the stored value
|
||||||
/// isn't a number).
|
/// isn't a number).
|
||||||
pub fn raster_ppi(&self) -> f64 {
|
pub fn raster_ppi(&self) -> f64 {
|
||||||
|
|||||||
+127
-4
@@ -60,12 +60,75 @@ pub enum BlendOrientation {
|
|||||||
AlignToPath,
|
AlignToPath,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Distribution of blend steps, as offered by the Blend panel's Type menu.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum BlendEaseType {
|
||||||
|
#[default]
|
||||||
|
In,
|
||||||
|
Out,
|
||||||
|
InOut,
|
||||||
|
OutIn,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BlendEaseType {
|
||||||
|
pub fn id(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::In => "easeIn",
|
||||||
|
Self::Out => "easeOut",
|
||||||
|
Self::InOut => "easeInOut",
|
||||||
|
Self::OutIn => "easeOutIn",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blend panel Type and Ramp. Ramp is a fraction from 0 (even) to 1.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct BlendEase {
|
||||||
|
#[serde(default)]
|
||||||
|
pub kind: BlendEaseType,
|
||||||
|
#[serde(default, deserialize_with = "blend_ramp")]
|
||||||
|
pub ramp: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BlendEase {
|
||||||
|
pub fn is_linear(&self) -> bool {
|
||||||
|
self.ramp <= 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only the default Type and Ramp can be omitted without losing a chosen Type at Ramp 0.
|
||||||
|
pub fn is_default(&self) -> bool {
|
||||||
|
self.kind == BlendEaseType::In && self.ramp == 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A monotone polynomial distribution, blended with even spacing by Ramp. Public help
|
||||||
|
/// describes the four distributions, not their numeric curves; these curves are original.
|
||||||
|
pub fn apply(&self, u: f64) -> f64 {
|
||||||
|
let u = if u.is_finite() { u.clamp(0.0, 1.0) } else { 0.0 };
|
||||||
|
let r = if self.ramp.is_finite() { self.ramp.clamp(0.0, 1.0) } else { 0.0 };
|
||||||
|
let eased = match self.kind {
|
||||||
|
BlendEaseType::In => u * u,
|
||||||
|
BlendEaseType::Out => u * (2.0 - u),
|
||||||
|
BlendEaseType::InOut => u * u * (3.0 - 2.0 * u),
|
||||||
|
BlendEaseType::OutIn if u < 0.5 => 2.0 * u * (1.0 - u),
|
||||||
|
BlendEaseType::OutIn => 0.5 + 2.0 * (u - 0.5) * (u - 0.5),
|
||||||
|
};
|
||||||
|
u + r * (eased - u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct BlendSpec {
|
pub struct BlendSpec {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub spacing: BlendSpacing,
|
pub spacing: BlendSpacing,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub orientation: BlendOrientation,
|
pub orientation: BlendOrientation,
|
||||||
|
/// Acceleration of the steps' shapes and positions along the spine between two keys.
|
||||||
|
#[serde(default, skip_serializing_if = "BlendEase::is_default")]
|
||||||
|
pub ease: BlendEase,
|
||||||
|
/// Independent color acceleration. -100..100, with 0 preserving the even color ramp.
|
||||||
|
#[serde(default, rename = "colorShift", skip_serializing_if = "is_zero", deserialize_with = "blend_color_shift")]
|
||||||
|
pub color_shift: f64,
|
||||||
/// Replaced spine. `None` = the straight lines between the key objects' centres.
|
/// Replaced spine. `None` = the straight lines between the key objects' centres.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub spine: Option<PathData>,
|
pub spine: Option<PathData>,
|
||||||
@@ -228,12 +291,15 @@ pub struct GradientMesh {
|
|||||||
pub points: Vec<MeshPoint>,
|
pub points: Vec<MeshPoint>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One tessellated piece of a mesh (a small quad with a solid colour).
|
/// One tessellated piece of a mesh: a small quad, its colour at the centre, and its colour and
|
||||||
|
/// opacity at each corner (in `pts` order), which a renderer shades between so neighbouring quads
|
||||||
|
/// meet without a step.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
pub struct MeshQuad {
|
pub struct MeshQuad {
|
||||||
pub pts: [Point; 4],
|
pub pts: [Point; 4],
|
||||||
pub color: Color,
|
pub color: Color,
|
||||||
pub opacity: f32,
|
pub opacity: f32,
|
||||||
|
pub corners: [(Color, f32); 4],
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create Gradient Mesh → Appearance.
|
/// Create Gradient Mesh → Appearance.
|
||||||
@@ -769,7 +835,7 @@ impl GradientMesh {
|
|||||||
(Color::rgb(rgb[0], rgb[1], rgb[2]), op)
|
(Color::rgb(rgb[0], rgb[1], rgb[2]), op)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tessellate into `n`×`n` quads per patch, each coloured at its centre.
|
/// Tessellate into `n`×`n` quads per patch, each coloured at its centre and its corners.
|
||||||
pub fn quads(&self, n: usize) -> Vec<MeshQuad> {
|
pub fn quads(&self, n: usize) -> Vec<MeshQuad> {
|
||||||
if !self.is_valid() {
|
if !self.is_valid() {
|
||||||
return vec![];
|
return vec![];
|
||||||
@@ -777,18 +843,27 @@ impl GradientMesh {
|
|||||||
let n = n.clamp(1, 64);
|
let n = n.clamp(1, 64);
|
||||||
let mut out = Vec::with_capacity(self.rows as usize * self.cols as usize * n * n);
|
let mut out = Vec::with_capacity(self.rows as usize * self.cols as usize * n * n);
|
||||||
let mut grid = vec![Point::ZERO; (n + 1) * (n + 1)];
|
let mut grid = vec![Point::ZERO; (n + 1) * (n + 1)];
|
||||||
|
let mut colors = vec![(Color::default(), 0.0f32); (n + 1) * (n + 1)];
|
||||||
for r in 0..self.rows as usize {
|
for r in 0..self.rows as usize {
|
||||||
for c in 0..self.cols as usize {
|
for c in 0..self.cols as usize {
|
||||||
for j in 0..=n {
|
for j in 0..=n {
|
||||||
for i in 0..=n {
|
for i in 0..=n {
|
||||||
grid[j * (n + 1) + i] = self.eval(r, c, i as f64 / n as f64, j as f64 / n as f64);
|
let (u, v) = (i as f64 / n as f64, j as f64 / n as f64);
|
||||||
|
grid[j * (n + 1) + i] = self.eval(r, c, u, v);
|
||||||
|
colors[j * (n + 1) + i] = self.color_at(r, c, u, v);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for j in 0..n {
|
for j in 0..n {
|
||||||
for i in 0..n {
|
for i in 0..n {
|
||||||
let (color, opacity) = self.color_at(r, c, (i as f64 + 0.5) / n as f64, (j as f64 + 0.5) / n as f64);
|
let (color, opacity) = self.color_at(r, c, (i as f64 + 0.5) / n as f64, (j as f64 + 0.5) / n as f64);
|
||||||
let g = |a: usize, b: usize| grid[b * (n + 1) + a];
|
let g = |a: usize, b: usize| grid[b * (n + 1) + a];
|
||||||
out.push(MeshQuad { pts: [g(i, j), g(i + 1, j), g(i + 1, j + 1), g(i, j + 1)], color, opacity });
|
let k = |a: usize, b: usize| colors[b * (n + 1) + a];
|
||||||
|
out.push(MeshQuad {
|
||||||
|
pts: [g(i, j), g(i + 1, j), g(i + 1, j + 1), g(i, j + 1)],
|
||||||
|
color,
|
||||||
|
opacity,
|
||||||
|
corners: [k(i, j), k(i + 1, j), k(i + 1, j + 1), k(i, j + 1)],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1757,10 +1832,58 @@ pub(crate) fn max_outset(nodes: &[Arc<Node>], symbols: Option<&crate::SymbolBoxe
|
|||||||
o
|
o
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reading a native file never fails over one bad blend number: out-of-range values clamp,
|
||||||
|
/// and anything that is not a finite number (NaN, null, text) becomes the default. The
|
||||||
|
/// commands still refuse such input (`ease_param`).
|
||||||
|
fn blend_number<'de, D: serde::Deserializer<'de>>(d: D, min: f64, max: f64) -> Result<f64, D::Error> {
|
||||||
|
let v = serde_json::Value::deserialize(d)?;
|
||||||
|
Ok(v.as_f64().filter(|n| n.is_finite()).map_or(0.0, |n| n.clamp(min, max)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blend_ramp<'de, D: serde::Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
|
||||||
|
blend_number(d, 0.0, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blend_color_shift<'de, D: serde::Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
|
||||||
|
blend_number(d, -100.0, 100.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether an optional stored color shift has its unchanged default.
|
||||||
|
fn is_zero(v: &f64) -> bool {
|
||||||
|
*v == 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BlendSpec {
|
||||||
|
/// Shift the color transition independently of shape placement. The exponential curve is
|
||||||
|
/// original; the documented behavior specifies the direction, not numeric sample values.
|
||||||
|
pub fn color_position(&self, u: f64) -> f64 {
|
||||||
|
let u = if u.is_finite() { u.clamp(0.0, 1.0) } else { 0.0 };
|
||||||
|
let shift = if self.color_shift.is_finite() { self.color_shift.clamp(-100.0, 100.0) } else { 0.0 };
|
||||||
|
if shift == 0.0 { u } else { u.powf(2.0f64.powf(shift / 50.0)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// A bad Ramp or Shift in a file clamps (non-finite values take the default) instead of
|
||||||
|
/// failing the whole document.
|
||||||
|
#[test]
|
||||||
|
fn blend_easing_numbers_clamp_when_read() {
|
||||||
|
use serde::de::value::{Error, F64Deserializer};
|
||||||
|
let spec: BlendSpec = serde_json::from_str(r#"{"ease":{"kind":"out","ramp":7.5},"colorShift":-900}"#).unwrap();
|
||||||
|
assert_eq!((spec.ease.ramp, spec.color_shift), (1.0, -100.0));
|
||||||
|
let spec: BlendSpec = serde_json::from_str(r#"{"ease":{"ramp":-3},"colorShift":250}"#).unwrap();
|
||||||
|
assert_eq!((spec.ease.ramp, spec.color_shift), (0.0, 100.0));
|
||||||
|
let spec: BlendSpec = serde_json::from_str(r#"{"ease":{"ramp":null},"colorShift":"NaN"}"#).unwrap();
|
||||||
|
assert_eq!((spec.ease.ramp, spec.color_shift), (0.0, 0.0));
|
||||||
|
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||||
|
assert_eq!(blend_ramp(F64Deserializer::<Error>::new(bad)).unwrap(), 0.0);
|
||||||
|
assert_eq!(blend_color_shift(F64Deserializer::<Error>::new(bad)).unwrap(), 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// #1578: Arc bends in the box's real proportions. In half-width units (y scaled by the
|
/// #1578: Arc bends in the box's real proportions. In half-width units (y scaled by the
|
||||||
/// aspect), the top and bottom edges are concentric circles about the bend's centre, at their
|
/// aspect), the top and bottom edges are concentric circles about the bend's centre, at their
|
||||||
/// real distance from the centre line; an inner edge that would pass the centre stops there.
|
/// real distance from the centre line; an inner edge that would pass the centre stops there.
|
||||||
|
|||||||
+26
-15
@@ -3,6 +3,7 @@
|
|||||||
//! document title is [`crate::Document::title`]. Every field is defaulted, so documents saved
|
//! document title is [`crate::Document::title`]. Every field is defaulted, so documents saved
|
||||||
//! before File Info existed load unchanged.
|
//! before File Info existed load unchanged.
|
||||||
|
|
||||||
|
use chrono::{Datelike, Timelike};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// File Info → Copyright Status.
|
/// File Info → Copyright Status.
|
||||||
@@ -118,23 +119,26 @@ pub fn now_unix() -> Option<i64> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unix seconds (UTC) → `[year, month, day, hour, minute, second]` (civil-from-days, proleptic
|
/// Unix seconds (UTC) → `[year, month, day, hour, minute, second]` (proleptic Gregorian; years
|
||||||
/// Gregorian; years clamped to 0–9999).
|
/// clamped to 0–9999).
|
||||||
pub fn civil(t: i64) -> [i64; 6] {
|
pub fn civil(t: i64) -> [i64; 6] {
|
||||||
// Clamp first so every step stays in range for any input.
|
// Clamp the input first so every input maps to a representable date (0000-01-01 .. 9999-12-31),
|
||||||
|
// exactly as the old code did. The clamped instant is always inside chrono's range, so the
|
||||||
|
// conversion below never falls back; the trailing `clamp(0, 9999)` is the old year clamp.
|
||||||
let t = t.clamp(-62_167_219_200, 253_402_300_799);
|
let t = t.clamp(-62_167_219_200, 253_402_300_799);
|
||||||
let days = t.div_euclid(86_400);
|
let (days, sod) = (t.div_euclid(86_400), t.rem_euclid(86_400));
|
||||||
let secs = t.rem_euclid(86_400);
|
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
|
||||||
let z = days + 719_468;
|
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
|
||||||
let era = z.div_euclid(146_097);
|
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
|
||||||
let doe = z - era * 146_097;
|
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0));
|
||||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
match (date, time) {
|
||||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
(Some(d), Some(tm)) => {
|
||||||
let mp = (5 * doy + 2) / 153;
|
let year = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||||
let day = doy - (153 * mp + 2) / 5 + 1;
|
let year = i64::try_from(year).unwrap_or(if year < 0 { i64::MIN } else { i64::MAX });
|
||||||
let month = if mp < 10 { mp + 3 } else { mp - 9 };
|
[year.clamp(0, 9999), i64::from(d.month()), i64::from(d.day()), i64::from(tm.hour()), i64::from(tm.minute()), i64::from(tm.second())]
|
||||||
let year = yoe + era * 400 + i64::from(month <= 2);
|
}
|
||||||
[year.clamp(0, 9999), month, day, secs / 3600, secs / 60 % 60, secs % 60]
|
_ => [1970, 1, 1, 0, 0, 0], // unreachable: the clamped `t` is always a valid date
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unix seconds → `2026-10-05T14:03:00Z`.
|
/// Unix seconds → `2026-10-05T14:03:00Z`.
|
||||||
@@ -151,8 +155,15 @@ mod tests {
|
|||||||
fn dates_and_statuses() {
|
fn dates_and_statuses() {
|
||||||
assert_eq!(iso8601(0), "1970-01-01T00:00:00Z");
|
assert_eq!(iso8601(0), "1970-01-01T00:00:00Z");
|
||||||
assert_eq!(iso8601(1_791_208_980), "2026-10-05T14:03:00Z");
|
assert_eq!(iso8601(1_791_208_980), "2026-10-05T14:03:00Z");
|
||||||
|
// The input is clamped to 0000-01-01..9999-12-31, then the year is clamped to 0..=9999,
|
||||||
|
// exactly as the old code did: inputs far past chrono's range land on the clamp bounds,
|
||||||
|
// never on the epoch.
|
||||||
assert_eq!(civil(i64::MIN)[0], 0);
|
assert_eq!(civil(i64::MIN)[0], 0);
|
||||||
assert_eq!(civil(i64::MAX)[0], 9999);
|
assert_eq!(civil(i64::MAX)[0], 9999);
|
||||||
|
assert_eq!(iso8601(i64::MIN), "0000-01-01T00:00:00Z");
|
||||||
|
assert_eq!(iso8601(i64::MAX), "9999-12-31T23:59:59Z");
|
||||||
|
assert_eq!(iso8601(10_000_000_000_000), "9999-12-31T23:59:59Z");
|
||||||
|
assert_eq!(iso8601(-10_000_000_000_000), "0000-01-01T00:00:00Z");
|
||||||
assert_eq!(CopyrightStatus::parse("Public Domain"), Some(CopyrightStatus::PublicDomain));
|
assert_eq!(CopyrightStatus::parse("Public Domain"), Some(CopyrightStatus::PublicDomain));
|
||||||
assert_eq!(CopyrightStatus::parse("publicdomain"), Some(CopyrightStatus::PublicDomain));
|
assert_eq!(CopyrightStatus::parse("publicdomain"), Some(CopyrightStatus::PublicDomain));
|
||||||
assert_eq!(CopyrightStatus::parse("maybe"), None);
|
assert_eq!(CopyrightStatus::parse("maybe"), None);
|
||||||
|
|||||||
@@ -523,8 +523,8 @@ pub enum NodeKind {
|
|||||||
Mesh(GradientMesh),
|
Mesh(GradientMesh),
|
||||||
/// Live Repeat (radial / grid / mirror) of source art.
|
/// Live Repeat (radial / grid / mirror) of source art.
|
||||||
Repeat(RepeatSpec),
|
Repeat(RepeatSpec),
|
||||||
/// A placed document: an artboard of another VectorCraft file, linked and locked (see
|
/// A placed document: an artboard of another VectorCraft file, or a page of a PDF or `.ai`
|
||||||
/// [`crate::placed_document`]).
|
/// file, linked and locked (see [`crate::placed_document`]).
|
||||||
PlacedDocument(Box<crate::placed_document::PlacedDocument>),
|
PlacedDocument(Box<crate::placed_document::PlacedDocument>),
|
||||||
/// Object › Intertwine: the objects keep their own stacking order, except where one of them
|
/// Object › Intertwine: the objects keep their own stacking order, except where one of them
|
||||||
/// is drawn over the others: the area [`NodeExtras::intertwine`] of that member (see
|
/// is drawn over the others: the area [`NodeExtras::intertwine`] of that member (see
|
||||||
@@ -1301,10 +1301,8 @@ impl Node {
|
|||||||
self.projected(Some(b)).map(|b| b.inflate(o, o))
|
self.projected(Some(b)).map(|b| b.inflate(o, o))
|
||||||
}
|
}
|
||||||
NodeKind::Text(t) => {
|
NodeKind::Text(t) => {
|
||||||
// The characters' strokes paint around the glyphs, scaled with the type.
|
// The characters' strokes paint around the glyphs.
|
||||||
let k = Scaling::factor(t.xf).unwrap_or(1.0);
|
let o = self.appearance.outset().max(t.char_stroke_outset());
|
||||||
let chars = t.runs.iter().map(|r| r.style.stroke_outset()).fold(0.0, f64::max) * k;
|
|
||||||
let o = self.appearance.outset().max(chars);
|
|
||||||
self.geometric(symbols).map(|b| b.inflate(o, o))
|
self.geometric(symbols).map(|b| b.inflate(o, o))
|
||||||
}
|
}
|
||||||
// An image is measured by its frame, without its own fills and strokes.
|
// An image is measured by its frame, without its own fills and strokes.
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ impl PatternDef {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
/// Offsets of the pattern-editing preview copies (`copies`×`copies` around the tile, the
|
/// Offsets of the pattern-editing preview copies (`copies`×`copies` around the tile, the
|
||||||
/// centre `(0, 0)` excluded).
|
/// centre `(0, 0)` excluded), in row-major order (not paint order).
|
||||||
pub fn preview_offsets(&self) -> Vec<Vec2> {
|
pub fn preview_offsets(&self) -> Vec<Vec2> {
|
||||||
let k = (self.copies.clamp(1, 15) / 2) as i64;
|
let k = (self.copies.clamp(1, 15) / 2) as i64;
|
||||||
let mut out = vec![];
|
let mut out = vec![];
|
||||||
@@ -292,6 +292,38 @@ impl PatternDef {
|
|||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
/// Offsets of every pattern-editing preview copy, including the centre tile, in the same paint
|
||||||
|
/// order [`offsets_covering`] uses for [`Overlap`].
|
||||||
|
pub fn preview_offsets_in_paint_order(&self) -> Vec<Vec2> {
|
||||||
|
let k = (self.copies.clamp(1, 15) / 2) as i64;
|
||||||
|
let (i0, i1, j0, j1) = (-k, k, -k, k);
|
||||||
|
let mut out = vec![];
|
||||||
|
let rows: Vec<i64> = if self.overlap.bottom_in_front { (j0..=j1).collect() } else { (j0..=j1).rev().collect() };
|
||||||
|
for &j in &rows {
|
||||||
|
let cols: Vec<i64> = if self.overlap.right_in_front { (i0..=i1).collect() } else { (i0..=i1).rev().collect() };
|
||||||
|
for i in cols {
|
||||||
|
out.push(self.offset(i, j));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
/// Preview copies drawn before and after the centre tile in [`Self::preview_offsets_in_paint_order`],
|
||||||
|
/// for one dimmed composite each (the centre tile is drawn at full strength between them).
|
||||||
|
pub fn preview_offsets_dimmed_sides(&self) -> (Vec<Vec2>, Vec<Vec2>) {
|
||||||
|
let mut before = vec![];
|
||||||
|
let mut after = vec![];
|
||||||
|
let mut past_center = false;
|
||||||
|
for o in self.preview_offsets_in_paint_order() {
|
||||||
|
if o.x == 0.0 && o.y == 0.0 {
|
||||||
|
past_center = true;
|
||||||
|
} else if past_center {
|
||||||
|
after.push(o);
|
||||||
|
} else {
|
||||||
|
before.push(o);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(before, after)
|
||||||
|
}
|
||||||
/// Instances covering `region` (document space) as plain nodes for a pattern painted with
|
/// Instances covering `region` (document space) as plain nodes for a pattern painted with
|
||||||
/// `paint_xf` (pattern space → document), fresh-id-less (id 0). Used by exporters and Distort
|
/// `paint_xf` (pattern space → document), fresh-id-less (id 0). Used by exporters and Distort
|
||||||
/// Pattern Fills; Expand uses [`Self::instances_in_with`].
|
/// Pattern Fills; Expand uses [`Self::instances_in_with`].
|
||||||
@@ -744,6 +776,20 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preview_paint_order_respects_overlap() {
|
||||||
|
let mut def = PatternDef::new("p", vec![sq(0.0, 0.0, 10.0)]);
|
||||||
|
def.copies = 3;
|
||||||
|
assert!(close(*def.preview_offsets_in_paint_order().last().unwrap(), Vec2::new(-10.0, -10.0)));
|
||||||
|
let mut d2 = def.clone();
|
||||||
|
d2.overlap = Overlap { right_in_front: true, bottom_in_front: true };
|
||||||
|
assert!(close(*d2.preview_offsets_in_paint_order().last().unwrap(), Vec2::new(10.0, 10.0)));
|
||||||
|
assert_eq!(def.preview_offsets_in_paint_order().len(), 9);
|
||||||
|
let (before, after) = def.preview_offsets_dimmed_sides();
|
||||||
|
assert_eq!(before.len() + after.len(), 8);
|
||||||
|
assert!(before.iter().all(|o| *o != Vec2::ZERO) && after.iter().all(|o| *o != Vec2::ZERO));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn covering_offsets_and_overlap_order() {
|
fn covering_offsets_and_overlap_order() {
|
||||||
let def = PatternDef::new("p", vec![sq(0.0, 0.0, 10.0)]);
|
let def = PatternDef::new("p", vec![sq(0.0, 0.0, 10.0)]);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
//! Placed documents: another VectorCraft file placed linked ([`PlacedDocument`]).
|
//! Placed documents: another VectorCraft file, or a page of a PDF or `.ai` file, placed linked
|
||||||
|
//! ([`PlacedDocument`]).
|
||||||
//!
|
//!
|
||||||
//! The object keeps the link to the file and the file's bytes, stored like image data in
|
//! The object keeps the link to the file and the file's bytes, stored like image data in
|
||||||
//! [`Document::images`] (with a small preview a save writes instead, as for linked images), so the
|
//! [`Document::images`] (with a small preview a save writes instead, as for linked images), so the
|
||||||
@@ -6,8 +7,9 @@
|
|||||||
//! joins the object tree, so editing can't reach it: it is locked by design. Drawing and exporting
|
//! joins the object tree, so editing can't reach it: it is locked by design. Drawing and exporting
|
||||||
//! expand it like a live object ([`crate::live::expanded`]).
|
//! expand it like a live object ([`crate::live::expanded`]).
|
||||||
//!
|
//!
|
||||||
//! The file is read by the native format's loader, which lives above this crate and registers
|
//! The file is read by the engine's loader (native documents, PDF and `.ai` pages), which lives
|
||||||
//! itself ([`set_loader`]); the art read is kept in a small cache by content. Before exporting,
|
//! above this crate and registers itself ([`set_loader`]); the art read is kept in a small cache
|
||||||
|
//! by content. Before exporting,
|
||||||
//! every exporter calls [`Document::with_placed_art`], which adds the resources the art uses
|
//! every exporter calls [`Document::with_placed_art`], which adds the resources the art uses
|
||||||
//! (images, symbols, patterns) under names of their own ([`RESOURCE_PREFIX`]) to a temporary copy
|
//! (images, symbols, patterns) under names of their own ([`RESOURCE_PREFIX`]) to a temporary copy
|
||||||
//! of the document, so they never meet the user's or show in panels. Placed documents inside a
|
//! of the document, so they never meet the user's or show in panels. Placed documents inside a
|
||||||
@@ -25,16 +27,21 @@ use vectorcraft_color::Paint;
|
|||||||
|
|
||||||
use crate::{AppearanceItem, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeKind, PatternDef, PlacementOptions, Symbol};
|
use crate::{AppearanceItem, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeKind, PatternDef, PlacementOptions, Symbol};
|
||||||
|
|
||||||
/// A placed document: an artboard of another VectorCraft file, shown as one locked object.
|
/// A placed document: an artboard of another VectorCraft file, or a page of a PDF or `.ai` file,
|
||||||
|
/// shown as one locked object.
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct PlacedDocument {
|
pub struct PlacedDocument {
|
||||||
/// The file (its `page` is the artboard shown, 1-based).
|
/// The file (its `page` is the artboard or page shown, 1-based).
|
||||||
pub link: LinkInfo,
|
pub link: LinkInfo,
|
||||||
/// Key into [`Document::images`]: the file's bytes (see [`key_for`]).
|
/// Key into [`Document::images`]: the file's bytes (see [`key_for`]).
|
||||||
pub key: String,
|
pub key: String,
|
||||||
/// The art's bounds rather than the artboard (File › Place's `crop: "bounding"`).
|
/// The art's bounds rather than the artboard (File › Place's `crop: "bounding"`).
|
||||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||||
pub bounding: bool,
|
pub bounding: bool,
|
||||||
|
/// The box of a PDF or `.ai` page shown when not `bounding` (File › Place's `crop`); a
|
||||||
|
/// VectorCraft document shows its artboard whatever this says.
|
||||||
|
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||||
|
pub page_box: PageBox,
|
||||||
/// The artboard's size (or the art's) at 100% (pt).
|
/// The artboard's size (or the art's) at 100% (pt).
|
||||||
pub width: f64,
|
pub width: f64,
|
||||||
pub height: f64,
|
pub height: f64,
|
||||||
@@ -45,11 +52,43 @@ pub struct PlacedDocument {
|
|||||||
pub placement: PlacementOptions,
|
pub placement: PlacementOptions,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The box of a PDF page a placed PDF or `.ai` file shows (File › Place's `crop`, other than
|
||||||
|
/// `bounding`).
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum PageBox {
|
||||||
|
/// The visible page area.
|
||||||
|
#[default]
|
||||||
|
Crop,
|
||||||
|
Art,
|
||||||
|
Trim,
|
||||||
|
Bleed,
|
||||||
|
Media,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PageBox {
|
||||||
|
/// The `crop` param value.
|
||||||
|
pub fn id(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
PageBox::Crop => "crop",
|
||||||
|
PageBox::Art => "art",
|
||||||
|
PageBox::Trim => "trim",
|
||||||
|
PageBox::Bleed => "bleed",
|
||||||
|
PageBox::Media => "media",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The images key of a file's bytes (`hash`, [`crate::links::hash_bytes`]) placed showing
|
/// The images key of a file's bytes (`hash`, [`crate::links::hash_bytes`]) placed showing
|
||||||
/// artboard `page` (`bounding`: its art's bounds): objects showing the same thing share a blob and
|
/// artboard or page `page` (`bounding`: its art's bounds; else the page's box `page_box`):
|
||||||
/// its preview.
|
/// objects showing the same thing share a blob and its preview.
|
||||||
pub fn key_for(hash: &str, page: u32, bounding: bool) -> String {
|
pub fn key_for(hash: &str, page: u32, bounding: bool, page_box: PageBox) -> String {
|
||||||
format!("{hash}-{page}{}", if bounding { "b" } else { "" })
|
let shown = match (bounding, page_box) {
|
||||||
|
(true, _) => "b",
|
||||||
|
(false, PageBox::Crop) => "",
|
||||||
|
(false, b) => b.id(),
|
||||||
|
};
|
||||||
|
format!("{hash}-{page}{shown}")
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PlacedDocument {
|
impl PlacedDocument {
|
||||||
@@ -576,6 +615,7 @@ mod tests {
|
|||||||
link: LinkInfo::new("/art/logo.vectorcraft"),
|
link: LinkInfo::new("/art/logo.vectorcraft"),
|
||||||
key: key.into(),
|
key: key.into(),
|
||||||
bounding: false,
|
bounding: false,
|
||||||
|
page_box: PageBox::Crop,
|
||||||
width: 80.0,
|
width: 80.0,
|
||||||
height: 40.0,
|
height: 40.0,
|
||||||
xf: Affine::translate((10.0, 10.0)),
|
xf: Affine::translate((10.0, 10.0)),
|
||||||
@@ -616,9 +656,18 @@ mod tests {
|
|||||||
let v = serde_json::to_value(&n).unwrap();
|
let v = serde_json::to_value(&n).unwrap();
|
||||||
assert_eq!(v["kind"]["type"], "placeddocument");
|
assert_eq!(v["kind"]["type"], "placeddocument");
|
||||||
assert_eq!(v["kind"]["link"]["path"], "/art/logo.vectorcraft");
|
assert_eq!(v["kind"]["link"]["path"], "/art/logo.vectorcraft");
|
||||||
|
// The box of a PDF page is written only when it isn't the crop box.
|
||||||
|
assert!(v["kind"].get("page_box").is_none(), "{v}");
|
||||||
let back: Node = serde_json::from_value(v).unwrap();
|
let back: Node = serde_json::from_value(v).unwrap();
|
||||||
assert_eq!(back, n);
|
assert_eq!(back, n);
|
||||||
assert_eq!(key_for("img00", 2, true), "img00-2b");
|
assert_eq!(key_for("img00", 2, true, PageBox::Crop), "img00-2b");
|
||||||
|
assert_eq!(key_for("img00", 2, false, PageBox::Crop), "img00-2");
|
||||||
|
assert_eq!(key_for("img00", 2, false, PageBox::Trim), "img00-2trim");
|
||||||
|
let mut trim = placed("doc1-1trim");
|
||||||
|
trim.page_box = PageBox::Trim;
|
||||||
|
let v = serde_json::to_value(&trim).unwrap();
|
||||||
|
assert_eq!(v["page_box"], "trim");
|
||||||
|
assert_eq!(serde_json::from_value::<PlacedDocument>(v).unwrap(), trim);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ pub const LANGUAGES: &[(&str, Quotes)] = &[
|
|||||||
("Polish", Quotes { double: ['„', '”'], single: ['‚', '’'] }),
|
("Polish", Quotes { double: ['„', '”'], single: ['‚', '’'] }),
|
||||||
("Czech", Quotes { double: ['„', '“'], single: ['‚', '‘'] }),
|
("Czech", Quotes { double: ['„', '“'], single: ['‚', '‘'] }),
|
||||||
("Russian", Quotes { double: ['«', '»'], single: ['„', '“'] }),
|
("Russian", Quotes { double: ['«', '»'], single: ['„', '“'] }),
|
||||||
|
("Turkish", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
|
||||||
("Japanese", Quotes { double: ['「', '」'], single: ['『', '』'] }),
|
("Japanese", Quotes { double: ['「', '」'], single: ['『', '』'] }),
|
||||||
("Chinese", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
|
("Chinese", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ fn fx(id: &str, params: Value) -> Effect {
|
|||||||
|
|
||||||
/// A gradient of `(offset, colour, opacity)` stops.
|
/// A gradient of `(offset, colour, opacity)` stops.
|
||||||
fn gradient(kind: GradientKind, stops: &[(f32, &str, f32)]) -> Gradient {
|
fn gradient(kind: GradientKind, stops: &[(f32, &str, f32)]) -> Gradient {
|
||||||
Gradient { kind, stops: stops.iter().map(|&(offset, c, opacity)| GradientStop { opacity, ..GradientStop::new(offset, hex(c)) }).collect() }
|
Gradient::new(kind, stops.iter().map(|&(offset, c, opacity)| GradientStop { opacity, ..GradientStop::new(offset, hex(c)) }).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A linear gradient fitted to each object's box at `angle` degrees (90: upwards).
|
/// A linear gradient fitted to each object's box at `angle` degrees (90: upwards).
|
||||||
|
|||||||
+40
-1
@@ -104,6 +104,12 @@ pub struct CharStyle {
|
|||||||
/// their line line up with it.
|
/// their line line up with it.
|
||||||
#[serde(default, rename = "charAlign", skip_serializing_if = "crate::skip::is_default")]
|
#[serde(default, rename = "charAlign", skip_serializing_if = "crate::skip::is_default")]
|
||||||
pub char_align: CharAlign,
|
pub char_align: CharAlign,
|
||||||
|
/// Character Direction (Character panel menu, Middle Eastern options): these characters are
|
||||||
|
/// set left to right or right to left inside their paragraph, each stretch of them a Unicode
|
||||||
|
/// bidi embedding (UAX #9 LRE or RLE); None = Default, the direction the bidi algorithm gives
|
||||||
|
/// them.
|
||||||
|
#[serde(default, rename = "charDirection", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub char_direction: Option<ParaDirection>,
|
||||||
/// Proportional Metrics (Character panel menu, East Asian options): full-width glyphs take the
|
/// Proportional Metrics (Character panel menu, East Asian options): full-width glyphs take the
|
||||||
/// font's own proportional widths, OpenType `palt` (#966); in vertical type upright glyphs take
|
/// font's own proportional widths, OpenType `palt` (#966); in vertical type upright glyphs take
|
||||||
/// its proportional heights, `vpal`.
|
/// its proportional heights, `vpal`.
|
||||||
@@ -297,6 +303,7 @@ impl Default for CharStyle {
|
|||||||
stroke_align: StrokeAlign::Center,
|
stroke_align: StrokeAlign::Center,
|
||||||
position: CharPosition::Normal,
|
position: CharPosition::Normal,
|
||||||
char_align: CharAlign::RomanBaseline,
|
char_align: CharAlign::RomanBaseline,
|
||||||
|
char_direction: None,
|
||||||
proportional_metrics: false,
|
proportional_metrics: false,
|
||||||
tate_chu_yoko: false,
|
tate_chu_yoko: false,
|
||||||
small_caps: None,
|
small_caps: None,
|
||||||
@@ -862,7 +869,8 @@ impl From<ParaStyleFile> for ParaStyle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A paragraph's base direction ([`ParaStyle::direction`]).
|
/// A paragraph's base direction ([`ParaStyle::direction`]), or the direction characters are set
|
||||||
|
/// in ([`CharStyle::char_direction`]).
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub enum ParaDirection {
|
pub enum ParaDirection {
|
||||||
@@ -1702,6 +1710,21 @@ impl TextObject {
|
|||||||
TextKind::Point => Some(self.xf.transform_rect_bbox(self.cached_bounds.unwrap_or_else(|| self.estimate_bounds()))),
|
TextKind::Point => Some(self.xf.transform_rect_bbox(self.cached_bounds.unwrap_or_else(|| self.estimate_bounds()))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/// How far the characters' strokes paint past their glyphs, in document space: they scale
|
||||||
|
/// with the type.
|
||||||
|
pub fn char_stroke_outset(&self) -> f64 {
|
||||||
|
let k = crate::node::Scaling::factor(self.xf).unwrap_or(1.0);
|
||||||
|
self.runs.iter().map(|r| r.style.stroke_outset()).fold(0.0, f64::max) * k
|
||||||
|
}
|
||||||
|
/// How far the characters' strokes can paint past their glyphs, their miter spikes and square
|
||||||
|
/// caps included ([`StrokeLayer::reach`]), in document space: what a change of the type must
|
||||||
|
/// redraw. They stretch with the type, so this is as far as its transform takes them on either
|
||||||
|
/// axis. The visual bounds take [`Self::char_stroke_outset`].
|
||||||
|
pub fn char_stroke_reach(&self) -> f64 {
|
||||||
|
let reach = self.runs.iter().filter(|r| r.style.has_stroke()).map(|r| r.style.stroke_layer().reach()).fold(0.0, f64::max);
|
||||||
|
let [a, b, c, d, ..] = self.xf.as_coeffs();
|
||||||
|
reach * (a.abs() + c.abs()).max(b.abs() + d.abs())
|
||||||
|
}
|
||||||
pub fn transform(&mut self, a: Affine) {
|
pub fn transform(&mut self, a: Affine) {
|
||||||
self.xf = a * self.xf;
|
self.xf = a * self.xf;
|
||||||
}
|
}
|
||||||
@@ -1906,6 +1929,22 @@ impl crate::Document {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// Character Direction is saved only when set, and styles written before it existed read as
|
||||||
|
/// Default.
|
||||||
|
#[test]
|
||||||
|
fn character_direction_is_saved_only_when_set() {
|
||||||
|
let st = CharStyle::default();
|
||||||
|
assert_eq!(st.char_direction, None);
|
||||||
|
assert!(serde_json::to_value(&st).unwrap().get("charDirection").is_none());
|
||||||
|
for (d, key) in [(ParaDirection::LeftToRight, "leftToRight"), (ParaDirection::RightToLeft, "rightToLeft")] {
|
||||||
|
let set = CharStyle { char_direction: Some(d), ..CharStyle::default() };
|
||||||
|
assert_eq!(serde_json::to_value(&set).unwrap()["charDirection"], key);
|
||||||
|
assert_eq!(serde_json::from_value::<CharStyle>(serde_json::to_value(&set).unwrap()).unwrap(), set);
|
||||||
|
}
|
||||||
|
let old = serde_json::to_value(&st).unwrap();
|
||||||
|
assert_eq!(serde_json::from_value::<CharStyle>(old).unwrap().char_direction, None);
|
||||||
|
}
|
||||||
|
|
||||||
/// Ruby saves with the type and a file saved before it (no `ruby` key) reads with none.
|
/// Ruby saves with the type and a file saved before it (no `ruby` key) reads with none.
|
||||||
#[test]
|
#[test]
|
||||||
fn ruby_is_saved_and_missing_ruby_reads_as_none() {
|
fn ruby_is_saved_and_missing_ruby_reads_as_none() {
|
||||||
|
|||||||
@@ -565,7 +565,7 @@ pub fn bake_appearance(n: &Node) -> Option<Node> {
|
|||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
let effects = st.effects.clone();
|
let effects = st.effects.clone();
|
||||||
let fill = FillLayer { opacity: st.opacity, blend: st.blend, effects, ..FillLayer::new(st.paint.clone()) };
|
let fill = FillLayer { opacity: st.opacity, blend: st.blend, effects, ..FillLayer::new(st.paint.stroke_as_fill()) };
|
||||||
piece(outline, FillRule::NonZero, AppearanceItem::Fill(fill))
|
piece(outline, FillRule::NonZero, AppearanceItem::Fill(fill))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -673,6 +673,22 @@ mod tests {
|
|||||||
assert_eq!(f.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["stylize.dropShadow"]);
|
assert_eq!(f.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["stylize.dropShadow"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_baked_stroke_leaves_dither_behind() {
|
||||||
|
// Strokes ignore dither, so the filled outline goes without it and looks as the stroke did.
|
||||||
|
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
|
||||||
|
if let AppearanceItem::Stroke(s) = &mut n.appearance.items[1] {
|
||||||
|
let mut g = vectorcraft_color::GradientPaint::new(Default::default());
|
||||||
|
g.gradient.dither = true;
|
||||||
|
s.paint = Paint::Gradient(Box::new(g));
|
||||||
|
}
|
||||||
|
let m = bake_appearance(&n).unwrap();
|
||||||
|
let ch = m.children().unwrap();
|
||||||
|
let Some(AppearanceItem::Fill(f)) = ch[1].appearance.items.first() else { panic!("the outline is filled") };
|
||||||
|
let Paint::Gradient(g) = &f.paint else { panic!("a gradient fill") };
|
||||||
|
assert!(!g.gradient.dither);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_baked_stroke_with_a_gradient_along_it_becomes_meshes_clipped_to_its_outline() {
|
fn a_baked_stroke_with_a_gradient_along_it_becomes_meshes_clipped_to_its_outline() {
|
||||||
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
|
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
|
||||||
@@ -1034,7 +1050,7 @@ mod tests {
|
|||||||
fn gradient_copies(id: u64) -> Node {
|
fn gradient_copies(id: u64) -> Node {
|
||||||
use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop};
|
use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop};
|
||||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||||
let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops })));
|
let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
|
||||||
let mut n = Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(paint, Paint::None, 0.0));
|
let mut n = Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(paint, Paint::None, 0.0));
|
||||||
n.appearance.effects.push(fx("distort.transform", json!({"moveH": 30, "copies": 2})));
|
n.appearance.effects.push(fx("distort.transform", json!({"moveH": 30, "copies": 2})));
|
||||||
n
|
n
|
||||||
|
|||||||
@@ -589,7 +589,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||||
let ramp = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
|
let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
|
||||||
// The type's characters fit the gradient to its layout; the square has it placed across itself.
|
// The type's characters fit the gradient to its layout; the square has it placed across itself.
|
||||||
let style = vectorcraft_doc::CharStyle { size: 40.0, fill: Paint::Gradient(Box::new(ramp.clone())), ..Default::default() };
|
let style = vectorcraft_doc::CharStyle { size: 40.0, fill: Paint::Gradient(Box::new(ramp.clone())), ..Default::default() };
|
||||||
let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(5.0, 60.0), "IIII", style);
|
let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(5.0, 60.0), "IIII", style);
|
||||||
@@ -615,7 +615,7 @@ mod tests {
|
|||||||
fn a_transform_on_a_group_paints_each_copy_with_its_own_gradient() {
|
fn a_transform_on_a_group_paints_each_copy_with_its_own_gradient() {
|
||||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||||
let ramp = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
|
let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
|
||||||
let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None };
|
let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None };
|
||||||
let paints = [Paint::Gradient(Box::new(ramp.clone())), Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }))];
|
let paints = [Paint::Gradient(Box::new(ramp.clone())), Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }))];
|
||||||
for (k, paint) in paints.into_iter().enumerate() {
|
for (k, paint) in paints.into_iter().enumerate() {
|
||||||
|
|||||||
@@ -598,6 +598,21 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
|||||||
"{detail: 0..10 (4; finer surface texture), smoothness: 0..10 (4; softer relief before the highlights and shadows)} treats the object as polished chrome lit from the top left: bright highlights on high points and dark shadows in low ones, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
|
"{detail: 0..10 (4; finer surface texture), smoothness: 0..10 (4; softer relief before the highlights and shadows)} treats the object as polished chrome lit from the top left: bright highlights on high points and dark shadows in low ones, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
|
||||||
json!({"detail": 4.0, "smoothness": 4.0}),
|
json!({"detail": 4.0, "smoothness": 4.0}),
|
||||||
),
|
),
|
||||||
|
r(
|
||||||
|
"sketch.conteCrayon",
|
||||||
|
"Conté Crayon…",
|
||||||
|
SKETCH,
|
||||||
|
"{foregroundLevel: 0..20 (12; how far the black crayon reaches from the shadows into the midtones), backgroundLevel: 0..20 (10; how far the white crayon reaches from the highlights into the midtones), texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\" (\"canvas\"; the paper's surface, made in code), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false; turns the surface's heights over)} redraws the object in a dense black crayon in its dark areas and a pure white crayon in its light areas, on mid-grey paper whose texture breaks the strokes and stands in relief, keeping its coverage. Public help names Foreground Level, Background Level and the texture options but not slider ranges or dialog defaults; ranges and defaults here are unverified VectorCraft choices. Original visual approximation, not a proprietary reproduction.",
|
||||||
|
json!({
|
||||||
|
"foregroundLevel": 12.0,
|
||||||
|
"backgroundLevel": 10.0,
|
||||||
|
"texture": "canvas",
|
||||||
|
"scaling": 100.0,
|
||||||
|
"relief": 4.0,
|
||||||
|
"lightDirection": "top",
|
||||||
|
"invert": false
|
||||||
|
}),
|
||||||
|
),
|
||||||
r(
|
r(
|
||||||
"sketch.graphicPen",
|
"sketch.graphicPen",
|
||||||
"Graphic Pen…",
|
"Graphic Pen…",
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ pub(crate) fn shade(c: Color, rgb: [f64; 3]) -> Color {
|
|||||||
pub(crate) fn smooth_paint(g: vectorcraft_three_d::LightGradient, color: Color) -> Paint {
|
pub(crate) fn smooth_paint(g: vectorcraft_three_d::LightGradient, color: Color) -> Paint {
|
||||||
use vectorcraft_doc::color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
use vectorcraft_doc::color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||||
let stops = g.stops().into_iter().map(|(at, rgb)| GradientStop::new(at, shade(color, rgb))).collect();
|
let stops = g.stops().into_iter().map(|(at, rgb)| GradientStop::new(at, shade(color, rgb))).collect();
|
||||||
let mut p = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
|
let mut p = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
|
||||||
p.geom = Some(GradientGeom { start: g.diffuse.start, end: g.diffuse.end, aspect: 1.0, focal: None });
|
p.geom = Some(GradientGeom { start: g.diffuse.start, end: g.diffuse.end, aspect: 1.0, focal: None });
|
||||||
Paint::Gradient(Box::new(p))
|
Paint::Gradient(Box::new(p))
|
||||||
}
|
}
|
||||||
@@ -334,7 +334,7 @@ mod tests {
|
|||||||
stop.opacity = 0.4;
|
stop.opacity = 0.4;
|
||||||
stop.midpoint = 0.3;
|
stop.midpoint = 0.3;
|
||||||
let g = GradientPaint {
|
let g = GradientPaint {
|
||||||
gradient: Gradient { kind: GradientKind::Linear, stops: vec![stop, GradientStop::new(1.0, Color::BLACK)] },
|
gradient: Gradient::new(GradientKind::Linear, vec![stop, GradientStop::new(1.0, Color::BLACK)]),
|
||||||
geom: Some(GradientGeom { start: Point::new(20.0, 30.0), end: Point::new(100.0, 30.0), aspect: 1.0, focal: None }),
|
geom: Some(GradientGeom { start: Point::new(20.0, 30.0), end: Point::new(100.0, 30.0), aspect: 1.0, focal: None }),
|
||||||
angle: 0.0,
|
angle: 0.0,
|
||||||
swatch: None,
|
swatch: None,
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
//! Poster Edges, Rough Pastels, Smudge Stick, Sponge and Watercolor, Blur › Radial Blur and Smart Blur, Brush Strokes › Accented Edges, Angled Strokes,
|
//! Poster Edges, Rough Pastels, Smudge Stick, Sponge and Watercolor, Blur › Radial Blur and Smart Blur, Brush Strokes › Accented Edges, Angled Strokes,
|
||||||
//! Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes and Sumi-e, Distort › Diffuse
|
//! Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes and Sumi-e, Distort › Diffuse
|
||||||
//! Glow, Glass and Ocean Ripple, Pixelate › Color Halftone, Crystallize, Mezzotint and Pointillize,
|
//! Glow, Glass and Ocean Ripple, Pixelate › Color Halftone, Crystallize, Mezzotint and Pointillize,
|
||||||
//! Sharpen › Unsharp Mask, Sketch › Bas Relief, Chalk & Charcoal, Charcoal, Graphic Pen, Halftone Pattern, Note Paper, Photocopy, Plaster,
|
//! Sharpen › Unsharp Mask, Sketch › Bas Relief, Chalk & Charcoal, Charcoal, Chrome, Conté Crayon, Graphic Pen, Halftone Pattern,
|
||||||
//! Reticulation, Stamp and Torn Edges, Stylize ›
|
//! Note Paper, Photocopy, Plaster, Reticulation, Stamp, Torn Edges and Water Paper, Stylize ›
|
||||||
//! Glowing Edges, Texture
|
//! Glowing Edges, Texture
|
||||||
//! › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, and Video ›
|
//! › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, and Video ›
|
||||||
//! De-Interlace and NTSC Colors): filters over premultiplied RGBA8 pixels.
|
//! De-Interlace and NTSC Colors): filters over premultiplied RGBA8 pixels.
|
||||||
@@ -44,7 +44,7 @@ pub use sketch::{HALFTONE_TYPES, HalftonePattern};
|
|||||||
pub use texture::{GRAIN_TYPES, Grain, LIGHT_DIRECTIONS, Light, TEXTURES, TEXTURES_LOADED, Texture};
|
pub use texture::{GRAIN_TYPES, Grain, LIGHT_DIRECTIONS, Light, TEXTURES, TEXTURES_LOADED, Texture};
|
||||||
|
|
||||||
/// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]).
|
/// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]).
|
||||||
pub const PIXEL_EFFECTS: [&str; 55] = [
|
pub const PIXEL_EFFECTS: [&str; 56] = [
|
||||||
"artistic.coloredPencil",
|
"artistic.coloredPencil",
|
||||||
"artistic.cutout",
|
"artistic.cutout",
|
||||||
"artistic.dryBrush",
|
"artistic.dryBrush",
|
||||||
@@ -82,6 +82,7 @@ pub const PIXEL_EFFECTS: [&str; 55] = [
|
|||||||
"sketch.chalkCharcoal",
|
"sketch.chalkCharcoal",
|
||||||
"sketch.charcoal",
|
"sketch.charcoal",
|
||||||
"sketch.chrome",
|
"sketch.chrome",
|
||||||
|
"sketch.conteCrayon",
|
||||||
"sketch.graphicPen",
|
"sketch.graphicPen",
|
||||||
"sketch.halftonePattern",
|
"sketch.halftonePattern",
|
||||||
"sketch.notePaper",
|
"sketch.notePaper",
|
||||||
@@ -210,6 +211,11 @@ pub enum PixelFx {
|
|||||||
ChalkCharcoal { chalk: f64, charcoal: f64, pressure: f64 },
|
ChalkCharcoal { chalk: f64, charcoal: f64, pressure: f64 },
|
||||||
/// Sketch › Chrome: polished chrome relief with `detail` (0..10) and `smoothness` (0..10).
|
/// Sketch › Chrome: polished chrome relief with `detail` (0..10) and `smoothness` (0..10).
|
||||||
Chrome { detail: f64, smoothness: f64 },
|
Chrome { detail: f64, smoothness: f64 },
|
||||||
|
/// Sketch › Conté Crayon: dark areas in a black crayon, light areas in a white one, on mid-grey
|
||||||
|
/// paper; `foreground` and `background` (0..20) set how far each crayon reaches into the
|
||||||
|
/// midtones, and the paper is a `texture` surface scaled by `scaling` (0.5..2), in relief as
|
||||||
|
/// strong as `relief` (0..50), lit from `light`, its heights turned over when `invert`.
|
||||||
|
ConteCrayon { foreground: f64, background: f64, texture: Texture, scaling: f64, relief: f64, light: Light, invert: bool },
|
||||||
/// Sketch › Graphic Pen: fine linear ink strokes along `dir`, each `length` (1..15) points long
|
/// Sketch › Graphic Pen: fine linear ink strokes along `dir`, each `length` (1..15) points long
|
||||||
/// on white paper; `balance` (0..100) sets how much of the tone range becomes black ink.
|
/// on white paper; `balance` (0..100) sets how much of the tone range becomes black ink.
|
||||||
GraphicPen { length: f64, balance: f64, direction: StrokeDirection },
|
GraphicPen { length: f64, balance: f64, direction: StrokeDirection },
|
||||||
@@ -483,6 +489,15 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
|
|||||||
pressure: num(p, "strokePressure", 2.0).clamp(0.0, 5.0),
|
pressure: num(p, "strokePressure", 2.0).clamp(0.0, 5.0),
|
||||||
},
|
},
|
||||||
"sketch.chrome" => PixelFx::Chrome { detail: num(p, "detail", 4.0).clamp(0.0, 10.0), smoothness: num(p, "smoothness", 4.0).clamp(0.0, 10.0) },
|
"sketch.chrome" => PixelFx::Chrome { detail: num(p, "detail", 4.0).clamp(0.0, 10.0), smoothness: num(p, "smoothness", 4.0).clamp(0.0, 10.0) },
|
||||||
|
"sketch.conteCrayon" => PixelFx::ConteCrayon {
|
||||||
|
foreground: num(p, "foregroundLevel", 12.0).clamp(0.0, 20.0),
|
||||||
|
background: num(p, "backgroundLevel", 10.0).clamp(0.0, 20.0),
|
||||||
|
texture: Texture::parse(text(p, "texture", "canvas")),
|
||||||
|
scaling: num(p, "scaling", 100.0).clamp(50.0, 200.0) / 100.0,
|
||||||
|
relief: num(p, "relief", 4.0).clamp(0.0, 50.0),
|
||||||
|
light: Light::parse(text(p, "lightDirection", "top")),
|
||||||
|
invert: flag(p, "invert", false),
|
||||||
|
},
|
||||||
"sketch.graphicPen" => PixelFx::GraphicPen {
|
"sketch.graphicPen" => PixelFx::GraphicPen {
|
||||||
length: num(p, "strokeLength", 5.0).clamp(1.0, 15.0),
|
length: num(p, "strokeLength", 5.0).clamp(1.0, 15.0),
|
||||||
balance: num(p, "lightDarkBalance", 50.0).clamp(0.0, 100.0),
|
balance: num(p, "lightDarkBalance", 50.0).clamp(0.0, 100.0),
|
||||||
@@ -617,6 +632,7 @@ impl PixelFx {
|
|||||||
PixelFx::UnsharpMask { .. }
|
PixelFx::UnsharpMask { .. }
|
||||||
| PixelFx::ChalkCharcoal { .. }
|
| PixelFx::ChalkCharcoal { .. }
|
||||||
| PixelFx::Chrome { .. }
|
| PixelFx::Chrome { .. }
|
||||||
|
| PixelFx::ConteCrayon { .. }
|
||||||
| PixelFx::HalftonePattern { .. }
|
| PixelFx::HalftonePattern { .. }
|
||||||
| PixelFx::NotePaper { .. }
|
| PixelFx::NotePaper { .. }
|
||||||
| PixelFx::Plaster { .. }
|
| PixelFx::Plaster { .. }
|
||||||
@@ -692,6 +708,7 @@ impl PixelFx {
|
|||||||
PixelFx::ColoredPencil { width, .. } => Some(artistic::colored_pencil_reach(width)),
|
PixelFx::ColoredPencil { width, .. } => Some(artistic::colored_pencil_reach(width)),
|
||||||
PixelFx::ChalkCharcoal { .. } => Some(sketch::CHALK_CHARCOAL_REACH),
|
PixelFx::ChalkCharcoal { .. } => Some(sketch::CHALK_CHARCOAL_REACH),
|
||||||
PixelFx::Chrome { smoothness, .. } => Some(sketch::chrome_reach(smoothness)),
|
PixelFx::Chrome { smoothness, .. } => Some(sketch::chrome_reach(smoothness)),
|
||||||
|
PixelFx::ConteCrayon { .. } => Some(sketch::CONTE_REACH),
|
||||||
PixelFx::FilmGrain { .. }
|
PixelFx::FilmGrain { .. }
|
||||||
| PixelFx::Mezzotint { .. }
|
| PixelFx::Mezzotint { .. }
|
||||||
| PixelFx::HalftonePattern { .. }
|
| PixelFx::HalftonePattern { .. }
|
||||||
@@ -778,6 +795,9 @@ impl PixelFx {
|
|||||||
PixelFx::UnsharpMask { amount, radius, threshold } => sharpen::unsharp(px, w, h, amount, to_px(radius), threshold),
|
PixelFx::UnsharpMask { amount, radius, threshold } => sharpen::unsharp(px, w, h, amount, to_px(radius), threshold),
|
||||||
PixelFx::ChalkCharcoal { chalk, charcoal, pressure } => sketch::chalk_charcoal(px, w, h, space, chalk, charcoal, pressure),
|
PixelFx::ChalkCharcoal { chalk, charcoal, pressure } => sketch::chalk_charcoal(px, w, h, space, chalk, charcoal, pressure),
|
||||||
PixelFx::Chrome { detail, smoothness } => sketch::chrome(px, w, h, space, detail, smoothness),
|
PixelFx::Chrome { detail, smoothness } => sketch::chrome(px, w, h, space, detail, smoothness),
|
||||||
|
PixelFx::ConteCrayon { foreground, background, ref texture, scaling, relief, light, invert } => {
|
||||||
|
sketch::conte_crayon(px, w, h, space, foreground, background, texture, scaling, relief, light, invert)
|
||||||
|
}
|
||||||
PixelFx::GraphicPen { length, balance, direction } => brushstrokes::graphic_pen(px, w, h, space, length, balance, direction),
|
PixelFx::GraphicPen { length, balance, direction } => brushstrokes::graphic_pen(px, w, h, space, length, balance, direction),
|
||||||
PixelFx::HalftonePattern { size, contrast, kind } => sketch::halftone_pattern(px, w, h, space, size, contrast, kind),
|
PixelFx::HalftonePattern { size, contrast, kind } => sketch::halftone_pattern(px, w, h, space, size, contrast, kind),
|
||||||
PixelFx::NotePaper { balance, graininess, relief } => sketch::note_paper(px, w, h, space, balance, graininess, relief),
|
PixelFx::NotePaper { balance, graininess, relief } => sketch::note_paper(px, w, h, space, balance, graininess, relief),
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
//! Effect › Sketch (Bas Relief, Chalk & Charcoal, Charcoal, Chrome, Halftone Pattern, Note Paper, Photocopy, Plaster,
|
//! Effect › Sketch (Bas Relief, Chalk & Charcoal, Charcoal, Chrome, Conté Crayon, Halftone Pattern, Note Paper, Photocopy,
|
||||||
//! Reticulation, Stamp, Torn Edges and Water Paper; Graphic Pen lives in
|
//! Plaster, Reticulation, Stamp, Torn Edges and Water Paper; Graphic Pen lives in
|
||||||
//! [`super::brushstrokes`]). Original filters built from public behaviour documentation:
|
//! [`super::brushstrokes`]). Original filters built from public behaviour documentation:
|
||||||
//! <https://helpx.adobe.com/illustrator/using/summary-effects.html> (Sketch effects redraw in
|
//! <https://helpx.adobe.com/illustrator/using/summary-effects.html> (Sketch effects redraw in
|
||||||
//! black and white; Water Paper flows colours on damp fibrous paper) and the public Photoshop Elements Artistic filters guide. Visual
|
//! black and white; Water Paper flows colours on damp fibrous paper; Conté Crayon draws dark areas
|
||||||
//! approximations, not proprietary reproductions.
|
//! in black and light areas in white) and the public Photoshop Elements Sketch filters guide
|
||||||
|
//! (<https://helpx.adobe.com/photoshop-elements/using/sketch-filters.html>: Conté Crayon's
|
||||||
|
//! foreground and background levels and texture options). Visual approximations, not proprietary
|
||||||
|
//! reproductions.
|
||||||
|
|
||||||
use std::f64::consts::{FRAC_1_SQRT_2, TAU};
|
use std::f64::consts::{FRAC_1_SQRT_2, TAU};
|
||||||
|
|
||||||
@@ -12,11 +15,12 @@ use vectorcraft_geom::{Point, Vec2};
|
|||||||
use super::distort::displace;
|
use super::distort::displace;
|
||||||
use super::edges::sobel;
|
use super::edges::sobel;
|
||||||
use super::pixelate::{Place, cell, rand, value_noise};
|
use super::pixelate::{Place, cell, rand, value_noise};
|
||||||
use super::texture::{Light, Tones, luma, premultiply, smooth_noise, straight};
|
use super::texture::{Light, Texture, Tones, luma, premultiply, smooth_noise, straight, texturizer};
|
||||||
use super::{PixelSpace, blur_plane, pick};
|
use super::{PixelSpace, blur_plane, pick};
|
||||||
|
|
||||||
const SALT_CHALK: u64 = 216;
|
const SALT_CHALK: u64 = 216;
|
||||||
const SALT_CHROME: u64 = 0xC40_4D45;
|
const SALT_CHROME: u64 = 0xC40_4D45;
|
||||||
|
const SALT_CONTE: u64 = 0xC047E;
|
||||||
const SALT_TORN: u64 = 0x746f726e5f6564;
|
const SALT_TORN: u64 = 0x746f726e5f6564;
|
||||||
const SALT_WATER: u64 = 208;
|
const SALT_WATER: u64 = 208;
|
||||||
const EDGE_SOFT: f64 = 0.8;
|
const EDGE_SOFT: f64 = 0.8;
|
||||||
@@ -362,6 +366,78 @@ pub(super) fn reticulation(px: &mut [[u8; 4]], w: usize, h: usize, space: &Pixel
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How far (document units) Conté Crayon samples the softened tones that choose its crayons.
|
||||||
|
pub(super) const CONTE_REACH: f64 = 3.0 * TONE_SOFT + 1.0;
|
||||||
|
|
||||||
|
/// How much of a crayon is laid down where `amount` (0..1: how dark, for the dark crayon, or how
|
||||||
|
/// light, for the light one) meets its `level` (0..1) and `grain` (0..1, the paper's tooth and the
|
||||||
|
/// stroke): at level 0 only the deepest tones take the crayon, at level 1 it reaches past the
|
||||||
|
/// middle tone; the grain breaks its edge.
|
||||||
|
fn crayon(amount: f64, level: f64, grain: f64) -> f64 {
|
||||||
|
ramp((amount - (0.9 - 0.45 * level) + 0.4 * (grain - 0.5)) / 0.12 + 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Conté Crayon: the dark areas drawn in a dense black crayon and the light areas in a pure white
|
||||||
|
/// one, on mid-grey paper whose tooth (`texture` scaled by `scaling` (0.5..2), its heights turned
|
||||||
|
/// over when `invert`) and diagonal strokes break the crayons. `foreground` and `background`
|
||||||
|
/// (0..20) set how far the black and the white crayon reach into the midtones; the paper then
|
||||||
|
/// stands in relief as strong as `relief` (0..50), lit from `light`, as Texturizer draws it.
|
||||||
|
/// Coverage is kept.
|
||||||
|
#[allow(clippy::too_many_arguments)] // the effect's seven options
|
||||||
|
pub(super) fn conte_crayon(
|
||||||
|
px: &mut [[u8; 4]],
|
||||||
|
w: usize,
|
||||||
|
h: usize,
|
||||||
|
space: &PixelSpace,
|
||||||
|
foreground: f64,
|
||||||
|
background: f64,
|
||||||
|
texture: &Texture,
|
||||||
|
scaling: f64,
|
||||||
|
relief: f64,
|
||||||
|
light: Light,
|
||||||
|
invert: bool,
|
||||||
|
) {
|
||||||
|
if !([foreground, background, scaling, relief].iter().all(|v| v.is_finite()) && scaling > 0.0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(place) = Place::new(space, w, h) else { return };
|
||||||
|
let fg = foreground.clamp(0.0, 20.0) / 20.0;
|
||||||
|
let bg = background.clamp(0.0, 20.0) / 20.0;
|
||||||
|
let tones = Tones::new(px, w, h, TONE_SOFT / place.px);
|
||||||
|
// The tooth: how far the surface stands above its neighbours a quarter point (or half a pixel)
|
||||||
|
// away, so that crayon catches on the peaks and leaves the hollows bare.
|
||||||
|
let step = (0.25f64).max(0.5 * place.px / scaling);
|
||||||
|
let flip = if invert { -1.0 } else { 1.0 };
|
||||||
|
place.each(px, |x, y, p| {
|
||||||
|
if p[3] == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let q = place.at(x, y);
|
||||||
|
let t = Point::new(q.x / scaling, q.y / scaling);
|
||||||
|
let tooth = if t.x.is_finite() && t.y.is_finite() {
|
||||||
|
let at = |dx: f64, dy: f64| texture.height(Point::new(t.x + dx, t.y + dy));
|
||||||
|
let ridge = (at(0.0, 0.0) - 0.25 * (at(-step, 0.0) + at(step, 0.0) + at(0.0, -step) + at(0.0, step))) / step;
|
||||||
|
let tooth = 0.5 + 0.5 * (2.0 * flip * ridge).tanh();
|
||||||
|
if tooth.is_finite() { tooth } else { 0.5 }
|
||||||
|
} else {
|
||||||
|
0.5
|
||||||
|
};
|
||||||
|
// Strokes along the two diagonals, about a point across and several long.
|
||||||
|
let (u, v) = ((q.x + q.y) * FRAC_1_SQRT_2, (q.x - q.y) * FRAC_1_SQRT_2);
|
||||||
|
let dark_stroke = value_noise(SALT_CONTE, u * 1.2, v * 0.15);
|
||||||
|
let light_stroke = value_noise(SALT_CONTE + 1, v * 1.2, u * 0.15);
|
||||||
|
let tone = tones.get(x, y);
|
||||||
|
let black = crayon(1.0 - tone, fg, 0.55 * tooth + 0.45 * dark_stroke);
|
||||||
|
let white = crayon(tone, bg, 0.55 * tooth + 0.45 * light_stroke);
|
||||||
|
let paper = 0.4 + 0.2 * tone;
|
||||||
|
let grey = ((paper + (1.0 - paper) * white) * (1.0 - black)) as f32;
|
||||||
|
*p = premultiply([grey; 3], f32::from(p[3]) / 255.0);
|
||||||
|
});
|
||||||
|
if relief > 0.0 {
|
||||||
|
texturizer(px, w, h, space, texture, scaling, relief, light, invert);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Torn Edges: the object in high-contrast black and white with ragged edges. `image_balance`
|
/// Torn Edges: the object in high-contrast black and white with ragged edges. `image_balance`
|
||||||
/// (0..50) shifts whether midtones fall to ink or paper; `smoothness` (0..15) softens tones and
|
/// (0..50) shifts whether midtones fall to ink or paper; `smoothness` (0..15) softens tones and
|
||||||
/// the fraying; `contrast` (0..20) hardens the split. Coverage is kept.
|
/// the fraying; `contrast` (0..20) hardens the split. Coverage is kept.
|
||||||
|
|||||||
@@ -1224,6 +1224,8 @@ fn texture_params_take_defaults_and_stay_in_range() {
|
|||||||
("texture.texturizer", "lightDirection", LIGHT_DIRECTIONS.as_slice()),
|
("texture.texturizer", "lightDirection", LIGHT_DIRECTIONS.as_slice()),
|
||||||
("artistic.roughPastels", "texture", TEXTURES.as_slice()),
|
("artistic.roughPastels", "texture", TEXTURES.as_slice()),
|
||||||
("artistic.roughPastels", "lightDirection", LIGHT_DIRECTIONS.as_slice()),
|
("artistic.roughPastels", "lightDirection", LIGHT_DIRECTIONS.as_slice()),
|
||||||
|
("sketch.conteCrayon", "texture", TEXTURES.as_slice()),
|
||||||
|
("sketch.conteCrayon", "lightDirection", LIGHT_DIRECTIONS.as_slice()),
|
||||||
] {
|
] {
|
||||||
let doc = crate::effect_info(id).unwrap().params;
|
let doc = crate::effect_info(id).unwrap().params;
|
||||||
let parsed: Vec<PixelFx> = table.iter().map(|(_, v)| fx(id, json!({ key: v }))).collect();
|
let parsed: Vec<PixelFx> = table.iter().map(|(_, v)| fx(id, json!({ key: v }))).collect();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! Sketch › Bas Relief, Charcoal, Photocopy and Stamp.
|
//! Sketch › Bas Relief, Charcoal, Conté Crayon, Photocopy and Stamp.
|
||||||
|
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use vectorcraft_doc::Effect;
|
use vectorcraft_doc::Effect;
|
||||||
@@ -173,9 +173,123 @@ fn bas_relief_lights_the_surfaces_facing_the_light() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sketch_filters_reach_beyond_nothing_they_do_not_paint() {
|
fn sketch_filters_reach_beyond_nothing_they_do_not_paint() {
|
||||||
for id in ["sketch.basRelief", "sketch.charcoal", "sketch.photocopy", "sketch.stamp"] {
|
for id in ["sketch.basRelief", "sketch.charcoal", "sketch.conteCrayon", "sketch.photocopy", "sketch.stamp"] {
|
||||||
let f = fx(id, json!({}));
|
let f = fx(id, json!({}));
|
||||||
assert_eq!(f.outset(Rect::new(0.0, 0.0, 50.0, 50.0)), 0.0, "{id} keeps the object's coverage");
|
assert_eq!(f.outset(Rect::new(0.0, 0.0, 50.0, 50.0)), 0.0, "{id} keeps the object's coverage");
|
||||||
assert!(f.reach().is_some_and(|r| r.is_finite() && r > 0.0), "{id} reach");
|
assert!(f.reach().is_some_and(|r| r.is_finite() && r > 0.0), "{id} reach");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The mean of channel 0 over columns `x0..x1` of every row.
|
||||||
|
fn mean(d: &[u8], w: usize, h: usize, x0: usize, x1: usize) -> f64 {
|
||||||
|
let sum: u32 = (0..h).flat_map(|y| (x0..x1).map(move |x| (x, y))).map(|(x, y)| u32::from(at(d, w, x, y))).sum();
|
||||||
|
f64::from(sum) / ((x1 - x0) * h) as f64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many pixels of channel 0 are lighter than 192.
|
||||||
|
fn white(d: &[u8]) -> usize {
|
||||||
|
d.as_chunks::<4>().0.iter().filter(|p| p[0] > 192).count()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn conte_crayon_params_take_defaults_and_stay_in_range() {
|
||||||
|
let defaults = PixelFx::ConteCrayon {
|
||||||
|
foreground: 12.0,
|
||||||
|
background: 10.0,
|
||||||
|
texture: Texture::Canvas,
|
||||||
|
scaling: 1.0,
|
||||||
|
relief: 4.0,
|
||||||
|
light: Light::Top,
|
||||||
|
invert: false,
|
||||||
|
};
|
||||||
|
assert_eq!(fx("sketch.conteCrayon", json!({})), defaults);
|
||||||
|
assert_eq!(
|
||||||
|
fx(
|
||||||
|
"sketch.conteCrayon",
|
||||||
|
json!({"foregroundLevel": -5, "backgroundLevel": 1e308, "texture": "Brick", "scaling": 10, "relief": 99, "lightDirection": "bottomRight", "invert": true})
|
||||||
|
),
|
||||||
|
PixelFx::ConteCrayon {
|
||||||
|
foreground: 0.0,
|
||||||
|
background: 20.0,
|
||||||
|
texture: Texture::Brick,
|
||||||
|
scaling: 0.5,
|
||||||
|
relief: 50.0,
|
||||||
|
light: Light::BottomRight,
|
||||||
|
invert: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
for junk in [
|
||||||
|
json!({"foregroundLevel": "NaN", "scaling": "1e999"}),
|
||||||
|
json!({"backgroundLevel": null, "texture": 3, "relief": [1], "lightDirection": "up", "invert": "yes"}),
|
||||||
|
] {
|
||||||
|
assert_eq!(fx("sketch.conteCrayon", junk.clone()), defaults, "{junk}");
|
||||||
|
}
|
||||||
|
let doc = crate::effect_info("sketch.conteCrayon").unwrap().params;
|
||||||
|
assert!(doc.contains("unverified") && doc.contains("foregroundLevel") && doc.contains("backgroundLevel"), "{doc}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A grey ramp: its shadows in black crayon, its highlights in white crayon, the midtones on grey
|
||||||
|
/// paper between them; grey only, coverage kept, the same on every run.
|
||||||
|
#[test]
|
||||||
|
fn conte_crayon_draws_the_shadows_black_and_the_highlights_white() {
|
||||||
|
let (w, h) = (96, 32);
|
||||||
|
let src = image(w, h, |x, _| grey((x * 255 / (w - 1)) as u8));
|
||||||
|
let out = run("sketch.conteCrayon", json!({"relief": 0}), &src, w, h);
|
||||||
|
for p in out.as_chunks::<4>().0 {
|
||||||
|
assert!(p[0] == p[1] && p[1] == p[2] && p[3] == 255, "{p:?}");
|
||||||
|
}
|
||||||
|
let (shadows, mids, lights) = (mean(&out, w, h, 0, 12), mean(&out, w, h, w / 2 - 6, w / 2 + 6), mean(&out, w, h, w - 12, w));
|
||||||
|
assert!(shadows < 40.0, "shadows {shadows}");
|
||||||
|
assert!(lights > 215.0, "highlights {lights}");
|
||||||
|
assert!(shadows + 30.0 < mids && mids + 30.0 < lights, "{shadows} {mids} {lights}");
|
||||||
|
assert_eq!(out, run("sketch.conteCrayon", json!({"relief": 0}), &src, w, h), "deterministic");
|
||||||
|
// The relief shades the paper but keeps it grey.
|
||||||
|
let lit = run("sketch.conteCrayon", json!({}), &src, w, h);
|
||||||
|
assert_ne!(lit, out);
|
||||||
|
assert!(lit.as_chunks::<4>().0.iter().all(|p| p[0] == p[1] && p[1] == p[2]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Foreground Level spreads the black crayon into the midtones, Background Level the white one.
|
||||||
|
#[test]
|
||||||
|
fn conte_crayon_levels_spread_each_crayon_into_the_midtones() {
|
||||||
|
let (w, h) = (64, 64);
|
||||||
|
let mid = image(w, h, |_, _| grey(128));
|
||||||
|
let with = |p: serde_json::Value| run("sketch.conteCrayon", p, &mid, w, h);
|
||||||
|
let (few, many) = (with(json!({"foregroundLevel": 0, "relief": 0})), with(json!({"foregroundLevel": 20, "relief": 0})));
|
||||||
|
assert!(black(&many) > black(&few) + w * h / 4, "{} vs {}", black(&many), black(&few));
|
||||||
|
let (few, many) = (
|
||||||
|
with(json!({"foregroundLevel": 0, "backgroundLevel": 0, "relief": 0})),
|
||||||
|
with(json!({"foregroundLevel": 0, "backgroundLevel": 20, "relief": 0})),
|
||||||
|
);
|
||||||
|
assert!(white(&many) > white(&few) + w * h / 4, "{} vs {}", white(&many), white(&few));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The paper's texture, scale and inversion break the crayon differently; the relief is lit from
|
||||||
|
/// the chosen side.
|
||||||
|
#[test]
|
||||||
|
fn conte_crayon_texture_options_change_the_paper() {
|
||||||
|
let (w, h) = (64, 64);
|
||||||
|
let mid = image(w, h, |x, y| grey((64 + x + y) as u8));
|
||||||
|
let with = |p: serde_json::Value| run("sketch.conteCrayon", p, &mid, w, h);
|
||||||
|
let canvas = with(json!({"relief": 0}));
|
||||||
|
for other in [
|
||||||
|
json!({"relief": 0, "texture": "brick"}),
|
||||||
|
json!({"relief": 0, "texture": "sandstone"}),
|
||||||
|
json!({"relief": 0, "scaling": 200}),
|
||||||
|
json!({"relief": 0, "invert": true}),
|
||||||
|
] {
|
||||||
|
assert_ne!(with(other.clone()), canvas, "{other}");
|
||||||
|
}
|
||||||
|
let top = with(json!({"texture": "brick", "relief": 20, "lightDirection": "top"}));
|
||||||
|
let bottom = with(json!({"texture": "brick", "relief": 20, "lightDirection": "bottom"}));
|
||||||
|
assert_ne!(top, bottom);
|
||||||
|
assert_ne!(top, with(json!({"texture": "brick", "relief": 0, "lightDirection": "top"})));
|
||||||
|
survives(
|
||||||
|
"sketch.conteCrayon",
|
||||||
|
&[
|
||||||
|
json!({}),
|
||||||
|
json!({"foregroundLevel": "NaN", "backgroundLevel": 1e308, "scaling": -1e308, "relief": 1e308, "texture": "burlap", "invert": true}),
|
||||||
|
json!({"foregroundLevel": -1e308, "backgroundLevel": [1], "texture": {}, "lightDirection": 3, "relief": "NaN"}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -181,7 +181,7 @@ fn shading_paint(g: vectorcraft_three_d::ShadeGradient, color: impl Fn(f64) -> C
|
|||||||
use vectorcraft_doc::color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
use vectorcraft_doc::color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||||
let stops =
|
let stops =
|
||||||
g.brightness.into_iter().enumerate().map(|(i, s)| GradientStop { opacity: opacity(s), ..GradientStop::new(i as f32, color(s)) }).collect();
|
g.brightness.into_iter().enumerate().map(|(i, s)| GradientStop { opacity: opacity(s), ..GradientStop::new(i as f32, color(s)) }).collect();
|
||||||
let mut paint = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
|
let mut paint = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
|
||||||
paint.geom = Some(GradientGeom { start: g.start, end: g.end, aspect: 1.0, focal: None });
|
paint.geom = Some(GradientGeom { start: g.start, end: g.end, aspect: 1.0, focal: None });
|
||||||
Paint::Gradient(Box::new(paint))
|
Paint::Gradient(Box::new(paint))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
use kurbo::{BezPath, Point, Shape, Vec2};
|
use kurbo::{BezPath, Point, Shape, Vec2};
|
||||||
use vectorcraft_doc::color::{Gradient, GradientGeom, GradientPaint, Paint};
|
use vectorcraft_doc::color::{Gradient, GradientGeom, GradientPaint, Paint};
|
||||||
use vectorcraft_doc::live::{H_DOWN, H_LEFT, H_RIGHT, H_UP, lerp_color};
|
use vectorcraft_doc::live::{H_DOWN, H_LEFT, H_RIGHT, H_UP};
|
||||||
use vectorcraft_doc::{GradientMesh, LineJoin, MeshPoint, StrokeAlign, StrokeGradientMode, StrokeLayer};
|
use vectorcraft_doc::{GradientMesh, LineJoin, MeshPoint, StrokeAlign, StrokeGradientMode, StrokeLayer};
|
||||||
use vectorcraft_geom::FillRule;
|
use vectorcraft_geom::FillRule;
|
||||||
|
|
||||||
@@ -375,7 +375,7 @@ pub fn written_slices(bp: &BezPath, rule: FillRule, st: &StrokeLayer, outlines:
|
|||||||
/// stroke's outline), coloured as [`gradient_slices`] paint it.
|
/// stroke's outline), coloured as [`gradient_slices`] paint it.
|
||||||
pub fn gradient_meshes(bp: &BezPath, rule: FillRule, st: &StrokeLayer, g: &Gradient) -> Vec<GradientMesh> {
|
pub fn gradient_meshes(bp: &BezPath, rule: FillRule, st: &StrokeLayer, g: &Gradient) -> Vec<GradientMesh> {
|
||||||
let across = st.gradient_mode == StrokeGradientMode::Across;
|
let across = st.gradient_mode == StrokeGradientMode::Across;
|
||||||
let color = |t: f64| g.sample_with(t.clamp(0.0, 1.0) as f32, lerp_color);
|
let color = |t: f64| g.sample_keep_model(t.clamp(0.0, 1.0) as f32);
|
||||||
// Along: a column at each stop (colours are linear between columns), close enough to look smooth.
|
// Along: a column at each stop (colours are linear between columns), close enough to look smooth.
|
||||||
let marks: Vec<f64> = if across { vec![] } else { g.expanded_stops().iter().map(|s| s.0 as f64).collect() };
|
let marks: Vec<f64> = if across { vec![] } else { g.expanded_stops().iter().map(|s| s.0 as f64).collect() };
|
||||||
let max_dt = match (across, st.profile.is_some()) {
|
let max_dt = match (across, st.profile.is_some()) {
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ fn a_masked_surface_under_a_colored_light_stays_whole() {
|
|||||||
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
|
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
|
||||||
let mut half = GradientStop::new(1.0, RED);
|
let mut half = GradientStop::new(1.0, RED);
|
||||||
half.opacity = 0.5;
|
half.opacity = 0.5;
|
||||||
let gradient = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops: vec![GradientStop::new(0.0, RED), half] });
|
let gradient = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, RED), half]));
|
||||||
for (lights, surfaces) in [(json!({"lights": [{"color": "#ff8080"}]}), 2), (json!({}), 1)] {
|
for (lights, surfaces) in [(json!({"lights": [{"color": "#ff8080"}]}), 2), (json!({}), 1)] {
|
||||||
let mut n = stroked_rect(100.0);
|
let mut n = stroked_rect(100.0);
|
||||||
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) {
|
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) {
|
||||||
|
|||||||
@@ -372,8 +372,10 @@ pub(crate) fn count_changed(d: &vectorcraft_doc::Document, before: &vectorcraft_
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Whether an edit aimed at `item` changes a stroke: the `stroke` param, else the kind of the
|
/// Whether an edit aimed at `item` changes a stroke: the `stroke` param, else the kind of the
|
||||||
/// targeted item (on the first target object), else `default`.
|
/// targeted item (on the first target object), else `default`. A `stroke` other than true, false or
|
||||||
pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bool) -> Result<bool> {
|
/// null is an error of command `cmd`.
|
||||||
|
pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bool, cmd: &str) -> Result<bool> {
|
||||||
|
check_switches(p, &["stroke"], cmd)?;
|
||||||
if let Some(b) = p.get("stroke").and_then(Value::as_bool) {
|
if let Some(b) = p.get("stroke").and_then(Value::as_bool) {
|
||||||
return Ok(b);
|
return Ok(b);
|
||||||
}
|
}
|
||||||
@@ -486,7 +488,7 @@ fn transfer(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
if source == target {
|
if source == target {
|
||||||
return Err(bad(C, "`source` and `target` are the same object"));
|
return Err(bad(C, "`source` and `target` are the same object"));
|
||||||
}
|
}
|
||||||
let copy = bool_or(p, "copy", false);
|
let copy = switch_or(p, "copy", false, C)?;
|
||||||
// When both objects come out as they were, the document and its undo history stay as they are.
|
// When both objects come out as they were, the document and its undo history stay as they are.
|
||||||
let before = s.doc()?.doc.clone();
|
let before = s.doc()?.doc.clone();
|
||||||
super::overprint::edit_counted(s, if copy { "Copy Appearance" } else { "Move Appearance" }, |d| {
|
super::overprint::edit_counted(s, if copy { "Copy Appearance" } else { "Move Appearance" }, |d| {
|
||||||
@@ -549,6 +551,8 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
|
fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let idx = index_param(p, "index", "appearance.setItem")?;
|
let idx = index_param(p, "index", "appearance.setItem")?;
|
||||||
super::paint::check_keep_model(p, "appearance.setItem")?;
|
super::paint::check_keep_model(p, "appearance.setItem")?;
|
||||||
|
check_numbers(p, &["weight", "opacity"], "appearance.setItem")?;
|
||||||
|
check_switches(p, &["visible"], "appearance.setItem")?;
|
||||||
let paint = paint_from(s, p)?;
|
let paint = paint_from(s, p)?;
|
||||||
let ids = appearance_targets(s, p)?;
|
let ids = appearance_targets(s, p)?;
|
||||||
if ids.is_empty() {
|
if ids.is_empty() {
|
||||||
@@ -762,7 +766,7 @@ fn target_contents(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
const C: &str = "appearance.copyFrom";
|
const C: &str = "appearance.copyFrom";
|
||||||
let clicked = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?;
|
let clicked = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?;
|
||||||
let (reverse, append) = (bool_or(p, "reverse", false), bool_or(p, "append", false));
|
let (reverse, append) = (switch_or(p, "reverse", false, C)?, switch_or(p, "append", false, C)?);
|
||||||
let attrs = s.prefs.eyedropper.attrs(p).map_err(|e| bad(C, e))?;
|
let attrs = s.prefs.eyedropper.attrs(p).map_err(|e| bad(C, e))?;
|
||||||
let range = if !reverse && !append && p.get("ids").is_none() { super::textedit::editing_range(s)?.filter(|(_, a, b)| a != b) } else { None };
|
let range = if !reverse && !append && p.get("ids").is_none() { super::textedit::editing_range(s)?.filter(|(_, a, b)| a != b) } else { None };
|
||||||
let selected = match ids_param(p, "ids") {
|
let selected = match ids_param(p, "ids") {
|
||||||
|
|||||||
@@ -970,7 +970,7 @@ fn symbol_place(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
let d = &s.doc()?.doc;
|
let d = &s.doc()?.doc;
|
||||||
let name = resolve_symbol(d, p, "symbol.place")?;
|
let name = resolve_symbol(d, p, "symbol.place")?;
|
||||||
let centre = d.artboards.first().map(|a| a.rect.center()).unwrap_or(Point::ZERO);
|
let centre = d.artboards.first().map(|a| a.rect.center()).unwrap_or(Point::ZERO);
|
||||||
let at = Point::new(f64_or(p, "x", centre.x), f64_or(p, "y", centre.y));
|
let at = Point::new(number_or(p, "x", centre.x, "symbol.place")?, number_or(p, "y", centre.y, "symbol.place")?);
|
||||||
let size = natural_size(d, &name);
|
let size = natural_size(d, &name);
|
||||||
let reg = d.symbol_registration(&name);
|
let reg = d.symbol_registration(&name);
|
||||||
let parent = s.doc()?.target_parent()?;
|
let parent = s.doc()?.target_parent()?;
|
||||||
@@ -1099,7 +1099,7 @@ fn symbol_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
if !s.doc()?.doc.symbols.iter().any(|x| x.name == name) {
|
if !s.doc()?.doc.symbols.iter().any(|x| x.name == name) {
|
||||||
return Err(bad(C, format!("no symbol named `{name}`")));
|
return Err(bad(C, format!("no symbol named `{name}`")));
|
||||||
}
|
}
|
||||||
let expand = bool_or(p, "expandInstances", true);
|
let expand = switch_or(p, "expandInstances", true, C)?;
|
||||||
s.edit("Delete Symbol", |d, _| {
|
s.edit("Delete Symbol", |d, _| {
|
||||||
let inst = instances_of(d, Some(&name));
|
let inst = instances_of(d, Some(&name));
|
||||||
if expand {
|
if expand {
|
||||||
@@ -1216,10 +1216,10 @@ fn symbol_spray(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
let pts = points(p, C)?;
|
let pts = points(p, C)?;
|
||||||
let st = s.doc()?;
|
let st = s.doc()?;
|
||||||
let name = resolve_symbol(&st.doc, p, C)?;
|
let name = resolve_symbol(&st.doc, p, C)?;
|
||||||
let radius = f64_or(p, "radius", 40.0).clamp(1.0, 2000.0);
|
let radius = number_or(p, "radius", 40.0, C)?.clamp(1.0, 2000.0);
|
||||||
let density = f64_or(p, "density", 5.0).clamp(1.0, 10.0);
|
let density = number_or(p, "density", 5.0, C)?.clamp(1.0, 10.0);
|
||||||
let scale = f64_or(p, "scale", 1.0).clamp(0.01, 100.0);
|
let scale = number_or(p, "scale", 1.0, C)?.clamp(0.01, 100.0);
|
||||||
let alt = bool_or(p, "alt", false);
|
let alt = switch_or(p, "alt", false, C)?;
|
||||||
let size = natural_size(&st.doc, &name);
|
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 set = st.selection.objects.first().copied().filter(|id| st.selection.len() == 1 && st.doc.node(*id).is_some_and(is_symbol_set));
|
||||||
// Only a new Symbol Set needs a layer that takes new art.
|
// Only a new Symbol Set needs a layer that takes new art.
|
||||||
@@ -1303,9 +1303,9 @@ fn symbol_adjust(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
}
|
}
|
||||||
super::paint::check_keep_model(p, C)?;
|
super::paint::check_keep_model(p, C)?;
|
||||||
let pts = points(p, C)?;
|
let pts = points(p, C)?;
|
||||||
let radius = f64_or(p, "radius", 40.0).clamp(1.0, 2000.0);
|
let radius = number_or(p, "radius", 40.0, C)?.clamp(1.0, 2000.0);
|
||||||
let intensity = f64_or(p, "intensity", 5.0).clamp(1.0, 10.0) / 10.0;
|
let intensity = number_or(p, "intensity", 5.0, C)?.clamp(1.0, 10.0) / 10.0;
|
||||||
let alt = bool_or(p, "alt", false);
|
let alt = switch_or(p, "alt", false, C)?;
|
||||||
let st = s.doc()?;
|
let st = s.doc()?;
|
||||||
let color = match p.get("color").and_then(color_value) {
|
let color = match p.get("color").and_then(color_value) {
|
||||||
Some(c) => super::paint::new_color(s, p, c),
|
Some(c) => super::paint::new_color(s, p, c),
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ use vectorcraft_tools::builder::{
|
|||||||
};
|
};
|
||||||
use vectorcraft_trace as tr;
|
use vectorcraft_trace as tr;
|
||||||
|
|
||||||
|
use super::draw2::replace_with_pieces;
|
||||||
use super::edit::{outermost, selected_roots};
|
use super::edit::{outermost, selected_roots};
|
||||||
use super::paint::paint_from;
|
use super::paint::paint_from;
|
||||||
use super::pathops::{Beside, shape_node};
|
use super::pathops::{Beside, shape_node};
|
||||||
@@ -39,7 +40,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
"Shape Builder",
|
"Shape Builder",
|
||||||
[],
|
[],
|
||||||
None,
|
None,
|
||||||
"{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of open paths that cut regions and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance; a deleted edge opens its path there, and edges bounding a deleted region go with it), tolerance?: pt (2, erase only), gap?: pt (0–72; 0: Gap Detection off), openFilledAsClosed?: true (Consider Open Filled Paths as Closed: a filled open path bounds a region as if an invisible edge closed it; false: it cuts the regions it crosses like an unfilled one), pickColorFrom?: \"artwork\" (default: the merged shape takes the fill of the front-most object over the first region the drag touches that an object covers; the current fill when only lines enclose every region touched) | \"swatches\" (the current fill), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (overrides pickColorFrom)} → {ids, merged, regions, lines, edges} (counts deleted or merged)",
|
"{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of open paths that cut regions and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance; a deleted edge opens its path there, and edges bounding a deleted region go with it; what is left of each path keeps its look, name, opacity mask and attributes, and its fill rule unless merged regions shaped it), splitStroke?: false (In Merge Mode, Clicking Stroke Splits the Path; not with erase: when the one point of a click is within tolerance of a stroke, the path that stroke is on splits in two instead of a merge: the path keeps the rest of it, and a copy of the path holding the run of it from the anchor or crossing point before the click to the one after it goes directly above it; the other objects stay as they are; with pickColorFrom \"swatches\" or fill, the run's stroke takes that paint; a path in a Live Paint group or a Shaper Group does not split, and nor does a path the run is all of, such as a lone line), tolerance?: pt (2, erase or splitStroke), gap?: pt (0–72; 0: Gap Detection off), openFilledAsClosed?: true (Consider Open Filled Paths as Closed: a filled open path bounds a region as if an invisible edge closed it; false: it cuts the regions it crosses like an unfilled one), pickColorFrom?: \"artwork\" (default: the merged shape takes the look of the front-most object under the first point, else under the last point, else of the front-most object) | \"swatches\" (the current fill, with the rest of the look of the front-most object over the first region the drag touches that an object covers, else of the front-most object), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (overrides pickColorFrom)} → {ids, merged, regions, lines, edges (counts deleted, merged or split), split?: id (the run split off, after a split)}; after a split, ids are the roots, with the rest and the run in place of the path when it was one",
|
||||||
has_selection_or_ids,
|
has_selection_or_ids,
|
||||||
sb_merge
|
sb_merge
|
||||||
),
|
),
|
||||||
@@ -248,10 +249,13 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
if pts.is_empty() || !pts.iter().all(|q| finite(*q)) {
|
if pts.is_empty() || !pts.iter().all(|q| finite(*q)) {
|
||||||
return Err(bad(C, "points must be a non-empty list of finite [x,y]"));
|
return Err(bad(C, "points must be a non-empty list of finite [x,y]"));
|
||||||
}
|
}
|
||||||
let erase = bool_or(p, "erase", false);
|
let erase = switch_or(p, "erase", false, C)?;
|
||||||
|
check_numbers(p, &["tolerance", "gap"], C)?;
|
||||||
// Line pieces and edges are only deleted (erase mode), never merged.
|
// Line pieces and edges are only deleted (erase mode), never merged.
|
||||||
let piece_tol = erase.then(|| f64_or(p, "tolerance", 2.0).clamp(0.0, 1000.0));
|
let piece_tol = erase.then(|| f64_or(p, "tolerance", 2.0).clamp(0.0, 1000.0));
|
||||||
let open_filled_as_closed = bool_or(p, "openFilledAsClosed", true);
|
// In Merge Mode, Clicking Stroke Splits the Path: a click (one point) on a stroke splits it.
|
||||||
|
let split_tol = (switch_or(p, "splitStroke", false, C)? && !erase && pts.len() == 1).then(|| f64_or(p, "tolerance", 2.0).clamp(0.0, 1000.0));
|
||||||
|
let open_filled_as_closed = switch_or(p, "openFilledAsClosed", true, C)?;
|
||||||
let swatches = match p.get("pickColorFrom") {
|
let swatches = match p.get("pickColorFrom") {
|
||||||
None | Some(Value::Null) => false,
|
None | Some(Value::Null) => false,
|
||||||
Some(v) => match v.as_str() {
|
Some(v) => match v.as_str() {
|
||||||
@@ -260,20 +264,47 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
_ => return Err(bad(C, format!("pickColorFrom is \"artwork\" or \"swatches\", not {v}"))),
|
_ => return Err(bad(C, format!("pickColorFrom is \"artwork\" or \"swatches\", not {v}"))),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
let current_fill = s.paint.fill.clone();
|
|
||||||
let fill = match fill_param(s, p, C)? {
|
let fill = match fill_param(s, p, C)? {
|
||||||
None if swatches => Some(current_fill.clone()),
|
None if swatches => Some(s.paint.fill.clone()),
|
||||||
f => f,
|
f => f,
|
||||||
};
|
};
|
||||||
let roots = root_ids(s, p)?;
|
let roots = root_ids(s, p)?;
|
||||||
let label = if erase { "Shape Builder Delete" } else { "Shape Builder" };
|
let label = if erase { "Shape Builder Delete" } else { "Shape Builder" };
|
||||||
let (ids, merged, touched) = s.edit(label, |d, sel| {
|
let (ids, merged, touched, split) = s.edit(label, |d, sel| {
|
||||||
let (map, leaves) = BuilderMap::with_gap(d, &roots, erase, open_filled_as_closed, gap_param(p));
|
let (map, leaves) = BuilderMap::with_gap(d, &roots, erase || split_tol.is_some(), open_filled_as_closed, gap_param(p));
|
||||||
|
let outlines = b::drawn_outlines(d, &leaves);
|
||||||
let leaves: Vec<Node> = leaves.into_iter().cloned().collect();
|
let leaves: Vec<Node> = leaves.into_iter().cloned().collect();
|
||||||
if map.shapes.is_empty() {
|
if map.shapes.is_empty() {
|
||||||
return Err(EngineError::Other("Shape Builder: select paths or compound paths".into()));
|
return Err(EngineError::Other("Shape Builder: select paths or compound paths".into()));
|
||||||
}
|
}
|
||||||
let step = (d.bounds_of(&roots, false).map(|r| r.width().max(r.height())).unwrap_or(100.0) / 2000.0).max(0.25);
|
let step = (d.bounds_of(&roots, false).map(|r| r.width().max(r.height())).unwrap_or(100.0) / 2000.0).max(0.25);
|
||||||
|
if let Some(tol) = split_tol
|
||||||
|
&& let Some(&q) = pts.first()
|
||||||
|
&& let Some((i, run)) = map.stroke_at(&outlines, q, tol)
|
||||||
|
&& let (Some(leaf), Some(drawn)) = (leaves.get(i), outlines.get(i))
|
||||||
|
&& let Some(rest) = po::cut_out(drawn, &run, open_filled_as_closed && !leaf.appearance.fill_paint().is_none(), b::outline_snap(drawn))
|
||||||
|
&& !rest.is_empty()
|
||||||
|
{
|
||||||
|
// The path keeps the rest of it, and a copy of it with the run under the click goes
|
||||||
|
// directly above it; the other objects stay as they are.
|
||||||
|
let pieces = [rest, PathData::single(run)].into_iter().filter(|pd| !pd.is_empty()).collect();
|
||||||
|
let mut replaced = Beside::replacing(d, &[leaf.id]);
|
||||||
|
let new_ids = replace_with_pieces(d, &mut replaced, leaf.id, pieces)?;
|
||||||
|
replaced.finish(d)?;
|
||||||
|
let split = *new_ids.last().ok_or(EngineError::NoNode(leaf.id))?;
|
||||||
|
// Pick Color From Color Swatches (or `fill`): the stroke takes that paint.
|
||||||
|
if let Some(f) = &fill
|
||||||
|
&& let Some(n) = d.node_mut(split)
|
||||||
|
{
|
||||||
|
n.appearance.set_stroke(f.clone());
|
||||||
|
}
|
||||||
|
// The roots stay selected, with the two paths in place of the path when it was one.
|
||||||
|
let ids: Vec<NodeId> = roots.iter().flat_map(|r| if *r == leaf.id { new_ids.clone() } else { vec![*r] }).collect();
|
||||||
|
sel.set(ids.iter().copied());
|
||||||
|
let mut touched = Touched::default();
|
||||||
|
touched.add(map.hit(q, Some(tol)));
|
||||||
|
return Ok((ids, None, touched, Some(split)));
|
||||||
|
}
|
||||||
let mut touched = Touched::default();
|
let mut touched = Touched::default();
|
||||||
for q in sample_polyline(&pts, step) {
|
for q in sample_polyline(&pts, step) {
|
||||||
touched.add(map.hit(q, piece_tol));
|
touched.add(map.hit(q, piece_tol));
|
||||||
@@ -283,16 +314,17 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
return Err(EngineError::Other("Shape Builder: no region under the pointer".into()));
|
return Err(EngineError::Other("Shape Builder: no region under the pointer".into()));
|
||||||
}
|
}
|
||||||
let cuts: Vec<&SubPath> = touched.edges.iter().filter_map(|&k| map.edges.get(k)?.0.path.subpaths.first()).collect();
|
let cuts: Vec<&SubPath> = touched.edges.iter().filter_map(|&k| map.edges.get(k)?.0.path.subpaths.first()).collect();
|
||||||
// Edges come back from the planar map within a few hundredths of the outlines they follow.
|
|
||||||
let cut_tol = step * 0.2;
|
|
||||||
let picked: Vec<&po::Region> = touched.regions.iter().filter_map(|&i| map.regions.get(i).map(|r| &r.0)).collect();
|
let picked: Vec<&po::Region> = touched.regions.iter().filter_map(|&i| map.regions.get(i).map(|r| &r.0)).collect();
|
||||||
let faces = po::FaceMerger::with_gap(&map.shapes, gap_param(p));
|
let faces = po::FaceMerger::with_gap(&map.shapes, gap_param(p));
|
||||||
let union = if picked.is_empty() { PathData::default() } else { faces.merge(&picked) };
|
let union = if picked.is_empty() { PathData::default() } else { faces.merge(&picked) };
|
||||||
// The merged shape takes the look of the front-most object over the first region, or (for an
|
// Pick Color From Artwork: the merged shape takes the look of the front-most object under the
|
||||||
// area only lines enclose) of the front-most object and the current fill.
|
// drag's first point, else under its last point, else of the front-most object. Color
|
||||||
let covered = picked.iter().find_map(|r| r.top());
|
// Swatches: the current fill, with the rest of the look of the front-most object over the
|
||||||
|
// first region the drag touched that an object covers, else of the front-most object.
|
||||||
|
let top_at = |q: &Point| map.region_at(*q).and_then(|k| map.regions.get(k)).and_then(|(r, ..)| r.top());
|
||||||
|
let covered =
|
||||||
|
if swatches { picked.iter().find_map(|r| r.top()) } else { pts.first().and_then(top_at).or_else(|| pts.last().and_then(top_at)) };
|
||||||
let style_src = covered.or(map.shapes.len().checked_sub(1));
|
let style_src = covered.or(map.shapes.len().checked_sub(1));
|
||||||
let merged_fill = fill.or_else(|| covered.is_none().then_some(current_fill));
|
|
||||||
let mut new_nodes: Vec<Node> = vec![];
|
let mut new_nodes: Vec<Node> = vec![];
|
||||||
let mut merged_id = None;
|
let mut merged_id = None;
|
||||||
for (i, (sh, leaf)) in map.shapes.iter().zip(&leaves).enumerate() {
|
for (i, (sh, leaf)) in map.shapes.iter().zip(&leaves).enumerate() {
|
||||||
@@ -306,7 +338,7 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
} else {
|
} else {
|
||||||
for k in keep {
|
for k in keep {
|
||||||
if let Some((l, _)) = map.lines.get(k) {
|
if let Some((l, _)) = map.lines.get(k) {
|
||||||
new_nodes.push(shape_node(d, l.path.clone(), Some(leaf)));
|
new_nodes.push(remnant(d, leaf, l.path.clone(), None));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -330,6 +362,9 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
b::node_outline(leaf).map_or_else(|| sh.path.clone(), |o| o.0)
|
b::node_outline(leaf).map_or_else(|| sh.path.clone(), |o| o.0)
|
||||||
};
|
};
|
||||||
let fill_closes = open_filled_as_closed && !leaf.appearance.fill_paint().is_none();
|
let fill_closes = open_filled_as_closed && !leaf.appearance.fill_paint().is_none();
|
||||||
|
// Edges come back from the planar map within a few hundredths of the outlines they
|
||||||
|
// follow, whatever else is selected.
|
||||||
|
let cut_tol = b::outline_snap(&sh.path);
|
||||||
let mut cut = false;
|
let mut cut = false;
|
||||||
for piece in &cuts {
|
for piece in &cuts {
|
||||||
if let Some(r) = po::cut_out(&rest, piece, fill_closes, cut_tol) {
|
if let Some(r) = po::cut_out(&rest, piece, fill_closes, cut_tol) {
|
||||||
@@ -340,12 +375,13 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
if !in_union && !cut {
|
if !in_union && !cut {
|
||||||
new_nodes.push(d.reid(leaf));
|
new_nodes.push(d.reid(leaf));
|
||||||
} else if !rest.is_empty() {
|
} else if !rest.is_empty() {
|
||||||
new_nodes.push(shape_node(d, rest, Some(leaf)));
|
// What is left of its own outline keeps its fill rule; united faces fill with non-zero.
|
||||||
|
new_nodes.push(remnant(d, leaf, rest, in_union.then_some(FillRule::NonZero)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !erase && !union.is_empty() && Some(i) == style_src {
|
if !erase && !union.is_empty() && Some(i) == style_src {
|
||||||
let mut m = shape_node(d, union.clone(), Some(leaf));
|
let mut m = shape_node(d, union.clone(), Some(leaf));
|
||||||
if let Some(f) = &merged_fill {
|
if let Some(f) = &fill {
|
||||||
m.appearance.set_fill(f.clone());
|
m.appearance.set_fill(f.clone());
|
||||||
}
|
}
|
||||||
merged_id = Some(m.id);
|
merged_id = Some(m.id);
|
||||||
@@ -354,15 +390,19 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
}
|
}
|
||||||
let new_ids = replace_roots(d, &roots, new_nodes)?;
|
let new_ids = replace_roots(d, &roots, new_nodes)?;
|
||||||
sel.set(new_ids.iter().copied());
|
sel.set(new_ids.iter().copied());
|
||||||
Ok((new_ids, merged_id, touched))
|
Ok((new_ids, merged_id, touched, None))
|
||||||
})?;
|
})?;
|
||||||
Ok(json!({
|
let mut out = json!({
|
||||||
"ids": ids_json(&ids),
|
"ids": ids_json(&ids),
|
||||||
"merged": merged.map(|m| m.0),
|
"merged": merged.map(|m| m.0),
|
||||||
"regions": touched.regions.len(),
|
"regions": touched.regions.len(),
|
||||||
"lines": touched.lines.len(),
|
"lines": touched.lines.len(),
|
||||||
"edges": touched.edges.len(),
|
"edges": touched.edges.len(),
|
||||||
}))
|
});
|
||||||
|
if let Some(e) = split {
|
||||||
|
out["split"] = json!(e.0);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove the roots and insert `nodes` where the front-most root was. Returns the new ids.
|
/// Remove the roots and insert `nodes` where the front-most root was. Returns the new ids.
|
||||||
@@ -381,10 +421,30 @@ fn replace_roots(d: &mut Document, roots: &[NodeId], nodes: Vec<Node>) -> Result
|
|||||||
Ok(ids)
|
Ok(ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What is left of path or compound path `src`: a copy of it with a fresh id and the geometry `path`
|
||||||
|
/// (a path for one subpath, else a compound path, as [`shape_node`] makes them). It keeps the rest
|
||||||
|
/// of `src`: its look, name, opacity mask and attributes, and its fill rule unless `rule` is given.
|
||||||
|
fn remnant(d: &mut Document, src: &Node, path: PathData, rule: Option<FillRule>) -> Node {
|
||||||
|
let own = match &src.kind {
|
||||||
|
NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } => *rule,
|
||||||
|
_ => FillRule::NonZero,
|
||||||
|
};
|
||||||
|
let shaped = shape_node(d, path, None);
|
||||||
|
let mut n = src.clone();
|
||||||
|
n.id = shaped.id;
|
||||||
|
n.kind = shaped.kind;
|
||||||
|
if let NodeKind::Path { rule: r, .. } | NodeKind::Compound { rule: r, .. } = &mut n.kind {
|
||||||
|
*r = rule.unwrap_or(own);
|
||||||
|
}
|
||||||
|
n
|
||||||
|
}
|
||||||
|
|
||||||
fn sb_regions(s: &mut Session, p: &Value) -> Result<Value> {
|
fn sb_regions(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
|
let open_filled_as_closed = switch_or(p, "openFilledAsClosed", true, "shapeBuilder.regions")?;
|
||||||
|
check_numbers(p, &["gap"], "shapeBuilder.regions")?;
|
||||||
let roots = root_ids(s, p)?;
|
let roots = root_ids(s, p)?;
|
||||||
let d = &s.doc()?.doc;
|
let d = &s.doc()?.doc;
|
||||||
let (map, leaves) = BuilderMap::with_gap(d, &roots, true, bool_or(p, "openFilledAsClosed", true), gap_param(p));
|
let (map, leaves) = BuilderMap::with_gap(d, &roots, true, open_filled_as_closed, gap_param(p));
|
||||||
let id_of = |i: usize| leaves.get(i).map(|n| n.id.0);
|
let id_of = |i: usize| leaves.get(i).map(|n| n.id.0);
|
||||||
let regions: Vec<Value> = map
|
let regions: Vec<Value> = map
|
||||||
.regions
|
.regions
|
||||||
@@ -714,7 +774,7 @@ fn lp_gap_options(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
Some(v) => Some(super::layer::layer_color_param(v).map_err(|e| bad(C, e))?.rgb()),
|
Some(v) => Some(super::layer::layer_color_param(v).map_err(|e| bad(C, e))?.rgb()),
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
let close = bool_or(p, "closeGaps", false);
|
let close = switch_or(p, "closeGaps", false, C)?;
|
||||||
let targets = live_targets(s, p)?;
|
let targets = live_targets(s, p)?;
|
||||||
let (faces, gaps, closed) = s.edit("Gap Options", |d, sel| {
|
let (faces, gaps, closed) = s.edit("Gap Options", |d, sel| {
|
||||||
let (mut faces, mut gaps, mut closed) = (0, vec![], 0);
|
let (mut faces, mut gaps, mut closed) = (0, vec![], 0);
|
||||||
@@ -801,11 +861,11 @@ fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
let g = id_param(p, "group").ok_or_else(|| bad(C, "missing group"))?;
|
let g = id_param(p, "group").ok_or_else(|| bad(C, "missing group"))?;
|
||||||
super::paint::check_keep_model(p, C)?;
|
super::paint::check_keep_model(p, C)?;
|
||||||
let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.stroke.clone());
|
let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.stroke.clone());
|
||||||
let width = p.get("width").and_then(Value::as_f64).unwrap_or(s.paint.stroke_width.max(0.0));
|
let width = number_or(p, "width", s.paint.stroke_width.max(0.0), C)?;
|
||||||
if !(0.0..=1000.0).contains(&width) {
|
if !(0.0..=1000.0).contains(&width) {
|
||||||
return Err(bad(C, "width out of range"));
|
return Err(bad(C, "width out of range"));
|
||||||
}
|
}
|
||||||
let tol = f64_or(p, "tolerance", 4.0).clamp(0.0, 1000.0);
|
let tol = number_or(p, "tolerance", 4.0, C)?.clamp(0.0, 1000.0);
|
||||||
let edge = s.edit("Live Paint Bucket", |d, _| {
|
let edge = s.edit("Live Paint Bucket", |d, _| {
|
||||||
let n = d.node(g).filter(|n| is_live_paint(n)).ok_or_else(|| bad(C, format!("{g} is not a Live Paint group")))?;
|
let n = d.node(g).filter(|n| is_live_paint(n)).ok_or_else(|| bad(C, format!("{g} is not a Live Paint group")))?;
|
||||||
let eid = b::visible_edge_near(n, pt, tol).map(|e| e.id).ok_or_else(|| EngineError::Other("no Live Paint edge near that point".into()))?;
|
let eid = b::visible_edge_near(n, pt, tol).map(|e| e.id).ok_or_else(|| EngineError::Other("no Live Paint edge near that point".into()))?;
|
||||||
@@ -863,7 +923,7 @@ fn view_param(cmd: &str, p: &Value) -> Result<Option<TraceView>> {
|
|||||||
|
|
||||||
fn trace_params(p: &Value) -> Result<tr::TraceParams> {
|
fn trace_params(p: &Value) -> Result<tr::TraceParams> {
|
||||||
let name = str_param(p, "preset").unwrap_or("Default");
|
let name = str_param(p, "preset").unwrap_or("Default");
|
||||||
let enhanced = p.get("enhanced").and_then(Value::as_bool).unwrap_or(false);
|
let enhanced = switch_or(p, "enhanced", false, "imageTrace.make")?;
|
||||||
// (A preset without an enhanced version is traced as it is.)
|
// (A preset without an enhanced version is traced as it is.)
|
||||||
let base = enhanced.then(|| tr::enhanced_preset(name)).flatten().or_else(|| tr::preset(name));
|
let base = enhanced.then(|| tr::enhanced_preset(name)).flatten().or_else(|| tr::preset(name));
|
||||||
let base = base.ok_or_else(|| bad("imageTrace.make", format!("unknown preset `{name}` (see imageTrace.presets)")))?;
|
let base = base.ok_or_else(|| bad("imageTrace.make", format!("unknown preset `{name}` (see imageTrace.presets)")))?;
|
||||||
@@ -949,7 +1009,7 @@ fn traced_shape(shape: tr::TracedShape, xf: vectorcraft_geom::Affine) -> LiveSha
|
|||||||
fn traced_gradient(g: &tr::TracedGradient, xf: vectorcraft_geom::Affine) -> Paint {
|
fn traced_gradient(g: &tr::TracedGradient, xf: vectorcraft_geom::Affine) -> Paint {
|
||||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||||
let stops = g.stops.iter().map(|&(at, c)| GradientStop::new(at as f32, Color::rgb8(c[0], c[1], c[2]))).collect();
|
let stops = g.stops.iter().map(|&(at, c)| GradientStop::new(at as f32, Color::rgb8(c[0], c[1], c[2]))).collect();
|
||||||
let mut paint = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
|
let mut paint = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
|
||||||
paint.geom = Some(GradientGeom { start: xf * g.start, end: xf * g.end, aspect: 1.0, focal: None });
|
paint.geom = Some(GradientGeom { start: xf * g.start, end: xf * g.end, aspect: 1.0, focal: None });
|
||||||
Paint::Gradient(Box::new(paint))
|
Paint::Gradient(Box::new(paint))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
),
|
),
|
||||||
cmd!("shape.line", "Line Segment", [], None, "{x1, y1, x2, y2} → {id}", has_doc, line),
|
cmd!("shape.line", "Line Segment", [], None, "{x1, y1, x2, y2} → {id}", has_doc, line),
|
||||||
cmd!("shape.spiral", "Spiral", [], None, "{cx, cy, radius, decay=80 (%), segments=10, clockwise?} → {id}", has_doc, spiral),
|
cmd!("shape.spiral", "Spiral", [], None, "{cx, cy, radius, decay=80 (%), segments=10, clockwise?} → {id}", has_doc, spiral),
|
||||||
cmd!("shape.arc", "Arc", [], None, "{x1, y1, x2, y2, closed?} → {id}", has_doc, arc),
|
cmd!(
|
||||||
|
"shape.arc",
|
||||||
|
"Arc",
|
||||||
|
[],
|
||||||
|
None,
|
||||||
|
"{x1, y1, x2, y2, closed?, slope?} or {x, y, lengthX, lengthY, reference?, baseAlong?: x|y, slope?, closed?, fillArc?} → {id}",
|
||||||
|
has_doc,
|
||||||
|
arc
|
||||||
|
),
|
||||||
cmd!(
|
cmd!(
|
||||||
"shape.rectangularGrid",
|
"shape.rectangularGrid",
|
||||||
"Rectangular Grid",
|
"Rectangular Grid",
|
||||||
@@ -207,6 +215,7 @@ fn rect_transform(r: Rect, p: &Value, cmd: &str) -> Result<Affine> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn rectangle(s: &mut Session, p: &Value) -> Result<Value> {
|
fn rectangle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
|
check_numbers(p, &["radius"], "shape.rectangle")?;
|
||||||
let r = rect_of(p, "shape.rectangle")?;
|
let r = rect_of(p, "shape.rectangle")?;
|
||||||
let radius = f64_or(p, "radius", 0.0).max(0.0);
|
let radius = f64_or(p, "radius", 0.0).max(0.0);
|
||||||
let live = LiveShape::Rectangle {
|
let live = LiveShape::Rectangle {
|
||||||
@@ -230,6 +239,7 @@ fn ellipse(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
fn polygon(s: &mut Session, p: &Value) -> Result<Value> {
|
fn polygon(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let c = Point::new(f64_req(p, "cx", "shape.polygon")?, f64_req(p, "cy", "shape.polygon")?);
|
let c = Point::new(f64_req(p, "cx", "shape.polygon")?, f64_req(p, "cy", "shape.polygon")?);
|
||||||
let r = f64_req(p, "radius", "shape.polygon")?.abs();
|
let r = f64_req(p, "radius", "shape.polygon")?.abs();
|
||||||
|
check_numbers(p, &["sides", "rotation"], "shape.polygon")?;
|
||||||
let sides = p.get("sides").and_then(Value::as_u64).unwrap_or(6).clamp(3, 1000) as u32;
|
let sides = p.get("sides").and_then(Value::as_u64).unwrap_or(6).clamp(3, 1000) as u32;
|
||||||
let rot = f64_or(p, "rotation", 0.0);
|
let rot = f64_or(p, "rotation", 0.0);
|
||||||
let xf = Affine::translate(c.to_vec2()) * Affine::rotate(rot.to_radians());
|
let xf = Affine::translate(c.to_vec2()) * Affine::rotate(rot.to_radians());
|
||||||
@@ -240,6 +250,7 @@ fn polygon(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
fn star(s: &mut Session, p: &Value) -> Result<Value> {
|
fn star(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let c = Point::new(f64_req(p, "cx", "shape.star")?, f64_req(p, "cy", "shape.star")?);
|
let c = Point::new(f64_req(p, "cx", "shape.star")?, f64_req(p, "cy", "shape.star")?);
|
||||||
let r1 = f64_req(p, "radius1", "shape.star")?.abs();
|
let r1 = f64_req(p, "radius1", "shape.star")?.abs();
|
||||||
|
check_numbers(p, &["radius2", "points", "rotation"], "shape.star")?;
|
||||||
let r2 = f64_or(p, "radius2", r1 / 2.0).abs();
|
let r2 = f64_or(p, "radius2", r1 / 2.0).abs();
|
||||||
let n = p.get("points").and_then(Value::as_u64).unwrap_or(5).clamp(2, 1000) as u32;
|
let n = p.get("points").and_then(Value::as_u64).unwrap_or(5).clamp(2, 1000) as u32;
|
||||||
let path = shapes::star(c, r1, r2, n, f64_or(p, "rotation", 0.0));
|
let path = shapes::star(c, r1, r2, n, f64_or(p, "rotation", 0.0));
|
||||||
@@ -251,6 +262,24 @@ fn star(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
fn flare(s: &mut Session, p: &Value) -> Result<Value> {
|
fn flare(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
use vectorcraft_color::{BlendMode, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
|
use vectorcraft_color::{BlendMode, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
|
||||||
let c = Point::new(f64_req(p, "cx", "shape.flare")?, f64_req(p, "cy", "shape.flare")?);
|
let c = Point::new(f64_req(p, "cx", "shape.flare")?, f64_req(p, "cy", "shape.flare")?);
|
||||||
|
let numbers = [
|
||||||
|
"diameter",
|
||||||
|
"opacity",
|
||||||
|
"brightness",
|
||||||
|
"growth",
|
||||||
|
"fuzziness",
|
||||||
|
"rays",
|
||||||
|
"longest",
|
||||||
|
"rayFuzziness",
|
||||||
|
"rings",
|
||||||
|
"largest",
|
||||||
|
"x2",
|
||||||
|
"y2",
|
||||||
|
"direction",
|
||||||
|
"pathLength",
|
||||||
|
"seed",
|
||||||
|
];
|
||||||
|
check_numbers(p, &numbers, "shape.flare")?;
|
||||||
let pct = |k: &str, d: f64| (f64_or(p, k, d) / 100.0).clamp(0.0, 10.0);
|
let pct = |k: &str, d: f64| (f64_or(p, k, d) / 100.0).clamp(0.0, 10.0);
|
||||||
let r = (f64_or(p, "diameter", 100.0) / 2.0).max(0.5);
|
let r = (f64_or(p, "diameter", 100.0) / 2.0).max(0.5);
|
||||||
let opacity = pct("opacity", 50.0).min(1.0) as f32;
|
let opacity = pct("opacity", 50.0).min(1.0) as f32;
|
||||||
@@ -279,7 +308,7 @@ fn flare(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
};
|
};
|
||||||
let stop = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, vectorcraft_color::Color::WHITE) };
|
let stop = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, vectorcraft_color::Color::WHITE) };
|
||||||
let radial = |centre: Point, radius: f64, stops: Vec<GradientStop>| {
|
let radial = |centre: Point, radius: f64, stops: Vec<GradientStop>| {
|
||||||
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Radial, stops });
|
let mut g = GradientPaint::new(Gradient::new(GradientKind::Radial, stops));
|
||||||
g.geom = Some(GradientGeom { start: centre, end: centre + vectorcraft_geom::Vec2::new(radius, 0.0), aspect: 1.0, focal: None });
|
g.geom = Some(GradientGeom { start: centre, end: centre + vectorcraft_geom::Vec2::new(radius, 0.0), aspect: 1.0, focal: None });
|
||||||
Paint::Gradient(Box::new(g))
|
Paint::Gradient(Box::new(g))
|
||||||
};
|
};
|
||||||
@@ -360,20 +389,46 @@ fn line(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
|
|
||||||
fn spiral(s: &mut Session, p: &Value) -> Result<Value> {
|
fn spiral(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let c = Point::new(f64_req(p, "cx", "shape.spiral")?, f64_req(p, "cy", "shape.spiral")?);
|
let c = Point::new(f64_req(p, "cx", "shape.spiral")?, f64_req(p, "cy", "shape.spiral")?);
|
||||||
|
check_numbers(p, &["segments"], "shape.spiral")?;
|
||||||
let path = shapes::spiral(
|
let path = shapes::spiral(
|
||||||
c,
|
c,
|
||||||
f64_req(p, "radius", "shape.spiral")?,
|
f64_req(p, "radius", "shape.spiral")?,
|
||||||
f64_or(p, "decay", 80.0),
|
number_or(p, "decay", 80.0, "shape.spiral")?,
|
||||||
p.get("segments").and_then(Value::as_u64).unwrap_or(10).clamp(2, 1000) as u32,
|
p.get("segments").and_then(Value::as_u64).unwrap_or(10).clamp(2, 1000) as u32,
|
||||||
bool_or(p, "clockwise", true),
|
switch_or(p, "clockwise", true, "shape.spiral")?,
|
||||||
);
|
);
|
||||||
add_art(s, "Spiral", path_kind(path, None), None)
|
add_art(s, "Spiral", path_kind(path, None), None)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn arc(s: &mut Session, p: &Value) -> Result<Value> {
|
fn arc(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let a = Point::new(f64_req(p, "x1", "shape.arc")?, f64_req(p, "y1", "shape.arc")?);
|
let closed = switch_or(p, "closed", false, "shape.arc")?;
|
||||||
let b = Point::new(f64_req(p, "x2", "shape.arc")?, f64_req(p, "y2", "shape.arc")?);
|
let fill_arc = switch_or(p, "fillArc", false, "shape.arc")?;
|
||||||
add_art(s, "Arc", path_kind(shapes::arc(a, b, 0.0, bool_or(p, "closed", false)), None), None)
|
let base_x = !matches!(p.get("baseAlong").and_then(Value::as_str), Some("y" | "Y"));
|
||||||
|
let path = if p.get("lengthX").is_some() || p.get("lengthY").is_some() {
|
||||||
|
let anchor = Point::new(f64_req(p, "x", "shape.arc")?, f64_req(p, "y", "shape.arc")?);
|
||||||
|
let reference = p.get("reference").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||||
|
shapes::arc_segment(
|
||||||
|
anchor,
|
||||||
|
reference,
|
||||||
|
number_or(p, "lengthX", 100.0, "shape.arc")?,
|
||||||
|
number_or(p, "lengthY", 100.0, "shape.arc")?,
|
||||||
|
base_x,
|
||||||
|
number_or(p, "slope", 50.0, "shape.arc")?,
|
||||||
|
closed,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let a = Point::new(f64_req(p, "x1", "shape.arc")?, f64_req(p, "y1", "shape.arc")?);
|
||||||
|
let b = Point::new(f64_req(p, "x2", "shape.arc")?, f64_req(p, "y2", "shape.arc")?);
|
||||||
|
let slope = number_or(p, "slope", 100.0, "shape.arc")?;
|
||||||
|
shapes::arc(a, b, slope, closed, base_x)
|
||||||
|
};
|
||||||
|
let fill = if fill_arc { s.paint.fill.clone() } else { vectorcraft_color::Paint::None };
|
||||||
|
let look = s.new_art_look(
|
||||||
|
fill,
|
||||||
|
if s.paint.stroke.is_none() { vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK) } else { s.paint.stroke.clone() },
|
||||||
|
s.paint.stroke_width.max(0.1),
|
||||||
|
);
|
||||||
|
add_look(s, "Arc", path_kind(path, None), look, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The grid `paths` (bottom first) as one selected group in the new-art stroke, filled with the
|
/// The grid `paths` (bottom first) as one selected group in the new-art stroke, filled with the
|
||||||
@@ -406,9 +461,11 @@ fn grid_group(s: &mut Session, label: &str, paths: Vec<PathData>, fill: bool) ->
|
|||||||
Ok(json!({ "id": id.0 }))
|
Ok(json!({ "id": id.0 }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A grid's divider count `count` with its skew `key` in percent (0 when absent, clamped to
|
/// A grid's divider count `count` (an error unless it is a number or null; 5 unless it is a JSON
|
||||||
/// ±[`shapes::GRID_SKEW_MAX`]).
|
/// integer of 0 or more, so 4.0 and 2.5 give 5; at most 999) with its skew `key` in percent (0 when
|
||||||
|
/// absent, clamped to ±[`shapes::GRID_SKEW_MAX`]).
|
||||||
fn dividers(p: &Value, count: &str, key: &str, cmd: &str) -> Result<(u32, f64)> {
|
fn dividers(p: &Value, count: &str, key: &str, cmd: &str) -> Result<(u32, f64)> {
|
||||||
|
check_numbers(p, &[count], cmd)?;
|
||||||
let n = p.get(count).and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
|
let n = p.get(count).and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
|
||||||
let skew = match p.get(key) {
|
let skew = match p.get(key) {
|
||||||
None | Some(Value::Null) => 0.0,
|
None | Some(Value::Null) => 0.0,
|
||||||
@@ -466,6 +523,8 @@ pub(crate) fn anchor_from_json(v: &Value, cmd: &str) -> Result<Anchor> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn path_create(s: &mut Session, p: &Value) -> Result<Value> {
|
fn path_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
|
// Checked with `d` too, where the path data alone decides whether the path is closed.
|
||||||
|
let closed = switch_or(p, "closed", false, "path.create")?;
|
||||||
let path = if let Some(d) = str_param(p, "d") {
|
let path = if let Some(d) = str_param(p, "d") {
|
||||||
let bp = vectorcraft_geom::BezPath::from_svg(d).map_err(|e| bad("path.create", format!("bad path data: {e}")))?;
|
let bp = vectorcraft_geom::BezPath::from_svg(d).map_err(|e| bad("path.create", format!("bad path data: {e}")))?;
|
||||||
PathData::from_bezpath(&bp)
|
PathData::from_bezpath(&bp)
|
||||||
@@ -480,7 +539,7 @@ fn path_create(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
if anchors.is_empty() {
|
if anchors.is_empty() {
|
||||||
return Err(bad("path.create", "need at least one anchor"));
|
return Err(bad("path.create", "need at least one anchor"));
|
||||||
}
|
}
|
||||||
PathData::single(SubPath::new(anchors, bool_or(p, "closed", false)))
|
PathData::single(SubPath::new(anchors, closed))
|
||||||
};
|
};
|
||||||
let mut look = s.new_art_look(s.paint.fill.clone(), s.paint.stroke.clone(), s.paint.stroke_width);
|
let mut look = s.new_art_look(s.paint.fill.clone(), s.paint.stroke.clone(), s.paint.stroke_width);
|
||||||
// Open paths drawn with a stroke of None would be invisible: mimic Illustrator and keep what the user set.
|
// Open paths drawn with a stroke of None would be invisible: mimic Illustrator and keep what the user set.
|
||||||
@@ -495,6 +554,11 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
let x = f64_req(p, "x", "text.create")?;
|
let x = f64_req(p, "x", "text.create")?;
|
||||||
let y = f64_req(p, "y", "text.create")?;
|
let y = f64_req(p, "y", "text.create")?;
|
||||||
super::paint::check_keep_model(p, "text.create")?;
|
super::paint::check_keep_model(p, "text.create")?;
|
||||||
|
check_numbers(p, &["size"], "text.create")?;
|
||||||
|
check_switches(p, &["vertical", "placeholder"], "text.create")?;
|
||||||
|
if let Some(a) = p.get("area") {
|
||||||
|
check_numbers(a, &["width", "height"], "text.create")?;
|
||||||
|
}
|
||||||
let text = str_param(p, "text").unwrap_or("");
|
let text = str_param(p, "text").unwrap_or("");
|
||||||
let mut t = TextObject::point(Point::new(x, y), text, new_type_style(s, p));
|
let mut t = TextObject::point(Point::new(x, y), text, new_type_style(s, p));
|
||||||
t.vertical = p.get("vertical").and_then(Value::as_bool).unwrap_or(false);
|
t.vertical = p.get("vertical").and_then(Value::as_bool).unwrap_or(false);
|
||||||
|
|||||||
@@ -665,9 +665,10 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
let pressure = stroke_pressure(&samples);
|
let pressure = stroke_pressure(&samples);
|
||||||
let mut pts: Vec<Point> = samples.into_iter().map(|(pt, _)| pt).collect();
|
let mut pts: Vec<Point> = samples.into_iter().map(|(pt, _)| pt).collect();
|
||||||
let tol = number_or(p, "fidelity", 1.5, C)?.clamp(0.05, 100.0);
|
let tol = number_or(p, "fidelity", 1.5, C)?.clamp(0.05, 100.0);
|
||||||
let closed = bool_or(p, "closed", false);
|
let closed = switch_or(p, "closed", false, C)?;
|
||||||
let brush = str_param(p, "style") == Some("brush");
|
let brush = str_param(p, "style") == Some("brush");
|
||||||
let keep_selected = bool_or(p, "keepSelected", true);
|
let keep_selected = switch_or(p, "keepSelected", true, C)?;
|
||||||
|
let filled = switch_or(p, "fill", false, C)?;
|
||||||
if let Some(ext) = p.get("extend").filter(|v| !v.is_null()) {
|
if let Some(ext) = p.get("extend").filter(|v| !v.is_null()) {
|
||||||
let id = id_param(ext, "id").ok_or_else(|| bad(C, "extend needs id"))?;
|
let id = id_param(ext, "id").ok_or_else(|| bad(C, "extend needs id"))?;
|
||||||
let at_start = str_param(ext, "end") == Some("start");
|
let at_start = str_param(ext, "end") == Some("start");
|
||||||
@@ -764,7 +765,7 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
return Err(bad(C, "need at least two points"));
|
return Err(bad(C, "need at least two points"));
|
||||||
}
|
}
|
||||||
let sp = fit_freehand(&pts, tol, closed);
|
let sp = fit_freehand(&pts, tol, closed);
|
||||||
let fill = if bool_or(p, "fill", false) { s.paint.fill.clone() } else { Paint::None };
|
let fill = if filled { s.paint.fill.clone() } else { Paint::None };
|
||||||
// A pencil path with a fill keeps a stroke of None (#1679); only one that would show nothing
|
// A pencil path with a fill keeps a stroke of None (#1679); only one that would show nothing
|
||||||
// gets black. A brush is its stroke.
|
// gets black. A brush is its stroke.
|
||||||
let stroke = if brush || fill.is_none() { stroke_paint(s) } else { s.paint.stroke.clone() };
|
let stroke = if brush || fill.is_none() { stroke_paint(s) } else { s.paint.stroke.clone() };
|
||||||
@@ -780,12 +781,11 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
fn curvature(s: &mut Session, p: &Value) -> Result<Value> {
|
fn curvature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
const C: &str = "path.curvature";
|
const C: &str = "path.curvature";
|
||||||
let arr = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(C, "missing points"))?;
|
let arr = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(C, "missing points"))?;
|
||||||
let pts: Vec<(Point, bool)> =
|
let pts: Vec<(Point, bool)> = arr.iter().map(|v| Ok((point_entry(v, C)?, switch_or(v, "corner", false, C)?))).collect::<Result<_>>()?;
|
||||||
arr.iter().map(|v| Ok((point_entry(v, C)?, v.get("corner").and_then(Value::as_bool).unwrap_or(false)))).collect::<Result<_>>()?;
|
|
||||||
if pts.is_empty() {
|
if pts.is_empty() {
|
||||||
return Err(bad(C, "need at least one point"));
|
return Err(bad(C, "need at least one point"));
|
||||||
}
|
}
|
||||||
let sp = vectorcraft_tools::draw2::curvature_spline(&pts, bool_or(p, "closed", false));
|
let sp = vectorcraft_tools::draw2::curvature_spline(&pts, switch_or(p, "closed", false, C)?);
|
||||||
if let Some(id) = id_param(p, "id") {
|
if let Some(id) = id_param(p, "id") {
|
||||||
s.edit("Curvature", |d, sel| {
|
s.edit("Curvature", |d, sel| {
|
||||||
*path_mut(d, id)? = PathData::single(sp);
|
*path_mut(d, id)? = PathData::single(sp);
|
||||||
@@ -831,7 +831,7 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
None => EndCurve::Keep,
|
None => EndCurve::Keep,
|
||||||
Some(f) => EndCurve::parse(f).ok_or_else(|| bad(C, format!("unknown from {f:?}")))?,
|
Some(f) => EndCurve::parse(f).ok_or_else(|| bad(C, format!("unknown from {f:?}")))?,
|
||||||
};
|
};
|
||||||
let corner = bool_or(p, "corner", false);
|
let corner = switch_or(p, "corner", false, C)?;
|
||||||
let op = match str_param(p, "op") {
|
let op = match str_param(p, "op") {
|
||||||
Some("move") => CurvatureOp::Move(usize_req(p, "anchor", C)?, to()?),
|
Some("move") => CurvatureOp::Move(usize_req(p, "anchor", C)?, to()?),
|
||||||
Some("insert") => CurvatureOp::Insert(usize_req(p, "segment", C)?, f64_or(p, "t", 0.5), at),
|
Some("insert") => CurvatureOp::Insert(usize_req(p, "segment", C)?, f64_or(p, "t", 0.5), at),
|
||||||
@@ -1030,7 +1030,7 @@ fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
if !(dv.x.is_finite() && dv.y.is_finite() && dv.hypot() < MAX_INPUT) {
|
if !(dv.x.is_finite() && dv.y.is_finite() && dv.hypot() < MAX_INPUT) {
|
||||||
return Err(bad(C, "invalid delta"));
|
return Err(bad(C, "invalid delta"));
|
||||||
}
|
}
|
||||||
let perpendicular = bool_or(p, "perpendicular", false);
|
let perpendicular = switch_or(p, "perpendicular", false, C)?;
|
||||||
s.edit("Reshape", |d, _| {
|
s.edit("Reshape", |d, _| {
|
||||||
let path = path_mut(d, id)?;
|
let path = path_mut(d, id)?;
|
||||||
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
|
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
|
||||||
@@ -1490,11 +1490,12 @@ fn blob(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
} else {
|
} else {
|
||||||
Paint::solid(Color::BLACK)
|
Paint::solid(Color::BLACK)
|
||||||
};
|
};
|
||||||
let keep_selected = bool_or(p, "keepSelected", false);
|
let keep_selected = switch_or(p, "keepSelected", false, C)?;
|
||||||
let only_selection = bool_or(p, "mergeOnlySelection", false);
|
let only_selection = switch_or(p, "mergeOnlySelection", false, C)?;
|
||||||
|
let merge = switch_or(p, "merge", true, C)?;
|
||||||
let st = s.doc()?;
|
let st = s.doc()?;
|
||||||
let mut merge_ids = vec![];
|
let mut merge_ids = vec![];
|
||||||
if bool_or(p, "merge", true) && !(only_selection && st.selection.is_empty()) {
|
if merge && !(only_selection && st.selection.is_empty()) {
|
||||||
let all = Selection::default();
|
let all = Selection::default();
|
||||||
let from = if only_selection { &st.selection } else { &all };
|
let from = if only_selection { &st.selection } else { &all };
|
||||||
for id in target_paths(&st.doc, from, poly_bounds(&pts, size)) {
|
for id in target_paths(&st.doc, from, poly_bounds(&pts, size)) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use super::clipboard::SwatchChoices;
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::{Clipboard, EngineError, inspect};
|
use crate::{Clipboard, EngineError, inspect};
|
||||||
|
|
||||||
|
/// Register editing commands, including document-to-document artwork drops and their parameter contracts.
|
||||||
pub fn specs() -> Vec<CommandSpec> {
|
pub fn specs() -> Vec<CommandSpec> {
|
||||||
vec![
|
vec![
|
||||||
cmd!(
|
cmd!(
|
||||||
@@ -73,6 +74,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
has_doc,
|
has_doc,
|
||||||
global_rulers
|
global_rulers
|
||||||
),
|
),
|
||||||
|
cmd!(
|
||||||
|
"edit.dropSelection",
|
||||||
|
"Drop Artwork",
|
||||||
|
[],
|
||||||
|
None,
|
||||||
|
"{sourceDocument: uid, ids: [id…], grab: [x,y], at: [x,y]} copy 1–10000 editable source objects from an open document to the active document, preserving the grab point and clipboard, as one undo step",
|
||||||
|
has_doc,
|
||||||
|
drop_selection
|
||||||
|
),
|
||||||
cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
|
cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
|
||||||
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo),
|
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo),
|
||||||
cmd!(
|
cmd!(
|
||||||
@@ -571,6 +581,40 @@ fn clear(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
ok()
|
ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Document ids scope node ids: two tabs may contain objects with the same node id.
|
||||||
|
fn drop_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
|
const CMD: &str = "edit.dropSelection";
|
||||||
|
let key = "sourceDocument";
|
||||||
|
let uid = p[key].as_u64().ok_or_else(|| bad(CMD, format!("missing integer `{key}`")))?;
|
||||||
|
let point = |key: &str| finite_numbers::<2>(&p[key]).ok_or_else(|| bad(CMD, format!("{key} must be [x, y] with finite coordinates")));
|
||||||
|
let grab = point("grab")?;
|
||||||
|
let at = point("at")?;
|
||||||
|
let source = s.documents().iter().find(|d| d.uid == uid).ok_or_else(|| bad(CMD, "that document is no longer open"))?;
|
||||||
|
let key = "ids";
|
||||||
|
let values = p[key].as_array().ok_or_else(|| bad(CMD, format!("{key} must be an array of non-negative integer object ids")))?;
|
||||||
|
if values.is_empty() || values.len() > 10000 {
|
||||||
|
return Err(bad(CMD, "ids must contain 1–10000 object ids"));
|
||||||
|
}
|
||||||
|
let ids = values
|
||||||
|
.iter()
|
||||||
|
.map(|value| value.as_u64().map(NodeId).ok_or_else(|| bad(CMD, format!("invalid object id {value}: expected a non-negative integer"))))
|
||||||
|
.collect::<Result<Vec<_>>>()?;
|
||||||
|
// Validate the entire batch in one walk; node ids are local to the source document.
|
||||||
|
let paths = source.doc.index_paths(ids.iter().copied());
|
||||||
|
if let Some(id) = ids.iter().find(|id| !paths.contains_key(id)) {
|
||||||
|
return Err(EngineError::NoNode(*id));
|
||||||
|
}
|
||||||
|
let clip = Clipboard::copy(source, &roots_of(&source.doc, ids));
|
||||||
|
let bounds = clip.bounds().ok_or_else(|| bad(CMD, "the art has no bounds"))?;
|
||||||
|
let center = bounds.center() + Vec2::new(at[0] - grab[0], at[1] - grab[1]);
|
||||||
|
if !center.x.is_finite() || !center.y.is_finite() {
|
||||||
|
return Err(bad(CMD, "result would exceed the canvas (coordinates out of range)"));
|
||||||
|
}
|
||||||
|
// A drop preserves the source colours even when the flyer has same-named swatches.
|
||||||
|
let choices = SwatchChoices::parse(PasteMode::Drag.label(), &json!({"swatchConflict": "add"}))?;
|
||||||
|
paste_clip(s, &json!({"center": [center.x, center.y]}), PasteMode::Drag, &clip, &choices)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
enum PasteMode {
|
enum PasteMode {
|
||||||
Offset,
|
Offset,
|
||||||
@@ -580,9 +624,11 @@ enum PasteMode {
|
|||||||
AllArtboards,
|
AllArtboards,
|
||||||
/// A graphic from the Libraries panel: centred where it is placed, as Paste centres.
|
/// A graphic from the Libraries panel: centred where it is placed, as Paste centres.
|
||||||
Library,
|
Library,
|
||||||
|
Drag,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PasteMode {
|
impl PasteMode {
|
||||||
|
/// Return the user-visible action label for this insertion mode.
|
||||||
fn label(self) -> &'static str {
|
fn label(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
PasteMode::Front => "Paste in Front",
|
PasteMode::Front => "Paste in Front",
|
||||||
@@ -591,6 +637,7 @@ impl PasteMode {
|
|||||||
PasteMode::AllArtboards => "Paste on All Artboards",
|
PasteMode::AllArtboards => "Paste on All Artboards",
|
||||||
PasteMode::Offset => "Paste",
|
PasteMode::Offset => "Paste",
|
||||||
PasteMode::Library => "Place from Library",
|
PasteMode::Library => "Place from Library",
|
||||||
|
PasteMode::Drag => "Drop Artwork",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -623,6 +670,7 @@ fn onto_artboard(doc: &Document, p: &Value, clip: &Clipboard) -> Affine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Insert clipboard resources atomically in the active editable context using the requested placement mode.
|
||||||
fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, choices: &SwatchChoices) -> Result<Value> {
|
fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, choices: &SwatchChoices) -> Result<Value> {
|
||||||
let off = s.prefs.paste_offset;
|
let off = s.prefs.paste_offset;
|
||||||
let st = s.doc()?;
|
let st = s.doc()?;
|
||||||
@@ -640,7 +688,10 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
|||||||
.map(|all| (&clip.artboards, if mode == PasteMode::Offset { super::panelcmds::beside_artboards(&st.doc, all) } else { Vec2::ZERO }));
|
.map(|all| (&clip.artboards, if mode == PasteMode::Offset { super::panelcmds::beside_artboards(&st.doc, all) } else { Vec2::ZERO }));
|
||||||
// Paste Remembers Layers (not while isolating a group: pastes stay in it); an artboard's art
|
// Paste Remembers Layers (not while isolating a group: pastes stay in it); an artboard's art
|
||||||
// always goes back into its layers.
|
// always goes back into its layers.
|
||||||
let remember = (st.doc.paste_remembers_layers || boards.is_some()) && parent.is_some_and(|p| st.doc.node(p).is_some_and(Node::is_layer));
|
// A drop goes into the current editable insertion context, not a same-named source layer.
|
||||||
|
let remember = mode != PasteMode::Drag
|
||||||
|
&& (st.doc.paste_remembers_layers || boards.is_some())
|
||||||
|
&& parent.is_some_and(|p| st.doc.node(p).is_some_and(Node::is_layer));
|
||||||
// Front/back: relative to the selection (top-most / bottom-most selected object); with
|
// Front/back: relative to the selection (top-most / bottom-most selected object); with
|
||||||
// nothing selected, the top / bottom of the current layer.
|
// nothing selected, the top / bottom of the current layer.
|
||||||
let anchor = match mode {
|
let anchor = match mode {
|
||||||
@@ -653,7 +704,7 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
|||||||
};
|
};
|
||||||
let placements: Vec<Affine> = match (mode, boards) {
|
let placements: Vec<Affine> = match (mode, boards) {
|
||||||
(_, Some((_, dv))) => vec![Affine::translate(dv)],
|
(_, Some((_, dv))) => vec![Affine::translate(dv)],
|
||||||
(PasteMode::Offset | PasteMode::Library, None) => vec![match point_param(p, "center") {
|
(PasteMode::Offset | PasteMode::Library | PasteMode::Drag, None) => vec![match point_param(p, "center") {
|
||||||
Some(c) => clip.bounds().map_or(Affine::IDENTITY, |b| Affine::translate(c - b.center())),
|
Some(c) => clip.bounds().map_or(Affine::IDENTITY, |b| Affine::translate(c - b.center())),
|
||||||
None => Affine::translate((f64_or(p, "dx", off), f64_or(p, "dy", off))),
|
None => Affine::translate((f64_or(p, "dx", off), f64_or(p, "dy", off))),
|
||||||
}],
|
}],
|
||||||
@@ -680,7 +731,9 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
|||||||
let mut c = d.reid(n);
|
let mut c = d.reid(n);
|
||||||
imported.apply(&mut c);
|
imported.apply(&mut c);
|
||||||
if xf != Affine::IDENTITY {
|
if xf != Affine::IDENTITY {
|
||||||
c.transform(xf, false);
|
// A cross-document drag carries the graphic's appearance with it,
|
||||||
|
// including the pattern phase, independently of transform-tool settings.
|
||||||
|
c.transform(xf, vectorcraft_doc::Scaling { patterns: mode == PasteMode::Drag, ..Default::default() });
|
||||||
}
|
}
|
||||||
let layer = match clip.source_layers.get(k) {
|
let layer = match clip.source_layers.get(k) {
|
||||||
Some(Some(name)) if remember => Some(*layers.entry(name).or_insert_with(|| layer_named(d, name))),
|
Some(Some(name)) if remember => Some(*layers.entry(name).or_insert_with(|| layer_named(d, name))),
|
||||||
|
|||||||
@@ -1151,7 +1151,7 @@ mod tests {
|
|||||||
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
|
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
|
||||||
let (mut s, id) = session_with_rect();
|
let (mut s, id) = session_with_rect();
|
||||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||||
let ramp = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops })));
|
let ramp = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
|
||||||
s.edit("gradient", |d, _| {
|
s.edit("gradient", |d, _| {
|
||||||
let n = d.node_mut(id).unwrap();
|
let n = d.node_mut(id).unwrap();
|
||||||
n.appearance.set_fill(ramp);
|
n.appearance.set_fill(ramp);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use vectorcraft_color::cms::Model;
|
|||||||
use vectorcraft_color::freeform::spread_scale;
|
use vectorcraft_color::freeform::spread_scale;
|
||||||
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, Paint};
|
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, Paint};
|
||||||
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer};
|
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer};
|
||||||
use vectorcraft_doc::live::{GradientMesh, lerp_color};
|
use vectorcraft_doc::live::GradientMesh;
|
||||||
use vectorcraft_doc::pattern::{PatternDef, place_art};
|
use vectorcraft_doc::pattern::{PatternDef, place_art};
|
||||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Scaling};
|
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Scaling};
|
||||||
use vectorcraft_geom::{Affine, PathData, Point, Rect, SubPath, Vec2, shapes};
|
use vectorcraft_geom::{Affine, PathData, Point, Rect, SubPath, Vec2, shapes};
|
||||||
@@ -736,7 +736,7 @@ pub(crate) fn gradient_sampler(g: &GradientPaint, bounds: Rect, doc_model: Model
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
let geom = g.resolve(bounds);
|
let geom = g.resolve(bounds);
|
||||||
Box::new(move |p| g.gradient.sample_with(geom.param_at(g.gradient.kind, p) as f32, lerp_color))
|
Box::new(move |p| g.gradient.sample_keep_model(geom.param_at(g.gradient.kind, p) as f32))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where the colour changes along a gradient between parameters `t0` and `t1`: both ends, every
|
/// Where the colour changes along a gradient between parameters `t0` and `t1`: both ends, every
|
||||||
@@ -745,8 +745,13 @@ pub(crate) fn gradient_sampler(g: &GradientPaint, bounds: Rect, doc_model: Model
|
|||||||
/// are over `max_gap` apart. The colour is linear in display RGB between neighbours, as the
|
/// are over `max_gap` apart. The colour is linear in display RGB between neighbours, as the
|
||||||
/// canvas draws it.
|
/// canvas draws it.
|
||||||
fn breaks(g: &Gradient, t0: f64, t1: f64, max_gap: f64) -> Vec<(f64, (Color, f32))> {
|
fn breaks(g: &Gradient, t0: f64, t1: f64, max_gap: f64) -> Vec<(f64, (Color, f32))> {
|
||||||
let at = |t: f64| (t, g.sample_with(t as f32, lerp_color));
|
let at = |t: f64| (t, g.sample_keep_model(t as f32));
|
||||||
let mut v = vec![at(t0)];
|
let mut v = vec![at(t0)];
|
||||||
|
// A Perceptual gradient curves between its stops: the mesh takes a column at each of the stops
|
||||||
|
// its expansion adds, so the linear mix between columns follows that curve.
|
||||||
|
if !g.method.is_classic() {
|
||||||
|
v.extend(g.expanded().map(|(o, ..)| f64::from(o)).filter(|o| *o > t0 && *o < t1).map(at));
|
||||||
|
}
|
||||||
for (i, s) in g.stops.iter().enumerate() {
|
for (i, s) in g.stops.iter().enumerate() {
|
||||||
let o = s.offset as f64;
|
let o = s.offset as f64;
|
||||||
if o > t0 && o < t1 {
|
if o > t0 && o < t1 {
|
||||||
@@ -844,7 +849,7 @@ impl Frame {
|
|||||||
/// is just its band, so nothing doubles up.
|
/// is just its band, so nothing doubles up.
|
||||||
fn pieces(&self, g: &Gradient, n: usize) -> Vec<(PathData, (Color, f32))> {
|
fn pieces(&self, g: &Gradient, n: usize) -> Vec<(PathData, (Color, f32))> {
|
||||||
let step = (self.t1 - self.t0) / n as f64;
|
let step = (self.t1 - self.t0) / n as f64;
|
||||||
let color = |k: usize| g.sample_with((self.t0 + (k as f64 + 0.5) * step) as f32, lerp_color);
|
let color = |k: usize| g.sample_keep_model((self.t0 + (k as f64 + 0.5) * step) as f32);
|
||||||
let opaque = g.stops.iter().all(|s| s.opacity >= 1.0);
|
let opaque = g.stops.iter().all(|s| s.opacity >= 1.0);
|
||||||
if self.radial {
|
if self.radial {
|
||||||
let ellipse = |t: f64| {
|
let ellipse = |t: f64| {
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -19,8 +19,15 @@ pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
let f = writable("document.serialize", Some(str_param(p, "format").unwrap_or("vectorcraft")), None)?;
|
let f = writable("document.serialize", Some(str_param(p, "format").unwrap_or("vectorcraft")), None)?;
|
||||||
let expanded = expand_presets(s, "document.serialize", p)?;
|
let expanded = expand_presets(s, "document.serialize", p)?;
|
||||||
let p = with_black_output(s, &expanded);
|
let p = with_black_output(s, &expanded);
|
||||||
let doc = &*source(s, f, &p, "document.serialize")?;
|
let (doc, display_doc_title) = source(s, f, &p, "document.serialize")?;
|
||||||
let enc = encode_all(doc, f.id, &p)?;
|
let doc = &*doc;
|
||||||
|
let mut q = p.clone();
|
||||||
|
if f.id == "pdf"
|
||||||
|
&& let Some(o) = q.as_object_mut()
|
||||||
|
{
|
||||||
|
o.insert("_displayDocTitle".into(), json!(display_doc_title));
|
||||||
|
}
|
||||||
|
let enc = encode_all(doc, f.id, &q)?;
|
||||||
let files = enc.named(doc, &default_name(doc, f.extensions[0]));
|
let files = enc.named(doc, &default_name(doc, f.extensions[0]));
|
||||||
let (main, linked) = files.split_at(enc.files.len());
|
let (main, linked) = files.split_at(enc.files.len());
|
||||||
// Text formats come back as text; an SVG in UTF-16 or ISO 8859-1 also as its bytes.
|
// Text formats come back as text; an SVG in UTF-16 or ISO 8859-1 also as its bytes.
|
||||||
@@ -53,8 +60,15 @@ pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
let f = writable("document.export", str_param(p, "format"), path)?;
|
let f = writable("document.export", str_param(p, "format"), path)?;
|
||||||
let expanded = expand_presets(s, "document.export", p)?;
|
let expanded = expand_presets(s, "document.export", p)?;
|
||||||
let p = with_black_output(s, &expanded);
|
let p = with_black_output(s, &expanded);
|
||||||
let doc = &*source(s, f, &p, "document.export")?;
|
let (doc, display_doc_title) = source(s, f, &p, "document.export")?;
|
||||||
let enc = encode_all(doc, f.id, &p)?;
|
let doc = &*doc;
|
||||||
|
let mut q = p.clone();
|
||||||
|
if f.id == "pdf"
|
||||||
|
&& let Some(o) = q.as_object_mut()
|
||||||
|
{
|
||||||
|
o.insert("_displayDocTitle".into(), json!(display_doc_title));
|
||||||
|
}
|
||||||
|
let enc = encode_all(doc, f.id, &q)?;
|
||||||
write_encoded(path, &default_name(doc, f.extensions[0]), doc, &enc, json!({ "format": f.id, "warnings": enc.warnings }))
|
write_encoded(path, &default_name(doc, f.extensions[0]), doc, &enc, json!({ "format": f.id, "warnings": enc.warnings }))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,13 +168,16 @@ fn expand_presets<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result<Cow<'a, Va
|
|||||||
|
|
||||||
/// The document an export of `f` writes: the active one (see [`export_source`]), or for text
|
/// The document an export of `f` writes: the active one (see [`export_source`]), or for text
|
||||||
/// with `selectionOnly` the selected objects alone.
|
/// with `selectionOnly` the selected objects alone.
|
||||||
fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<Cow<'a, Document>> {
|
fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<(Cow<'a, Document>, bool)> {
|
||||||
if f.id == "txt" && bool_or(p, "selectionOnly", false) {
|
if f.id == "txt" && bool_or(p, "selectionOnly", false) {
|
||||||
return Ok(Cow::Owned(selection(s, cmd)?.0));
|
let doc = selection(s, cmd)?.0;
|
||||||
|
let display_doc_title = super::has_own_title(&doc);
|
||||||
|
return Ok((Cow::Owned(doc), display_doc_title));
|
||||||
}
|
}
|
||||||
let st = s.doc()?;
|
let st = s.doc()?;
|
||||||
let doc = export_source(st, p)?;
|
let doc = export_source(st, p)?;
|
||||||
Ok(super::titled_for_export(&doc, st.path.as_deref(), f.id).map_or(doc, Cow::Owned))
|
let display_doc_title = super::has_own_title(&doc);
|
||||||
|
Ok((super::titled_for_export(&doc, st.path.as_deref(), f.id).map_or(doc, Cow::Owned), display_doc_title))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The selected objects (not on template layers) alone on one layer, with one artboard: their
|
/// The selected objects (not on template layers) alone on one layer, with one artboard: their
|
||||||
|
|||||||
@@ -370,7 +370,7 @@ pub fn open_bytes(s: &mut Session, name: &str, bytes: &[u8], path: Option<String
|
|||||||
/// [`open_bytes`] with the `document.open` options in `p` ([`LoadOptions::from_params`]).
|
/// [`open_bytes`] with the `document.open` options in `p` ([`LoadOptions::from_params`]).
|
||||||
pub fn open_bytes_with(s: &mut Session, name: &str, bytes: &[u8], path: Option<String>, p: &Value) -> Result<Value> {
|
pub fn open_bytes_with(s: &mut Session, name: &str, bytes: &[u8], path: Option<String>, p: &Value) -> Result<Value> {
|
||||||
let update_links = s.prefs.update_links == "automatically";
|
let update_links = s.prefs.update_links == "automatically";
|
||||||
open_read(s, read_for_open(name, bytes, path, p, update_links)?)
|
open_read(s, read_for_open(name, bytes, path, p, update_links, s.prefs.low_res_proxy_eps)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A file read for `document.open` but not opened yet ([`read_for_open`], [`open_read`]).
|
/// A file read for `document.open` but not opened yet ([`read_for_open`], [`open_read`]).
|
||||||
@@ -396,10 +396,10 @@ impl ReadFile {
|
|||||||
/// [`open_read`] opens. The linked files are looked for from `path`, where the file was read from,
|
/// [`open_read`] opens. The linked files are looked for from `path`, where the file was read from,
|
||||||
/// and modified ones are read again when `update_links` (Preferences › File Handling › Update
|
/// and modified ones are read again when `update_links` (Preferences › File Handling › Update
|
||||||
/// Links set to Automatically; see [`crate::cmd::links::resolve`]).
|
/// Links set to Automatically; see [`crate::cmd::links::resolve`]).
|
||||||
pub fn read_for_open(name: &str, bytes: &[u8], path: Option<String>, p: &Value, update_links: bool) -> Result<ReadFile> {
|
pub fn read_for_open(name: &str, bytes: &[u8], path: Option<String>, p: &Value, update_links: bool, low_res_proxy_eps: bool) -> Result<ReadFile> {
|
||||||
let opts = LoadOptions::from_params("document.open", p)?;
|
let opts = LoadOptions::from_params("document.open", p)?;
|
||||||
let mut loaded = load_with(name, bytes, &opts)?;
|
let mut loaded = load_with(name, bytes, &opts)?;
|
||||||
let links = crate::cmd::links::resolve(&mut loaded.doc, path.as_deref(), update_links);
|
let links = crate::cmd::links::resolve(&mut loaded.doc, path.as_deref(), update_links, low_res_proxy_eps);
|
||||||
Session::refresh_text_bounds(&mut loaded.doc);
|
Session::refresh_text_bounds(&mut loaded.doc);
|
||||||
Ok(ReadFile { loaded, opts, path, links })
|
Ok(ReadFile { loaded, opts, path, links })
|
||||||
}
|
}
|
||||||
@@ -419,7 +419,7 @@ pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&st
|
|||||||
"only this Affinity file's embedded preview could be read: use File › Open to see it with its warning, or export SVG or PDF from Affinity first",
|
"only this Affinity file's embedded preview could be read: use File › Open to see it with its warning, or export SVG or PDF from Affinity first",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let links = crate::cmd::links::resolve(&mut loaded.doc, path, s.prefs.update_links == "automatically");
|
let links = crate::cmd::links::resolve(&mut loaded.doc, path, s.prefs.update_links == "automatically", s.prefs.low_res_proxy_eps);
|
||||||
open_loaded(s, loaded, links, path.map(str::to_string), &LoadOptions::default(), true)
|
open_loaded(s, loaded, links, path.map(str::to_string), &LoadOptions::default(), true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -793,6 +793,17 @@ pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static
|
|||||||
.chain(std::iter::once(("Plug-ins", super::plugin::EXTS)))
|
.chain(std::iter::once(("Plug-ins", super::plugin::EXTS)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open-dialog filters for Swatch Libraries and Graphic Style Libraries Other Library… and Locate
|
||||||
|
/// Library…. The library formats come first so Linux file portals (which grey out files that don't
|
||||||
|
/// match the default filter) still list `.ase` and the other library extensions; then the same
|
||||||
|
/// filters as [`open_filters`] so a document can be picked for its swatches or graphic styles.
|
||||||
|
pub fn library_open_filters(
|
||||||
|
library_label: &'static str,
|
||||||
|
library_exts: &'static [&'static str],
|
||||||
|
) -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
|
||||||
|
std::iter::once((library_label, library_exts)).chain(open_filters())
|
||||||
|
}
|
||||||
|
|
||||||
/// File → Place dialog filters: "All placeable files", then one per placeable format, then text,
|
/// File → Place dialog filters: "All placeable files", then one per placeable format, then text,
|
||||||
/// RTF and Word.
|
/// RTF and Word.
|
||||||
pub fn place_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
|
pub fn place_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
|
||||||
@@ -860,9 +871,12 @@ pub fn file_stem(name: &str) -> String {
|
|||||||
/// document that only has the Untitled-N name New gave it is titled after its file, as its tab is
|
/// document that only has the Untitled-N name New gave it is titled after its file, as its tab is
|
||||||
/// (the title PDF, PNG and SVG metadata carry). A title of its own stays, and native files keep
|
/// (the title PDF, PNG and SVG metadata carry). A title of its own stays, and native files keep
|
||||||
/// the stored title.
|
/// the stored title.
|
||||||
|
pub fn has_own_title(doc: &vectorcraft_doc::Document) -> bool {
|
||||||
|
!doc.title.trim().is_empty() && !doc.title.strip_prefix("Untitled-").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn titled_for_export(doc: &vectorcraft_doc::Document, path: Option<&str>, format: &str) -> Option<vectorcraft_doc::Document> {
|
pub fn titled_for_export(doc: &vectorcraft_doc::Document, path: Option<&str>, format: &str) -> Option<vectorcraft_doc::Document> {
|
||||||
let untitled = doc.title.strip_prefix("Untitled-").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()));
|
let path = path.filter(|_| !has_own_title(doc) && !matches!(format, "vectorcraft" | "template" | "ai"))?;
|
||||||
let path = path.filter(|_| untitled && !matches!(format, "vectorcraft" | "template" | "ai"))?;
|
|
||||||
let mut d = doc.clone();
|
let mut d = doc.clone();
|
||||||
d.title = file_stem(path);
|
d.title = file_stem(path);
|
||||||
Some(d)
|
Some(d)
|
||||||
|
|||||||
@@ -243,7 +243,14 @@ pub fn options(cmd: &str, doc: &Document, p: &Value) -> Result<PdfOptions> {
|
|||||||
let pick = if p.is_object() { ArtboardPick::deserialize(p).map_err(|e| bad(cmd, format!("PDF options: {e}")))? } else { ArtboardPick::default() };
|
let pick = if p.is_object() { ArtboardPick::deserialize(p).map_err(|e| bad(cmd, format!("PDF options: {e}")))? } else { ArtboardPick::default() };
|
||||||
let artboards = pick.resolve(doc.artboards.len()).map_err(|e| bad(cmd, e))?;
|
let artboards = pick.resolve(doc.artboards.len()).map_err(|e| bad(cmd, e))?;
|
||||||
let now = vectorcraft_doc::metadata::output_time();
|
let now = vectorcraft_doc::metadata::output_time();
|
||||||
Ok(PdfOptions { settings: settings(cmd, p)?, artboards, created: doc.metadata.created.or(now), modified: now, ..Default::default() })
|
Ok(PdfOptions {
|
||||||
|
settings: settings(cmd, p)?,
|
||||||
|
artboards,
|
||||||
|
display_doc_title: bool_or(p, "_displayDocTitle", super::has_own_title(doc)),
|
||||||
|
created: doc.metadata.created.or(now),
|
||||||
|
modified: now,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Why a PDF of some artboards carries no editing data.
|
/// Why a PDF of some artboards carries no editing data.
|
||||||
@@ -387,8 +394,15 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
let expanded = expand_preset(s, C, p)?;
|
let expanded = expand_preset(s, C, p)?;
|
||||||
let p = &*expanded;
|
let p = &*expanded;
|
||||||
let st = s.doc()?;
|
let st = s.doc()?;
|
||||||
|
let display_doc_title = super::has_own_title(&st.doc);
|
||||||
let titled = super::titled_for_export(&st.doc, st.path.as_deref(), "pdf");
|
let titled = super::titled_for_export(&st.doc, st.path.as_deref(), "pdf");
|
||||||
let (bytes, warnings) = encode(C, titled.as_ref().unwrap_or(&st.doc), p)?;
|
let mut q = p.clone();
|
||||||
|
if let Some(o) = q.as_object_mut() {
|
||||||
|
o.insert("_displayDocTitle".into(), json!(display_doc_title));
|
||||||
|
} else {
|
||||||
|
q = json!({ "_displayDocTitle": display_doc_title });
|
||||||
|
}
|
||||||
|
let (bytes, warnings) = encode(C, titled.as_ref().unwrap_or(&st.doc), &q)?;
|
||||||
write_or_return(str_param(p, "path"), &bytes, json!({ "warnings": warnings }))
|
write_or_return(str_param(p, "path"), &bytes, json!({ "warnings": warnings }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -800,10 +800,12 @@ fn items(
|
|||||||
};
|
};
|
||||||
if o.full_document {
|
if o.full_document {
|
||||||
// The bounds of all visible art, as the one artboard of the files that aren't PDFs (a PDF
|
// The bounds of all visible art, as the one artboard of the files that aren't PDFs (a PDF
|
||||||
// row writes every artboard of the document itself).
|
// row writes every artboard of the document itself). It is named after the document: an
|
||||||
|
// SVG of one artboard takes the artboard's name as its title (#3544), and this one stands
|
||||||
|
// for the whole document.
|
||||||
let (whole, region) = if crop {
|
let (whole, region) = if crop {
|
||||||
let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?;
|
let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?;
|
||||||
(Arc::new(single_artboard(doc, bounds, "Document")), bounds)
|
(Arc::new(single_artboard(doc, bounds, &doc.title)), bounds)
|
||||||
} else {
|
} else {
|
||||||
(doc.clone(), Rect::ZERO)
|
(doc.clone(), Rect::ZERO)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -179,9 +179,10 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Enco
|
|||||||
let doc = flat.as_ref().unwrap_or(doc);
|
let doc = flat.as_ref().unwrap_or(doc);
|
||||||
let mut enc = Encoded { warnings, ..Encoded::default() };
|
let mut enc = Encoded { warnings, ..Encoded::default() };
|
||||||
for artboard in boards {
|
for artboard in boards {
|
||||||
// A chosen artboard's file holds the art over it (#550).
|
// A chosen artboard's file holds the art over it (#550) and is named after it (#3544).
|
||||||
let over = artboard.filter(|_| chosen).and_then(|b| doc.artboards.get(b)).map(|a| super::export::art_over(doc, a.rect));
|
let over = artboard.filter(|_| chosen).and_then(|b| doc.artboards.get(b)).map(|a| super::export::art_over(doc, a.rect));
|
||||||
let mut out = vectorcraft_svg::export_full(over.as_ref().unwrap_or(doc), &ExportOptions { artboard, ..opts.clone() }, native.as_deref());
|
let file = ExportOptions { artboard, artboard_title: chosen, ..opts.clone() };
|
||||||
|
let mut out = vectorcraft_svg::export_full(over.as_ref().unwrap_or(doc), &file, native.as_deref());
|
||||||
let bytes = out.take_bytes();
|
let bytes = out.take_bytes();
|
||||||
for l in out.linked {
|
for l in out.linked {
|
||||||
if !enc.linked.iter().any(|e| e.name == l.name) {
|
if !enc.linked.iter().any(|e| e.name == l.name) {
|
||||||
|
|||||||
@@ -384,6 +384,14 @@ fn open_exts_cover_every_readable_format() {
|
|||||||
assert_eq!(filters.last(), Some(&("Plug-ins", crate::cmd::plugin::EXTS)), "File › Open installs plug-ins");
|
assert_eq!(filters.last(), Some(&("Plug-ins", crate::cmd::plugin::EXTS)), "File › Open installs plug-ins");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn library_open_filters_list_library_formats_first() {
|
||||||
|
let filters: Vec<_> = library_open_filters("Swatch libraries", super::swatchlib::LIBRARY_EXTS).collect();
|
||||||
|
assert_eq!(filters[0], ("Swatch libraries", super::swatchlib::LIBRARY_EXTS));
|
||||||
|
assert!(filters[0].1.contains(&"ase"));
|
||||||
|
assert_eq!(filters[1], ("All readable files", OPEN_EXTS));
|
||||||
|
}
|
||||||
|
|
||||||
/// The extensions usage texts list for export: one per writable format, each picking that format
|
/// The extensions usage texts list for export: one per writable format, each picking that format
|
||||||
/// (`.png` picks PNG; PNG-8 is asked for by `format`).
|
/// (`.png` picks PNG; PNG-8 is asked for by `format`).
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -125,6 +125,24 @@ fn placed_images_come_back_as_linked_files() {
|
|||||||
let _ = std::fs::remove_dir_all(dir);
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Two placed images whose pixel data can't be decoded are left out of the drawing and get no
|
||||||
|
/// linked file, and `warnings` holds the note raster exports give, once, without naming either
|
||||||
|
/// image's key.
|
||||||
|
#[test]
|
||||||
|
fn images_that_cant_be_decoded_give_the_raster_note_once() {
|
||||||
|
const NOTE: &str = "an image couldn't be decoded and is missing from the picture";
|
||||||
|
let mut s = session(200.0, 100.0, 1);
|
||||||
|
crate::tests_menucmds::place_undecodable_png(&mut s);
|
||||||
|
crate::tests_menucmds::place_undecodable_png_sized(&mut s, 32, 16);
|
||||||
|
let keys: Vec<String> = s.doc().unwrap().doc.images.keys().cloned().collect();
|
||||||
|
assert_eq!(keys.len(), 2);
|
||||||
|
let r = s.execute("document.export", &json!({"format": "dxf"})).unwrap();
|
||||||
|
let w: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
|
||||||
|
assert_eq!(w.iter().filter(|w| **w == NOTE).count(), 1, "{w:?}");
|
||||||
|
assert!(!w.iter().any(|w| keys.iter().any(|k| w.contains(k.as_str()))), "{w:?}");
|
||||||
|
assert!(entities(&text(&r)).is_empty() && r.get("linked").is_none(), "{r}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bad_dxf_options_are_refused() {
|
fn bad_dxf_options_are_refused() {
|
||||||
let mut s = two_boards();
|
let mut s = two_boards();
|
||||||
|
|||||||
@@ -156,6 +156,23 @@ fn placed_images_are_written_and_previews_are_black_and_white_on_request() {
|
|||||||
assert!(l3.contains("/FlateDecode filter >> image"));
|
assert!(l3.contains("/FlateDecode filter >> image"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Two placed images whose pixel data can't be decoded are left out of the EPS, and `warnings`
|
||||||
|
/// holds the note raster exports give, once, without naming either image's key.
|
||||||
|
#[test]
|
||||||
|
fn images_that_cant_be_decoded_give_the_raster_note_once() {
|
||||||
|
const NOTE: &str = "an image couldn't be decoded and is missing from the picture";
|
||||||
|
let mut s = session(200.0, 100.0, 1);
|
||||||
|
crate::tests_menucmds::place_undecodable_png(&mut s);
|
||||||
|
crate::tests_menucmds::place_undecodable_png_sized(&mut s, 32, 16);
|
||||||
|
let keys: Vec<String> = s.doc().unwrap().doc.images.keys().cloned().collect();
|
||||||
|
assert_eq!(keys.len(), 2);
|
||||||
|
let r = s.execute("document.exportEps", &json!({"previewFormat": "none"})).unwrap();
|
||||||
|
let w = warnings(&r);
|
||||||
|
assert_eq!(w.iter().filter(|w| *w == NOTE).count(), 1, "{w:?}");
|
||||||
|
assert!(!w.iter().any(|w| keys.iter().any(|k| w.contains(k.as_str()))), "{w:?}");
|
||||||
|
assert!(!postscript(&b64(&r)).contains("filter >> image"), "no image is written");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bad_eps_options_are_refused_and_formats_list_eps() {
|
fn bad_eps_options_are_refused_and_formats_list_eps() {
|
||||||
let mut s = session(100.0, 100.0, 1);
|
let mut s = session(100.0, 100.0, 1);
|
||||||
|
|||||||
@@ -95,6 +95,24 @@ fn raster_effects_are_written_as_images() {
|
|||||||
assert!(kinds.contains(&114), "the shadow is an image with transparency");
|
assert!(kinds.contains(&114), "the shadow is an image with transparency");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Two placed images whose pixel data can't be decoded are left out of an EMF or WMF, and
|
||||||
|
/// `warnings` holds the note raster exports give, once, without naming either image's key.
|
||||||
|
#[test]
|
||||||
|
fn images_that_cant_be_decoded_give_the_raster_note_once() {
|
||||||
|
const NOTE: &str = "an image couldn't be decoded and is missing from the picture";
|
||||||
|
let mut s = session(200.0, 100.0, 1);
|
||||||
|
crate::tests_menucmds::place_undecodable_png(&mut s);
|
||||||
|
crate::tests_menucmds::place_undecodable_png_sized(&mut s, 32, 16);
|
||||||
|
let keys: Vec<String> = s.doc().unwrap().doc.images.keys().cloned().collect();
|
||||||
|
assert_eq!(keys.len(), 2);
|
||||||
|
for format in ["emf", "wmf"] {
|
||||||
|
let r = s.execute("document.export", &json!({"format": format})).unwrap();
|
||||||
|
let w: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
|
||||||
|
assert_eq!(w.iter().filter(|w| **w == NOTE).count(), 1, "{format}: {w:?}");
|
||||||
|
assert!(!w.iter().any(|w| keys.iter().any(|k| w.contains(k.as_str()))), "{format}: {w:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn metafiles_open_and_place() {
|
fn metafiles_open_and_place() {
|
||||||
let mut s = two_boards();
|
let mut s = two_boards();
|
||||||
|
|||||||
@@ -131,6 +131,32 @@ fn options_not_applied_yet_and_document_features_warn() {
|
|||||||
assert!(warnings(&s.execute("document.export", &json!({"format": "png"})).unwrap()).is_empty());
|
assert!(warnings(&s.execute("document.export", &json!({"format": "png"})).unwrap()).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A placed 16-bit PNG is written at 8 bits a sample in PDF 1.3 and 1.4 files (PDF/X-3 and the
|
||||||
|
/// pages of a .ai file too), and keeps 16 bits from PDF 1.5.
|
||||||
|
#[test]
|
||||||
|
fn a_16_bit_png_is_written_at_8_bits_before_pdf_1_5() {
|
||||||
|
let mut s = session(1);
|
||||||
|
let deep = image::ImageBuffer::from_fn(8, 8, |x, y| image::Rgb([x as u16 * 8000, y as u16 * 8000, 40000]));
|
||||||
|
let mut png = vec![];
|
||||||
|
image::DynamicImage::ImageRgb16(deep).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||||
|
s.execute("file.place", &json!({"name": "deep.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [50, 40]})).unwrap();
|
||||||
|
let readable = json!({"compressText": false});
|
||||||
|
for (cmd, p, depth) in [
|
||||||
|
("document.exportPdf", json!({"compatibility": "1.3", "compression": readable}), 8),
|
||||||
|
("document.exportPdf", json!({"compatibility": "1.4", "compression": readable}), 8),
|
||||||
|
("document.exportPdf", json!({"standard": "pdfX3", "compression": readable}), 8),
|
||||||
|
("file.saveCopy", json!({"format": "ai", "compatibility": "1.4", "compress": false}), 8),
|
||||||
|
("document.exportPdf", json!({"compatibility": "1.5", "compression": readable}), 16),
|
||||||
|
("document.exportPdf", json!({"compression": readable}), 16),
|
||||||
|
] {
|
||||||
|
let v = s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"));
|
||||||
|
let text = String::from_utf8_lossy(&b64(&v)).into_owned();
|
||||||
|
let (eight, sixteen) = (text.contains("/BitsPerComponent 8"), text.contains("/BitsPerComponent 16"));
|
||||||
|
assert_eq!((eight, sixteen), (depth == 8, depth == 16), "{cmd} {p}");
|
||||||
|
assert!(warnings(&v).iter().all(|w| !w.contains("decoded")), "{cmd} {p}: {}", v["warnings"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A placed image whose pixel data can't be decoded is left out of every PDF the engine writes,
|
/// A placed image whose pixel data can't be decoded is left out of every PDF the engine writes,
|
||||||
/// and the rest of the page is written. Each reply that carries the PDF's warnings has the note
|
/// and the rest of the page is written. Each reply that carries the PDF's warnings has the note
|
||||||
/// raster exports give, once.
|
/// raster exports give, once.
|
||||||
|
|||||||
@@ -89,3 +89,51 @@ fn choosing_a_pdfx_standard_sets_its_version_and_turns_off_what_it_forbids() {
|
|||||||
assert!(s.execute("document.exportPdf", &json!({"preset": preset})).is_ok(), "{preset}");
|
assert!(s.execute("document.exportPdf", &json!({"preset": preset})).is_ok(), "{preset}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The ink amounts (0..1) `doc` shows at `p` in its images (the topmost image there).
|
||||||
|
fn image_inks_at(doc: &vectorcraft_doc::Document, p: vectorcraft_geom::Point) -> Option<[f32; 4]> {
|
||||||
|
fn visit(n: &vectorcraft_doc::Node, out: &mut Vec<vectorcraft_doc::ImageObject>) {
|
||||||
|
if let vectorcraft_doc::NodeKind::Image(im) = &n.kind {
|
||||||
|
out.push((**im).clone());
|
||||||
|
}
|
||||||
|
for c in n.children().into_iter().flatten() {
|
||||||
|
visit(c, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut images = vec![];
|
||||||
|
for l in &doc.layers {
|
||||||
|
visit(l, &mut images);
|
||||||
|
}
|
||||||
|
images.iter().rev().find_map(|im| {
|
||||||
|
let q = im.xf.inverse() * p;
|
||||||
|
if q.x < 0.0 || q.y < 0.0 || q.x >= im.width as f64 || q.y >= im.height as f64 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let ink = doc.images.get(&im.key)?.sample_cmyk_object(q.x, q.y, (im.width, im.height), 1)?;
|
||||||
|
Some(ink.map(|v| v as f32 / 255.0))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pdfx_1a_flattening_keeps_the_inks_behind_an_image_in_a_cmyk_document() {
|
||||||
|
// #2160: a CMYK panel under a PNG with see-through pixels. The flattener rasterizes where the
|
||||||
|
// image reaches; the panel there keeps its ink numbers instead of going through RGB.
|
||||||
|
let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let mut s = Session::new();
|
||||||
|
s.execute("file.new", &json!({"width": 200, "height": 100, "colorMode": "cmyk"})).unwrap();
|
||||||
|
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 180, "height": 80})).unwrap();
|
||||||
|
let teal = [1.0, 0.06, 0.41, 0.03];
|
||||||
|
s.execute("paint.setFill", &json!({"color": {"c": teal[0], "m": teal[1], "y": teal[2], "k": teal[3]}})).unwrap();
|
||||||
|
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
||||||
|
let mut png = vec![];
|
||||||
|
image::RgbaImage::from_fn(40, 40, |x, _| image::Rgba([0, 120, 255, if x < 20 { 0 } else { 255 }]))
|
||||||
|
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||||
|
.unwrap();
|
||||||
|
s.execute("file.place", &json!({"name": "logos.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [100, 50]})).unwrap();
|
||||||
|
let v = s.execute("document.exportPdf", &json!({"standard": "pdfX1a"})).unwrap();
|
||||||
|
let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap();
|
||||||
|
let back = vectorcraft_pdf::import(&bytes).unwrap();
|
||||||
|
// Behind the image's see-through half: the panel's own inks, K included.
|
||||||
|
let ink = image_inks_at(&back, vectorcraft_geom::Point::new(88.0, 50.0)).expect("a CMYK image behind the see-through pixels");
|
||||||
|
assert!(ink.iter().zip(teal).all(|(a, b)| (a - b).abs() <= 1.5 / 255.0), "{ink:?} is not {teal:?}");
|
||||||
|
}
|
||||||
@@ -108,3 +108,39 @@ fn a_chosen_artboard_s_svg_keeps_an_object_whose_shadow_reaches_it() {
|
|||||||
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [id], "params": {"x": 20, "y": 0, "blur": 0}})).unwrap();
|
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [id], "params": {"x": 20, "y": 0, "blur": 0}})).unwrap();
|
||||||
assert_eq!(paths(&mut s), 1, "its shadow reaches 10 pt onto the artboard");
|
assert_eq!(paths(&mut s), 1, "its shadow reaches 10 pt onto the artboard");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #3544: an SVG of one artboard on its own (an `artboard` or `range`, Export for Screens, an asset)
|
||||||
|
/// has the artboard's (or asset's) name as `<title>`; Save's SVG and Export for Screens' Full
|
||||||
|
/// Document keep the document's.
|
||||||
|
#[test]
|
||||||
|
fn an_svg_of_one_artboard_is_titled_after_it() {
|
||||||
|
let mut s = Session::new();
|
||||||
|
s.execute("file.new", &json!({"name": "Icons", "width": 24, "height": 24, "artboards": 2})).unwrap();
|
||||||
|
for (index, name) in [(0, "arrow-left"), (1, "arrow-right")] {
|
||||||
|
s.execute("artboard.setProps", &json!({"index": index, "name": name})).unwrap();
|
||||||
|
}
|
||||||
|
let square = s.execute("shape.rectangle", &json!({"x": 4, "y": 4, "width": 16, "height": 16})).unwrap()["id"].clone();
|
||||||
|
let title = |svg: &str| svg.lines().find_map(|l| l.trim().strip_prefix("<title>")?.strip_suffix("</title>")).map(str::to_string);
|
||||||
|
let serialize = |s: &mut Session, p: serde_json::Value| s.execute("document.serialize", &p).unwrap()["text"].as_str().unwrap().to_string();
|
||||||
|
assert_eq!(title(&serialize(&mut s, json!({"format": "svg", "artboard": 1}))).as_deref(), Some("arrow-right"));
|
||||||
|
assert_eq!(title(&serialize(&mut s, json!({"format": "svg", "artboard": 0}))).as_deref(), Some("arrow-left"));
|
||||||
|
assert_eq!(title(&serialize(&mut s, json!({"format": "svg"}))).as_deref(), Some("Icons"), "Save's SVG");
|
||||||
|
let all = s.execute("document.serialize", &json!({"format": "svg", "useArtboards": true, "range": "all"})).unwrap();
|
||||||
|
let titles: Vec<_> = all["files"].as_array().unwrap().iter().map(|f| title(f["text"].as_str().unwrap())).collect();
|
||||||
|
assert_eq!(titles, [Some("arrow-left".into()), Some("arrow-right".into())]);
|
||||||
|
// Export for Screens: each artboard, an asset, and the Full Document.
|
||||||
|
let screens = |s: &mut Session, p: serde_json::Value| -> Vec<Option<String>> {
|
||||||
|
let r = s.execute("document.exportForScreens", &p).unwrap();
|
||||||
|
r["files"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|f| title(&String::from_utf8(vectorcraft_format::base64_decode(f["dataBase64"].as_str().unwrap()).unwrap()).unwrap()))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let svg = json!([{"format": "svg"}]);
|
||||||
|
assert_eq!(screens(&mut s, json!({"formats": svg})), [Some("arrow-left".into()), Some("arrow-right".into())]);
|
||||||
|
let asset = s.execute("assets.add", &json!({"ids": [square], "name": "Square"})).unwrap()["assets"][0].clone();
|
||||||
|
assert_eq!(screens(&mut s, json!({"assets": [asset], "formats": svg})), [Some("Square".into())]);
|
||||||
|
assert_eq!(screens(&mut s, json!({"fullDocument": true, "formats": svg})), [Some("Icons".into())]);
|
||||||
|
}
|
||||||
@@ -11,7 +11,8 @@
|
|||||||
//! effects reach there is no single colour: those regions are rendered into one image per group,
|
//! effects reach there is no single colour: those regions are rendered into one image per group,
|
||||||
//! clipped to them with Clip Complex Regions (else the image is a rectangle that takes in the
|
//! clipped to them with Clip Complex Regions (else the image is a rectangle that takes in the
|
||||||
//! regions it overlaps). The raster/vector balance rasterizes whole groups that split into more
|
//! regions it overlaps). The raster/vector balance rasterizes whole groups that split into more
|
||||||
//! regions than it allows (all of them at 0).
|
//! regions than it allows (all of them at 0). For files without transparency (PDF/X-1a, PDF/X-3)
|
||||||
|
//! a CMYK document's images are opaque CMYK: its colours keep their inks there too.
|
||||||
//!
|
//!
|
||||||
//! Edit → Transparency Flattener Presets: the built-in presets plus the user's, saved with the
|
//! Edit → Transparency Flattener Presets: the built-in presets plus the user's, saved with the
|
||||||
//! preferences ([`crate::Prefs::flattener_presets`]); a `preset` named anywhere options are taken
|
//! preferences ([`crate::Prefs::flattener_presets`]); a `preset` named anywhere options are taken
|
||||||
@@ -28,6 +29,7 @@ use serde_json::{Value, json};
|
|||||||
use vectorcraft_color::blend::{cmyk_planes, composite, planes_cmyk};
|
use vectorcraft_color::blend::{cmyk_planes, composite, planes_cmyk};
|
||||||
use vectorcraft_color::{BlendMode, Color, Paint};
|
use vectorcraft_color::{BlendMode, Color, Paint};
|
||||||
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer};
|
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer};
|
||||||
|
use vectorcraft_doc::cmyk::Inks;
|
||||||
use vectorcraft_doc::{Appearance, ColorMode, Document, Effect, ImageBlob, ImageObject, Knockout, Node, NodeId, NodeKind};
|
use vectorcraft_doc::{Appearance, ColorMode, Document, Effect, ImageBlob, ImageObject, Knockout, Node, NodeId, NodeKind};
|
||||||
use vectorcraft_geom::{Affine, FillRule, PathData, Rect, shapes};
|
use vectorcraft_geom::{Affine, FillRule, PathData, Rect, shapes};
|
||||||
use vectorcraft_pathops as po;
|
use vectorcraft_pathops as po;
|
||||||
@@ -132,8 +134,8 @@ pub struct FlattenOptions {
|
|||||||
pub preserve_overprints: bool,
|
pub preserve_overprints: bool,
|
||||||
/// Flattening for files without any transparency (PDF/X-1a, PDF/X-3): images with
|
/// Flattening for files without any transparency (PDF/X-1a, PDF/X-3): images with
|
||||||
/// see-through pixels (these keys, [`see_through_images`]) count as transparency, and
|
/// see-through pixels (these keys, [`see_through_images`]) count as transparency, and
|
||||||
/// rasterized areas clipped to their regions are opaque. `None` for ordinary flattening. Not a
|
/// rasterized areas clipped to their regions are opaque (in a CMYK document, images of ink
|
||||||
/// preset option.
|
/// amounts). `None` for ordinary flattening. Not a preset option.
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub no_transparency: Option<BTreeSet<String>>,
|
pub no_transparency: Option<BTreeSet<String>>,
|
||||||
}
|
}
|
||||||
@@ -805,7 +807,7 @@ struct RegionFill {
|
|||||||
|
|
||||||
/// A rendered image of complex regions, clipped to them or not.
|
/// A rendered image of complex regions, clipped to them or not.
|
||||||
struct Raster {
|
struct Raster {
|
||||||
png: Vec<u8>,
|
blob: ImageBlob,
|
||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
xf: Affine,
|
xf: Affine,
|
||||||
@@ -945,7 +947,9 @@ fn region_fill(elems: &[Elem], sources: &[usize], o: &FlattenOptions, cmyk: bool
|
|||||||
|
|
||||||
/// `art` rendered over `rect` at `ppi`: over white with the white the art doesn't cover taken out
|
/// `art` rendered over `rect` at `ppi`: over white with the white the art doesn't cover taken out
|
||||||
/// again (so blend modes see the white page), or over nothing with Preserve Alpha. The image and
|
/// again (so blend modes see the white page), or over nothing with Preserve Alpha. The image and
|
||||||
/// its `clip` reach a pixel further. Fails when the image can't be encoded.
|
/// its `clip` reach a pixel further. An opaque image in a CMYK document holds the art's ink
|
||||||
|
/// amounts (a CMYK TIFF), so its CMYK colours keep their numbers (#2160); other images are PNGs.
|
||||||
|
/// Fails when the image can't be encoded.
|
||||||
fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Option<PathData>, o: &FlattenOptions) -> Result<Raster> {
|
fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Option<PathData>, o: &FlattenOptions) -> Result<Raster> {
|
||||||
let mut scale = ppi / 72.0;
|
let mut scale = ppi / 72.0;
|
||||||
scale = scale.min((MAX_PIXELS / (rect.width() * rect.height()).max(1.0)).sqrt());
|
scale = scale.min((MAX_PIXELS / (rect.width() * rect.height()).max(1.0)).sqrt());
|
||||||
@@ -958,11 +962,26 @@ fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Optio
|
|||||||
rect.x0 + (rect.width() * scale).ceil().max(1.0) / scale,
|
rect.x0 + (rect.width() * scale).ceil().max(1.0) / scale,
|
||||||
rect.y0 + (rect.height() * scale).ceil().max(1.0) / scale,
|
rect.y0 + (rect.height() * scale).ceil().max(1.0) / scale,
|
||||||
);
|
);
|
||||||
|
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
|
||||||
|
let tmp = isolated_doc(doc, art.to_vec());
|
||||||
let mut r = vectorcraft_render::Renderer::new();
|
let mut r = vectorcraft_render::Renderer::new();
|
||||||
let mut img = r.render_region(&isolated_doc(doc, art.to_vec()), region, scale, !o.preserve_alpha);
|
let no_alpha = o.no_transparency.is_some() && clip.is_some() && !o.preserve_alpha;
|
||||||
|
if no_alpha && doc.color_mode == ColorMode::Cmyk {
|
||||||
|
// Drawn as inks over the paper: as screen colours, the file would separate them again,
|
||||||
|
// and a CMYK colour comes back with other inks (K moves into C, M and Y).
|
||||||
|
let opts = vectorcraft_render::RenderOptions { background: Some([255; 4]), skip_templates: true, ..Default::default() };
|
||||||
|
let inks = r.render_region_inks(&tmp, region, scale, &opts);
|
||||||
|
vectorcraft_doc::cancel::check()?;
|
||||||
|
let (width, height) = vectorcraft_render::region_pixels(region, scale);
|
||||||
|
// Past the size of a CMYK image: drawn in screen colours, below.
|
||||||
|
if let Some(blob) = Inks::new(width, height, inks).and_then(|i| ImageBlob::cmyk_tiff(&i)) {
|
||||||
|
return Ok(Raster { blob, width, height, xf, clip });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut img = r.render_region(&tmp, region, scale, !o.preserve_alpha);
|
||||||
// The drawing stops early when cancelled: this image is not whole.
|
// The drawing stops early when cancelled: this image is not whole.
|
||||||
vectorcraft_doc::cancel::check()?;
|
vectorcraft_doc::cancel::check()?;
|
||||||
if o.no_transparency.is_some() && clip.is_some() && !o.preserve_alpha {
|
if no_alpha {
|
||||||
// Without transparency the clip alone shapes the image: it is opaque, over white (its
|
// Without transparency the clip alone shapes the image: it is opaque, over white (its
|
||||||
// edge pixels too).
|
// edge pixels too).
|
||||||
for p in img.pixels.as_chunks_mut::<4>().0 {
|
for p in img.pixels.as_chunks_mut::<4>().0 {
|
||||||
@@ -1002,9 +1021,8 @@ fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Optio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
|
|
||||||
let png = img.to_png().map_err(|e| EngineError::Other(format!("Flatten Transparency: {e}")))?;
|
let png = img.to_png().map_err(|e| EngineError::Other(format!("Flatten Transparency: {e}")))?;
|
||||||
Ok(Raster { png, width: img.width, height: img.height, xf, clip })
|
Ok(Raster { blob: ImageBlob::new("image/png", png), width: img.width, height: img.height, xf, clip })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `n` at full opacity with Normal blending throughout (objects, fills and strokes).
|
/// `n` at full opacity with Normal blending throughout (objects, fills and strokes).
|
||||||
@@ -1115,7 +1133,7 @@ fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan: Plan, o:
|
|||||||
/// An image of `r` (in a clip group when it is clipped).
|
/// An image of `r` (in a clip group when it is clipped).
|
||||||
fn raster_node(d: &mut Document, r: Raster) -> Node {
|
fn raster_node(d: &mut Document, r: Raster) -> Node {
|
||||||
let key = unique_key(d, "flattened");
|
let key = unique_key(d, "flattened");
|
||||||
d.images.insert(key.clone(), ImageBlob::new("image/png", r.png));
|
d.images.insert(key.clone(), r.blob);
|
||||||
let image = Node::new(
|
let image = Node::new(
|
||||||
d.alloc_id(),
|
d.alloc_id(),
|
||||||
NodeKind::Image(Box::new(ImageObject { key, width: r.width, height: r.height, xf: r.xf, link: None, placement: Default::default() })),
|
NodeKind::Image(Box::new(ImageObject { key, width: r.width, height: r.height, xf: r.xf, link: None, placement: Default::default() })),
|
||||||
|
|||||||
@@ -101,6 +101,12 @@ pub(super) fn font_label((family, style, version): &UsedFont) -> String {
|
|||||||
|
|
||||||
/// The fonts (family, style, version) of the type in the layers and symbols of `d`.
|
/// The fonts (family, style, version) of the type in the layers and symbols of `d`.
|
||||||
pub(super) fn used_fonts(d: &Document) -> BTreeSet<UsedFont> {
|
pub(super) fn used_fonts(d: &Document) -> BTreeSet<UsedFont> {
|
||||||
|
art_fonts(d, &[])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fonts (family, style, version) of the type in the layers and symbols of `d` and in `art`
|
||||||
|
/// (art read from a file, its resources in `d`).
|
||||||
|
pub(super) fn art_fonts(d: &Document, art: &[Node]) -> BTreeSet<UsedFont> {
|
||||||
let mut fonts = BTreeSet::new();
|
let mut fonts = BTreeSet::new();
|
||||||
let mut add = |n: &Node| {
|
let mut add = |n: &Node| {
|
||||||
if let NodeKind::Text(t) = &n.kind {
|
if let NodeKind::Text(t) = &n.kind {
|
||||||
@@ -111,6 +117,9 @@ pub(super) fn used_fonts(d: &Document) -> BTreeSet<UsedFont> {
|
|||||||
for s in &d.symbols {
|
for s in &d.symbols {
|
||||||
s.art.walk(&mut add);
|
s.art.walk(&mut add);
|
||||||
}
|
}
|
||||||
|
for n in art {
|
||||||
|
n.walk(&mut add);
|
||||||
|
}
|
||||||
fonts
|
fonts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ pub(crate) fn parse_mode(m: &str) -> std::result::Result<FreeformMode, String> {
|
|||||||
/// automatic points it shows first, and the stops follow the points' colours.
|
/// automatic points it shows first, and the stops follow the points' colours.
|
||||||
fn edit<R>(s: &mut Session, p: &Value, cmd: &str, mut f: impl FnMut(&mut Freeform, Space) -> std::result::Result<R, String>) -> Result<R> {
|
fn edit<R>(s: &mut Session, p: &Value, cmd: &str, mut f: impl FnMut(&mut Freeform, Space) -> std::result::Result<R, String>) -> Result<R> {
|
||||||
let item = item_target(s, p, cmd)?;
|
let item = item_target(s, p, cmd)?;
|
||||||
let stroke = edits_stroke(s, p, item, !s.fill_active)?;
|
let stroke = edits_stroke(s, p, item, !s.fill_active, cmd)?;
|
||||||
let item = item.of_kind(s, !stroke);
|
let item = item.of_kind(s, !stroke);
|
||||||
let ids = item.targets(s, p)?;
|
let ids = item.targets(s, p)?;
|
||||||
if ids.is_empty() {
|
if ids.is_empty() {
|
||||||
@@ -308,7 +308,7 @@ fn split_line(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
const C: &str = "paint.freeform.splitLine";
|
const C: &str = "paint.freeform.splitLine";
|
||||||
let line = index_param(p, "line", C)?;
|
let line = index_param(p, "line", C)?;
|
||||||
let segment = index_param(p, "segment", C)?;
|
let segment = index_param(p, "segment", C)?;
|
||||||
let t = f64_or(p, "t", 0.5);
|
let t = number_or(p, "t", 0.5, C)?;
|
||||||
let index = edit(s, p, C, |f, _| f.split_line(line, segment, t).ok_or_else(|| format!("no segment {segment} on line {line}")))?;
|
let index = edit(s, p, C, |f, _| f.split_line(line, segment, t).ok_or_else(|| format!("no segment {segment} on line {line}")))?;
|
||||||
s.select_freeform_point(Some(index));
|
s.select_freeform_point(Some(index));
|
||||||
Ok(json!({ "index": index }))
|
Ok(json!({ "index": index }))
|
||||||
@@ -332,7 +332,7 @@ fn select_point(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
|
|
||||||
fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
const C: &str = "paint.freeform.get";
|
const C: &str = "paint.freeform.get";
|
||||||
let stroke = bool_or(p, "stroke", !s.fill_active);
|
let stroke = switch_or(p, "stroke", !s.fill_active, C)?;
|
||||||
let (f, _) = s.proxy_freeform(stroke).ok_or_else(|| bad(C, NOT_FREEFORM))?;
|
let (f, _) = s.proxy_freeform(stroke).ok_or_else(|| bad(C, NOT_FREEFORM))?;
|
||||||
let selected = if stroke != s.fill_active { s.selected_freeform_point().filter(|i| *i < f.points.len()) } else { None };
|
let selected = if stroke != s.fill_active { s.selected_freeform_point().filter(|i| *i < f.points.len()) } else { None };
|
||||||
let mut v = vectorcraft_tools::params::freeform_json(&f);
|
let mut v = vectorcraft_tools::params::freeform_json(&f);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! The Gradient panel and the Gradient tool: in-place gradient edits and the gradient vector.
|
//! The Gradient panel and the Gradient tool: in-place gradient edits and the gradient vector.
|
||||||
|
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
|
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientMethod, GradientPaint, GradientStop, Paint};
|
||||||
use vectorcraft_doc::appearance::stroke_paint_bounds;
|
use vectorcraft_doc::appearance::stroke_paint_bounds;
|
||||||
use vectorcraft_doc::{Document, Node, NodeKind, StrokeGradientMode};
|
use vectorcraft_doc::{Document, Node, NodeKind, StrokeGradientMode};
|
||||||
use vectorcraft_geom::{Affine, Point, Rect};
|
use vectorcraft_geom::{Affine, Point, Rect};
|
||||||
@@ -20,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
"Gradient",
|
"Gradient",
|
||||||
["Window", "Gradient"],
|
["Window", "Gradient"],
|
||||||
None,
|
None,
|
||||||
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), stops?: [{offset 0..1, color? (needed without swatch), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg, aspect?: %, reverse?: bool, item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
|
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even, which SVG and PDF exports draw as an image; freeform gradients mix their own way), stops?: [{offset 0..1, color? (needed without swatch), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg, aspect?: %, reverse?: bool, dither?: bool (fills only: a linear or radial gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
|
||||||
has_doc,
|
has_doc,
|
||||||
edit_gradient
|
edit_gradient
|
||||||
),
|
),
|
||||||
@@ -105,6 +105,14 @@ fn parse_kind(k: &str) -> Parsed<GradientKind> {
|
|||||||
GradientKind::parse(k).ok_or_else(|| format!("unknown gradient kind `{k}` (linear, radial, freeform)"))
|
GradientKind::parse(k).ok_or_else(|| format!("unknown gradient kind `{k}` (linear, radial, freeform)"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The `method` param of `p`, if given: how the colours between the stops mix.
|
||||||
|
fn method_param(p: &Value) -> Parsed<Option<GradientMethod>> {
|
||||||
|
match p.get("method") {
|
||||||
|
None | Some(Value::Null) => Ok(None),
|
||||||
|
Some(v) => v.as_str().and_then(GradientMethod::parse).map(Some).ok_or_else(|| "`method` must be classic or perceptual".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `v` (params holding `stops`) with each stop that names a `swatch` resolved through `d`
|
/// `v` (params holding `stops`) with each stop that names a `swatch` resolved through `d`
|
||||||
/// ([`swatch_solid`]): a global or spot colour or a tint swatch gives the stop its colour, link and
|
/// ([`swatch_solid`]): a global or spot colour or a tint swatch gives the stop its colour, link and
|
||||||
/// tint, a process colour just its colour. A stop linked to a swatch that no longer exists keeps
|
/// tint, a process colour just its colour. A stop linked to a swatch that no longer exists keeps
|
||||||
@@ -117,7 +125,12 @@ pub(crate) fn link_stops(d: &Document, v: &Value) -> Parsed<Value> {
|
|||||||
if d.swatch(&name).is_none() && st.get("color").is_some() {
|
if d.swatch(&name).is_none() && st.get("color").is_some() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let tint = st.get("tint").and_then(Value::as_f64).map(|t| (t / 100.0).clamp(0.0, 1.0) as f32);
|
let tint = st
|
||||||
|
.get("tint")
|
||||||
|
.filter(|v| !v.is_null())
|
||||||
|
.map(|v| v.as_f64().ok_or_else(|| format!("stop {i}: `{k}` must be a number", k = "tint")))
|
||||||
|
.transpose()?;
|
||||||
|
let tint = tint.map(|t| (t / 100.0).clamp(0.0, 1.0) as f32);
|
||||||
let Paint::Solid { color, swatch, tint } = swatch_solid(d, &name, tint).map_err(|e| format!("stop {i}: {e}"))? else {
|
let Paint::Solid { color, swatch, tint } = swatch_solid(d, &name, tint).map_err(|e| format!("stop {i}: {e}"))? else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
@@ -219,7 +232,12 @@ pub(crate) fn parse_gradient(g: &Value) -> Parsed<GradientPaint> {
|
|||||||
}
|
}
|
||||||
let kind = str_param(g, "kind").map(parse_kind).transpose()?.unwrap_or_default();
|
let kind = str_param(g, "kind").map(parse_kind).transpose()?.unwrap_or_default();
|
||||||
let stops = g.get("stops").map(parse_stops).transpose()?.unwrap_or_else(|| Gradient::default().stops);
|
let stops = g.get("stops").map(parse_stops).transpose()?.unwrap_or_else(|| Gradient::default().stops);
|
||||||
let mut gp = GradientPaint::new(Gradient { kind, stops });
|
let mut gp = GradientPaint::new(Gradient::new(kind, stops));
|
||||||
|
gp.gradient.dither = match g.get("dither") {
|
||||||
|
None | Some(Value::Null) => false,
|
||||||
|
Some(v) => v.as_bool().ok_or("`dither` must be true or false")?,
|
||||||
|
};
|
||||||
|
gp.gradient.method = method_param(g)?.unwrap_or_default();
|
||||||
gp.angle = f64_or(g, "angle", 0.0);
|
gp.angle = f64_or(g, "angle", 0.0);
|
||||||
gp.swatch = str_param(g, "swatch").map(str::to_string);
|
gp.swatch = str_param(g, "swatch").map(str::to_string);
|
||||||
let geom = GeomEdit::parse(g)?;
|
let geom = GeomEdit::parse(g)?;
|
||||||
@@ -314,6 +332,12 @@ pub(crate) fn apply_gradient_edit_in(
|
|||||||
// Midpoints mirrored with their segments.
|
// Midpoints mirrored with their segments.
|
||||||
gp.gradient.reverse();
|
gp.gradient.reverse();
|
||||||
}
|
}
|
||||||
|
if let Some(d) = p.get("dither").and_then(Value::as_bool) {
|
||||||
|
gp.gradient.dither = d;
|
||||||
|
}
|
||||||
|
if let Some(m) = method_param(p)? {
|
||||||
|
gp.gradient.method = m;
|
||||||
|
}
|
||||||
// Angle and aspect edits keep the focal point in its place in the extent ellipse.
|
// Angle and aspect edits keep the focal point in its place in the extent ellipse.
|
||||||
let before = gp.geom;
|
let before = gp.geom;
|
||||||
if let Some(a) = p.get("angle").and_then(Value::as_f64) {
|
if let Some(a) = p.get("angle").and_then(Value::as_f64) {
|
||||||
@@ -372,7 +396,9 @@ pub(crate) fn apply_gradient_edit_in(
|
|||||||
fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
const C: &str = "paint.editGradient";
|
const C: &str = "paint.editGradient";
|
||||||
let item = item_target(s, p, C)?;
|
let item = item_target(s, p, C)?;
|
||||||
let stroke = edits_stroke(s, p, item, !s.fill_active)?;
|
let stroke = edits_stroke(s, p, item, !s.fill_active, C)?;
|
||||||
|
check_switches(p, &["reverse", "dither"], C)?;
|
||||||
|
check_numbers(p, &["angle"], C)?;
|
||||||
let item = item.of_kind(s, !stroke);
|
let item = item.of_kind(s, !stroke);
|
||||||
let ids = item.targets(s, p)?;
|
let ids = item.targets(s, p)?;
|
||||||
// How the gradient lies on a stroke (the panel's Stroke buttons).
|
// How the gradient lies on a stroke (the panel's Stroke buttons).
|
||||||
@@ -381,6 +407,9 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
Some(_) if !stroke => return Err(bad(C, "`strokeMode` applies to strokes (stroke: true)")),
|
Some(_) if !stroke => return Err(bad(C, "`strokeMode` applies to strokes (stroke: true)")),
|
||||||
Some(m) => Some(StrokeGradientMode::parse(m).ok_or_else(|| bad(C, format!("strokeMode must be within|along|across, got {m}")))?),
|
Some(m) => Some(StrokeGradientMode::parse(m).ok_or_else(|| bad(C, format!("strokeMode must be within|along|across, got {m}")))?),
|
||||||
};
|
};
|
||||||
|
if p.get("dither").and_then(Value::as_bool).is_some() && stroke {
|
||||||
|
return Err(bad(C, "`dither` applies to gradient fills (stroke: false)"));
|
||||||
|
}
|
||||||
super::paint::check_keep_model(p, C)?;
|
super::paint::check_keep_model(p, C)?;
|
||||||
// New RGB stop colors take the document's model; those of the swatches the stops name don't.
|
// New RGB stop colors take the document's model; those of the swatches the stops name don't.
|
||||||
let stored = stop_colors(s, p, p);
|
let stored = stop_colors(s, p, p);
|
||||||
@@ -554,7 +583,7 @@ fn set_gradient_geom(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
}
|
}
|
||||||
let p = &p;
|
let p = &p;
|
||||||
let item = item_target(s, p, C)?;
|
let item = item_target(s, p, C)?;
|
||||||
let stroke = edits_stroke(s, p, item, !s.fill_active)?;
|
let stroke = edits_stroke(s, p, item, !s.fill_active, C)?;
|
||||||
let item = item.of_kind(s, !stroke);
|
let item = item.of_kind(s, !stroke);
|
||||||
let targets = match item {
|
let targets = match item {
|
||||||
ItemTarget::Top => leaf_targets(s, &ids_param(p, "ids").map_or_else(|| selected_roots(s), |ids| Ok(outermost(&s.doc()?.doc, ids)))?)?,
|
ItemTarget::Top => leaf_targets(s, &ids_param(p, "ids").map_or_else(|| selected_roots(s), |ids| Ok(outermost(&s.doc()?.doc, ids)))?)?,
|
||||||
|
|||||||
@@ -870,6 +870,11 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
}
|
}
|
||||||
None => vec![p],
|
None => vec![p],
|
||||||
};
|
};
|
||||||
|
check_switches(p, &["moveArt", "scaleArt", "strokes", "corners", "patterns", "lockedAndHidden"], "artboard.setProps")?;
|
||||||
|
for q in &entries {
|
||||||
|
check_switches(q, &["locked"], "artboard.setProps")?;
|
||||||
|
check_numbers(q, &["x", "y", "width", "height"], "artboard.setProps")?;
|
||||||
|
}
|
||||||
// Scale Artwork with Artboard: resized, an artboard takes the art fully inside it (as Move
|
// Scale Artwork with Artboard: resized, an artboard takes the art fully inside it (as Move
|
||||||
// Artwork with Artboard gathers it) and its guides from the old rectangle onto the new one.
|
// Artwork with Artboard gathers it) and its guides from the old rectangle onto the new one.
|
||||||
let scale_art = bool_or(p, "scaleArt", false);
|
let scale_art = bool_or(p, "scaleArt", false);
|
||||||
|
|||||||
@@ -17,16 +17,17 @@
|
|||||||
//! (`links.embed`) and Unembed writes an embedded image to a file it then links to
|
//! (`links.embed`) and Unembed writes an embedded image to a file it then links to
|
||||||
//! (`links.unembed`).
|
//! (`links.unembed`).
|
||||||
//!
|
//!
|
||||||
//! Placed documents ([`PlacedDocument`], VectorCraft documents placed with Link) link to their
|
//! Placed documents ([`PlacedDocument`], VectorCraft documents and PDF or `.ai` pages placed with
|
||||||
//! files the same way: checking, updating, relinking and finding them on open work alike, and they
|
//! Link) link to their files the same way: checking, updating, relinking and finding them on open
|
||||||
//! are listed. Embed (Break Link) turns one into an editable copy of its art.
|
//! work alike, and they are listed. Embed (Break Link) turns one into an editable copy of its art.
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::borrow::Cow;
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::path::{Component, Path};
|
use std::path::{Component, Path};
|
||||||
|
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use vectorcraft_doc::links::{Align, Preserve, hash_bytes};
|
use vectorcraft_doc::links::{Align, Preserve, hash_bytes};
|
||||||
use vectorcraft_doc::{Appearance, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PlacedDocument, PlacementOptions};
|
use vectorcraft_doc::{Appearance, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions};
|
||||||
use vectorcraft_geom::{Affine, Rect, shapes};
|
use vectorcraft_geom::{Affine, Rect, shapes};
|
||||||
|
|
||||||
use super::fileio::{self, RasterImage, absolute_path, file_created, file_stamp, read_file, write_file};
|
use super::fileio::{self, RasterImage, absolute_path, file_created, file_stamp, read_file, write_file};
|
||||||
@@ -66,7 +67,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
"Relink",
|
"Relink",
|
||||||
[],
|
[],
|
||||||
None,
|
None,
|
||||||
"{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, copy?: false (with path, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy)}",
|
"{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, copy?: false (with path, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy)}",
|
||||||
has_doc,
|
has_doc,
|
||||||
relink
|
relink
|
||||||
),
|
),
|
||||||
@@ -111,7 +112,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
"Link Info",
|
"Link Info",
|
||||||
[],
|
[],
|
||||||
None,
|
None,
|
||||||
"{id?} an image's or placed document's Link Info (default: the one selected) → image.info's fields (id, name, linked, link, colorMode, pixelWidth, pixelHeight, width, height) and status: ok|modified|missing|embedded, format, ppi: [x, y] (the file's), effectivePpi: [x, y] (at its placed size), scale: [x%, y%] (of its 100% size), rotation (degrees, counter-clockwise), placement: {preserve, align, clip}; linked images also fileName, location (its folder), page?, fileSize? (bytes), modified?, created? (ms since the Unix epoch), refused? (why the path is not followed); a placed document has document: true, pageWidth and pageHeight (pt) instead of pixels, ppi and colorMode, and its scale is of its artboard's 100% size",
|
"{id?} an image's or placed document's Link Info (default: the one selected) → image.info's fields (id, name, linked, link, colorMode, pixelWidth, pixelHeight, width, height) and status: ok|modified|missing|embedded, format, ppi: [x, y] (the file's), effectivePpi: [x, y] (at its placed size), scale: [x%, y%] (of its 100% size), rotation (degrees, counter-clockwise), placement: {preserve, align, clip}; linked images also fileName, location (its folder), page?, fileSize? (bytes), modified?, created? (ms since the Unix epoch), refused? (why the path is not followed); a placed document has document: true, pageWidth and pageHeight (pt) instead of pixels, ppi and colorMode, and its scale is of its artboard's or page's 100% size",
|
||||||
has_doc,
|
has_doc,
|
||||||
info
|
info
|
||||||
),
|
),
|
||||||
@@ -148,21 +149,25 @@ impl Status {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The image objects that show one linked file as one content (same path, same image), or the
|
/// The image objects that show one linked file as one content (same path, same image), or the
|
||||||
/// placed documents that show one artboard of it.
|
/// placed documents that show one artboard or page of it.
|
||||||
struct Group {
|
struct Group {
|
||||||
link: LinkInfo,
|
link: LinkInfo,
|
||||||
key: String,
|
key: String,
|
||||||
/// The objects in the layers (images in symbols and patterns share the content, not an id).
|
/// The objects in the layers (images in symbols and patterns share the content, not an id).
|
||||||
ids: Vec<NodeId>,
|
ids: Vec<NodeId>,
|
||||||
/// Placed documents: whether the art's bounds (not the artboard) are shown.
|
/// Placed documents: what they show.
|
||||||
document: Option<bool>,
|
document: Option<Shown>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The linked object `n` as (link, content key, placed document's bounding).
|
/// What a placed document shows: whether the art's bounds (not the artboard or page) are shown,
|
||||||
fn linked(n: &Node) -> Option<(&LinkInfo, &str, Option<bool>)> {
|
/// and a PDF page's box.
|
||||||
|
type Shown = (bool, PageBox);
|
||||||
|
|
||||||
|
/// The linked object `n` as (link, content key, what a placed document shows).
|
||||||
|
fn linked(n: &Node) -> Option<(&LinkInfo, &str, Option<Shown>)> {
|
||||||
match &n.kind {
|
match &n.kind {
|
||||||
NodeKind::Image(im) => Some((im.link.as_ref()?, &im.key, None)),
|
NodeKind::Image(im) => Some((im.link.as_ref()?, &im.key, None)),
|
||||||
NodeKind::PlacedDocument(p) => Some((&p.link, &p.key, Some(p.bounding))),
|
NodeKind::PlacedDocument(p) => Some((&p.link, &p.key, Some((p.bounding, p.page_box)))),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,7 +182,7 @@ pub(crate) fn linked_groups(d: &Document) -> Vec<(String, Vec<u64>)> {
|
|||||||
fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec<Group> {
|
fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec<Group> {
|
||||||
let mut out: Vec<Group> = vec![];
|
let mut out: Vec<Group> = vec![];
|
||||||
let mut index: BTreeMap<(String, String, bool), usize> = BTreeMap::new();
|
let mut index: BTreeMap<(String, String, bool), usize> = BTreeMap::new();
|
||||||
let mut add = |link: &LinkInfo, key: &str, document: Option<bool>, id: Option<NodeId>| {
|
let mut add = |link: &LinkInfo, key: &str, document: Option<Shown>, id: Option<NodeId>| {
|
||||||
let i = *index.entry((link.path.clone(), key.to_string(), document.is_some())).or_insert_with(|| {
|
let i = *index.entry((link.path.clone(), key.to_string(), document.is_some())).or_insert_with(|| {
|
||||||
out.push(Group { link: link.clone(), key: key.to_string(), ids: vec![], document });
|
out.push(Group { link: link.clone(), key: key.to_string(), ids: vec![], document });
|
||||||
out.len() - 1
|
out.len() - 1
|
||||||
@@ -199,7 +204,7 @@ fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec<Group> {
|
|||||||
add(l, &im.key, None, None);
|
add(l, &im.key, None, None);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
d.visit_placed(|_, p| add(&p.link, &p.key, Some(p.bounding), None));
|
d.visit_placed(|_, p| add(&p.link, &p.key, Some((p.bounding, p.page_box)), None));
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
@@ -282,6 +287,18 @@ fn previewing(d: &Document, key: &str) -> bool {
|
|||||||
d.images.get(key).is_none_or(ImageBlob::is_proxy)
|
d.images.get(key).is_none_or(ImageBlob::is_proxy)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A linked raster image whose file is an EPS (Preferences › File Handling › Use Low Resolution
|
||||||
|
/// Proxy for Linked EPS).
|
||||||
|
fn linked_eps(link: &LinkInfo) -> bool {
|
||||||
|
fileio::format_for_name(link.name()).is_some_and(|f| f.id == "eps")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// With `low_res_proxy_eps`, a linked EPS image that already shows its saved preview keeps it on
|
||||||
|
/// open instead of reading the file again (Update Links still reads modified files).
|
||||||
|
fn keep_eps_preview(low_res_proxy_eps: bool, g: &Group, d: &Document) -> bool {
|
||||||
|
low_res_proxy_eps && g.document.is_none() && linked_eps(&g.link) && previewing(d, &g.key)
|
||||||
|
}
|
||||||
|
|
||||||
/// Does group `g` of `d` show something other than its file: an image its preview, a placed
|
/// Does group `g` of `d` show something other than its file: an image its preview, a placed
|
||||||
/// document nothing (its file's bytes are gone)?
|
/// document nothing (its file's bytes are gone)?
|
||||||
fn stale(d: &Document, g: &Group) -> bool {
|
fn stale(d: &Document, g: &Group) -> bool {
|
||||||
@@ -320,6 +337,47 @@ pub(crate) fn linked_files(d: &Document, doc_path: Option<&str>) -> Vec<LinkedFi
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `d` for output: linked raster images that still show only their saved preview read their files
|
||||||
|
/// again (Preferences › Use Low Resolution Proxy for Linked EPS keeps the preview on screen only).
|
||||||
|
pub(crate) fn full_linked_images<'a>(d: &'a Document, doc_path: Option<&str>) -> (Cow<'a, Document>, Vec<String>) {
|
||||||
|
let mut keys = BTreeSet::new();
|
||||||
|
d.visit_images(|_, im| {
|
||||||
|
if im.link.is_some() && d.images.get(&im.key).is_some_and(ImageBlob::is_proxy) {
|
||||||
|
keys.insert(im.key.clone());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if keys.is_empty() {
|
||||||
|
return (Cow::Borrowed(d), vec![]);
|
||||||
|
}
|
||||||
|
let mut out = d.clone();
|
||||||
|
let mut previews = BTreeSet::new();
|
||||||
|
let all = groups(d, None);
|
||||||
|
for key in keys {
|
||||||
|
let Some(g) = all.iter().find(|g| g.document.is_none() && g.key == key) else { continue };
|
||||||
|
let Some(mut f) = locate(&g.link, doc_path) else {
|
||||||
|
previews.insert(key);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(bytes) = f.read().ok() else {
|
||||||
|
previews.insert(key);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok(img) = fileio::raster_image(&bytes) else {
|
||||||
|
previews.insert(key);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let preview = d.images.get(&key).map(|b| b.bytes.clone());
|
||||||
|
let mime = fileio::format_for_name(g.link.name()).map(|f| f.mime.to_string()).unwrap_or_else(|| img.blob.mime.clone());
|
||||||
|
out.images.insert(key, ImageBlob { mime, bytes: img.blob.bytes, proxy: preview });
|
||||||
|
}
|
||||||
|
let mut warnings = vec![];
|
||||||
|
if !previews.is_empty() {
|
||||||
|
warnings
|
||||||
|
.push(format!("{} linked image(s) output as their previews: their files can't be found or read (relink or update them)", previews.len()));
|
||||||
|
}
|
||||||
|
(Cow::Owned(out), warnings)
|
||||||
|
}
|
||||||
|
|
||||||
/// The linked files of the open document `st` that `links.findFiles` looks for, each with the
|
/// The linked files of the open document `st` that `links.findFiles` looks for, each with the
|
||||||
/// objects in the layers that show it: the files of objects `ids` whatever their status, else the
|
/// objects in the layers that show it: the files of objects `ids` whatever their status, else the
|
||||||
/// files that can't be found.
|
/// files that can't be found.
|
||||||
@@ -355,16 +413,17 @@ fn read_image(f: &mut Found, base: &LinkInfo) -> Result<FileRead> {
|
|||||||
/// A linked file read for the objects that show it.
|
/// A linked file read for the objects that show it.
|
||||||
enum Read {
|
enum Read {
|
||||||
Image(FileRead),
|
Image(FileRead),
|
||||||
/// A VectorCraft document read for placed documents: the link to it and what they show.
|
/// A VectorCraft document or a PDF or `.ai` file read for placed documents: the link to it and
|
||||||
|
/// what they show.
|
||||||
Document(LinkInfo, super::place::document::Source),
|
Document(LinkInfo, super::place::document::Source),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The file at `f` read for group `g`: its image, or the artboard `g` shows.
|
/// The file at `f` read for group `g`: its image, or the artboard or page `g` shows.
|
||||||
fn read_for(f: &mut Found, g: &Group, cmd: &str) -> Result<Read> {
|
fn read_for(f: &mut Found, g: &Group, cmd: &str) -> Result<Read> {
|
||||||
let Some(bounding) = g.document else { return read_image(f, &g.link).map(Read::Image) };
|
let Some((bounding, page_box)) = g.document else { return read_image(f, &g.link).map(Read::Image) };
|
||||||
let bytes = f.read()?;
|
let bytes = f.read()?;
|
||||||
let page = g.link.page.unwrap_or(1);
|
let page = g.link.page.unwrap_or(1);
|
||||||
let src = super::place::document::source(&f.path, &bytes, Some(&f.path), page, bounding, cmd)?;
|
let src = super::place::document::source(&f.path, &bytes, Some(&f.path), page, bounding, page_box, cmd)?;
|
||||||
Ok(Read::Document(f.link(&bytes, &g.link), src))
|
Ok(Read::Document(f.link(&bytes, &g.link), src))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,11 +613,12 @@ impl Resolved {
|
|||||||
/// away from their path take the new path. A link nothing is known of whose image shows a preview
|
/// away from their path take the new path. A link nothing is known of whose image shows a preview
|
||||||
/// (a file another app wrote kept only that) was never read: its file is read now, into the
|
/// (a file another app wrote kept only that) was never read: its file is read now, into the
|
||||||
/// image's bounds, which isn't a change to report. Nothing here is an undo step.
|
/// image's bounds, which isn't a change to report. Nothing here is an undo step.
|
||||||
pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool) -> Resolved {
|
pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_proxy_eps: bool) -> Resolved {
|
||||||
let mut out = Resolved::default();
|
let mut out = Resolved::default();
|
||||||
for g in groups(d, None) {
|
for g in groups(d, None) {
|
||||||
let row = rows(std::iter::once(&g), doc_path);
|
let row = rows(std::iter::once(&g), doc_path);
|
||||||
let unread = g.document.is_none() && g.link.hash.is_none() && g.link.size.is_none() && previewing(d, &g.key);
|
let unread = g.document.is_none() && g.link.hash.is_none() && g.link.size.is_none() && previewing(d, &g.key);
|
||||||
|
let eps_preview = keep_eps_preview(low_res_proxy_eps, &g, d);
|
||||||
match probe(&g, doc_path) {
|
match probe(&g, doc_path) {
|
||||||
(Status::Missing, _) => out.missing.extend(row),
|
(Status::Missing, _) => out.missing.extend(row),
|
||||||
(Status::Modified, Some(mut f)) if update || unread => {
|
(Status::Modified, Some(mut f)) if update || unread => {
|
||||||
@@ -593,8 +653,8 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool) -> Resolv
|
|||||||
(Status::Ok, Some(mut f)) => {
|
(Status::Ok, Some(mut f)) => {
|
||||||
let preview = previewing(d, &g.key);
|
let preview = previewing(d, &g.key);
|
||||||
// Read when the images show the preview, or when the file's details changed.
|
// Read when the images show the preview, or when the file's details changed.
|
||||||
let bytes = if preview || f.bytes.is_some() { f.read().ok() } else { None };
|
let bytes = if (preview && !eps_preview) || f.bytes.is_some() { f.read().ok() } else { None };
|
||||||
if preview {
|
if preview && !eps_preview {
|
||||||
let Some(img) = bytes.as_deref().and_then(|b| fileio::raster_image(b).ok()) else {
|
let Some(img) = bytes.as_deref().and_then(|b| fileio::raster_image(b).ok()) else {
|
||||||
out.missing.extend(row);
|
out.missing.extend(row);
|
||||||
continue;
|
continue;
|
||||||
@@ -834,13 +894,13 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
let (mut copied, mut kept) = (vec![], vec![]);
|
let (mut copied, mut kept) = (vec![], vec![]);
|
||||||
for (ids, path) in files {
|
for (ids, path) in files {
|
||||||
let bytes = read_file(&path)?;
|
let bytes = read_file(&path)?;
|
||||||
// Images show the file; placed documents each show their artboard of it.
|
// Images show the file; placed documents each show their artboard or page of it.
|
||||||
let mut images = vec![];
|
let mut images = vec![];
|
||||||
let mut documents: Vec<((u32, bool), Vec<NodeId>)> = vec![];
|
let mut documents: Vec<((u32, bool, PageBox), Vec<NodeId>)> = vec![];
|
||||||
for id in ids {
|
for id in ids {
|
||||||
match st.doc.node(id).map(|n| &n.kind) {
|
match st.doc.node(id).map(|n| &n.kind) {
|
||||||
Some(NodeKind::PlacedDocument(pl)) => {
|
Some(NodeKind::PlacedDocument(pl)) => {
|
||||||
let k = (pl.page(), pl.bounding);
|
let k = (pl.page(), pl.bounding, pl.page_box);
|
||||||
match documents.iter_mut().find(|(e, _)| *e == k) {
|
match documents.iter_mut().find(|(e, _)| *e == k) {
|
||||||
Some((_, ids)) => ids.push(id),
|
Some((_, ids)) => ids.push(id),
|
||||||
None => documents.push((k, vec![id])),
|
None => documents.push((k, vec![id])),
|
||||||
@@ -852,8 +912,8 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
// Read before it is copied: a file that can't be shown isn't copied.
|
// Read before it is copied: a file that can't be shown isn't copied.
|
||||||
let img = if images.is_empty() { None } else { Some(fileio::raster_image(&bytes).map_err(|e| bad(C, format!("{path}: {e}")))?) };
|
let img = if images.is_empty() { None } else { Some(fileio::raster_image(&bytes).map_err(|e| bad(C, format!("{path}: {e}")))?) };
|
||||||
let mut sources = vec![];
|
let mut sources = vec![];
|
||||||
for ((page, bounding), ids) in documents {
|
for ((page, bounding, page_box), ids) in documents {
|
||||||
sources.push((page, ids, super::place::document::source(&path, &bytes, Some(&path), page, bounding, C)?));
|
sources.push((page, ids, super::place::document::source(&path, &bytes, Some(&path), page, bounding, page_box, C)?));
|
||||||
}
|
}
|
||||||
let to = match ©_to {
|
let to = match ©_to {
|
||||||
Some(dir) => {
|
Some(dir) => {
|
||||||
@@ -997,10 +1057,12 @@ fn format_label(d: &Document, im: &ImageObject) -> &'static str {
|
|||||||
.map_or("Image", |f| f.label)
|
.map_or("Image", |f| f.label)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A placed document's `links.list` fields: `format`, `document: true` and its artboard's size
|
/// A placed document's `links.list` fields: `format` (`VectorCraft`, or the PDF or `.ai` file's
|
||||||
/// (`pageWidth`, `pageHeight`, pt) instead of pixels.
|
/// format), `document: true` and its artboard's or page's size (`pageWidth`, `pageHeight`, pt)
|
||||||
|
/// instead of pixels.
|
||||||
fn document_row(p: &PlacedDocument) -> Value {
|
fn document_row(p: &PlacedDocument) -> Value {
|
||||||
json!({ "format": "VectorCraft", "document": true, "pageWidth": p.width, "pageHeight": p.height })
|
let pdf = fileio::format_for_name(p.link.name()).filter(|f| matches!(f.id, "pdf" | "ai" | "ait"));
|
||||||
|
json!({ "format": pdf.map_or("VectorCraft", |f| f.label), "document": true, "pageWidth": p.width, "pageHeight": p.height })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sort rank of a `links.list` status: missing, modified, ok, embedded.
|
/// Sort rank of a `links.list` status: missing, modified, ok, embedded.
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ use std::sync::Arc;
|
|||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use vectorcraft_color::{Color, Paint};
|
use vectorcraft_color::{Color, Paint};
|
||||||
use vectorcraft_doc::live::{
|
use vectorcraft_doc::live::{
|
||||||
self, BlendDefaults, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, EnvelopeMap, EnvelopeOptions, GradientMesh, MeshAppearance,
|
self, BlendDefaults, BlendEase, BlendEaseType, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, EnvelopeMap, EnvelopeOptions,
|
||||||
PreserveShape, Spine,
|
GradientMesh, MeshAppearance, PreserveShape, Spine,
|
||||||
};
|
};
|
||||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection};
|
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection};
|
||||||
use vectorcraft_geom::{Affine, Anchor, PathData, Point, Vec2};
|
use vectorcraft_geom::{Affine, Anchor, PathData, Point, Vec2};
|
||||||
@@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
"Make",
|
"Make",
|
||||||
["Object", "Blend"],
|
["Object", "Blend"],
|
||||||
Some("Cmd+Alt+B"),
|
Some("Cmd+Alt+B"),
|
||||||
"{ids?, steps?: n | distance?: pt | smooth?: bool, orientation?: page|path (default: the Blend Options set with nothing selected, else smooth colour and page), starts?: [anchor index | null, …] (per object of `ids`, else of the selection in paint order: the anchor of its first subpath the blend starts from; an open path's last anchor runs it the other way), spacing?: smooth|steps|distance (with value), value?: n} blend the selected objects (paint order) into a live blend; a blend among them takes the others in as more key objects (keeping its options, name and transparency) instead of nesting → {id}",
|
"{ids?, steps?: n | distance?: pt | smooth?: bool, orientation?: page|path (default: the Blend Options set with nothing selected, else smooth colour and page), starts?: [anchor index | null, …] (per object of `ids`, else of the selection in paint order: the anchor of its first subpath the blend starts from; an open path's last anchor runs it the other way), spacing?: smooth|steps|distance (with value), value?: n, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct (0..100), colorShift?: pct (-100..100; 0: even)} blend the selected objects (paint order) into a live blend; a blend among them takes the others in as more key objects (keeping its options, name and transparency) instead of nesting → {id}",
|
||||||
has_doc,
|
has_doc,
|
||||||
blend_make
|
blend_make
|
||||||
),
|
),
|
||||||
@@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
"Blend Options…",
|
"Blend Options…",
|
||||||
["Object", "Blend"],
|
["Object", "Blend"],
|
||||||
None,
|
None,
|
||||||
"{spacing?: smooth|steps|distance, value?: n, steps?: n, distance?: pt, orientation?: page|path, smooth?: bool} set the options of the selected blends; with no blend selected, the options new blends start with (a tool setting, not an undo step) → {defaults?: true}",
|
"{spacing?: smooth|steps|distance, value?: n, steps?: n, distance?: pt, orientation?: page|path, smooth?: bool, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct} set the options of the selected blends; easingType chooses the distribution, ramp sets its strength (0..100; 0 = even), colorShift (-100..100; 0 = even) shifts colours independently of shape/position easing (omitted ones keep their value; out of range is an error); with no blend selected, the options new blends start with (spacing and orientation only; a tool setting, not an undo step) → {defaults?: true}",
|
||||||
always,
|
always,
|
||||||
blend_options
|
blend_options
|
||||||
),
|
),
|
||||||
@@ -83,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
"Blend Info",
|
"Blend Info",
|
||||||
[],
|
[],
|
||||||
None,
|
None,
|
||||||
"{} the options of the first selected blend, else those new blends start with → {target: blend|defaults, id?, spacing: smooth|steps|distance, steps, distance, orientation: page|path, keys?: [ids], starts?: [anchor|null], spine?: {anchors: [{x, y, in: [x, y], out: [x, y]}], closed, keyAnchors: [anchor each key sits on] | null (keys spread evenly: a spine from Replace Spine), explicit: false for the straight lines between the key centres}}",
|
"{} the options of the first selected blend, else those new blends start with → {target: blend|defaults, id?, spacing: smooth|steps|distance, steps, distance, orientation: page|path, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct, keys?: [ids], starts?: [anchor|null], spine?: {anchors: [{x, y, in: [x, y], out: [x, y]}], closed, keyAnchors: [anchor each key sits on] | null (keys spread evenly: a spine from Replace Spine), explicit: false for the straight lines between the key centres}}",
|
||||||
always,
|
always,
|
||||||
blend_info
|
blend_info
|
||||||
),
|
),
|
||||||
@@ -451,6 +451,32 @@ fn orientation_param(p: &Value, current: BlendOrientation) -> BlendOrientation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Blend panel Type and Ramp, validated before any document mutation.
|
||||||
|
fn ease_param(p: &Value, cmd: &str, current: BlendEase) -> Result<BlendEase> {
|
||||||
|
let kind = match p.get("easingType").filter(|v| !v.is_null()) {
|
||||||
|
None => current.kind,
|
||||||
|
Some(v) => match v.as_str() {
|
||||||
|
Some("easeIn") => BlendEaseType::In,
|
||||||
|
Some("easeOut") => BlendEaseType::Out,
|
||||||
|
Some("easeInOut") => BlendEaseType::InOut,
|
||||||
|
Some("easeOutIn") => BlendEaseType::OutIn,
|
||||||
|
_ => return Err(bad(cmd, "easingType must be easeIn, easeOut, easeInOut or easeOutIn")),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
Ok(BlendEase { kind, ramp: percent_param(p, cmd, "ramp", current.ramp * 100.0, 0.0)? / 100.0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounded percentage input; missing or null preserves the current value.
|
||||||
|
fn percent_param(p: &Value, cmd: &str, key: &str, current: f64, min: f64) -> Result<f64> {
|
||||||
|
match p.get(key).filter(|v| !v.is_null()) {
|
||||||
|
None => Ok(current),
|
||||||
|
Some(v) => v
|
||||||
|
.as_f64()
|
||||||
|
.filter(|n| n.is_finite() && (min..=100.0).contains(n))
|
||||||
|
.ok_or_else(|| bad(cmd, format!("{key} must be a percentage {min}..100"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `starts`: per object of `ids` (else of `roots`), the anchor its blend starts from.
|
/// `starts`: per object of `ids` (else of `roots`), the anchor its blend starts from.
|
||||||
fn starts_param(p: &Value, roots: &[NodeId]) -> Result<Vec<(NodeId, u32)>> {
|
fn starts_param(p: &Value, roots: &[NodeId]) -> Result<Vec<(NodeId, u32)>> {
|
||||||
const C: &str = "object.blend.make";
|
const C: &str = "object.blend.make";
|
||||||
@@ -533,6 +559,12 @@ fn blend_make(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
};
|
};
|
||||||
let spacing = spacing_param(p, C, cur.spacing)?;
|
let spacing = spacing_param(p, C, cur.spacing)?;
|
||||||
let orientation = orientation_param(p, cur.orientation);
|
let orientation = orientation_param(p, cur.orientation);
|
||||||
|
let (ease0, color0) = match base.as_ref().map(|n| &n.kind) {
|
||||||
|
Some(NodeKind::Blend { spec, .. }) => (spec.ease, spec.color_shift),
|
||||||
|
_ => Default::default(),
|
||||||
|
};
|
||||||
|
let ease = ease_param(p, C, ease0)?;
|
||||||
|
let color_shift = percent_param(p, C, "colorShift", color0, -100.0)?;
|
||||||
let id = s.edit("Make Blend", |d, sel| {
|
let id = s.edit("Make Blend", |d, sel| {
|
||||||
wrap(d, sel, &roots, |id, nodes| {
|
wrap(d, sel, &roots, |id, nodes| {
|
||||||
let (children, starts) = merge_keys(nodes, &starts);
|
let (children, starts) = merge_keys(nodes, &starts);
|
||||||
@@ -543,7 +575,7 @@ fn blend_make(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
n.knockout = vectorcraft_doc::Knockout::On;
|
n.knockout = vectorcraft_doc::Knockout::On;
|
||||||
n
|
n
|
||||||
});
|
});
|
||||||
let mut spec = BlendSpec { spacing, orientation, starts, ..Default::default() };
|
let mut spec = BlendSpec { spacing, orientation, ease, color_shift, starts, ..Default::default() };
|
||||||
if let NodeKind::Blend { children: old, spec: old_spec } = &n.kind {
|
if let NodeKind::Blend { children: old, spec: old_spec } = &n.kind {
|
||||||
extend_spine(&mut spec, old, old_spec, &children);
|
extend_spine(&mut spec, old, old_spec, &children);
|
||||||
}
|
}
|
||||||
@@ -772,7 +804,10 @@ fn selected_spec(s: &Session) -> Option<(NodeId, BlendSpec, Vec<NodeId>)> {
|
|||||||
fn blend_options(s: &mut Session, p: &Value) -> Result<Value> {
|
fn blend_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
const C: &str = "object.blend.options";
|
const C: &str = "object.blend.options";
|
||||||
let Some((_, cur, _)) = selected_spec(s) else {
|
let Some((_, cur, _)) = selected_spec(s) else {
|
||||||
// Nothing to set them on: the options new blends start with.
|
if ["easingType", "ramp", "colorShift"].iter().any(|key| p.get(*key).is_some_and(|v| !v.is_null())) {
|
||||||
|
return Err(bad(C, "select a blend to set easing or color shift"));
|
||||||
|
}
|
||||||
|
// Nothing to set them on: the options new blends start with (easing is per blend).
|
||||||
let d = s.prefs.blend_options.unwrap_or_default();
|
let d = s.prefs.blend_options.unwrap_or_default();
|
||||||
let spacing = spacing_param(p, C, d.spacing)?;
|
let spacing = spacing_param(p, C, d.spacing)?;
|
||||||
s.prefs.blend_options = Some(BlendDefaults { spacing, orientation: orientation_param(p, d.orientation) });
|
s.prefs.blend_options = Some(BlendDefaults { spacing, orientation: orientation_param(p, d.orientation) });
|
||||||
@@ -780,9 +815,13 @@ fn blend_options(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
};
|
};
|
||||||
let spacing = spacing_param(p, C, cur.spacing)?;
|
let spacing = spacing_param(p, C, cur.spacing)?;
|
||||||
let orientation = orientation_param(p, cur.orientation);
|
let orientation = orientation_param(p, cur.orientation);
|
||||||
|
let ease = ease_param(p, C, cur.ease)?;
|
||||||
|
let color_shift = percent_param(p, C, "colorShift", cur.color_shift, -100.0)?;
|
||||||
edit_blends(s, "Blend Options", |_, spec| {
|
edit_blends(s, "Blend Options", |_, spec| {
|
||||||
spec.spacing = spacing;
|
spec.spacing = spacing;
|
||||||
spec.orientation = orientation;
|
spec.orientation = orientation;
|
||||||
|
spec.ease = ease;
|
||||||
|
spec.color_shift = color_shift;
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -804,6 +843,9 @@ fn blend_info(s: &mut Session, _: &Value) -> Result<Value> {
|
|||||||
let mut v = blend_options_json(spec.spacing, spec.orientation);
|
let mut v = blend_options_json(spec.spacing, spec.orientation);
|
||||||
v["target"] = json!("blend");
|
v["target"] = json!("blend");
|
||||||
v["id"] = json!(id.0);
|
v["id"] = json!(id.0);
|
||||||
|
v["easingType"] = json!(spec.ease.kind.id());
|
||||||
|
v["ramp"] = json!(spec.ease.ramp * 100.0);
|
||||||
|
v["colorShift"] = json!(spec.color_shift);
|
||||||
v["starts"] = json!((0..keys.len()).map(|i| spec.start(i)).collect::<Vec<_>>());
|
v["starts"] = json!((0..keys.len()).map(|i| spec.start(i)).collect::<Vec<_>>());
|
||||||
v["keys"] = json!(keys.iter().map(|k| k.0).collect::<Vec<_>>());
|
v["keys"] = json!(keys.iter().map(|k| k.0).collect::<Vec<_>>());
|
||||||
if let Some(NodeKind::Blend { children, .. }) = s.doc()?.doc.node(id).map(|n| &n.kind)
|
if let Some(NodeKind::Blend { children, .. }) = s.doc()?.doc.node(id).map(|n| &n.kind)
|
||||||
|
|||||||
@@ -353,6 +353,7 @@ fn transform_each_as(s: &mut Session, p: &Value, label: &str) -> Result<Value> {
|
|||||||
let random = switch_or(p, "random", false, "object.transformEach")?;
|
let random = switch_or(p, "random", false, "object.transformEach")?;
|
||||||
let refi = whole_or(p, "reference", 4, "object.transformEach")?.min(8) as usize;
|
let refi = whole_or(p, "reference", 4, "object.transformEach")?.min(8) as usize;
|
||||||
let copy = switch_or(p, "copy", false, "object.transformEach")?;
|
let copy = switch_or(p, "copy", false, "object.transformEach")?;
|
||||||
|
check_switches(p, &["strokes", "corners", "objects", "patterns"], "object.transformEach")?;
|
||||||
let objects = super::object::transforms_objects(p);
|
let objects = super::object::transforms_objects(p);
|
||||||
if sh == 0.0 || sv == 0.0 {
|
if sh == 0.0 || sv == 0.0 {
|
||||||
return Err(bad("object.transformEach", "scale must be non-zero"));
|
return Err(bad("object.transformEach", "scale must be non-zero"));
|
||||||
@@ -1018,6 +1019,7 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
check_numbers(p, &["spacing"], CMD)?;
|
check_numbers(p, &["spacing"], CMD)?;
|
||||||
let spacing = f64_or(p, "spacing", 20.0).clamp(-1.0e5, 1.0e5);
|
let spacing = f64_or(p, "spacing", 20.0).clamp(-1.0e5, 1.0e5);
|
||||||
let move_art = switch_or(p, "moveArtwork", true, CMD)?;
|
let move_art = switch_or(p, "moveArtwork", true, CMD)?;
|
||||||
|
check_switches(p, &["patterns"], CMD)?;
|
||||||
let locked_and_hidden = s.prefs.move_locked_with_artboard;
|
let locked_and_hidden = s.prefs.move_locked_with_artboard;
|
||||||
let d = &s.doc()?.doc;
|
let d = &s.doc()?.doc;
|
||||||
let rects: Vec<Rect> = d.artboards.iter().map(|a| a.rect).collect();
|
let rects: Vec<Rect> = d.artboards.iter().map(|a| a.rect).collect();
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ mod object;
|
|||||||
pub(crate) mod objects_on_path;
|
pub(crate) mod objects_on_path;
|
||||||
mod opacitymask;
|
mod opacitymask;
|
||||||
mod overprint;
|
mod overprint;
|
||||||
mod package;
|
pub mod package;
|
||||||
mod paint;
|
mod paint;
|
||||||
mod panelcmds;
|
mod panelcmds;
|
||||||
pub mod params;
|
pub mod params;
|
||||||
|
|||||||
@@ -252,8 +252,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
"Align",
|
"Align",
|
||||||
["Window", "Align"],
|
["Window", "Align"],
|
||||||
None,
|
None,
|
||||||
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection; a key guide, select.key {guide}, stays put and the objects line up on its line: a vertical guide sets where left, center and right go and vertical alignment leaves them, a horizontal one the other way round; aligning to the key, the other selected ruler guides move too: a vertical guide to the key's left edge, center or right edge, a horizontal one to its top, center or bottom), artboard?: index (0-based; with to: \"artboard\", the artboard to align to; the app passes the active one; default: the artboard under the center of the selection, else the first), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} With anchor points selected (Direct Selection), the points align instead: to their own bounds, or to the artboard (#1180)",
|
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection; a key guide, select.key {guide}, stays put and the objects line up on its line: a vertical guide sets where left, center and right go and vertical alignment leaves them, a horizontal one the other way round; aligning to the key, the other selected ruler guides move too: a vertical guide to the key's left edge, center or right edge, a horizontal one to its top, center or bottom; aligning to the artboard moves selected guides to its edges too (#3463), and with only guides selected aligning to the selection moves them to their collective bounds), artboard?: index (0-based; with to: \"artboard\", the artboard to align to; the app passes the active one; default: the artboard under the center of the selection, else the first), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} With anchor points selected (Direct Selection), the points align instead: to their own bounds, or to the artboard (#1180)",
|
||||||
has_selection,
|
has_selection_or_guides,
|
||||||
align
|
align
|
||||||
),
|
),
|
||||||
cmd!(
|
cmd!(
|
||||||
@@ -1155,6 +1155,7 @@ fn data_param(m: &serde_json::Map<String, Value>) -> Result<Vec<(String, Option<
|
|||||||
|
|
||||||
fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
|
fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
check_switches(p, &["visible", "locked", "isolate", "knockoutShape"], "object.setProps")?;
|
check_switches(p, &["visible", "locked", "isolate", "knockoutShape"], "object.setProps")?;
|
||||||
|
check_numbers(p, &["opacity"], "object.setProps")?;
|
||||||
let ids = targets(s, p)?;
|
let ids = targets(s, p)?;
|
||||||
let opacity = p.get("opacity").and_then(Value::as_f64).map(percent);
|
let opacity = p.get("opacity").and_then(Value::as_f64).map(percent);
|
||||||
let blend = match str_param(p, "blend") {
|
let blend = match str_param(p, "blend") {
|
||||||
@@ -1361,11 +1362,22 @@ fn align_reference(s: &Session, p: &Value, ids: &[NodeId], preview: bool, own: O
|
|||||||
Ok((r, true, true))
|
Ok((r, true, true))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bounds of the selected ruler guides (each guide is a zero-thickness line), for Align to
|
||||||
|
/// Selection when no objects are selected.
|
||||||
|
fn selected_guides_bounds(st: &crate::DocState) -> Option<Rect> {
|
||||||
|
st.selection.guides.iter().filter_map(|&i| st.doc.guides.get(i)).fold(None, |acc: Option<Rect>, g| {
|
||||||
|
let r = if g.vertical { Rect::new(g.pos, 0.0, g.pos, 0.0) } else { Rect::new(0.0, g.pos, 0.0, g.pos) };
|
||||||
|
Some(acc.map(|a| a.union(r)).unwrap_or(r))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Result<Rect> {
|
fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Result<Rect> {
|
||||||
let st = s.doc()?;
|
let st = s.doc()?;
|
||||||
match align_to(s, p) {
|
match align_to(s, p) {
|
||||||
Some("artboard") => {
|
Some("artboard") => {
|
||||||
let b = effects::preview::bounds_of(&st.doc, ids, preview).unwrap_or_default();
|
let b = effects::preview::bounds_of(&st.doc, ids, preview)
|
||||||
|
.or_else(|| ids.is_empty().then(|| selected_guides_bounds(st)).flatten())
|
||||||
|
.unwrap_or_default();
|
||||||
// Align to Artboard uses artboard `artboard` (the app passes the active one). Without
|
// Align to Artboard uses artboard `artboard` (the app passes the active one). Without
|
||||||
// it, or with an index the document doesn't have, it uses the artboard under the center
|
// it, or with an index the document doesn't have, it uses the artboard under the center
|
||||||
// of the selection, or the first one when no artboard is there.
|
// of the selection, or the first one when no artboard is there.
|
||||||
@@ -1377,7 +1389,13 @@ fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Resu
|
|||||||
let k = st.selection.key.ok_or_else(|| EngineError::Other("no key object".into()))?;
|
let k = st.selection.key.ok_or_else(|| EngineError::Other("no key object".into()))?;
|
||||||
effects::preview::bounds_of(&st.doc, &[k], preview).ok_or(EngineError::NoNode(k))
|
effects::preview::bounds_of(&st.doc, &[k], preview).ok_or(EngineError::NoNode(k))
|
||||||
}
|
}
|
||||||
_ => effects::preview::bounds_of(&st.doc, ids, preview).ok_or_else(|| EngineError::Other("nothing to align".into())),
|
_ => {
|
||||||
|
if ids.is_empty() {
|
||||||
|
selected_guides_bounds(st).ok_or_else(|| EngineError::Other("nothing to align".into()))
|
||||||
|
} else {
|
||||||
|
effects::preview::bounds_of(&st.doc, ids, preview).ok_or_else(|| EngineError::Other("nothing to align".into()))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1407,6 +1425,7 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
let (h, v) = direction(p, "object.align")?;
|
let (h, v) = direction(p, "object.align")?;
|
||||||
choice(p, "object.align", "to", &["selection", "artboard", "key"])?;
|
choice(p, "object.align", "to", &["selection", "artboard", "key"])?;
|
||||||
check_switches(p, &["patterns"], "object.align")?;
|
check_switches(p, &["patterns"], "object.align")?;
|
||||||
|
check_numbers(p, &["artboard"], "object.align")?;
|
||||||
if h.is_none() && v.is_none() {
|
if h.is_none() && v.is_none() {
|
||||||
return Err(bad("object.align", "give horizontal or vertical"));
|
return Err(bad("object.align", "give horizontal or vertical"));
|
||||||
}
|
}
|
||||||
@@ -1443,15 +1462,22 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
apply_moves(s, "Align", p, &ids, &moves, &guides)
|
apply_moves(s, "Align", p, &ids, &moves, &guides)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The selected ruler guides that Align to the key object (or key guide) moves, and where: a
|
/// The selected ruler guides that Align moves, and where: a vertical guide goes to the left edge,
|
||||||
/// vertical guide goes to the left edge, centre or right edge of `r` that `h` names, a horizontal
|
/// centre or right edge of `r` that `h` names, a horizontal one to the top, centre or bottom that
|
||||||
/// one to the top, centre or bottom that `v` names. The key guide stays put, and so do guides
|
/// `v` names. Align to the key object (or key guide) moves them too, and the key guide stays put.
|
||||||
/// when aligning to the selection or the artboard.
|
/// Align to the artboard moves them (#3463). Align to the selection moves them when only guides are
|
||||||
|
/// selected; with objects in the selection they stay put.
|
||||||
fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str>) -> Result<Vec<(usize, f64)>> {
|
fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str>) -> Result<Vec<(usize, f64)>> {
|
||||||
if align_to(s, p) != Some("key") {
|
let st = s.doc()?;
|
||||||
|
let to = align_to(s, p);
|
||||||
|
let move_guides = match to {
|
||||||
|
Some("key" | "artboard") => true,
|
||||||
|
Some("selection") => st.selection.is_empty(),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
if !move_guides {
|
||||||
return Ok(vec![]);
|
return Ok(vec![]);
|
||||||
}
|
}
|
||||||
let st = s.doc()?;
|
|
||||||
let at = |vertical: bool| match (vertical, h, v) {
|
let at = |vertical: bool| match (vertical, h, v) {
|
||||||
(true, Some("left"), _) => Some(r.x0),
|
(true, Some("left"), _) => Some(r.x0),
|
||||||
(true, Some("center"), _) => Some(r.center().x),
|
(true, Some("center"), _) => Some(r.center().x),
|
||||||
@@ -1461,11 +1487,12 @@ fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str>
|
|||||||
(false, _, Some("bottom")) => Some(r.y1),
|
(false, _, Some("bottom")) => Some(r.y1),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
let skip_key = to == Some("key");
|
||||||
Ok(st
|
Ok(st
|
||||||
.selection
|
.selection
|
||||||
.guides
|
.guides
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|&&i| Some(i) != st.selection.key_guide)
|
.filter(|&&i| !skip_key || Some(i) != st.selection.key_guide)
|
||||||
.filter_map(|&i| Some((i, at(st.doc.guides.get(i)?.vertical)?)))
|
.filter_map(|&i| Some((i, at(st.doc.guides.get(i)?.vertical)?)))
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -1626,6 +1653,7 @@ fn distribute_anchors(s: &mut Session, label: &str, horiz: bool, gap: Option<f64
|
|||||||
|
|
||||||
fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
|
fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
check_switches(p, &["patterns"], "object.distribute")?;
|
check_switches(p, &["patterns"], "object.distribute")?;
|
||||||
|
check_numbers(p, &["artboard"], "object.distribute")?;
|
||||||
let ids = selected_roots(s)?;
|
let ids = selected_roots(s)?;
|
||||||
let preview = preview_bounds(s, p, "object.distribute")?;
|
let preview = preview_bounds(s, p, "object.distribute")?;
|
||||||
let board = distribute_to(s, p, &ids, preview, "object.distribute")?;
|
let board = distribute_to(s, p, &ids, preview, "object.distribute")?;
|
||||||
@@ -1700,6 +1728,7 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
|
|
||||||
fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
check_switches(p, &["patterns"], "object.distributeSpacing")?;
|
check_switches(p, &["patterns"], "object.distributeSpacing")?;
|
||||||
|
check_numbers(p, &["artboard"], "object.distributeSpacing")?;
|
||||||
let ids = selected_roots(s)?;
|
let ids = selected_roots(s)?;
|
||||||
let horiz = choice(p, "object.distributeSpacing", "axis", &["horizontal", "vertical"])? != Some("vertical");
|
let horiz = choice(p, "object.distributeSpacing", "axis", &["horizontal", "vertical"])? != Some("vertical");
|
||||||
let preview = preview_bounds(s, p, "object.distributeSpacing")?;
|
let preview = preview_bounds(s, p, "object.distributeSpacing")?;
|
||||||
@@ -1786,7 +1815,7 @@ pub(crate) fn bounds_rewrite(s: &Session, cmd: &str, params: &Value) -> Option<(
|
|||||||
/// How `object.setBounds` with params `p` changes the objects `ids`. A scale that would leave the
|
/// How `object.setBounds` with params `p` changes the objects `ids`. A scale that would leave the
|
||||||
/// art with a width or height below `least` of the one it has keeps `least` of it.
|
/// art with a width or height below `least` of the one it has keeps `least` of it.
|
||||||
fn bounds_transform(s: &Session, p: &Value, ids: Vec<NodeId>, least: f64) -> Result<Bounds> {
|
fn bounds_transform(s: &Session, p: &Value, ids: Vec<NodeId>, least: f64) -> Result<Bounds> {
|
||||||
check_numbers(p, &["x", "y", "width", "height"], "object.setBounds")?;
|
check_numbers(p, &["x", "y", "width", "height", "reference"], "object.setBounds")?;
|
||||||
let st = s.doc()?;
|
let st = s.doc()?;
|
||||||
check_switches(p, &TRANSFORM_SWITCHES, "object.setBounds")?;
|
check_switches(p, &TRANSFORM_SWITCHES, "object.setBounds")?;
|
||||||
check_switches(p, &["proportional"], "object.setBounds")?;
|
check_switches(p, &["proportional"], "object.setBounds")?;
|
||||||
@@ -2321,7 +2350,8 @@ fn corners_param(p: &Value) -> Result<Option<BTreeSet<usize>>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
|
fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
check_switches(p, &["invertPie", "makeSidesEqual"], "object.setLiveShape")?;
|
check_switches(p, &["invertPie", "makeSidesEqual", "relativeRadii"], "object.setLiveShape")?;
|
||||||
|
check_numbers(p, &["radius", "sides"], "object.setLiveShape")?;
|
||||||
// `items`: objects each with its own `corners`; else `ids` (or the selection) sharing them.
|
// `items`: objects each with its own `corners`; else `ids` (or the selection) sharing them.
|
||||||
let items = match p.get("items").filter(|v| !v.is_null()) {
|
let items = match p.get("items").filter(|v| !v.is_null()) {
|
||||||
None => None,
|
None => None,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
//! report). The packaged document links to the copies (Relink); the open document doesn't change.
|
//! report). The packaged document links to the copies (Relink); the open document doesn't change.
|
||||||
//! Without a folder (the web, agents) the same files come back as a zip archive.
|
//! Without a folder (the web, agents) the same files come back as a zip archive.
|
||||||
|
|
||||||
use std::collections::{BTreeMap, HashSet};
|
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
@@ -101,6 +101,34 @@ fn safe_asset_name(name: &str) -> String {
|
|||||||
if name.is_empty() { "asset".into() } else { name.into() }
|
if name.is_empty() { "asset".into() } else { name.into() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A file placed: (page, bounding, page box) of each placed object showing it.
|
||||||
|
type PlacedPages = Vec<(u32, bool, vectorcraft_doc::PageBox)>;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
thread_local! {
|
||||||
|
/// Tests: how many placed PDF or `.ai` pages [`page_fonts`] read on this thread.
|
||||||
|
pub(crate) static PAGES_READ: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||||
|
/// Tests: how many linked `.vectorcraft` files had their preview and PDF read on this thread,
|
||||||
|
/// to keep them in the rewritten copy.
|
||||||
|
pub(crate) static PARTS_READ: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fonts of the type on `pages` of the PDF or `.ai` file `name` (`bytes`, found at `path`);
|
||||||
|
/// none for other files.
|
||||||
|
fn page_fonts(name: &str, bytes: &[u8], path: &str, pages: &PlacedPages) -> BTreeSet<super::fonts::UsedFont> {
|
||||||
|
let mut fonts = BTreeSet::new();
|
||||||
|
if super::place::document::is_pdf(name, bytes) {
|
||||||
|
for &(page, bounding, page_box) in pages {
|
||||||
|
#[cfg(test)]
|
||||||
|
PAGES_READ.with(|c| c.set(c.get() + 1));
|
||||||
|
if let Ok(a) = super::place::document::document_art(name, bytes, Some(path), page, bounding, page_box, C) {
|
||||||
|
fonts.extend(super::fonts::art_fonts(&a.doc, &a.nodes));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fonts
|
||||||
|
}
|
||||||
|
|
||||||
/// Collect dependencies depth-first so nested VectorCraft files are rewritten before
|
/// Collect dependencies depth-first so nested VectorCraft files are rewritten before
|
||||||
/// their parents: each parent can record the final hash of the rewritten child.
|
/// their parents: each parent can record the final hash of the rewritten child.
|
||||||
/// Cycles, excessive nesting, and unreadable children are reported, not fatal.
|
/// Cycles, excessive nesting, and unreadable children are reported, not fatal.
|
||||||
@@ -148,7 +176,20 @@ impl<'a> Collector<'a> {
|
|||||||
self.warnings.push(message);
|
self.warnings.push(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect(&mut self, doc: &mut Document, source: &str, destination: &str, depth: usize) -> Result<()> {
|
/// Warns when a file that placed `.ai` file `source` links (`name`) was copied as `copy`: the
|
||||||
|
/// copy of `source` finds it only by its name, beside it.
|
||||||
|
fn renamed(&mut self, lines: &mut Vec<String>, source: &str, path: &str, name: &str, copy: &str) {
|
||||||
|
if Path::new(copy).file_name().and_then(|n| n.to_str()) != Some(name) {
|
||||||
|
let message = format!(
|
||||||
|
"{path}: copied as {copy}; the copy of {source} looks for it as {name} and shows the file of that name, if any, else its preview"
|
||||||
|
);
|
||||||
|
self.warn(lines, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collects the files `doc` links. `by_name`: `doc` is a placed `.ai` file's editing data,
|
||||||
|
/// whose links are not rewritten, so its copy finds them by name beside it.
|
||||||
|
fn collect(&mut self, doc: &mut Document, source: &str, destination: &str, depth: usize, by_name: bool) -> Result<()> {
|
||||||
// The caller checks cycles and depth before descending; active documents have
|
// The caller checks cycles and depth before descending; active documents have
|
||||||
// a destination reserved so back-links can point at their single packaged copy.
|
// a destination reserved so back-links can point at their single packaged copy.
|
||||||
self.visiting.insert(source.to_string(), destination.to_string());
|
self.visiting.insert(source.to_string(), destination.to_string());
|
||||||
@@ -167,11 +208,24 @@ impl<'a> Collector<'a> {
|
|||||||
// Different linked documents can reuse the same stale absolute path yet
|
// Different linked documents can reuse the same stale absolute path yet
|
||||||
// resolve it to distinct local assets. Keep a mapping per document.
|
// resolve it to distinct local assets. Keep a mapping per document.
|
||||||
let mut local = BTreeMap::<String, Copied>::new();
|
let mut local = BTreeMap::<String, Copied>::new();
|
||||||
|
// The fonts of the type on the PDF and `.ai` pages placed linked.
|
||||||
|
let mut placed_fonts = BTreeSet::new();
|
||||||
|
let mut placed = BTreeMap::<String, PlacedPages>::new();
|
||||||
|
doc.visit_placed(|_, p| {
|
||||||
|
let page = (p.link.page.unwrap_or(1), p.bounding, p.page_box);
|
||||||
|
let pages = placed.entry(p.link.path.clone()).or_default();
|
||||||
|
if !pages.contains(&page) {
|
||||||
|
pages.push(page);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if self.opts.copy_fonts && !self.opts.copy_links && !placed.is_empty() {
|
||||||
|
for file in super::links::linked_files(doc, Some(source)) {
|
||||||
|
if let (Some(pages), Some(bytes)) = (placed.get(&file.path), &file.bytes) {
|
||||||
|
placed_fonts.extend(page_fonts(&file.name, bytes, file.found_path.as_deref().unwrap_or(&file.path), pages));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if self.opts.copy_links {
|
if self.opts.copy_links {
|
||||||
let mut placed = HashSet::new();
|
|
||||||
doc.visit_placed(|_, p| {
|
|
||||||
placed.insert(p.link.path.clone());
|
|
||||||
});
|
|
||||||
heading(&mut lines, "LINKED FILES");
|
heading(&mut lines, "LINKED FILES");
|
||||||
for file in super::links::linked_files(doc, Some(source)) {
|
for file in super::links::linked_files(doc, Some(source)) {
|
||||||
let found_path = file.found_path.as_deref().unwrap_or(&file.path);
|
let found_path = file.found_path.as_deref().unwrap_or(&file.path);
|
||||||
@@ -183,8 +237,17 @@ impl<'a> Collector<'a> {
|
|||||||
local.insert(file.path.clone(), Copied { name: ancestor, size: None, hash: None });
|
local.insert(file.path.clone(), Copied { name: ancestor, size: None, hash: None });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(existing) = self.copies.get(found_path) {
|
if let Some(existing) = self.copies.get(found_path).cloned() {
|
||||||
local.insert(file.path.clone(), existing.clone());
|
if by_name {
|
||||||
|
self.renamed(&mut lines, source, &file.path, &file.name, &existing.name);
|
||||||
|
}
|
||||||
|
// Another document may place other pages of a PDF copied already.
|
||||||
|
if self.opts.copy_fonts
|
||||||
|
&& let (Some(pages), Some(bytes)) = (placed.get(&file.path), &file.bytes)
|
||||||
|
{
|
||||||
|
placed_fonts.extend(page_fonts(&file.name, bytes, found_path, pages));
|
||||||
|
}
|
||||||
|
local.insert(file.path.clone(), existing);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Some(mut bytes) = file.bytes else {
|
let Some(mut bytes) = file.bytes else {
|
||||||
@@ -199,24 +262,42 @@ impl<'a> Collector<'a> {
|
|||||||
let name = safe_asset_name(&file.name);
|
let name = safe_asset_name(&file.name);
|
||||||
let unique = free_name(&name, if self.opts.links_folder { &mut self.link_names } else { &mut self.taken });
|
let unique = free_name(&name, if self.opts.links_folder { &mut self.link_names } else { &mut self.taken });
|
||||||
let destination_file = format!("{dir}{unique}");
|
let destination_file = format!("{dir}{unique}");
|
||||||
if placed.contains(&file.path) {
|
if by_name {
|
||||||
|
self.renamed(&mut lines, source, &file.path, &file.name, &destination_file);
|
||||||
|
}
|
||||||
|
// A placed PDF or .ai file is copied as it is, as an image is; the files a .ai file
|
||||||
|
// read from its editing data links are copied beside it.
|
||||||
|
let pages = placed.get(&file.path);
|
||||||
|
let placed_file = pages.is_some();
|
||||||
|
if placed_file && let Some(mut art) = super::place::document::editing_data(&file.name, &bytes) {
|
||||||
|
if depth >= vectorcraft_doc::placed_document::MAX_DEPTH {
|
||||||
|
self.warn(&mut lines, format!("{}: nesting limit reached; copied unchanged without collecting its own links", file.path));
|
||||||
|
} else {
|
||||||
|
self.collect(&mut art, found_path, &destination_file, depth + 1, true)?;
|
||||||
|
}
|
||||||
|
} else if self.opts.copy_fonts
|
||||||
|
&& let Some(pages) = pages
|
||||||
|
{
|
||||||
|
placed_fonts.extend(page_fonts(&file.name, &bytes, found_path, pages));
|
||||||
|
}
|
||||||
|
if placed_file && super::place::document::is_native(&file.name, &bytes) {
|
||||||
if depth >= vectorcraft_doc::placed_document::MAX_DEPTH {
|
if depth >= vectorcraft_doc::placed_document::MAX_DEPTH {
|
||||||
self.warn(&mut lines, format!("{}: nesting limit reached; copied unchanged without collecting its own links", file.path));
|
self.warn(&mut lines, format!("{}: nesting limit reached; copied unchanged without collecting its own links", file.path));
|
||||||
} else {
|
} else {
|
||||||
match vectorcraft_format::load_file(&bytes) {
|
match vectorcraft_format::load_file(&bytes) {
|
||||||
Ok(saved) => {
|
Ok(saved) => {
|
||||||
let preview = vectorcraft_format::preview(&bytes);
|
|
||||||
let pdf = vectorcraft_format::pdf_content(&bytes);
|
|
||||||
let compressed = bytes.starts_with(&[0x1f, 0x8b]);
|
let compressed = bytes.starts_with(&[0x1f, 0x8b]);
|
||||||
let include_linked =
|
let include_linked =
|
||||||
saved.doc.linked_only_images().iter().any(|key| saved.doc.images.get(key).is_some_and(|blob| !blob.is_proxy()));
|
saved.doc.linked_only_images().iter().any(|key| saved.doc.images.get(key).is_some_and(|blob| !blob.is_proxy()));
|
||||||
let mut nested = saved.doc;
|
let mut nested = saved.doc;
|
||||||
self.collect(&mut nested, found_path, &destination_file, depth + 1)?;
|
self.collect(&mut nested, found_path, &destination_file, depth + 1, false)?;
|
||||||
if self.opts.relink {
|
if self.opts.relink {
|
||||||
|
#[cfg(test)]
|
||||||
|
PARTS_READ.with(|c| c.set(c.get() + 1));
|
||||||
// Preserve auxiliary native-file content when rewriting.
|
// Preserve auxiliary native-file content when rewriting.
|
||||||
let mut options = vectorcraft_format::SaveOptions::for_doc(&nested);
|
let mut options = vectorcraft_format::SaveOptions::for_doc(&nested);
|
||||||
options.preview = preview;
|
options.preview = vectorcraft_format::preview(&bytes);
|
||||||
options.pdf = pdf;
|
options.pdf = vectorcraft_format::pdf_content(&bytes);
|
||||||
options.profiles = saved.profiles;
|
options.profiles = saved.profiles;
|
||||||
options.compress = compressed;
|
options.compress = compressed;
|
||||||
options.include_linked = include_linked;
|
options.include_linked = include_linked;
|
||||||
@@ -250,7 +331,9 @@ impl<'a> Collector<'a> {
|
|||||||
if self.opts.copy_fonts {
|
if self.opts.copy_fonts {
|
||||||
let db = vectorcraft_text::FontDb::global();
|
let db = vectorcraft_text::FontDb::global();
|
||||||
heading(&mut lines, "FONTS");
|
heading(&mut lines, "FONTS");
|
||||||
for used in super::fonts::used_fonts(doc) {
|
let mut used_fonts = super::fonts::used_fonts(doc);
|
||||||
|
used_fonts.append(&mut placed_fonts);
|
||||||
|
for used in used_fonts {
|
||||||
let font = super::fonts::font_label(&used);
|
let font = super::fonts::font_label(&used);
|
||||||
let (family, style, version) = used;
|
let (family, style, version) = used;
|
||||||
// Found by any of its names, as the canvas draws it (in the version the type names).
|
// Found by any of its names, as the canvas draws it (in the version the type names).
|
||||||
@@ -306,6 +389,51 @@ impl<'a> Collector<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn package(s: &mut Session, p: &Value) -> Result<Value> {
|
fn package(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
|
let package = prepare(s, p)?;
|
||||||
|
match &mut s.package_hand_off {
|
||||||
|
Some(slot) => {
|
||||||
|
*slot = Some(package);
|
||||||
|
Ok(Value::Null)
|
||||||
|
}
|
||||||
|
None => package.write(&mut |path, bytes| write_file(path, bytes)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A package of the active document as `file.package` takes it from the session: the options, the
|
||||||
|
/// names, the report's Document Info text and the document as it is now
|
||||||
|
/// ([`Session::prepare_package`]). [`Package::write`] collects and writes it without the session,
|
||||||
|
/// on any thread.
|
||||||
|
pub struct Package {
|
||||||
|
o: Options,
|
||||||
|
doc_path: String,
|
||||||
|
stem: String,
|
||||||
|
name: String,
|
||||||
|
root: Option<PathBuf>,
|
||||||
|
info: Option<String>,
|
||||||
|
doc: std::sync::Arc<Document>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Session {
|
||||||
|
/// Run `file.package` with `params` as a command (checked and journaled as any other) that
|
||||||
|
/// collects and writes nothing → the package it prepared, which [`Package::write`] collects
|
||||||
|
/// and writes (on the desktop with Background Export on, File › Package… does this on the
|
||||||
|
/// background thread).
|
||||||
|
pub fn prepare_package(&mut self, params: &Value) -> Result<Package> {
|
||||||
|
self.package_hand_off = Some(None);
|
||||||
|
let r = self.execute(C, params);
|
||||||
|
let handed = self.package_hand_off.take().flatten();
|
||||||
|
match (r, handed) {
|
||||||
|
(Err(e), _) => Err(e),
|
||||||
|
(Ok(_), Some(package)) => Ok(package),
|
||||||
|
// Every run that succeeds hands one over; otherwise the package is prepared here.
|
||||||
|
(Ok(_), None) => prepare(self, params),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What `file.package` reads from the session: the options, the checks of the document and the
|
||||||
|
/// name, the report's Document Info text and the document.
|
||||||
|
fn prepare(s: &mut Session, p: &Value) -> Result<Package> {
|
||||||
let o = Options {
|
let o = Options {
|
||||||
copy_links: bool_or(p, "copyLinks", true),
|
copy_links: bool_or(p, "copyLinks", true),
|
||||||
links_folder: bool_or(p, "linksFolder", true),
|
links_folder: bool_or(p, "linksFolder", true),
|
||||||
@@ -322,8 +450,23 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
let folder = str_param(p, "folder");
|
let folder = str_param(p, "folder");
|
||||||
let root = folder.map(|f| Path::new(f).join(&name));
|
let root = folder.map(|f| Path::new(f).join(&name));
|
||||||
let info = if o.report { Some(super::docinfo::report(s, false)?) } else { None };
|
let info = if o.report { Some(super::docinfo::report(s, false)?) } else { None };
|
||||||
let st = s.doc()?;
|
let doc = s.doc()?.doc.clone();
|
||||||
let mut doc = (*st.doc).clone();
|
Ok(Package { o, doc_path, stem, name, root, info, doc })
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Package {
|
||||||
|
/// Collect the linked files and fonts and write the package's files with `write` into
|
||||||
|
/// `folder/name`, making its folders first, or into the zip without a folder → the reply of
|
||||||
|
/// `file.package`.
|
||||||
|
pub fn write(self, write: &mut dyn FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> {
|
||||||
|
collect_and_write(self, write)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Package::write`].
|
||||||
|
fn collect_and_write(package: Package, write: &mut dyn FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> {
|
||||||
|
let Package { o, doc_path, stem, name, root, info, doc } = package;
|
||||||
|
let mut doc = (*doc).clone();
|
||||||
let doc_name = format!("{stem}.{}", vectorcraft_format::EXTENSION);
|
let doc_name = format!("{stem}.{}", vectorcraft_format::EXTENSION);
|
||||||
// Prepare original links for the destination *before* rebasing copied links.
|
// Prepare original links for the destination *before* rebasing copied links.
|
||||||
// Recalculating afterward would overwrite the portable relative paths.
|
// Recalculating afterward would overwrite the portable relative paths.
|
||||||
@@ -333,7 +476,7 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
doc = d;
|
doc = d;
|
||||||
}
|
}
|
||||||
let mut collector = Collector::new(&o, root.clone(), &doc_name);
|
let mut collector = Collector::new(&o, root.clone(), &doc_name);
|
||||||
collector.collect(&mut doc, &doc_path, &doc_name, 0)?;
|
collector.collect(&mut doc, &doc_path, &doc_name, 0, false)?;
|
||||||
let mut entries = std::mem::take(&mut collector.entries);
|
let mut entries = std::mem::take(&mut collector.entries);
|
||||||
entries.insert(0, (doc_name.clone(), vectorcraft_format::save_file(&doc)));
|
entries.insert(0, (doc_name.clone(), vectorcraft_format::save_file(&doc)));
|
||||||
let mut taken = std::mem::take(&mut collector.taken);
|
let mut taken = std::mem::take(&mut collector.taken);
|
||||||
@@ -366,7 +509,7 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
if let Some(dir) = path.parent() {
|
if let Some(dir) = path.parent() {
|
||||||
create_dir(&dir.to_string_lossy())?;
|
create_dir(&dir.to_string_lossy())?;
|
||||||
}
|
}
|
||||||
write_file(&path.to_string_lossy(), bytes)?;
|
write(&path.to_string_lossy(), bytes)?;
|
||||||
}
|
}
|
||||||
out["folder"] = json!(root.to_string_lossy());
|
out["folder"] = json!(root.to_string_lossy());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
"Fill",
|
"Fill",
|
||||||
[],
|
[],
|
||||||
None,
|
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?|id?, 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), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only",
|
"{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, method?: classic|perceptual (as paint.editGradient; default classic), 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), dither?: bool (linear and radial fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), 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?|id?, 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), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only",
|
||||||
has_doc,
|
has_doc,
|
||||||
|s, p| set_paint(s, p, true)
|
|s, p| set_paint(s, p, true)
|
||||||
),
|
),
|
||||||
@@ -351,6 +351,7 @@ pub(crate) fn paint_from(s: &Session, p: &Value) -> Result<Option<Paint>> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if let Some(g) = p.get("gradient") {
|
if let Some(g) = p.get("gradient") {
|
||||||
|
check_numbers(g, &["angle"], "paint")?;
|
||||||
// A stop naming a process swatch keeps the swatch's model.
|
// A stop naming a process swatch keeps the swatch's model.
|
||||||
let stored = super::gradient::stop_colors(s, p, g);
|
let stored = super::gradient::stop_colors(s, p, g);
|
||||||
let g = super::gradient::link_stops(&s.doc()?.doc, g).map_err(|e| bad("paint", e))?;
|
let g = super::gradient::link_stops(&s.doc()?.doc, g).map_err(|e| bad("paint", e))?;
|
||||||
@@ -402,6 +403,11 @@ fn set_paint(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
|
|||||||
}
|
}
|
||||||
check_keep_model(p, cmd)?;
|
check_keep_model(p, cmd)?;
|
||||||
let paint = paint_from(s, p)?.ok_or_else(|| bad(cmd, "give color, none, swatch or gradient"))?;
|
let paint = paint_from(s, p)?.ok_or_else(|| bad(cmd, "give color, none, swatch or gradient"))?;
|
||||||
|
// As `paint.editGradient`: a `dither` given applies to gradient fills only. A gradient swatch
|
||||||
|
// (named) keeps its own dither; `"swatch": null` names none.
|
||||||
|
if !fill && matches!(&paint, Paint::Gradient(g) if g.gradient.dither) && p.get("swatch").and_then(Value::as_str).is_none() {
|
||||||
|
return Err(bad(cmd, "`dither` applies to gradient fills (paint.setFill)"));
|
||||||
|
}
|
||||||
apply_paint(s, p, paint, fill)
|
apply_paint(s, p, paint, fill)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
"Character / Paragraph",
|
"Character / Paragraph",
|
||||||
[],
|
[],
|
||||||
None,
|
None,
|
||||||
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
|
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
|
||||||
has_doc,
|
has_doc,
|
||||||
set_format
|
set_format
|
||||||
),
|
),
|
||||||
@@ -72,6 +72,7 @@ fn artboard_reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
/// copy commands: by default the artboards the Artboard tool has selected (its active one when none
|
/// copy commands: by default the artboards the Artboard tool has selected (its active one when none
|
||||||
/// of them is in the document) and its Move/Copy Artwork with Artboard option.
|
/// of them is in the document) and its Move/Copy Artwork with Artboard option.
|
||||||
fn boards_and_art(s: &Session, p: &Value, cmd: &str) -> Result<(Vec<usize>, bool)> {
|
fn boards_and_art(s: &Session, p: &Value, cmd: &str) -> Result<(Vec<usize>, bool)> {
|
||||||
|
check_switches(p, &["art"], cmd)?;
|
||||||
let t = s.tool_options_of("artboard");
|
let t = s.tool_options_of("artboard");
|
||||||
let d = &s.doc()?.doc;
|
let d = &s.doc()?.doc;
|
||||||
let indices = if p.get("index").is_some() || p.get("indices").is_some() {
|
let indices = if p.get("index").is_some() || p.get("indices").is_some() {
|
||||||
@@ -159,6 +160,7 @@ pub(crate) fn beside_artboards(d: &vectorcraft_doc::Document, rect: Rect) -> Vec
|
|||||||
/// Duplicate Artboards: a copy of each artboard with its art, in one undo step. The copies keep
|
/// Duplicate Artboards: a copy of each artboard with its art, in one undo step. The copies keep
|
||||||
/// their places relative to each other, together right of the last artboard.
|
/// their places relative to each other, together right of the last artboard.
|
||||||
fn artboard_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
fn artboard_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
|
check_switches(p, &["patterns"], "artboard.duplicate")?;
|
||||||
let (indices, art) = boards_and_art(s, p, "artboard.duplicate")?;
|
let (indices, art) = boards_and_art(s, p, "artboard.duplicate")?;
|
||||||
let arts = artboards_art(s, &indices, art, false)?;
|
let arts = artboards_art(s, &indices, art, false)?;
|
||||||
let all: Vec<NodeId> = arts.iter().flat_map(|(_, a)| a.iter().copied()).collect();
|
let all: Vec<NodeId> = arts.iter().flat_map(|(_, a)| a.iter().copied()).collect();
|
||||||
@@ -327,6 +329,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
"direction",
|
"direction",
|
||||||
"leadingModel",
|
"leadingModel",
|
||||||
"charAlign",
|
"charAlign",
|
||||||
|
"charDirection",
|
||||||
"proportionalMetrics",
|
"proportionalMetrics",
|
||||||
"tateChuYoko",
|
"tateChuYoko",
|
||||||
"noBreak",
|
"noBreak",
|
||||||
@@ -350,6 +353,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
};
|
};
|
||||||
let (position, small_caps) = super::docsetup::script_params(p, &s.doc()?.doc.setup, C)?;
|
let (position, small_caps) = super::docsetup::script_params(p, &s.doc()?.doc.setup, C)?;
|
||||||
let char_align = super::textedit::char_align_param(p, C)?;
|
let char_align = super::textedit::char_align_param(p, C)?;
|
||||||
|
let char_direction = super::textedit::char_direction_param(p, C)?;
|
||||||
let digits = super::textedit::digits_param(p, C)?;
|
let digits = super::textedit::digits_param(p, C)?;
|
||||||
let font_axes = super::textedit::font_axes_param(p, C)?;
|
let font_axes = super::textedit::font_axes_param(p, C)?;
|
||||||
let mojikumi = match p.get("mojikumi") {
|
let mojikumi = match p.get("mojikumi") {
|
||||||
@@ -460,6 +464,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
if let Some(v) = char_align {
|
if let Some(v) = char_align {
|
||||||
st.char_align = v;
|
st.char_align = v;
|
||||||
}
|
}
|
||||||
|
if let Some(v) = char_direction {
|
||||||
|
st.char_direction = v;
|
||||||
|
}
|
||||||
if let Some(v) = digits {
|
if let Some(v) = digits {
|
||||||
st.digits = v;
|
st.digits = v;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ fn close(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
None => None,
|
None => None,
|
||||||
Some(h) => Some(finite_numbers(h).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad("path.close", "`in` must be [x, y]"))?),
|
Some(h) => Some(finite_numbers(h).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad("path.close", "`in` must be [x, y]"))?),
|
||||||
};
|
};
|
||||||
let independent = bool_or(p, "independent", false);
|
let independent = switch_or(p, "independent", false, "path.close")?;
|
||||||
s.edit("Close Path", |d, sel| {
|
s.edit("Close Path", |d, sel| {
|
||||||
let path = path_mut(d, id)?;
|
let path = path_mut(d, id)?;
|
||||||
let sp = path.subpaths.iter_mut().rev().find(|s| !s.closed).ok_or_else(|| EngineError::Other("path is already closed".into()))?;
|
let sp = path.subpaths.iter_mut().rev().find(|s| !s.closed).ok_or_else(|| EngineError::Other("path is already closed".into()))?;
|
||||||
@@ -182,6 +182,7 @@ fn close(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
|
|
||||||
pub(crate) fn move_anchors(s: &mut Session, p: &Value) -> Result<Value> {
|
pub(crate) fn move_anchors(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let d = Vec2::new(number_or(p, "dx", 0.0, "path.moveAnchors")?, number_or(p, "dy", 0.0, "path.moveAnchors")?);
|
let d = Vec2::new(number_or(p, "dx", 0.0, "path.moveAnchors")?, number_or(p, "dy", 0.0, "path.moveAnchors")?);
|
||||||
|
check_switches(p, &["patterns"], "path.moveAnchors")?;
|
||||||
// Objects selected whole move as objects, and their pattern tiles move with them when
|
// Objects selected whole move as objects, and their pattern tiles move with them when
|
||||||
// `patterns` (default: General › Transform Pattern Tiles) is on.
|
// `patterns` (default: General › Transform Pattern Tiles) is on.
|
||||||
let whole = s.doc()?.selection.whole();
|
let whole = s.doc()?.selection.whole();
|
||||||
@@ -232,7 +233,7 @@ fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.setHandle", "missing anchor"))?;
|
let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.setHandle", "missing anchor"))?;
|
||||||
let out = str_param(p, "which") != Some("in");
|
let out = str_param(p, "which") != Some("in");
|
||||||
let pos = Point::new(f64_req(p, "x", "path.setHandle")?, f64_req(p, "y", "path.setHandle")?);
|
let pos = Point::new(f64_req(p, "x", "path.setHandle")?, f64_req(p, "y", "path.setHandle")?);
|
||||||
let independent = bool_or(p, "independent", false);
|
let independent = switch_or(p, "independent", false, "path.setHandle")?;
|
||||||
s.edit("Reshape", |d, _| {
|
s.edit("Reshape", |d, _| {
|
||||||
let path = path_mut(d, id)?;
|
let path = path_mut(d, id)?;
|
||||||
let a = path.anchor_mut(si, ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?;
|
let a = path.anchor_mut(si, ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?;
|
||||||
|
|||||||
@@ -635,7 +635,7 @@ pub(crate) fn outlined_stroke(
|
|||||||
blend: st.blend,
|
blend: st.blend,
|
||||||
effects: st.effects.clone(),
|
effects: st.effects.clone(),
|
||||||
overprint: st.overprint,
|
overprint: st.overprint,
|
||||||
..vectorcraft_doc::appearance::FillLayer::new(st.paint.clone())
|
..vectorcraft_doc::appearance::FillLayer::new(st.paint.stroke_as_fill())
|
||||||
};
|
};
|
||||||
n.appearance = Appearance { items: vec![AppearanceItem::Fill(fill)], ..Default::default() }.into();
|
n.appearance = Appearance { items: vec![AppearanceItem::Fill(fill)], ..Default::default() }.into();
|
||||||
Some(n)
|
Some(n)
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
//! Placed documents (`file.place` of a VectorCraft document from a file, with `link`): one
|
//! Placed documents (`file.place` of a VectorCraft document, a PDF or a `.ai` file from a file,
|
||||||
//! locked object showing an artboard of the document, a [`PlacedDocument`].
|
//! with `link`): one locked object showing an artboard of the document or a page of the PDF, a
|
||||||
|
//! [`PlacedDocument`].
|
||||||
//!
|
//!
|
||||||
//! The object keeps the file's bytes (in the document's images, like image data) and the link; the
|
//! The object keeps the file's bytes (in the document's images, like image data) and the link; the
|
||||||
//! art it shows is read from them the way placing the file without Link reads it
|
//! art it shows is read from them the way placing the file without Link reads it
|
||||||
//! ([`document_art`]: the artboard's art, with the file's own linked images found), so Break Link
|
//! ([`document_art`]: the artboard's or page's art, with the file's own linked images found), so
|
||||||
//! and Object › Expand ([`expand`]) give exactly that editable copy. The Links commands
|
//! Break Link and Object › Expand ([`expand`]) give exactly that editable copy. The Links commands
|
||||||
//! ([`super::super::links`]) tell when the file changed and read it again.
|
//! ([`super::super::links`]) tell when the file changed and read it again.
|
||||||
//!
|
//!
|
||||||
//! Each placed file also gets a preview ([`PREVIEW_PX_PER_PT`]; JPEG when opaque, else PNG), kept
|
//! Each placed file also gets a preview ([`PREVIEW_PX_PER_PT`]; JPEG when opaque, else PNG), kept
|
||||||
@@ -19,8 +20,9 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use vectorcraft_doc::links::hash_bytes;
|
use vectorcraft_doc::links::hash_bytes;
|
||||||
use vectorcraft_doc::placed_document::{PREVIEW_MAX, PREVIEW_PX_PER_PT, key_for};
|
use vectorcraft_doc::placed_document::{PREVIEW_MAX, PREVIEW_PX_PER_PT, key_for};
|
||||||
use vectorcraft_doc::{Document, ImageBlob, LinkInfo, Node, NodeKind, PlacedDocument, PlacementOptions};
|
use vectorcraft_doc::{Document, ImageBlob, LinkInfo, Node, NodeKind, PageBox, PlacedDocument, PlacementOptions};
|
||||||
use vectorcraft_geom::{Affine, Rect};
|
use vectorcraft_geom::{Affine, Rect};
|
||||||
|
use vectorcraft_pdf::CropTo;
|
||||||
|
|
||||||
use super::super::fileio;
|
use super::super::fileio;
|
||||||
use super::super::*;
|
use super::super::*;
|
||||||
@@ -29,7 +31,7 @@ use super::super::*;
|
|||||||
pub(crate) const MAX_FILE: usize = 256 << 20;
|
pub(crate) const MAX_FILE: usize = 256 << 20;
|
||||||
/// The format ids of VectorCraft documents.
|
/// The format ids of VectorCraft documents.
|
||||||
const NATIVE: [&str; 2] = ["vectorcraft", "template"];
|
const NATIVE: [&str; 2] = ["vectorcraft", "template"];
|
||||||
/// The type a placed file's bytes are stored with.
|
/// The type a placed VectorCraft document's bytes are stored with (a PDF's: its format's).
|
||||||
const MIME: &str = "application/json";
|
const MIME: &str = "application/json";
|
||||||
|
|
||||||
/// Is `name` with `bytes` a VectorCraft document?
|
/// Is `name` with `bytes` a VectorCraft document?
|
||||||
@@ -37,7 +39,55 @@ pub(crate) fn is_native(name: &str, bytes: &[u8]) -> bool {
|
|||||||
fileio::detect(name, bytes).is_some_and(|f| NATIVE.contains(&f.id))
|
fileio::detect(name, bytes).is_some_and(|f| NATIVE.contains(&f.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An artboard of a VectorCraft document, read as art to place.
|
/// Is `name` with `bytes` a PDF, or a `.ai` or `.ait` file read as PDF pages (not PostScript)?
|
||||||
|
pub(crate) fn is_pdf(name: &str, bytes: &[u8]) -> bool {
|
||||||
|
fileio::detect(name, bytes).is_some_and(|f| matches!(f.id, "pdf" | "ai" | "ait")) && !vectorcraft_pdf::is_postscript(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The editing data of the `.ai` file `name` (`bytes`) when its PDF part is only a placeholder:
|
||||||
|
/// the document its pages are read from ([`super::pdf_art`]), with the files it links. `None` for
|
||||||
|
/// other files.
|
||||||
|
pub(crate) fn editing_data(name: &str, bytes: &[u8]) -> Option<Document> {
|
||||||
|
if !is_pdf(name, bytes) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let read = fileio::page_document(bytes, 0, &fileio::LoadOptions::default());
|
||||||
|
match fileio::page_or_editing_data(name, bytes, None, read) {
|
||||||
|
Ok((doc, _, true)) => Some(doc),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The type the bytes of placed file `name` (`bytes`) are stored with: a PDF's format's, else
|
||||||
|
/// [`MIME`].
|
||||||
|
fn mime(name: &str, bytes: &[u8]) -> &'static str {
|
||||||
|
fileio::detect(name, bytes).filter(|_| is_pdf(name, bytes)).map_or(MIME, |f| f.mime)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The box of a PDF page placed linked for File › Place's `crop` (Bounding Box: the crop box,
|
||||||
|
/// the art's bounds being [`PlacedDocument::bounding`]).
|
||||||
|
pub(crate) fn page_box(crop: CropTo) -> PageBox {
|
||||||
|
match crop {
|
||||||
|
CropTo::Art => PageBox::Art,
|
||||||
|
CropTo::Trim => PageBox::Trim,
|
||||||
|
CropTo::Bleed => PageBox::Bleed,
|
||||||
|
CropTo::Media => PageBox::Media,
|
||||||
|
CropTo::Crop | CropTo::Bounding => PageBox::Crop,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `crop` a PDF page placed linked showing `b` is read with.
|
||||||
|
fn crop_to(b: PageBox) -> CropTo {
|
||||||
|
match b {
|
||||||
|
PageBox::Crop => CropTo::Crop,
|
||||||
|
PageBox::Art => CropTo::Art,
|
||||||
|
PageBox::Trim => CropTo::Trim,
|
||||||
|
PageBox::Bleed => CropTo::Bleed,
|
||||||
|
PageBox::Media => CropTo::Media,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An artboard of a VectorCraft document, or a page of a PDF or `.ai` file, read as art to place.
|
||||||
pub(crate) struct DocumentArt {
|
pub(crate) struct DocumentArt {
|
||||||
/// The document, its layers taken: the art's resources.
|
/// The document, its layers taken: the art's resources.
|
||||||
pub doc: Document,
|
pub doc: Document,
|
||||||
@@ -51,18 +101,33 @@ pub(crate) struct DocumentArt {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Artboard `page` (1-based) of the VectorCraft document `name` (`bytes`, read from `path` when
|
/// Artboard `page` (1-based) of the VectorCraft document `name` (`bytes`, read from `path` when
|
||||||
/// there is one, so its linked images are found), clipped to the artboard unless `bounding`.
|
/// there is one, so its linked images are found), clipped to the artboard unless `bounding`; or
|
||||||
pub(crate) fn document_art(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bounding: bool, cmd: &str) -> Result<DocumentArt> {
|
/// page `page` of the PDF or `.ai` file `name`, clipped to its box `page_box` unless `bounding`
|
||||||
|
/// ([`super::pdf_art`]).
|
||||||
|
pub(crate) fn document_art(
|
||||||
|
name: &str,
|
||||||
|
bytes: &[u8],
|
||||||
|
path: Option<&str>,
|
||||||
|
page: u32,
|
||||||
|
bounding: bool,
|
||||||
|
page_box: PageBox,
|
||||||
|
cmd: &str,
|
||||||
|
) -> Result<DocumentArt> {
|
||||||
let file = fileio::file_name(name);
|
let file = fileio::file_name(name);
|
||||||
if !is_native(name, bytes) {
|
let pdf = is_pdf(name, bytes);
|
||||||
return Err(bad(cmd, format!("`{file}` isn't a VectorCraft document")));
|
if !pdf && !is_native(name, bytes) {
|
||||||
|
return Err(bad(cmd, format!("`{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file")));
|
||||||
}
|
}
|
||||||
if bytes.len() > MAX_FILE {
|
if bytes.len() > MAX_FILE {
|
||||||
return Err(bad(cmd, format!("`{file}` is over {} MB", MAX_FILE >> 20)));
|
return Err(bad(cmd, format!("`{file}` is over {} MB", MAX_FILE >> 20)));
|
||||||
}
|
}
|
||||||
|
if pdf {
|
||||||
|
let o = fileio::LoadOptions { crop: crop_to(page_box), ..Default::default() };
|
||||||
|
return super::pdf_art(name, bytes, path, page as usize, o, bounding, cmd);
|
||||||
|
}
|
||||||
let mut l = fileio::load(name, bytes)?;
|
let mut l = fileio::load(name, bytes)?;
|
||||||
// Its linked images show their files; its placed documents stay as they are.
|
// Its linked images show their files; its placed documents stay as they are.
|
||||||
super::super::links::resolve(&mut l.doc, path, false);
|
super::super::links::resolve(&mut l.doc, path, false, false);
|
||||||
l.doc.drop_edit_modes();
|
l.doc.drop_edit_modes();
|
||||||
let n = l.doc.artboards.len();
|
let n = l.doc.artboards.len();
|
||||||
let board = (page as usize).checked_sub(1).and_then(|i| l.doc.artboards.get(i)).map(|a| a.rect);
|
let board = (page as usize).checked_sub(1).and_then(|i| l.doc.artboards.get(i)).map(|a| a.rect);
|
||||||
@@ -87,7 +152,7 @@ fn group(nodes: Vec<Node>, clip: Option<Rect>) -> Node {
|
|||||||
/// `p`'s file (`bytes`) read as the art it shows: how the document model reads placed documents
|
/// `p`'s file (`bytes`) read as the art it shows: how the document model reads placed documents
|
||||||
/// ([`vectorcraft_doc::placed_document::set_loader`]).
|
/// ([`vectorcraft_doc::placed_document::set_loader`]).
|
||||||
pub(crate) fn read_document(p: &PlacedDocument, bytes: &[u8]) -> Option<(Document, Node, Rect)> {
|
pub(crate) fn read_document(p: &PlacedDocument, bytes: &[u8]) -> Option<(Document, Node, Rect)> {
|
||||||
let a = document_art(p.link.name(), bytes, Some(&p.link.path), p.page(), p.bounding, "draw").ok()?;
|
let a = document_art(p.link.name(), bytes, Some(&p.link.path), p.page(), p.bounding, p.page_box, "draw").ok()?;
|
||||||
Some((a.doc, group(a.nodes, a.clip), a.frame))
|
Some((a.doc, group(a.nodes, a.clip), a.frame))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,32 +166,36 @@ pub(crate) fn read_file_again(p: &PlacedDocument) -> Option<Vec<u8>> {
|
|||||||
Some(bytes)
|
Some(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A VectorCraft document read for placing linked.
|
/// A VectorCraft document, or a PDF or `.ai` file, read for placing linked.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub(crate) struct Source {
|
pub(crate) struct Source {
|
||||||
/// The file's bytes, with their preview.
|
/// The file's bytes, with their preview.
|
||||||
pub blob: ImageBlob,
|
pub blob: ImageBlob,
|
||||||
/// The blob's key ([`key_for`]).
|
/// The blob's key ([`key_for`]).
|
||||||
pub key: String,
|
pub key: String,
|
||||||
/// What is shown, in the file's coordinates: the artboard or the art's bounds.
|
/// The box of a PDF page shown (unless the art's bounds are).
|
||||||
|
pub page_box: PageBox,
|
||||||
|
/// What is shown, in the file's coordinates: the artboard, the page's box or the art's bounds.
|
||||||
pub frame: Rect,
|
pub frame: Rect,
|
||||||
pub warnings: Vec<String>,
|
pub warnings: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The VectorCraft document `name` (`bytes`, from `path` when there is one) read for showing
|
/// The VectorCraft document `name` (`bytes`, from `path` when there is one) read for showing
|
||||||
/// artboard `page` (its art's bounds when `bounding`).
|
/// artboard `page`, or the PDF or `.ai` file for showing page `page` clipped to its box
|
||||||
pub(crate) fn source(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bounding: bool, cmd: &str) -> Result<Source> {
|
/// `page_box` (its art's bounds when `bounding`).
|
||||||
|
pub(crate) fn source(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bounding: bool, page_box: PageBox, cmd: &str) -> Result<Source> {
|
||||||
// The same document placed the same way is read once (placing it again is free).
|
// The same document placed the same way is read once (placing it again is free).
|
||||||
let key = key_for(&hash_bytes(bytes), page, bounding);
|
let key = key_for(&hash_bytes(bytes), page, bounding, page_box);
|
||||||
if let Some(s) = lock_memo().iter().find(|s| s.key == key).cloned() {
|
if let Some(s) = lock_memo().iter().find(|s| s.key == key).cloned() {
|
||||||
return Ok(s);
|
return Ok(s);
|
||||||
}
|
}
|
||||||
let a = document_art(name, bytes, path, page, bounding, cmd)?;
|
let a = document_art(name, bytes, path, page, bounding, page_box, cmd)?;
|
||||||
let mut blob = ImageBlob::new(MIME, bytes.to_vec());
|
let mut blob = ImageBlob::new(mime(name, bytes), bytes.to_vec());
|
||||||
let shown = PlacedDocument {
|
let shown = PlacedDocument {
|
||||||
link: LinkInfo { page: Some(page), ..LinkInfo::new(path.unwrap_or(name)) },
|
link: LinkInfo { page: Some(page), ..LinkInfo::new(path.unwrap_or(name)) },
|
||||||
key: key.clone(),
|
key: key.clone(),
|
||||||
bounding,
|
bounding,
|
||||||
|
page_box,
|
||||||
width: a.frame.width(),
|
width: a.frame.width(),
|
||||||
height: a.frame.height(),
|
height: a.frame.height(),
|
||||||
xf: Affine::IDENTITY,
|
xf: Affine::IDENTITY,
|
||||||
@@ -136,7 +205,7 @@ pub(crate) fn source(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bo
|
|||||||
blob.proxy = preview(&a.doc, &art, a.frame).map(Arc::new);
|
blob.proxy = preview(&a.doc, &art, a.frame).map(Arc::new);
|
||||||
// Drawing it needn't read it again.
|
// Drawing it needn't read it again.
|
||||||
vectorcraft_doc::placed_document::prime(&shown, blob.bytes.clone(), (a.doc, art, a.frame));
|
vectorcraft_doc::placed_document::prime(&shown, blob.bytes.clone(), (a.doc, art, a.frame));
|
||||||
let s = Source { blob, key, frame: a.frame, warnings: a.warnings };
|
let s = Source { blob, key, page_box, frame: a.frame, warnings: a.warnings };
|
||||||
let mut m = lock_memo();
|
let mut m = lock_memo();
|
||||||
if m.len() >= MEMO_SIZE {
|
if m.len() >= MEMO_SIZE {
|
||||||
m.remove(0);
|
m.remove(0);
|
||||||
@@ -203,6 +272,7 @@ pub(crate) fn node(d: &mut Document, s: &Source, link: LinkInfo, bounding: bool)
|
|||||||
link,
|
link,
|
||||||
key: s.key.clone(),
|
key: s.key.clone(),
|
||||||
bounding,
|
bounding,
|
||||||
|
page_box: s.page_box,
|
||||||
width: s.frame.width(),
|
width: s.frame.width(),
|
||||||
height: s.frame.height(),
|
height: s.frame.height(),
|
||||||
xf: Affine::translate((s.frame.x0, s.frame.y0)),
|
xf: Affine::translate((s.frame.x0, s.frame.y0)),
|
||||||
@@ -211,32 +281,39 @@ pub(crate) fn node(d: &mut Document, s: &Source, link: LinkInfo, bounding: bool)
|
|||||||
Node::new(d.alloc_id(), NodeKind::PlacedDocument(Box::new(placed)))
|
Node::new(d.alloc_id(), NodeKind::PlacedDocument(Box::new(placed)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `d` for output: the placed documents that keep only their previews get their files back,
|
/// `d` for output: linked images and placed documents that keep only their previews get their
|
||||||
/// else (their files gone or changed) output as their previews, with a warning.
|
/// files back, else output as their previews, with a warning.
|
||||||
pub(crate) fn full_documents(d: &Document) -> (Cow<'_, Document>, Vec<String>) {
|
pub(crate) fn full_documents(d: &Document) -> (Cow<'_, Document>, Vec<String>) {
|
||||||
|
let (d, mut warnings) = super::super::links::full_linked_images(d, None);
|
||||||
|
let doc = match &d {
|
||||||
|
Cow::Borrowed(doc) => doc,
|
||||||
|
Cow::Owned(doc) => doc,
|
||||||
|
};
|
||||||
let mut need: Vec<PlacedDocument> = vec![];
|
let mut need: Vec<PlacedDocument> = vec![];
|
||||||
d.visit_placed(|_, p| {
|
doc.visit_placed(|_, p| {
|
||||||
if d.images.get(&p.key).is_some_and(ImageBlob::is_proxy) && !need.iter().any(|n| n.key == p.key) {
|
if doc.images.get(&p.key).is_some_and(ImageBlob::is_proxy) && !need.iter().any(|n| n.key == p.key) {
|
||||||
need.push(p.clone());
|
need.push(p.clone());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if need.is_empty() {
|
if need.is_empty() {
|
||||||
return (Cow::Borrowed(d), vec![]);
|
return (d, warnings);
|
||||||
}
|
}
|
||||||
let mut out = d.clone();
|
let mut out = match d {
|
||||||
|
Cow::Borrowed(doc) => doc.clone(),
|
||||||
|
Cow::Owned(doc) => doc,
|
||||||
|
};
|
||||||
let mut previews = BTreeSet::new();
|
let mut previews = BTreeSet::new();
|
||||||
for p in need {
|
for p in need {
|
||||||
let Some(blob) = d.images.get(&p.key) else { continue };
|
let Some(blob) = out.images.get(&p.key) else { continue };
|
||||||
match vectorcraft_doc::placed_document::full_bytes(&p, blob) {
|
match vectorcraft_doc::placed_document::full_bytes(&p, blob) {
|
||||||
Some(bytes) => {
|
Some(bytes) => {
|
||||||
out.images.insert(p.key.clone(), ImageBlob { mime: MIME.into(), bytes, proxy: blob.proxy.clone() });
|
out.images.insert(p.key.clone(), ImageBlob { mime: mime(p.link.name(), &bytes).into(), bytes, proxy: blob.proxy.clone() });
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
previews.insert(p.key);
|
previews.insert(p.key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut warnings = vec![];
|
|
||||||
if !previews.is_empty() {
|
if !previews.is_empty() {
|
||||||
warnings.push(format!(
|
warnings.push(format!(
|
||||||
"{} placed document(s) output as their previews: their files can't be found, or changed since they were read (relink or update them)",
|
"{} placed document(s) output as their previews: their files can't be found, or changed since they were read (relink or update them)",
|
||||||
@@ -256,7 +333,7 @@ pub(crate) fn expand(d: &mut Document, id: NodeId, cmd: &str) -> Result<()> {
|
|||||||
let blob = d.images.get(&p.key).ok_or_else(|| bad(cmd, format!("object {} has lost its file's contents: relink it", id.0)))?;
|
let blob = d.images.get(&p.key).ok_or_else(|| bad(cmd, format!("object {} has lost its file's contents: relink it", id.0)))?;
|
||||||
let bytes = vectorcraft_doc::placed_document::full_bytes(p, blob)
|
let bytes = vectorcraft_doc::placed_document::full_bytes(p, blob)
|
||||||
.ok_or_else(|| bad(cmd, format!("{} can't be read, or changed since it was read: relink or update it first", p.link.path)))?;
|
.ok_or_else(|| bad(cmd, format!("{} can't be read, or changed since it was read: relink or update it first", p.link.path)))?;
|
||||||
let a = document_art(p.link.name(), &bytes, Some(&p.link.path), p.page(), p.bounding, cmd)?;
|
let a = document_art(p.link.name(), &bytes, Some(&p.link.path), p.page(), p.bounding, p.page_box, cmd)?;
|
||||||
let m = p.art_xf(a.frame).ok_or_else(|| bad(cmd, format!("object {} has no size", id.0)))?;
|
let m = p.art_xf(a.frame).ok_or_else(|| bad(cmd, format!("object {} has no size", id.0)))?;
|
||||||
let mut nodes = a.nodes;
|
let mut nodes = a.nodes;
|
||||||
super::adopt::adopt(d, &a.doc, &mut nodes);
|
super::adopt::adopt(d, &a.doc, &mut nodes);
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
//! preview ([`fileio::load`]).
|
//! preview ([`fileio::load`]).
|
||||||
//! - A PDF/.ai/.ait page or a native document's artboard becomes one group, clipped to the page
|
//! - A PDF/.ai/.ait page or a native document's artboard becomes one group, clipped to the page
|
||||||
//! (`crop: "crop"`; a PDF page's `art`, `trim`, `bleed` or `media` box too) or bounded by its art
|
//! (`crop: "crop"`; a PDF page's `art`, `trim`, `bleed` or `media` box too) or bounded by its art
|
||||||
//! (`crop: "bounding"`).
|
//! (`crop: "bounding"`). With Link, a PDF/.ai/.ait page placed from a file becomes a placed
|
||||||
|
//! document instead, as a VectorCraft document does (below).
|
||||||
//! - A text file becomes area type, decoded and cleaned up with the Text Import Options (`text`,
|
//! - A text file becomes area type, decoded and cleaned up with the Text Import Options (`text`,
|
||||||
//! [`text`]).
|
//! [`text`]).
|
||||||
//! - An RTF file becomes area type with its character and paragraph formatting ([`rtf`]).
|
//! - An RTF file becomes area type with its character and paragraph formatting ([`rtf`]).
|
||||||
@@ -38,7 +39,7 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use vectorcraft_color::Paint;
|
use vectorcraft_color::Paint;
|
||||||
use vectorcraft_doc::{Appearance, Document, ImageObject, LinkInfo, Node, NodeId, NodeKind, Scaling, SymbolBoxes};
|
use vectorcraft_doc::{Appearance, Document, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, Scaling, SymbolBoxes};
|
||||||
use vectorcraft_geom::{Affine, Point, Rect, shapes};
|
use vectorcraft_geom::{Affine, Point, Rect, shapes};
|
||||||
use vectorcraft_pdf::CropTo;
|
use vectorcraft_pdf::CropTo;
|
||||||
|
|
||||||
@@ -62,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
"Place…",
|
"Place…",
|
||||||
["File"],
|
["File"],
|
||||||
Some("Cmd+Shift+P"),
|
Some("Cmd+Shift+P"),
|
||||||
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard",
|
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard",
|
||||||
has_doc,
|
has_doc,
|
||||||
place
|
place
|
||||||
),
|
),
|
||||||
@@ -104,8 +105,8 @@ enum Art {
|
|||||||
/// A text file's, an RTF file's or a Word document's text, placed as area type filling the
|
/// A text file's, an RTF file's or a Word document's text, placed as area type filling the
|
||||||
/// natural box.
|
/// natural box.
|
||||||
Text(text::Story),
|
Text(text::Story),
|
||||||
/// A VectorCraft document placed linked: the file read, the link, and whether the art's
|
/// A VectorCraft document or a PDF/.ai page placed linked: the file read, the link, and
|
||||||
/// bounds (not the artboard) are shown.
|
/// whether the art's bounds (not the artboard or page) are shown.
|
||||||
Document(document::Source, LinkInfo, bool),
|
Document(document::Source, LinkInfo, bool),
|
||||||
Vector {
|
Vector {
|
||||||
/// The loaded document: the source of the art's resources.
|
/// The loaded document: the source of the art's resources.
|
||||||
@@ -124,7 +125,7 @@ struct Loaded {
|
|||||||
/// The art's natural box (what `at` centres and `rect` fits).
|
/// The art's natural box (what `at` centres and `rect` fits).
|
||||||
natural: Rect,
|
natural: Rect,
|
||||||
warnings: Vec<String>,
|
warnings: Vec<String>,
|
||||||
/// The link to a raster image's file (for Link), when read from a path.
|
/// The link to a raster image's file or a placed document's (for Link), when read from a path.
|
||||||
link: Option<LinkInfo>,
|
link: Option<LinkInfo>,
|
||||||
/// The artboard a DXF drawing fitted to an artboard, or not centred, was laid out on: it lands
|
/// The artboard a DXF drawing fitted to an artboard, or not centred, was laid out on: it lands
|
||||||
/// on the target artboard the same way.
|
/// on the target artboard the same way.
|
||||||
@@ -138,8 +139,9 @@ pub(crate) fn pt_per_px(ppi: Option<(f64, f64)>) -> (f64, f64) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The file `p` names, read into placeable art; a DXF drawing's Fit to Artboard fills `board`
|
/// The file `p` names, read into placeable art; a DXF drawing's Fit to Artboard fills `board`
|
||||||
/// (unless the params name their own box).
|
/// (unless the params name their own box). A PDF/.ai page read from a path is placed linked with
|
||||||
fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
|
/// Link when `link_pdf` (false for the queries, which read it embedded).
|
||||||
|
fn load(p: &Value, cmd: &str, board: Option<Rect>, link_pdf: bool) -> Result<Loaded> {
|
||||||
let src = fileio::source(p, cmd)?;
|
let src = fileio::source(p, cmd)?;
|
||||||
let name = fileio::file_name(src.name);
|
let name = fileio::file_name(src.name);
|
||||||
// An Affinity file is recognised by its signature, whatever its name says.
|
// An Affinity file is recognised by its signature, whatever its name says.
|
||||||
@@ -192,12 +194,12 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
|
|||||||
if matches!(format.id, "vectorcraft" | "template") {
|
if matches!(format.id, "vectorcraft" | "template") {
|
||||||
let page = page as u32;
|
let page = page as u32;
|
||||||
if let Some(path) = src.path.filter(|_| bool_or(p, "link", true)) {
|
if let Some(path) = src.path.filter(|_| bool_or(p, "link", true)) {
|
||||||
let s = document::source(src.name, &src.bytes, Some(path), page, !crop, cmd)?;
|
let s = document::source(src.name, &src.bytes, Some(path), page, !crop, PageBox::Crop, cmd)?;
|
||||||
let link = LinkInfo { page: Some(page), ..super::links::link_info(path, &src.bytes) };
|
let link = LinkInfo { page: Some(page), ..super::links::link_info(path, &src.bytes) };
|
||||||
let (natural, warnings) = (s.frame, s.warnings.clone());
|
let (natural, warnings) = (s.frame, s.warnings.clone());
|
||||||
return Ok(Loaded { name, format, art: Art::Document(s, link.clone(), !crop), natural, warnings, link: Some(link), board: None });
|
return Ok(Loaded { name, format, art: Art::Document(s, link.clone(), !crop), natural, warnings, link: Some(link), board: None });
|
||||||
}
|
}
|
||||||
let a = document::document_art(src.name, &src.bytes, src.path, page, !crop, cmd)?;
|
let a = document::document_art(src.name, &src.bytes, src.path, page, !crop, PageBox::Crop, cmd)?;
|
||||||
let art = Art::Vector { src: Box::new(a.doc), nodes: a.nodes, clip: a.clip };
|
let art = Art::Vector { src: Box::new(a.doc), nodes: a.nodes, clip: a.clip };
|
||||||
return Ok(Loaded { name, format, art, natural: a.frame, warnings: a.warnings, link: None, board: None });
|
return Ok(Loaded { name, format, art, natural: a.frame, warnings: a.warnings, link: None, board: None });
|
||||||
}
|
}
|
||||||
@@ -209,48 +211,39 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
|
|||||||
let warnings = fileio::too_large_note(&img.blob.bytes).into_iter().collect();
|
let warnings = fileio::too_large_note(&img.blob.bytes).into_iter().collect();
|
||||||
return Ok(Loaded { name, format, art: Art::Image(img), natural, warnings, link, board: None });
|
return Ok(Loaded { name, format, art: Art::Image(img), natural, warnings, link, board: None });
|
||||||
}
|
}
|
||||||
|
if pdf {
|
||||||
|
// Linked: placed like a VectorCraft document, read again with the page's box only. A page
|
||||||
|
// asked for with a password or with its text as outlines is embedded.
|
||||||
|
let linked = link_pdf && bool_or(p, "link", true) && opts.password.is_none() && opts.text_as == vectorcraft_pdf::TextAs::Text;
|
||||||
|
if let Some(path) = src.path.filter(|_| linked) {
|
||||||
|
let page = page as u32;
|
||||||
|
let s = document::source(src.name, &src.bytes, Some(path), page, !crop, document::page_box(opts.crop), cmd)?;
|
||||||
|
let link = LinkInfo { page: Some(page), ..super::links::link_info(path, &src.bytes) };
|
||||||
|
let (natural, warnings) = (s.frame, s.warnings.clone());
|
||||||
|
return Ok(Loaded { name, format, art: Art::Document(s, link.clone(), !crop), natural, warnings, link: Some(link), board: None });
|
||||||
|
}
|
||||||
|
let a = pdf_art(src.name, &src.bytes, src.path, page, opts, !crop, cmd)?;
|
||||||
|
let art = Art::Vector { src: Box::new(a.doc), nodes: a.nodes, clip: a.clip };
|
||||||
|
return Ok(Loaded { name, format, art, natural: a.frame, warnings: a.warnings, link: None, board: None });
|
||||||
|
}
|
||||||
let anchored = format.id == "dxf" && (opts.dxf.fit || !opts.dxf.center);
|
let anchored = format.id == "dxf" && (opts.dxf.fit || !opts.dxf.center);
|
||||||
let (mut doc, warnings, pdf) = match format.id {
|
let (mut doc, warnings) = {
|
||||||
// Only the page placed is read, its artboard the box asked for (Bounding Box: the art's
|
// Of the open options, only a DXF drawing's apply (the colour mode stays the file's).
|
||||||
// bounds, below). A .ai saved without PDF compatibility is read from its editing data, as
|
let o = fileio::LoadOptions { dxf: opts.dxf, ..Default::default() };
|
||||||
// it opens, and placed as an EPS is: the art on artboard `page`.
|
let mut l = fileio::load_with(src.name, &src.bytes, &o)?;
|
||||||
"pdf" | "ai" | "ait" if pdf => {
|
// An Affinity file whose native document couldn't be read is only its preview: it
|
||||||
let o = fileio::LoadOptions { crop: if crop { opts.crop } else { CropTo::Crop }, ..opts };
|
// must never silently stand in for the art.
|
||||||
let mut read = fileio::page_document(&src.bytes, page - 1, &o);
|
if l.preview_only {
|
||||||
// Such a file can have more artboards than placeholder pages.
|
return Err(bad(
|
||||||
if page > 1
|
cmd,
|
||||||
&& read.is_err()
|
format!(
|
||||||
&& let Err(e) = fileio::page_document(&src.bytes, 0, &o)
|
"{name}: only this Affinity file's embedded preview could be read; use File › Open to see it with its warning, or export SVG or PDF from Affinity before placing"
|
||||||
&& fileio::is_placeholder(&e)
|
),
|
||||||
{
|
));
|
||||||
read = Err(e);
|
|
||||||
}
|
|
||||||
let (mut doc, warnings, alone) =
|
|
||||||
fileio::page_or_editing_data(src.name, &src.bytes, o.password.as_deref(), read).map_err(|e| bad(cmd, format!("{name}: {e}")))?;
|
|
||||||
if alone {
|
|
||||||
// Linked images show their files, found from its folder.
|
|
||||||
super::links::resolve(&mut doc, src.path, false);
|
|
||||||
}
|
|
||||||
(doc, warnings, !alone)
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
// Of the open options, only a DXF drawing's apply (the colour mode stays the file's).
|
|
||||||
let o = fileio::LoadOptions { dxf: opts.dxf, ..Default::default() };
|
|
||||||
let mut l = fileio::load_with(src.name, &src.bytes, &o)?;
|
|
||||||
// An Affinity file whose native document couldn't be read is only its preview: it
|
|
||||||
// must never silently stand in for the art.
|
|
||||||
if l.preview_only {
|
|
||||||
return Err(bad(
|
|
||||||
cmd,
|
|
||||||
format!(
|
|
||||||
"{name}: only this Affinity file's embedded preview could be read; use File › Open to see it with its warning, or export SVG or PDF from Affinity before placing"
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
// Linked images show their files, found from its folder.
|
|
||||||
super::links::resolve(&mut l.doc, src.path, false);
|
|
||||||
(l.doc, l.warnings, false)
|
|
||||||
}
|
}
|
||||||
|
// Linked images show their files, found from its folder.
|
||||||
|
super::links::resolve(&mut l.doc, src.path, false, false);
|
||||||
|
(l.doc, l.warnings)
|
||||||
};
|
};
|
||||||
doc.drop_edit_modes();
|
doc.drop_edit_modes();
|
||||||
let board = doc.artboards.first().map(|a| a.rect).filter(|_| anchored);
|
let board = doc.artboards.first().map(|a| a.rect).filter(|_| anchored);
|
||||||
@@ -264,19 +257,64 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
|
|||||||
let frame = doc.artboards.first().map(|a| a.rect).filter(|_| framed);
|
let frame = doc.artboards.first().map(|a| a.rect).filter(|_| framed);
|
||||||
(art_of(layer_art(&layers)), frame)
|
(art_of(layer_art(&layers)), frame)
|
||||||
} else {
|
} else {
|
||||||
// The page placed is a PDF import's only one.
|
|
||||||
let index = if pdf { 0 } else { page - 1 };
|
|
||||||
let n = doc.artboards.len();
|
let n = doc.artboards.len();
|
||||||
let board = doc.artboards.get(index).map(|a| a.rect).ok_or_else(|| bad(cmd, format!("page {page}: `{name}` has {n} page(s)")))?;
|
let board = doc.artboards.get(page - 1).map(|a| a.rect).ok_or_else(|| bad(cmd, format!("page {page}: `{name}` has {n} page(s)")))?;
|
||||||
// A PDF import has one layer per page; an EPS file's art is whatever lies on the artboard.
|
// An EPS file's art is whatever lies on the artboard.
|
||||||
let pages = if pdf { layers.get(index..=index).unwrap_or_default() } else { &layers[..] };
|
(on_artboard(&symbols, &layers, board), crop.then_some(board))
|
||||||
(on_artboard(&symbols, pages, board), crop.then_some(board))
|
|
||||||
};
|
};
|
||||||
let natural = clip.or_else(|| shown_bounds(&symbols, &nodes));
|
let natural = clip.or_else(|| shown_bounds(&symbols, &nodes));
|
||||||
let natural = natural.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{name}` has no art to place")))?;
|
let natural = natural.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{name}` has no art to place")))?;
|
||||||
Ok(Loaded { name, format, art: Art::Vector { src: Box::new(doc), nodes, clip }, natural, warnings, link: None, board })
|
Ok(Loaded { name, format, art: Art::Vector { src: Box::new(doc), nodes, clip }, natural, warnings, link: None, board })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Page `page` (1-based) of the PDF/.ai/.ait file `src` (`bytes`, read from `path` when there is
|
||||||
|
/// one) as art to place: clipped to the box `opts.crop` names, or (`bounding`) the bounds of its
|
||||||
|
/// art; read with `opts.password`. Only the page placed is read. A .ai saved without PDF
|
||||||
|
/// compatibility is read from its editing data, as it opens, and placed as an EPS is: the art on
|
||||||
|
/// artboard `page`, its linked images found from its folder.
|
||||||
|
pub(crate) fn pdf_art(
|
||||||
|
src: &str,
|
||||||
|
bytes: &[u8],
|
||||||
|
path: Option<&str>,
|
||||||
|
page: usize,
|
||||||
|
opts: fileio::LoadOptions,
|
||||||
|
bounding: bool,
|
||||||
|
cmd: &str,
|
||||||
|
) -> Result<document::DocumentArt> {
|
||||||
|
let name = fileio::file_name(src);
|
||||||
|
let index = page.checked_sub(1).ok_or_else(|| bad(cmd, "page must be a whole number from 1"))?;
|
||||||
|
let o = fileio::LoadOptions { crop: if bounding { CropTo::Crop } else { opts.crop }, ..opts };
|
||||||
|
let mut read = fileio::page_document(bytes, index, &o);
|
||||||
|
// Such a file can have more artboards than placeholder pages.
|
||||||
|
if page > 1
|
||||||
|
&& read.is_err()
|
||||||
|
&& let Err(e) = fileio::page_document(bytes, 0, &o)
|
||||||
|
&& fileio::is_placeholder(&e)
|
||||||
|
{
|
||||||
|
read = Err(e);
|
||||||
|
}
|
||||||
|
let (mut doc, warnings, alone) =
|
||||||
|
fileio::page_or_editing_data(src, bytes, o.password.as_deref(), read).map_err(|e| bad(cmd, format!("{name}: {e}")))?;
|
||||||
|
if alone {
|
||||||
|
// Linked images show their files, found from its folder.
|
||||||
|
super::links::resolve(&mut doc, path, false, false);
|
||||||
|
}
|
||||||
|
doc.drop_edit_modes();
|
||||||
|
let layers = std::mem::take(&mut doc.layers);
|
||||||
|
let symbols = doc.symbol_boxes();
|
||||||
|
// The page placed is a PDF import's only one, with one layer; the editing data's art is
|
||||||
|
// whatever lies on the artboard.
|
||||||
|
let index = if alone { index } else { 0 };
|
||||||
|
let n = doc.artboards.len();
|
||||||
|
let board = doc.artboards.get(index).map(|a| a.rect).ok_or_else(|| bad(cmd, format!("page {page}: `{name}` has {n} page(s)")))?;
|
||||||
|
let pages = if alone { &layers[..] } else { layers.get(index..=index).unwrap_or_default() };
|
||||||
|
let nodes = on_artboard(&symbols, pages, board);
|
||||||
|
let clip = (!bounding).then_some(board);
|
||||||
|
let frame = clip.or_else(|| shown_bounds(&symbols, &nodes));
|
||||||
|
let frame = frame.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{name}` has no art to place")))?;
|
||||||
|
Ok(document::DocumentArt { doc, nodes, clip, frame, warnings })
|
||||||
|
}
|
||||||
|
|
||||||
/// The bounds of what `nodes` draw, as an export measures art
|
/// The bounds of what `nodes` draw, as an export measures art
|
||||||
/// ([`vectorcraft_render::encode::drawn_bounds`]): hidden objects, guides and template layers
|
/// ([`vectorcraft_render::encode::drawn_bounds`]): hidden objects, guides and template layers
|
||||||
/// left out, symbol instances measured by their art in `symbols`.
|
/// left out, symbol instances measured by their art in `symbols`.
|
||||||
@@ -435,7 +473,7 @@ fn place(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
(true, _) => return Err(bad(PLACE, "replace: select the one object to replace")),
|
(true, _) => return Err(bad(PLACE, "replace: select the one object to replace")),
|
||||||
};
|
};
|
||||||
let target = board_at(&st.doc, at);
|
let target = board_at(&st.doc, at);
|
||||||
let mut loaded = load(p, PLACE, target)?;
|
let mut loaded = load(p, PLACE, target, true)?;
|
||||||
let old_node = old.and_then(|id| st.doc.node(id));
|
let old_node = old.and_then(|id| st.doc.node(id));
|
||||||
// Type reflows rather than scales: its frame takes the size it is placed at.
|
// Type reflows rather than scales: its frame takes the size it is placed at.
|
||||||
let is_text = matches!(loaded.art, Art::Text(_));
|
let is_text = matches!(loaded.art, Art::Text(_));
|
||||||
@@ -548,7 +586,8 @@ fn queue(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
q[k] = v.clone();
|
q[k] = v.clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
match load(&q, QUEUE, None) {
|
// Read for its size and thumbnail: each click places it with file.place.
|
||||||
|
match load(&q, QUEUE, None, false) {
|
||||||
Ok(l) => {
|
Ok(l) => {
|
||||||
let (w, h) = (l.natural.width(), l.natural.height());
|
let (w, h) = (l.natural.width(), l.natural.height());
|
||||||
let mut f = json!({ "name": l.name, "format": l.format.id, "width": w, "height": h });
|
let mut f = json!({ "name": l.name, "format": l.format.id, "width": w, "height": h });
|
||||||
@@ -606,7 +645,8 @@ fn thumbnail_png(l: Loaded, px: f64) -> Result<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn info(_: &mut Session, p: &Value) -> Result<Value> {
|
fn info(_: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let mut l = load(p, "file.place.info", None)?;
|
// A PDF/.ai page linked or not has the same size and art.
|
||||||
|
let mut l = load(p, "file.place.info", None, false)?;
|
||||||
let natural = l.natural;
|
let natural = l.natural;
|
||||||
let mut out = json!({
|
let mut out = json!({
|
||||||
"name": l.name,
|
"name": l.name,
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
None => s.prefs.plugins_folder.trim().to_string(),
|
None => s.prefs.plugins_folder.trim().to_string(),
|
||||||
};
|
};
|
||||||
if folder.is_empty() {
|
if folder.is_empty() {
|
||||||
return Err(bad(C, "no folder: pass `path` or set Preferences › Performance & Storage › Additional Plug-ins Folder"));
|
return Err(bad(C, "no folder: pass `path` or set Preferences › Plug-ins & Scratch Disks › Additional Plug-ins Folder"));
|
||||||
}
|
}
|
||||||
let report = load_folder(&folder);
|
let report = load_folder(&folder);
|
||||||
if let Some(e) = report.get("error").and_then(Value::as_str) {
|
if let Some(e) = report.get("error").and_then(Value::as_str) {
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ pub struct PrefSpec {
|
|||||||
pub kind: PrefKind,
|
pub kind: PrefKind,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Categories in Illustrator's order (Plug-ins & Scratch Disks is "Performance & Storage" here).
|
/// Categories in Illustrator's order.
|
||||||
pub const PREF_CATEGORIES: &[&str] = &[
|
pub const PREF_CATEGORIES: &[&str] = &[
|
||||||
"General",
|
"General",
|
||||||
"Selection & Anchor Display",
|
"Selection & Anchor Display",
|
||||||
@@ -59,7 +59,7 @@ pub const PREF_CATEGORIES: &[&str] = &[
|
|||||||
"Smart Guides",
|
"Smart Guides",
|
||||||
"Slices",
|
"Slices",
|
||||||
"Hyphenation",
|
"Hyphenation",
|
||||||
"Performance & Storage",
|
"Plug-ins & Scratch Disks",
|
||||||
"User Interface",
|
"User Interface",
|
||||||
"Performance",
|
"Performance",
|
||||||
"File Handling",
|
"File Handling",
|
||||||
@@ -256,10 +256,10 @@ pub const PREF_SPECS: &[PrefSpec] = &[
|
|||||||
])
|
])
|
||||||
),
|
),
|
||||||
p!("hyphenationExceptions", "Hyphenation", "", "Exceptions (comma separated)", text),
|
p!("hyphenationExceptions", "Hyphenation", "", "Exceptions (comma separated)", text),
|
||||||
// Performance & Storage
|
// Plug-ins & Scratch Disks
|
||||||
p!("pluginsFolder", "Performance & Storage", "Plug-ins", "Additional Plug-ins Folder", text),
|
p!("pluginsFolder", "Plug-ins & Scratch Disks", "Plug-ins", "Additional Plug-ins Folder", text),
|
||||||
p!("scratchPrimary", "Performance & Storage", "Scratch Disks", "Primary", text),
|
p!("scratchPrimary", "Plug-ins & Scratch Disks", "Scratch Disks", "Primary", text),
|
||||||
p!("scratchSecondary", "Performance & Storage", "Scratch Disks", "Secondary", text),
|
p!("scratchSecondary", "Plug-ins & Scratch Disks", "Scratch Disks", "Secondary", text),
|
||||||
// User Interface
|
// User Interface
|
||||||
p!(
|
p!(
|
||||||
"uiBrightness",
|
"uiBrightness",
|
||||||
|
|||||||
@@ -192,8 +192,10 @@ pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&N
|
|||||||
if !area.is_finite() {
|
if !area.is_finite() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
// Whole pixels, and no larger than 64 Mpx.
|
// Whole pixels, no larger than 64 Mpx, and no side longer than the renderer can address
|
||||||
let scale = scale.min((64.0e6 / area.max(1.0)).sqrt());
|
// (a long thin object stays within the area but not within a side).
|
||||||
|
let side = f64::from(vectorcraft_render::MAX_RASTER_SIDE) - 2.0;
|
||||||
|
let scale = scale.min((64.0e6 / area.max(1.0)).sqrt()).min(side / b.width().max(b.height()).max(1.0));
|
||||||
if !(scale.is_finite() && scale > 0.0) {
|
if !(scale.is_finite() && scale > 0.0) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|||||||
Loaded 100 of 355 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user