Compare commits
93
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcd7e3b42d | ||
|
|
a17e3057de | ||
|
|
82ac5c92ea | ||
|
|
7718b62735 | ||
|
|
63e0bff91d | ||
|
|
e2177c0d3a | ||
|
|
2cf2b972f5 | ||
|
|
8a3458a974 | ||
|
|
f4e72fe6a5 | ||
|
|
8754646784 | ||
|
|
2b34554ac8 | ||
|
|
9f0a91b638 | ||
|
|
f2c7c17eb1 | ||
|
|
182a2cda4b | ||
|
|
80af380a6a | ||
|
|
9251edf1dc | ||
|
|
988f52ecc8 | ||
|
|
ffa948dcc3 | ||
|
|
25c33b6927 | ||
|
|
baf9693f0f | ||
|
|
e01a0a70be | ||
|
|
911c65de34 | ||
|
|
83d271fb89 | ||
|
|
8811cc552e | ||
|
|
9e4ed2d430 | ||
|
|
42c923d62a | ||
|
|
939bc02f6b | ||
|
|
77120ada62 | ||
|
|
92f117cf1d | ||
|
|
5b413448d9 | ||
|
|
34b3c59a03 | ||
|
|
04bc43c802 | ||
|
|
57e7cd3b0e | ||
|
|
487cc29b57 | ||
|
|
4d17702536 | ||
|
|
a41d59604b | ||
|
|
003234a056 | ||
|
|
c70bd33de0 | ||
|
|
3da2685065 | ||
|
|
2c508ea867 | ||
|
|
54afb67b4f | ||
|
|
22f14bec24 | ||
|
|
1168c319b0 | ||
|
|
c26ff9a22a | ||
|
|
aed5d380e9 | ||
|
|
b4c1cdbcea | ||
|
|
2a0a279c93 | ||
|
|
ec38b00a22 | ||
|
|
d6bace14c3 | ||
|
|
a366e8ee0d | ||
|
|
e72a010e1c | ||
|
|
92ed381625 | ||
|
|
772f056f9c | ||
|
|
7f5d8a3db1 | ||
|
|
bfb173e967 | ||
|
|
f908545204 | ||
|
|
bf2b5643dc | ||
|
|
5e4fc4e3af | ||
|
|
1b79120568 | ||
|
|
106925c74b | ||
|
|
e22a11eda8 | ||
|
|
689ad46999 | ||
|
|
2ff64993eb | ||
|
|
ddebe5e479 | ||
|
|
dc640f3d61 | ||
|
|
ce55aaf2fe | ||
|
|
718751b658 | ||
|
|
28e694623d | ||
|
|
3073f0ab03 | ||
|
|
412e83c825 | ||
|
|
a9a7a4cddf | ||
|
|
de777a1b09 | ||
|
|
4744c2f04b | ||
|
|
155d921fe7 | ||
|
|
0cf59a6cbd | ||
|
|
46131bd091 | ||
|
|
b185fbbe45 | ||
|
|
8e16c8529f | ||
|
|
8ce63aa1d1 | ||
|
|
885a6e38cd | ||
|
|
ab50796a27 | ||
|
|
0765bc9bcd | ||
|
|
39c14abd35 | ||
|
|
0a357c68a9 | ||
|
|
57598c067d | ||
|
|
fc0f732677 | ||
|
|
ef0c37349d | ||
|
|
71174477cc | ||
|
|
9d63926291 | ||
|
|
2cf0452beb | ||
|
|
4251e337c1 | ||
|
|
8b96db8f0b | ||
|
|
8716fc8fbc |
No files matched your search
Generated
+29
-27
@@ -4490,7 +4490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"block2 0.6.2",
|
||||
@@ -4524,7 +4524,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-affinity"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4535,7 +4535,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-brush"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
@@ -4547,7 +4547,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cad"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4563,7 +4563,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cli"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"objc2-core-foundation",
|
||||
@@ -4584,7 +4584,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-color"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"moxcms",
|
||||
@@ -4596,7 +4596,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-doc"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"jpeg-encoder",
|
||||
@@ -4613,7 +4613,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-effects"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde_json",
|
||||
@@ -4628,7 +4628,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-engine"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4663,12 +4663,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"ruzstd",
|
||||
"serde_json",
|
||||
"vectorcraft-brush",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
@@ -4683,7 +4684,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-format"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
@@ -4697,7 +4698,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
@@ -4709,7 +4710,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-mcp"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4728,7 +4729,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-metafile"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4742,7 +4743,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"linesweeper",
|
||||
@@ -4754,7 +4755,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pdf"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"flate2",
|
||||
@@ -4783,7 +4784,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-plugins"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4797,7 +4798,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-render"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"gif",
|
||||
@@ -4822,7 +4823,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-svg"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4846,7 +4847,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-testkit"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4863,7 +4864,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-text"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"icu_segmenter",
|
||||
@@ -4883,14 +4884,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-three-d"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-tools"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4905,7 +4907,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-trace"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4918,7 +4920,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-ui-egui"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
@@ -4951,7 +4953,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-web"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -6061,7 +6063,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
+11
-4
@@ -6,7 +6,7 @@
|
||||
|
||||
**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-12 · **Change:** minor (G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Image Trace places edges to a fraction of a pixel, measured in `docs/image-trace-quality.md`, headline numbers unchanged; Transform dialogs preview and the transform tools act on direct-selected anchors, #1412 and #1408; `.ai` compound path paint, #1410, overprint, #1388, and text-only PDF parts, #1387; community PRs: strokes wider than their shape, corner drags without the bounding box, pasted pictures, compound paths in `document.inspect`, MCP guide; headline numbers unchanged; community PRs: `.ai` editing-data follow-ups (gradient stops, type of objects with an appearance, short area-type frames), PDF bleed with editing data, dialog keyboard focus, art flush with an artboard, font rescan, symbol instance redraw, `--control` port check; headline numbers unchanged; Rectangular Grid and Polar Grid tool options, #1377 in part; `.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%; `.ai` gradients, meshes and artboard names from the editing data; headline numbers unchanged; `.ai` art styles and clipping fills from the editing data; headline numbers unchanged; `editingData: "always"`; 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
|
||||
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
|
||||
@@ -26,7 +26,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
|
||||
|
||||
| | Value | Kind |
|
||||
|---|---|---|
|
||||
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 44/50, Photoshop effects 29/57, 3D 1/4 (initial Revolve) |
|
||||
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 44/50, Photoshop effects 29/57, 3D 4/4 (initial types, no materials) |
|
||||
| **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated |
|
||||
| **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated |
|
||||
| **To full parity** | **~360–590 h** one agent · ~95–165 h with 4–6 agents | estimated |
|
||||
@@ -74,7 +74,7 @@ The dimension hours overlap (feature rows hold some UI, format and AI work), so
|
||||
| Appearance, transparency, graphic styles, masks | 97% | 1–2 |
|
||||
| Live vector effects | 85% | 6–10 |
|
||||
| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 49% | 23–39 |
|
||||
| 3D and Materials | ~8% | 50–85 |
|
||||
| 3D and Materials | ~25% | 30–55 |
|
||||
| Type core — [type-parity.md](docs/type-parity.md) | 80% | 14–19 |
|
||||
| Type advanced (CJK, spelling, Touch Type) | 50% | 20–28 |
|
||||
| Symbols, blends, envelopes, Repeat, perspective | 85% | 6–10 |
|
||||
@@ -127,8 +127,15 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-13 | Image Trace traces anti-aliased and blurred images to a fraction of a pixel in Black and White, Grayscale and Color, with corners put back, straight edges as lines and round outlines as circles; a harness measures every preset before and after ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)): outlines 0.31 → 0.09 px off on average (Default), 0.27 → 0.06 (Black and White Logo). |
|
||||
| 2026-10-12 | Effect › 3D and Materials: Extrude & Bevel (depth, twist, taper, solid or hollow caps, five bevel shapes with repeat, spacing, inside and both sides), Inflate (volume, both sides) and Rotate, with Position presets and the rotation and light gizmos; live, exported as vector faces, expandable (#1407). |
|
||||
| 2026-10-12 | `convert --editing-data` (`editingData: "always"`) opens a `.ai` from its editing data even where its page, which can be older, draws differently, with a note. |
|
||||
| 2026-10-12 | `.ai` import reads an object's art style (a fill and a stroke, variable widths too, with an Outer Glow or Drop Shadow) as its own appearance, paints a clipping path's own fill under what it clips and keeps a group's transparency as its PDF part draws them. |
|
||||
| 2026-10-12 | `.ai` import reads gradients with their matrix (radials and skew too), focal point and stop order, gradient meshes and the artboards' names from the editing data. |
|
||||
| 2026-10-12 | The Rectangular Grid and Polar Grid tools take their options: a click opens the size, the divider counts with a skew slider each, Use Outside Rectangle As Frame (rectangular) or Create Compound Path From Ellipses (polar) and Fill Grid; the choices stay with the tool, so dragging draws with them, and scripts and agents pass them to `shape.rectangularGrid` and `shape.polarGrid` (#1377, in part). |
|
||||
| 2026-10-12 | A `.ai` file with placed (linked) pictures opens from its editing data, with its guides, sublayers and hidden objects: each picture is a linked image in its place, showing the low-resolution preview the file keeps until its file is found or relinked (#1264). Scores unchanged. |
|
||||
| 2026-10-12 | Desktop printing adopts the shared `craft-print` spooler with exact physical sheet sizes. Windows 7 retains its WMI/viewer transport and excludes the shared backend; compatibility CI checks the dependency tree and PE imports. Scores unchanged. |
|
||||
| 2026-10-12 | Graphs: Object › Graph › Design… and Marker… (designs with a preview, New Design, Rename, Delete Design, Paste Design, Select Unused, and markers drawn with a design or None) (#1092); Graph Data opens as a worksheet with Import Data…, column width and decimals (#1138), and takes Transpose, Switch x/y, `|` line breaks in labels and the pie Legend, Position and Sort options (#1115); Graph Type adds Add Drop Shadow and Add Legend Across Top (#1119), widths past 100%, First Row and First Column in Front, and Draw Filled Lines (#1136); graphs keep their label formatting when drawn again (#1137). Align: a Horizontal & Vertical Align Center button (#1073); Align to Key Object makes the topmost selected object the key (#1221), and the palette's key entry reads Cancel Key Object (#1224). Pen: the nearest end wins over snapping, Alt on the last anchor edits it, and Enter, Escape or a tool switch finish a handle preview as one undo step (#821). Direct Selection: a click on an anchor of an already selected path selects that anchor alone (#1237). Make Compound Path keeps the backmost object's paint, and a selected member reads and changes the compound's fill rule (#1225). Shape Builder and Live Paint no longer turn a line's loose end past a crossing into a piece that runs out and back (#1230). Art moved or duplicated with an artboard follows Transform Pattern Tiles (#1219). A click with the Rectangular Grid tool opens its options dialog (#1169). Commands that change nothing (Lock, Hide, Transform Each, Rearrange Artboards and others) add no undo step (#1233). Saving with missing placed files keeps their relink data, and .ai export honours the chosen artboards (#718). SVG export keeps Unicode data-attribute names, and SVG Options refuses an invalid artboard range (#989). CMYK colours given in percent with a component under 1 keep their scale (#1249). Colour Image Trace keeps close dark tones apart (#1247). macOS: the Save sheet has a Format menu (#1235). The Hand and Zoom tools show drawn cursors on every platform (#1238). Czech: the remaining strings are translated, and Pathfinder's Add reads "Sjednotit" (#1226). MCP: a lost reply reports that the edit may have happened instead of replaying it (#1246); `select.key` checks its id and agents can read the key object (#1222). Tests: the command sweeps and import fuzz tests fail on a panic caught by the engine's guard (#1218), and the envelope export test ignores PDF dates and file IDs (#1220). Effect › Artistic › Film Grain (Grain, Highlight Area, Intensity; Photoshop-style filters 29/57) (#1282), and Craquelure, Mosaic Tiles and Stained Glass redraw 25–30% faster with identical pixels (#1265). Direct Selection: clicking a segment selects only that segment, and Delete removes just it (#1300); Shift-click deselects one anchor of a path selected whole (#1263). Pen + Alt converts anchors of paths inside a selected group or compound path (#1306). The Curvature tool shows circular point markers without direction handles (#1284). Snap to Point: pressing near an anchor drags by that anchor, with Smart Guides on or off (#1313). Transform Again repeats the options of the transform it repeats (#1303). Aligning anchors that are already aligned adds no undo step (#1276), and type commands that change nothing record none either (#1316). Simplify keeps the path selected with its new anchors, and its dialog previews and shows the anchor count (#1305). Adding a stroke colour to an unpainted zero-width stroke gives it a visible 1 pt width (#1256). The Eyedropper samples locked objects, template layers and art outside isolation mode (#1260), and shows a pipette cursor (#1288). Fonts named by their full name resolve to the right face (#1267), and Proportional Metrics brings the matching kerning (`apkn`, `vkrn`, `vapk`) (#1291). Artboards: Escape leaves Artboard editing (#1296), Document Setup's Edit Artboards switches to the Artboard tool (#1294), and the Artboard tool starts on the active artboard (#1309). New and imported documents use the Gridline every and Subdivisions preferences (#1314). Send to Current Layer refuses a hidden or locked current layer (#1301), and layer row commands check explicit ids (#1255). Links: Find in Folder… finds missing linked files in a folder and its subfolders and can copy them into the document's folder (#1287). Image Trace traces images over 64 megapixels scaled down instead of refusing them (#1315). DXF export marks closed curves as closed SPLINE entities (#1320). EPS import: closepath respects the path size limit (#1311). The interface is available in Hungarian (#1302), and more Czech labels are translated (#1259). Linux RPMs declare shared-library capabilities (#1290). CI pins every third-party action to a verified commit (#1321). Select menu commands skip hidden and locked art and stay inside the isolated group in isolation mode (#1325). Fill, stroke, gradient and Appearance panel commands that change nothing add no undLine truncated
|
||||
| 2026-10-12 | Graphs: Object › Graph › Design… and Marker… (designs with a preview, New Design, Rename, Delete Design, Paste Design, Select Unused, and markers drawn with a design or None) (#1092); Graph Data opens as a worksheet with Import Data…, column width and decimals (#1138), and takes Transpose, Switch x/y, `|` line breaks in labels and the pie Legend, Position and Sort options (#1115); Graph Type adds Add Drop Shadow and Add Legend Across Top (#1119), widths past 100%, First Row and First Column in Front, and Draw Filled Lines (#1136); graphs keep their label formatting when drawn again (#1137). Align: a Horizontal & Vertical Align Center button (#1073); Align to Key Object makes the topmost selected object the key (#1221), and the palette's key entry reads Cancel Key Object (#1224). Pen: the nearest end wins over snapping, Alt on the last anchor edits it, and Enter, Escape or a tool switch finish a handle preview as one undo step (#821). Direct Selection: a click on an anchor of an already selected path selects that anchor alone (#1237). Make Compound Path keeps the backmost object's paint, and a selected member reads and changes the compound's fill rule (#1225). Shape Builder and Live Paint no longer turn a line's loose end past a crossing into a piece that runs out and back (#1230). Art moved or duplicated with an artboard follows Transform Pattern Tiles (#1219). A click with the Rectangular Grid tool opens its options dialog (#1169). Commands that change nothing (Lock, Hide, Transform Each, Rearrange Artboards and others) add no undo step (#1233). Saving with missing placed files keeps their relink data, and .ai export honours the chosen artboards (#718). SVG export keeps Unicode data-attribute names, and SVG Options refuses an invalid artboard range (#989). CMYK colours given in percent with a component under 1 keep their scale (#1249). Colour Image Trace keeps close dark tones apart (#1247). macOS: the Save sheet has a Format menu (#1235). The Hand and Zoom tools show drawn cursors on every platform (#1238). Czech: the remaining strings are translated, and Pathfinder's Add reads "Sjednotit" (#1226). MCP: a lost reply reports that the edit may have happened instead of replaying it (#1246); `select.key` checks its id and agents can read the key object (#1222). Tests: the command sweeps and import fuzz tests fail on a panic caught by the engine's guard (#1218), and the envelope export test ignores PDF dates and file IDs (#1220). Effect › Artistic › Film Grain (Grain, Highlight Area, Intensity; Photoshop-style filters 29/57) (#1282), and Craquelure, Mosaic Tiles and Stained Glass redraw 25–30% faster with identical pixels (#1265). Direct Selection: clicking a segment selects only that segment, and Delete removes just it (#1300); Shift-click deselects one anchor of a path selected whole (#1263). Pen + Alt converts anchors of paths inside a selected group or compound path (#1306). The Curvature tool shows circular point markers without direction handles (#1284). Snap to Point: pressing near an anchor drags by that anchor, with Smart Guides on or off (#1313). Transform Again repeats the options of the transform it repeats (#1303). Aligning anchors that are already aligned adds no undo step (#1276), and type commands that change nothing record none either (#1316). Simplify keeps the path selected with its new anchors, and its dialog previews and shows the anchor count (#1305). Adding a stroke colour to an unpainted zero-width stroke gives it a visible 1 pt width (#1256). The Eyedropper samples locked objects, template layers and art outside isolation mode (#1260), and shows a pipette cursor (#1288). Fonts named by their full name resolve to the right face (#1267), and Proportional Metrics brings the matching kerning (`apkn`, `vkrn`, `vapk`) (#1291). Artboards: Escape leaves Artboard editing (#1296), Document Setup's Edit Artboards switches to the Artboard tool (#1294), and the Artboard tool starts on the active artboard (#1309). New and imported documents use the Gridline every and Subdivisions preferences (#1314). Send to Current Layer refuses a hidden or locked current layer (#1301), and layer row commands check explicit ids (#1255). Links: Find in Folder… finds missing linked files in a folder and its subfolders and can copy them into the document's folder (#1287). Image Trace traces images over 64 megapixels scaled down instead of refusing them (#1315). DXF export marks closed curves as closed SPLINE entities (#1320). EPS import: closepath respects the path size limit (#1311). The interface is available in Hungarian (#1302), and more Czech labels are translated (#1259). Linux RPMs declare shared-library capabilities (#1290). CI pins every third-party action to a verified commit (#1321). Select menu commands skip hidden and locked art and stay inside the isolated group in isolation mode (#1325). Fill, stroke, gradient and Appearance panel commands that change nothing add no undLine truncated
|
||||
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and DisLine truncated
|
||||
| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) |
|
||||
| 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day |
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! 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 commands
|
||||
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--replace-lossy]
|
||||
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data]
|
||||
//! vectorcraft-cli info FILE
|
||||
//! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5]
|
||||
//! vectorcraft-cli perf [--paths 50000]
|
||||
@@ -84,14 +84,16 @@ USAGE:
|
||||
vectorcraft-cli commands
|
||||
Print the command catalogue as JSON.
|
||||
|
||||
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] [--replace-lossy]
|
||||
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data]
|
||||
Open IN (any readable format) and export OUT in the format its extension picks (see Writable
|
||||
formats). --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
|
||||
unless one of them is given, EPS the bounds of the art, the other formats the first artboard.
|
||||
Live effects are kept, and hidden layers and objects (written hidden in SVG and PSD), with
|
||||
SVG's data-* attributes; --outline-text writes SVG text as paths. OUT may not be IN when reading
|
||||
SVG's data-* attributes; --outline-text writes SVG text as paths, --decimals sets SVG's coordinate
|
||||
precision (1–7, default 3). OUT may not be IN when reading
|
||||
IN left things out (hidden text, art or layers it could not read; the notes say which) unless
|
||||
--replace-lossy is given.
|
||||
--replace-lossy is given. --editing-data opens a .ai from its editing copy even where that
|
||||
draws differently from its PDF part (which can be older), as long as it can be read.
|
||||
|
||||
vectorcraft-cli info FILE
|
||||
Print a JSON summary: the import warnings (what didn't come in as it was, such as an EPS
|
||||
@@ -215,7 +217,9 @@ fn commands() -> Result<(), String> {
|
||||
|
||||
fn convert(args: &[String]) -> Result<(), String> {
|
||||
let mut files = vec![];
|
||||
let (mut scale, mut artboard, mut range, mut outline_text, mut replace_lossy) = (1.0f64, None::<u64>, None::<String>, false, false);
|
||||
let (mut scale, mut artboard, mut range, mut outline_text, mut replace_lossy, mut editing_data) =
|
||||
(1.0f64, None::<u64>, None::<String>, false, false, false);
|
||||
let mut decimals = None::<u64>;
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
@@ -223,16 +227,26 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
"--artboard" | "-a" => artboard = Some(it.next().and_then(|v| v.parse().ok()).ok_or("--artboard needs an index")?),
|
||||
"--range" | "-r" => range = Some(it.next().cloned().ok_or("--range needs artboards such as 1-3,5")?),
|
||||
"--outline-text" => outline_text = true,
|
||||
"--decimals" => decimals = Some(it.next().and_then(|v| v.parse::<u64>().ok()).ok_or("--decimals needs a number")?),
|
||||
"--replace-lossy" => replace_lossy = true,
|
||||
"--editing-data" => editing_data = true,
|
||||
f => files.push(f.to_string()),
|
||||
}
|
||||
}
|
||||
let [input, output] = <[String; 2]>::try_from(files).map_err(|_| "convert needs IN and OUT")?;
|
||||
let mut h = Headless::new();
|
||||
h.call("app.open", json!({"path": input})).map_err(|e| format!("open {input}: {e}"))?;
|
||||
if editing_data {
|
||||
let params = json!({"path": input, "editingData": "always"});
|
||||
h.call("engine.execute", json!({"command": "document.open", "params": params})).map_err(|e| format!("open {input}: {e}"))?;
|
||||
} else {
|
||||
h.call("app.open", json!({"path": input})).map_err(|e| format!("open {input}: {e}"))?;
|
||||
}
|
||||
// A conversion keeps hidden layers and objects, written hidden, where the format can (SVG,
|
||||
// PSD).
|
||||
let params = json!({"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy});
|
||||
let mut params = json!({"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy});
|
||||
if let Some(decimals) = decimals {
|
||||
params["decimals"] = json!(decimals);
|
||||
}
|
||||
let r = h.call("engine.execute", json!({"command": "document.export", "params": params})).map_err(|e| format!("export {output}: {e}"))?;
|
||||
outln!("{r}");
|
||||
Ok(())
|
||||
|
||||
@@ -205,7 +205,8 @@ fn track_paste(pasted: &Rc<RefCell<Pasted>>) {
|
||||
let Some(data) = e.clipboard_data() else { return };
|
||||
let Some(files) = data.files().filter(|f| f.length() > 0) else { return };
|
||||
// Text is egui's, but for the address a browser's Copy Image puts next to the picture.
|
||||
if data.get_data("text").is_ok_and(|t| !t.is_empty() && !is_address(&t)) {
|
||||
// Blank text is not a caption. A picture pasted with it is still the picture.
|
||||
if data.get_data("text").is_ok_and(|t| !t.trim().is_empty() && !is_address(&t)) {
|
||||
return;
|
||||
}
|
||||
e.prevent_default();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Parse automation roots before starting any desktop services. A malformed flag must not
|
||||
//! silently remove a root and leave the control channel unconfined.
|
||||
//! Parse the control port and automation roots before starting any desktop services. A malformed
|
||||
//! flag must not silently remove a root and leave the control channel unconfined, or leave the app
|
||||
//! running without the control channel that was asked for.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct AutomationArgs {
|
||||
@@ -46,6 +48,50 @@ impl AutomationArgs {
|
||||
}
|
||||
}
|
||||
|
||||
/// `--control <port>` or `--control=<port>`, given at most once.
|
||||
#[derive(Default)]
|
||||
pub struct ControlArg {
|
||||
pub port: Option<u16>,
|
||||
}
|
||||
|
||||
impl ControlArg {
|
||||
/// Returns whether this argument is `--control`. The next argument is its port whatever it
|
||||
/// is, so `--control --automation-read-root` reports the flag rather than reading it as a root.
|
||||
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
|
||||
let value = match arg.strip_prefix("--control") {
|
||||
Some("") => args.next(),
|
||||
Some(rest) => match rest.strip_prefix('=') {
|
||||
Some(value) => Some(value.to_owned()),
|
||||
None => return Ok(false),
|
||||
},
|
||||
None => return Ok(false),
|
||||
};
|
||||
if self.port.is_some() {
|
||||
return Err("--control given twice".into());
|
||||
}
|
||||
self.port = Some(port("--control", value.as_deref())?);
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// `VECTORCRAFT_CONTROL_PORT`, for when `--control` isn't given: unset or empty is no control
|
||||
/// channel, anything else must be a port.
|
||||
pub fn env_control_port(value: Option<OsString>) -> Result<Option<u16>, String> {
|
||||
match value {
|
||||
Some(v) if !v.is_empty() => port("VECTORCRAFT_CONTROL_PORT", Some(&v.to_string_lossy())).map(Some),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// A port from 1 to 65535 (the server would listen on 0's random port without saying which).
|
||||
fn port(name: &str, value: Option<&str>) -> Result<u16, String> {
|
||||
let need = format!("{name} needs a port number from 1 to 65535");
|
||||
match value {
|
||||
None => Err(need),
|
||||
Some(v) => v.parse().ok().filter(|&p| p != 0).ok_or(format!("{need}, not `{v}`")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -87,4 +133,53 @@ mod tests {
|
||||
assert_eq!(roots.read_root, Some("-folder".into()));
|
||||
assert_eq!(roots.write_root, Some("./-folder".into()));
|
||||
}
|
||||
|
||||
fn control(args: &[&str]) -> Result<Option<u16>, String> {
|
||||
let mut control = ControlArg::default();
|
||||
let mut args = args.iter().map(|a| a.to_string());
|
||||
while let Some(a) = args.next() {
|
||||
assert!(control.consume(&a, &mut args)?, "{a}");
|
||||
}
|
||||
Ok(control.port)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_needs_a_port_from_1_to_65535() {
|
||||
let need = "--control needs a port number from 1 to 65535";
|
||||
assert_eq!(control(&["--control"]), Err(need.into()));
|
||||
assert_eq!(control(&["--control", "--automation-read-root"]), Err(format!("{need}, not `--automation-read-root`")));
|
||||
for bad in ["abc", "0", "70000", "-1", ""] {
|
||||
assert_eq!(control(&["--control", bad]), Err(format!("{need}, not `{bad}`")));
|
||||
assert_eq!(control(&[&format!("--control={bad}")]), Err(format!("{need}, not `{bad}`")));
|
||||
}
|
||||
assert_eq!(control(&["--control", "7979"]), Ok(Some(7979)));
|
||||
assert_eq!(control(&["--control=65535"]), Ok(Some(65535)));
|
||||
assert_eq!(control(&["--control", "7979", "--control=7980"]), Err("--control given twice".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_arguments_are_not_control() {
|
||||
let mut control = ControlArg::default();
|
||||
let mut args = ["7979".to_owned()].into_iter();
|
||||
for other in ["--controls", "--control-port", "drawing.svg", "--in-window-menus"] {
|
||||
assert!(!control.consume(other, &mut args).unwrap(), "{other}");
|
||||
}
|
||||
assert_eq!(args.next().as_deref(), Some("7979"));
|
||||
assert_eq!(control.port, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_port_variable_is_a_port_or_unset() {
|
||||
assert_eq!(env_control_port(None), Ok(None));
|
||||
assert_eq!(env_control_port(Some("".into())), Ok(None));
|
||||
assert_eq!(env_control_port(Some("7979".into())), Ok(Some(7979)));
|
||||
let need = "VECTORCRAFT_CONTROL_PORT needs a port number from 1 to 65535";
|
||||
assert_eq!(env_control_port(Some("abc".into())), Err(format!("{need}, not `abc`")));
|
||||
assert_eq!(env_control_port(Some("0".into())), Err(format!("{need}, not `0`")));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
assert_eq!(env_control_port(Some(OsString::from_vec(vec![0x39, 0xff]))), Err(format!("{need}, not `9\u{fffd}`")));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,8 @@
|
||||
//! `--in-window-menus` (or `VECTORCRAFT_IN_WINDOW_MENUS=1`) keeps the menus inside the window on
|
||||
//! macOS instead of the macOS menu bar (`mac_menu`).
|
||||
//!
|
||||
//! `--control <port>` (or `VECTORCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
|
||||
//! `--control <port>` (or `VECTORCRAFT_CONTROL_PORT`; a port from 1 to 65535, or the app doesn't
|
||||
//! start) starts a localhost JSON-lines control server:
|
||||
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
|
||||
//! See `vectorcraft_ui_egui::control` for the methods. `--automation-read-root` and
|
||||
//! `--automation-write-root` (or `VECTORCRAFT_AUTOMATION_READ_ROOT` / `_WRITE_ROOT`) confine the
|
||||
@@ -605,7 +606,7 @@ fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
|
||||
fn main() -> std::process::ExitCode {
|
||||
// Validate roots before logging, font scans, preferences or the window: a missing value
|
||||
// must not clear an environment root and start the control channel without confinement.
|
||||
let mut control_port: Option<u16> = std::env::var("VECTORCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let mut control = automation_args::ControlArg::default();
|
||||
let root_env = |name: &str| std::env::var_os(name).filter(|v| !v.is_empty()).map(std::path::PathBuf::from);
|
||||
let mut roots_args =
|
||||
automation_args::AutomationArgs::new(root_env("VECTORCRAFT_AUTOMATION_READ_ROOT"), root_env("VECTORCRAFT_AUTOMATION_WRITE_ROOT"));
|
||||
@@ -613,7 +614,7 @@ fn main() -> std::process::ExitCode {
|
||||
let mut in_window_menus = std::env::var_os("VECTORCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match roots_args.consume(&a, &mut args) {
|
||||
match roots_args.consume(&a, &mut args).and_then(|root| if root { Ok(true) } else { control.consume(&a, &mut args) }) {
|
||||
Ok(true) => continue,
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
@@ -622,7 +623,6 @@ fn main() -> std::process::ExitCode {
|
||||
}
|
||||
}
|
||||
match a.as_str() {
|
||||
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
|
||||
"--in-window-menus" => in_window_menus = true,
|
||||
"--version" => {
|
||||
println!("vectorcraft {}", env!("CARGO_PKG_VERSION"));
|
||||
@@ -632,6 +632,17 @@ fn main() -> std::process::ExitCode {
|
||||
}
|
||||
}
|
||||
let automation_args::AutomationArgs { read_root, write_root, .. } = roots_args;
|
||||
// After the arguments: `--control` and `--version` win over a bad environment value.
|
||||
let control_port = match control.port {
|
||||
Some(port) => Some(port),
|
||||
None => match automation_args::env_control_port(std::env::var_os("VECTORCRAFT_CONTROL_PORT")) {
|
||||
Ok(port) => port,
|
||||
Err(e) => {
|
||||
eprintln!("vectorcraft: {e}");
|
||||
return std::process::ExitCode::FAILURE;
|
||||
}
|
||||
},
|
||||
};
|
||||
// Before desktop services, so every start-up warning is recorded (`logging`).
|
||||
let logger = logging::install();
|
||||
vectorcraft_ui_egui::i18n::detect_system_lang_in_background();
|
||||
|
||||
@@ -9,12 +9,17 @@ const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft");
|
||||
/// Invalid roots must exit before desktop initialization on every platform, including a CI
|
||||
/// machine with no display. A regression must fail rather than hang with a window open.
|
||||
fn rejects(args: &[&str], message: &str) {
|
||||
rejects_with_env(args, &[], message);
|
||||
}
|
||||
|
||||
fn rejects_with_env(args: &[&str], env: &[(&str, &str)], message: &str) {
|
||||
let mut child = Command::new(BIN)
|
||||
.args(args)
|
||||
.env_remove("VECTORCRAFT_CONTROL_PORT")
|
||||
.env_remove("VECTORCRAFT_AUTOMATION_READ_ROOT")
|
||||
.env_remove("VECTORCRAFT_AUTOMATION_WRITE_ROOT")
|
||||
.env("VECTORCRAFT_NO_PREFS", "1")
|
||||
.envs(env.iter().copied())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
@@ -68,3 +73,24 @@ fn explicit_dash_folder_does_not_consume_version() {
|
||||
assert_eq!(String::from_utf8(out.stdout).unwrap(), format!("vectorcraft {}\n", env!("CARGO_PKG_VERSION")));
|
||||
assert!(out.stderr.is_empty());
|
||||
}
|
||||
|
||||
/// `--control` without a port no longer starts the app without a control server, and the
|
||||
/// argument after it isn't reinterpreted (#1373).
|
||||
#[test]
|
||||
fn control_without_a_valid_port_fails_before_startup() {
|
||||
let need = "--control needs a port number from 1 to 65535";
|
||||
rejects(&["--control", "--automation-read-root", ".", "--automation-write-root", "."], &format!("{need}, not `--automation-read-root`"));
|
||||
rejects(&["--control"], need);
|
||||
rejects(&["--control", "abc"], &format!("{need}, not `abc`"));
|
||||
rejects(&["--control=0"], &format!("{need}, not `0`"));
|
||||
rejects(&["--control="], &format!("{need}, not ``"));
|
||||
rejects(&["--control", "7979", "--control", "7980"], "--control given twice");
|
||||
rejects_with_env(&[], &[("VECTORCRAFT_CONTROL_PORT", "abc")], "VECTORCRAFT_CONTROL_PORT needs a port number from 1 to 65535, not `abc`");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_wins_over_a_bad_control_port_variable() {
|
||||
let out = Command::new(BIN).arg("--version").env("VECTORCRAFT_CONTROL_PORT", "abc").output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
assert_eq!(String::from_utf8(out.stdout).unwrap(), format!("vectorcraft {}\n", env!("CARGO_PKG_VERSION")));
|
||||
}
|
||||
@@ -86,3 +86,6 @@ sync_github_name = false
|
||||
real_name = "Aaron Guo"
|
||||
display_name = "Aaron Guo"
|
||||
sync_github_name = false
|
||||
|
||||
[people.Nael-Nathanael]
|
||||
sync_github_name = true
|
||||
+17
-3
@@ -31,6 +31,10 @@ impl Hit {
|
||||
/// The object the Selection tool selects: the child of the layer (the outermost group).
|
||||
/// Inside isolation mode (`scope`), the child of the isolated container instead.
|
||||
pub fn top_object(&self, scope: Option<NodeId>) -> NodeId {
|
||||
// A single object isolated: it is the only object.
|
||||
if scope == Some(self.leaf) {
|
||||
return self.leaf;
|
||||
}
|
||||
// The innermost of the isolated container and an envelope whose contents are edited.
|
||||
let at = |s: Option<NodeId>| s.and_then(|s| self.ancestry.iter().position(|x| *x == s));
|
||||
if let Some(i) = at(scope).max(at(self.contents_of)) {
|
||||
@@ -101,10 +105,16 @@ fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(No
|
||||
// locked, and clicks pass through it (#833).
|
||||
if editable_only
|
||||
&& let Some(scope) = opt.scope
|
||||
&& let Some(container) = doc.node(scope).filter(|n| n.is_container())
|
||||
&& let Some(isolated) = doc.node(scope)
|
||||
&& let Some(mut chain) = doc.ancestry(scope)
|
||||
{
|
||||
return hit_children(container, p, opt, skip, &mut chain, editable_only).map(finish);
|
||||
if isolated.is_container() {
|
||||
return hit_children(isolated, p, opt, skip, &mut chain, editable_only).map(finish);
|
||||
}
|
||||
// A single object isolated (a path, an image…): only it hits, among its siblings.
|
||||
chain.pop();
|
||||
let parent = chain.last().and_then(|id| doc.node(*id))?;
|
||||
return hit_children(parent, p, opt, &|id| id != scope || skip(id), &mut chain, editable_only).map(finish);
|
||||
}
|
||||
let mut chain = Vec::new();
|
||||
for layer in doc.layers.iter().rev() {
|
||||
@@ -283,7 +293,11 @@ fn hit_any(n: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
|
||||
pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) -> Vec<NodeId> {
|
||||
let mut out = Vec::new();
|
||||
let tops: Vec<&std::sync::Arc<Node>> = match scope.and_then(|s| doc.node(s)) {
|
||||
Some(s) => s.children().map(|c| c.iter().collect()).unwrap_or_default(),
|
||||
Some(s) => match s.children() {
|
||||
Some(c) => c.iter().collect(),
|
||||
// A single object isolated: only it.
|
||||
None => doc.parent_of(s.id).and_then(|p| doc.node(p)).and_then(Node::children).into_iter().flatten().filter(|c| c.id == s.id).collect(),
|
||||
},
|
||||
// The objects of the layers, looking through sublayers (hidden, locked and template ones
|
||||
// are left out).
|
||||
None => doc
|
||||
|
||||
@@ -693,6 +693,10 @@ fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Listed nodes by parent: the parent's index path (empty for the top-level layers), then the index
|
||||
/// of each listed child in it, in order, with the child's place in the list.
|
||||
type Places = BTreeMap<Vec<usize>, Vec<(usize, usize)>>;
|
||||
|
||||
pub const FORMAT_VERSION: u32 = 1;
|
||||
|
||||
impl Document {
|
||||
@@ -1021,6 +1025,128 @@ impl Document {
|
||||
Some((parent, idx, n))
|
||||
}
|
||||
|
||||
/// Remove the nodes `ids` names and return them in the order of `ids`, in one pass over the
|
||||
/// children of each parent. Each id is listed once and none lies inside another listed node, as
|
||||
/// in a list of top-level objects. For an id that is not in the tree, is listed twice or lies
|
||||
/// inside another listed node, it fails with `NoNode` and leaves the document as it was.
|
||||
pub fn remove_many(&mut self, ids: &[NodeId]) -> Result<Vec<Arc<Node>>, DocError> {
|
||||
let places = self.places(ids)?;
|
||||
let removed = self.take(places, ids.len())?;
|
||||
self.index.clear();
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
/// Remove the nodes `ids` names, as [`Document::remove_many`] does, and put the node that `make`
|
||||
/// builds from them (in the order of `ids`) in the parent of the last of them, at that node's
|
||||
/// index less the number of listed nodes below it there. Returns the new node's id.
|
||||
pub fn wrap(&mut self, ids: &[NodeId], make: impl FnOnce(Vec<Arc<Node>>) -> Node) -> Result<NodeId, DocError> {
|
||||
let places = self.places(ids)?;
|
||||
let last = ids.len().checked_sub(1);
|
||||
let found = places.iter().find_map(|(up, kids)| kids.iter().find(|(_, k)| Some(*k) == last).map(|(i, _)| (up.clone(), *i)));
|
||||
let Some((up, i)) = found else { return Err(DocError::Invalid("nothing to wrap".into())) };
|
||||
// The parent and each node it is in move down by the listed nodes before them.
|
||||
let below = |parent: &[usize], index: usize| places.get(parent).map_or(0, |kids| kids.partition_point(|(j, _)| *j < index));
|
||||
let to: Vec<usize> = up.iter().enumerate().map(|(l, &j)| j.saturating_sub(below(&up[..l], j))).collect();
|
||||
let at = i.saturating_sub(below(&up, i));
|
||||
let node = make(self.take(places, ids.len())?);
|
||||
let id = node.id;
|
||||
let children = self.children_at(&to).ok_or_else(|| DocError::Invalid("the container of the listed objects is gone".into()))?;
|
||||
let at = at.min(children.len());
|
||||
children.insert(at, Arc::new(node));
|
||||
self.index.clear();
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Put each list of nodes in `items` directly above its anchor (`replace`: in its place), in
|
||||
/// list order, in one pass over the children of each parent. Each anchor is listed once and none
|
||||
/// lies inside another. For an anchor that is not in the tree, is listed twice or lies inside
|
||||
/// another listed anchor, it fails with `NoNode` and leaves the document as it was.
|
||||
pub fn insert_beside(&mut self, items: Vec<(NodeId, Vec<Node>)>, replace: bool) -> Result<(), DocError> {
|
||||
let anchors: Vec<NodeId> = items.iter().map(|(id, _)| *id).collect();
|
||||
let places = self.places(&anchors)?;
|
||||
let mut lists: Vec<Vec<Node>> = items.into_iter().map(|(_, nodes)| nodes).collect();
|
||||
self.edit_children(places, |k, anchor, children| {
|
||||
if !replace {
|
||||
children.push(anchor);
|
||||
}
|
||||
let nodes = lists.get_mut(k).map(std::mem::take).unwrap_or_default();
|
||||
children.extend(nodes.into_iter().map(Arc::new));
|
||||
})?;
|
||||
self.index.clear();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where each of `ids` is ([`Places`]). Fails with `NoNode` for an id that is not in the tree,
|
||||
/// is listed twice or lies inside another listed node.
|
||||
fn places(&self, ids: &[NodeId]) -> Result<Places, DocError> {
|
||||
let paths = self.index_paths(ids.iter().copied());
|
||||
let mut places = Places::new();
|
||||
for (k, id) in ids.iter().enumerate() {
|
||||
let (&i, up) = paths.get(id).and_then(|p| p.split_last()).ok_or(DocError::NoNode(*id))?;
|
||||
match places.get_mut(up) {
|
||||
Some(kids) => kids.push((i, k)),
|
||||
None => {
|
||||
places.insert(up.to_vec(), vec![(i, k)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
for kids in places.values_mut() {
|
||||
kids.sort_unstable();
|
||||
}
|
||||
// An id listed twice has one path, so its parent lists the same child twice. A listed node
|
||||
// lies inside another when its parent's path, or a part of it from the top, is the path of a
|
||||
// listed node.
|
||||
let twice = places.values().flat_map(|kids| kids.windows(2)).find(|w| w[0].0 == w[1].0).map(|w| w[1].1);
|
||||
let listed = |path: &[usize]| {
|
||||
path.split_last().is_some_and(|(i, up)| places.get(up).is_some_and(|kids| kids.binary_search_by_key(i, |(j, _)| *j).is_ok()))
|
||||
};
|
||||
let nested = places.iter().find(|(up, _)| (1..=up.len()).any(|l| listed(&up[..l])));
|
||||
let inner = nested.and_then(|(_, kids)| kids.first()).map(|(_, k)| *k);
|
||||
if let Some(id) = twice.or(inner).and_then(|k| ids.get(k)) {
|
||||
return Err(DocError::NoNode(*id));
|
||||
}
|
||||
Ok(places)
|
||||
}
|
||||
|
||||
/// Take the nodes `places` lists out of their parents and return them in the order of the list,
|
||||
/// which is `len` long.
|
||||
fn take(&mut self, places: Places, len: usize) -> Result<Vec<Arc<Node>>, DocError> {
|
||||
let mut taken = vec![None; len];
|
||||
self.edit_children(places, |k, node, _| {
|
||||
if let Some(t) = taken.get_mut(k) {
|
||||
*t = Some(node);
|
||||
}
|
||||
})?;
|
||||
Ok(taken.into_iter().flatten().collect())
|
||||
}
|
||||
|
||||
/// Rebuild the children of each parent in `places` once, the parents later in tree order
|
||||
/// first. `each` gets each listed child, its place in the list and the parent's new children,
|
||||
/// onto which it pushes what takes the child's place.
|
||||
fn edit_children(&mut self, places: Places, mut each: impl FnMut(usize, Arc<Node>, &mut Vec<Arc<Node>>)) -> Result<(), DocError> {
|
||||
for (up, kids) in places.into_iter().rev() {
|
||||
let children = self.children_at(&up).ok_or_else(|| DocError::Invalid("a container of the listed objects is gone".into()))?;
|
||||
let old = std::mem::take(children);
|
||||
children.reserve(old.len());
|
||||
let mut kids = kids.into_iter().peekable();
|
||||
for (i, node) in old.into_iter().enumerate() {
|
||||
match kids.next_if(|(j, _)| *j == i) {
|
||||
Some((_, k)) => each(k, node, children),
|
||||
None => children.push(node),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The children of the node at index path `path`, or the top-level layers for an empty path.
|
||||
fn children_at(&mut self, path: &[usize]) -> Option<&mut Vec<Arc<Node>>> {
|
||||
match path {
|
||||
[] => Some(&mut self.layers),
|
||||
_ => self.node_mut_at(path)?.children_mut(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Default target layer: the topmost visible, unlocked layer.
|
||||
pub fn default_layer(&self) -> Option<NodeId> {
|
||||
self.layers.iter().rev().find(|l| l.visible && !l.locked).or(self.layers.last()).map(|l| l.id)
|
||||
@@ -1100,6 +1226,9 @@ impl Document {
|
||||
/// `locked_and_hidden` (Selection & Anchor Display › Move Locked and Hidden Artwork with
|
||||
/// Artboard).
|
||||
pub fn art_on_artboard(&self, rect: Rect, locked_and_hidden: bool) -> Vec<NodeId> {
|
||||
// Art drawn flush with the artboard can end up a rounding error outside it (a background
|
||||
// 3e-14 pt past the top edge, #1295): a millionth of a point is far below anything visible.
|
||||
let rect = rect.inflate(1e-6, 1e-6);
|
||||
fn collect(n: &Node, rect: Rect, all: bool, out: &mut Vec<NodeId>) {
|
||||
for c in n.children().into_iter().flatten().filter(|c| c.rides_with_artboard(all)) {
|
||||
if c.is_layer() {
|
||||
@@ -1405,6 +1534,26 @@ mod tests {
|
||||
assert_eq!(d.art_on_artboard(wide, true), vec![a, b], "a locked layer's art too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_on_artboard_tolerates_roundoff_at_each_edge() {
|
||||
let (d, a, _) = doc_with_rects();
|
||||
let roundoff = 1e-12;
|
||||
for board in [
|
||||
Rect::new(roundoff, 0.0, 10.0, 10.0),
|
||||
Rect::new(0.0, roundoff, 10.0, 10.0),
|
||||
Rect::new(0.0, 0.0, 10.0 - roundoff, 10.0),
|
||||
Rect::new(0.0, 0.0, 10.0, 10.0 - roundoff),
|
||||
] {
|
||||
assert_eq!(d.art_on_artboard(board, false), vec![a], "roundoff at {board:?}");
|
||||
}
|
||||
// A real overhang remains outside.
|
||||
for board in
|
||||
[Rect::new(0.001, 0.0, 10.0, 10.0), Rect::new(0.0, 0.001, 10.0, 10.0), Rect::new(0.0, 0.0, 9.999, 10.0), Rect::new(0.0, 0.0, 10.0, 9.999)]
|
||||
{
|
||||
assert!(d.art_on_artboard(board, false).is_empty(), "overhang at {board:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_and_paths() {
|
||||
let (d, a, b) = doc_with_rects();
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Document, NodeId};
|
||||
use crate::{Document, NodeId, NodeKind};
|
||||
|
||||
/// (subpath index, anchor index) inside a path.
|
||||
pub type AnchorRef = (usize, usize);
|
||||
@@ -125,6 +125,25 @@ impl Selection {
|
||||
pub fn partial(&self, id: NodeId) -> Option<&BTreeSet<AnchorRef>> {
|
||||
self.anchors.get(&id)
|
||||
}
|
||||
/// The objects selected whole: those with no direct-selected anchors.
|
||||
pub fn whole(&self) -> Vec<NodeId> {
|
||||
self.objects.iter().copied().filter(|id| !self.anchors.contains_key(id)).collect()
|
||||
}
|
||||
/// With direct-selected anchors, what a transform of them measures from: the box around those
|
||||
/// anchors and the objects selected whole (`visual`: with their strokes). `None` without any.
|
||||
pub fn anchor_bounds(&self, doc: &Document, visual: bool) -> Option<vectorcraft_geom::Rect> {
|
||||
if self.anchors.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut acc = doc.bounds_of(&self.whole(), visual);
|
||||
for (id, set) in &self.anchors {
|
||||
let Some(NodeKind::Path { path, .. }) = doc.node(*id).map(|n| &n.kind) else { continue };
|
||||
for a in set.iter().filter_map(|&(si, ai)| path.subpaths.get(si)?.anchors.get(ai)) {
|
||||
acc = vectorcraft_geom::union_opt(acc, Some(vectorcraft_geom::Rect::from_points(a.p, a.p)));
|
||||
}
|
||||
}
|
||||
acc
|
||||
}
|
||||
/// Which direction handles belong to selected segments; both for an anchor selection.
|
||||
pub fn handle_sides(&self, id: NodeId, si: usize, ai: usize, sp: &vectorcraft_geom::SubPath) -> (bool, bool) {
|
||||
let Some(segments) = self.segments.get(&id) else { return (true, true) };
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! The batched lookups ([`Document::index_paths`], `node_at`, `node_mut_at`, `ancestry_at`) agree
|
||||
//! with the one-id ones on nested trees with missing and duplicated ids.
|
||||
//! with the one-id ones on nested trees with missing and duplicated ids, and the edits of many nodes
|
||||
//! ([`Document::remove_many`], `wrap`, `insert_beside`) leave the tree that `remove`, `insert` and
|
||||
//! `move_node` leave one node at a time.
|
||||
use super::*;
|
||||
use vectorcraft_geom::shapes;
|
||||
|
||||
@@ -154,3 +156,129 @@ fn cached_lookups_follow_any_edit_of_the_tree() {
|
||||
agree(&snapshot, &ids, "the earlier clone");
|
||||
}
|
||||
}
|
||||
|
||||
/// Nodes of `d` picked and ordered by `seed`. None lies inside another, and each of them, and each
|
||||
/// node it is in, appears in the tree once.
|
||||
fn picks(d: &Document, seed: u64) -> Vec<NodeId> {
|
||||
let mut count = std::collections::HashMap::<NodeId, usize>::new();
|
||||
d.walk(|n| *count.entry(n.id).or_default() += 1);
|
||||
let once = |id: &NodeId| count.get(id) == Some(&1);
|
||||
let mut found: Vec<(Vec<usize>, NodeId)> = count
|
||||
.keys()
|
||||
.filter(|id| !(id.0 + seed).is_multiple_of(3))
|
||||
.filter_map(|id| d.index_path(*id).map(|p| (p, *id)))
|
||||
.filter(|(p, _)| d.ancestry_at(p).is_some_and(|a| a.iter().all(once)))
|
||||
.collect();
|
||||
found.sort();
|
||||
let mut kept: Vec<(Vec<usize>, NodeId)> = vec![];
|
||||
for (p, id) in found {
|
||||
if !kept.iter().any(|(q, _)| p.starts_with(q)) {
|
||||
kept.push((p, id));
|
||||
}
|
||||
}
|
||||
let mut out: Vec<NodeId> = kept.into_iter().map(|(_, id)| id).collect();
|
||||
out.sort_by_key(|id| id.0.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ seed);
|
||||
out
|
||||
}
|
||||
|
||||
/// `a` and `b` hold the same tree, and their lookups agree with each other and with a walk.
|
||||
fn same(a: &Document, b: &Document, ids: &[NodeId], what: &str) {
|
||||
assert_eq!(serde_json::to_string(a).ok(), serde_json::to_string(b).ok(), "{what}");
|
||||
for id in ids {
|
||||
assert_eq!(a.index_path(*id), b.index_path(*id), "{what}: {id:?}");
|
||||
}
|
||||
agree(b, ids, what);
|
||||
}
|
||||
|
||||
/// A 1 pt square with a new id.
|
||||
fn square(d: &mut Document) -> Node {
|
||||
Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_many_matches_removing_one_at_a_time() {
|
||||
for seed in 1..40 {
|
||||
let (d, ids) = tree(seed);
|
||||
let picked = picks(&d, seed);
|
||||
let (mut a, mut b) = (d.clone(), d.clone());
|
||||
let one: Vec<Arc<Node>> = picked.iter().map(|id| a.remove(*id).unwrap()).collect();
|
||||
let many = b.remove_many(&picked).unwrap();
|
||||
assert_eq!(many.len(), one.len(), "seed {seed}");
|
||||
assert!(one.iter().zip(&many).all(|(x, y)| Arc::ptr_eq(x, y)), "seed {seed}: the same nodes, in the order of the list");
|
||||
same(&a, &b, &ids, &format!("seed {seed}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_beside_matches_inserting_one_at_a_time() {
|
||||
for seed in 1..40 {
|
||||
for replace in [false, true] {
|
||||
let (mut d, mut ids) = tree(seed);
|
||||
let picked = picks(&d, seed);
|
||||
// None to three new nodes for each anchor.
|
||||
let items: Vec<(NodeId, Vec<Node>)> =
|
||||
picked.iter().enumerate().map(|(k, id)| (*id, (0..k % 4).map(|_| square(&mut d)).collect())).collect();
|
||||
ids.extend(items.iter().flat_map(|(_, new)| new.iter().map(|n| n.id)));
|
||||
let (mut a, mut b) = (d.clone(), d.clone());
|
||||
for (id, new) in items.clone() {
|
||||
let (parent, at, _) = a.position(id).unwrap();
|
||||
let at = if replace {
|
||||
a.remove(id).unwrap();
|
||||
at
|
||||
} else {
|
||||
at + 1
|
||||
};
|
||||
for (k, n) in new.into_iter().enumerate() {
|
||||
a.insert(parent, at + k, n).unwrap();
|
||||
}
|
||||
}
|
||||
b.insert_beside(items, replace).unwrap();
|
||||
same(&a, &b, &ids, &format!("seed {seed}, replace {replace}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_matches_inserting_a_group_and_moving_the_nodes_into_it_one_at_a_time() {
|
||||
for seed in 1..40 {
|
||||
let (mut d, mut ids) = tree(seed);
|
||||
let picked = picks(&d, seed);
|
||||
let Some(&last) = picked.last() else { continue };
|
||||
let group = d.alloc_id();
|
||||
ids.push(group);
|
||||
let (mut a, mut b) = (d.clone(), d.clone());
|
||||
let (parent, at, _) = a.position(last).unwrap();
|
||||
a.insert(parent, at + 1, Node::group(group, vec![])).unwrap();
|
||||
for id in &picked {
|
||||
a.move_node(*id, Some(group), usize::MAX).unwrap();
|
||||
}
|
||||
assert_eq!(b.wrap(&picked, |nodes| Node::group(group, nodes)), Ok(group), "seed {seed}");
|
||||
same(&a, &b, &ids, &format!("seed {seed}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// An id not in the tree, listed twice, or inside another listed node fails with `NoNode`, and the
|
||||
/// document stays as it was.
|
||||
#[test]
|
||||
fn edits_of_many_refuse_missing_repeated_and_nested_ids() {
|
||||
let mut d = Document::new(612.0, 792.0);
|
||||
let layer = d.layers[0].id;
|
||||
let (x, y, z) = (square(&mut d), square(&mut d), square(&mut d));
|
||||
let (inner, other) = (y.id, z.id);
|
||||
let group = d.alloc_id();
|
||||
d.insert(Some(layer), 0, Node::group(group, vec![Arc::new(x), Arc::new(y)])).unwrap();
|
||||
d.insert(Some(layer), 1, z).unwrap();
|
||||
let saved = serde_json::to_string(&d).ok();
|
||||
let missing = NodeId(999_999);
|
||||
for (list, bad) in [(vec![other, missing], missing), (vec![other, other], other), (vec![group, inner], inner), (vec![inner, group], inner)] {
|
||||
let what = format!("{list:?}");
|
||||
let mut b = d.clone();
|
||||
assert_eq!(b.remove_many(&list).err(), Some(DocError::NoNode(bad)), "{what}");
|
||||
assert_eq!(b.wrap(&list, |nodes| Node::group(NodeId(1_000_000), nodes)).err(), Some(DocError::NoNode(bad)), "{what}");
|
||||
for replace in [false, true] {
|
||||
let items = list.iter().map(|id| (*id, vec![])).collect();
|
||||
assert_eq!(b.insert_beside(items, replace).err(), Some(DocError::NoNode(bad)), "{what}");
|
||||
}
|
||||
assert_eq!(serde_json::to_string(&b).ok(), saved, "{what}: the document stays as it was");
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ fn clear_item_effects(item: &mut AppearanceItem) {
|
||||
/// Does anything in `n`'s subtree need baking?
|
||||
pub fn needs_bake(n: &Node) -> bool {
|
||||
crate::has_revolve(n)
|
||||
|| crate::has_solid(n)
|
||||
|| has_adjustment(n)
|
||||
|| has_crop_marks(n)
|
||||
|| has_pathfinder(n)
|
||||
@@ -93,7 +94,7 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
if let Some(m) = crop_marks_art(n) {
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
if let Some(m) = crate::revolve_art(n) {
|
||||
if let Some(m) = crate::revolve_art(n).or_else(|| crate::solid_art(n)) {
|
||||
let m = evaluate_container(&m).unwrap_or(m);
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
@@ -232,7 +233,7 @@ pub type StrokeArt<'a> = &'a mut dyn FnMut(&mut Document, &PathData, FillRule, &
|
||||
/// without transparency or effects of its own takes `n`'s place itself. Hidden fills, strokes and
|
||||
/// effects are dropped. `None` for other kinds of objects.
|
||||
pub fn expand_leaf(d: &mut Document, n: &Node, stroke_art: StrokeArt) -> Option<Node> {
|
||||
if let Some(m) = crate::revolve::expand_revolve_art(n) {
|
||||
if let Some(m) = crate::revolve::expand_revolve_art(n).or_else(|| crate::solid_art(n)) {
|
||||
let m = evaluate_container(&m).unwrap_or(m);
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
@@ -299,7 +300,7 @@ pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) {
|
||||
/// the members of a group keeping `n`'s id, transparency, opacity mask and raster effects. `None`
|
||||
/// when it has neither a stroke to outline nor a geometry effect, so bending first changes nothing.
|
||||
pub fn bake_appearance(n: &Node) -> Option<Node> {
|
||||
if let Some(m) = crate::revolve_art(n) {
|
||||
if let Some(m) = crate::revolve_art(n).or_else(|| crate::solid_art(n)) {
|
||||
return Some(m);
|
||||
}
|
||||
if !matches!(n.kind, NodeKind::Path { clipping: false, guide: false, .. } | NodeKind::Compound { .. }) {
|
||||
|
||||
@@ -43,6 +43,7 @@ pub mod pixel;
|
||||
mod raster;
|
||||
mod reshape;
|
||||
mod revolve;
|
||||
mod solids;
|
||||
pub mod stroke;
|
||||
mod stylize;
|
||||
mod util;
|
||||
@@ -70,6 +71,10 @@ pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
|
||||
pub use raster::{RasterFx, outset, raster_effects};
|
||||
pub use reshape::{expand_outlined, needs_outline, outline_art, outline_text, reshape};
|
||||
pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve};
|
||||
pub use solids::{
|
||||
EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, extrude_options, has_solid, inflate_options, is_three_d, solid_art, validate_solid,
|
||||
view_options,
|
||||
};
|
||||
pub use warp::{WarpStyle, warp_point};
|
||||
|
||||
/// Catalogue entry for one effect.
|
||||
@@ -125,6 +130,8 @@ fn lengths_of(id: &str) -> Lengths {
|
||||
"distort.transform" => always(&["moveH", "moveV"]),
|
||||
"path.offsetPath" => always(&["offset"]),
|
||||
"threeD.revolve" => always(&["offset"]),
|
||||
"threeD.extrude" => always(&["depth", "bevelWidth", "bevelHeight"]),
|
||||
"threeD.inflate" => always(&["depth"]),
|
||||
"path.outlineStroke" => always(&["width"]),
|
||||
"stylize.roundCorners" | "stylize.feather" | "blur.gaussian" | "blur.smart" | "sharpen.unsharpMask" => always(&["radius"]),
|
||||
"stylize.scribble" => always(&["overlap", "strokeWidth", "spacing", "variation"]),
|
||||
@@ -196,6 +203,27 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspective: % (0..100, 0), segments: integer (8..128, 64), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Open paths create uncapped surfaces; intersecting profiles use approximate painter visibility.",
|
||||
json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspective":0.0,"segments":64,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0,"expandVisibleOnly":true}),
|
||||
),
|
||||
g(
|
||||
EXTRUDE,
|
||||
"Extrude & Bevel…",
|
||||
&["Effect", "3D and Materials"],
|
||||
"{depth: pt (0..2000, 50), twist: degrees (-360..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspective: % (0..100, 0), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).",
|
||||
json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspective":0.0,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0}),
|
||||
),
|
||||
g(
|
||||
INFLATE,
|
||||
"Inflate…",
|
||||
&["Effect", "3D and Materials"],
|
||||
"{depth: pt (0..2000, 0; flat depth between front and back), volume: % (0..100, 50; 50 rounds the edge like a half sphere), bothSides: bool (false: flat back), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspective: % (0..100, 0), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25)}. Painted with the top fill (else the top stroke).",
|
||||
json!({"depth":0.0,"volume":50.0,"bothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspective":0.0,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0}),
|
||||
),
|
||||
g(
|
||||
ROTATE,
|
||||
"Rotate…",
|
||||
&["Effect", "3D and Materials"],
|
||||
"{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspective: % (0..100, 0), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.",
|
||||
json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspective":0.0,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0}),
|
||||
),
|
||||
g(
|
||||
"convertToShape.rectangle",
|
||||
"Rectangle…",
|
||||
@@ -669,7 +697,7 @@ pub fn is_geometry(id: &str) -> bool {
|
||||
&& !is_pathfinder(id)
|
||||
&& !is_adjustment(id)
|
||||
&& id != CROP_MARKS
|
||||
&& id != REVOLVE
|
||||
&& !is_three_d(id)
|
||||
&& (catalog_index().contains_key(id) || vectorcraft_plugins::effect::plugin_id(id).is_some())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
//! Extrude & Bevel, Inflate and Rotate as live, shaded vector art, shared by the canvas and every
|
||||
//! exporter. Like Revolve, the source path stays editable and the effect draws its 3D faces.
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{FillRule, PathData, Rect};
|
||||
use vectorcraft_three_d::{Bevel, BevelShape, Extrude, Facet, Inflate, View};
|
||||
|
||||
use crate::util::{flag, num, text};
|
||||
use crate::{GeomContext, apply_geometry_with, is_geometry};
|
||||
|
||||
pub const EXTRUDE: &str = "threeD.extrude";
|
||||
pub const INFLATE: &str = "threeD.inflate";
|
||||
pub const ROTATE: &str = "threeD.rotate";
|
||||
/// The 3D effects this module draws (Revolve has its own).
|
||||
pub const SOLIDS: [&str; 3] = [EXTRUDE, INFLATE, ROTATE];
|
||||
|
||||
/// Is `id` one of the 3D and Materials effects (Revolve included)?
|
||||
pub fn is_three_d(id: &str) -> bool {
|
||||
id == crate::REVOLVE || SOLIDS.contains(&id)
|
||||
}
|
||||
|
||||
fn solid(fx: &[Effect]) -> Option<(usize, &Effect)> {
|
||||
fx.iter().enumerate().find(|(_, e)| e.visible && SOLIDS.contains(&e.id.as_str()))
|
||||
}
|
||||
|
||||
/// Does `n` carry a visible Extrude & Bevel, Inflate or Rotate effect?
|
||||
pub fn has_solid(n: &Node) -> bool {
|
||||
solid(&n.appearance.effects).is_some() || n.appearance.items.iter().any(|i| i.visible() && solid(i.effects()).is_some())
|
||||
}
|
||||
|
||||
/// The Position presets: (id, [rotation X, Y, Z]). Front, Back, Left, Right, Top and Bottom face
|
||||
/// the art that way; Off-Axis tilts each 20° down and 25° aside (showing the top and right);
|
||||
/// Isometric lays the art on the left, right, top or bottom plane of an isometric view (the
|
||||
/// visible axes foreshortened equally: atan(1/√2) ≈ 35.26° and 45°). Our own values, from the
|
||||
/// geometry of each view.
|
||||
pub const ROTATION_PRESETS: [(&str, [f64; 3]); 16] = [
|
||||
("front", [0.0, 0.0, 0.0]),
|
||||
("back", [0.0, 180.0, 0.0]),
|
||||
("left", [0.0, 90.0, 0.0]),
|
||||
("right", [0.0, -90.0, 0.0]),
|
||||
("top", [-90.0, 0.0, 0.0]),
|
||||
("bottom", [90.0, 0.0, 0.0]),
|
||||
("offAxisFront", [-21.88, -23.4, 9.06]),
|
||||
("offAxisBack", [-158.12, 23.4, -170.94]),
|
||||
("offAxisLeft", [-40.74, 58.39, -36.26]),
|
||||
("offAxisRight", [-139.26, -58.39, 143.74]),
|
||||
("offAxisTop", [-111.88, -23.4, 9.06]),
|
||||
("offAxisBottom", [68.12, -23.4, 9.06]),
|
||||
("isometricLeft", [-45.0, -35.26, 30.0]),
|
||||
("isometricRight", [-45.0, 35.26, -30.0]),
|
||||
("isometricTop", [45.0, -35.26, 30.0]),
|
||||
("isometricBottom", [-45.0, -35.26, -30.0]),
|
||||
];
|
||||
|
||||
/// The view parameters every 3D effect shares, named as Revolve's.
|
||||
pub fn view_options(p: &Value) -> View {
|
||||
View {
|
||||
rotation_x: num(p, "rotationX", ROTATION_PRESETS[6].1[0]),
|
||||
rotation_y: num(p, "rotationY", ROTATION_PRESETS[6].1[1]),
|
||||
rotation_z: num(p, "rotationZ", ROTATION_PRESETS[6].1[2]),
|
||||
perspective: num(p, "perspective", 0.0) / 100.0,
|
||||
light_azimuth: num(p, "lightAzimuth", -45.0),
|
||||
light_elevation: num(p, "lightElevation", 45.0),
|
||||
light_intensity: num(p, "lightIntensity", 80.0) / 100.0,
|
||||
ambient: num(p, "ambient", 25.0) / 100.0,
|
||||
shade: flag(p, "shade", true),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extrude & Bevel's parameters. `bevel` is `"none"` or a [`BevelShape`] id.
|
||||
pub fn extrude_options(p: &Value) -> Extrude {
|
||||
Extrude {
|
||||
depth: num(p, "depth", 50.0),
|
||||
twist: num(p, "twist", 0.0),
|
||||
taper: num(p, "taper", 100.0) / 100.0,
|
||||
cap: flag(p, "cap", true),
|
||||
bevel: BevelShape::parse(text(p, "bevel", "none")).map(|shape| Bevel {
|
||||
shape,
|
||||
width: num(p, "bevelWidth", 4.0),
|
||||
height: num(p, "bevelHeight", 4.0),
|
||||
repeat: num(p, "bevelRepeat", 1.0).clamp(1.0, 32.0) as usize,
|
||||
space: num(p, "bevelSpace", 0.0) / 100.0,
|
||||
inside: flag(p, "bevelInside", false),
|
||||
both_sides: flag(p, "bevelBothSides", false),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn inflate_options(p: &Value) -> Inflate {
|
||||
Inflate { depth: num(p, "depth", 0.0), volume: num(p, "volume", 50.0), both_sides: flag(p, "bothSides", false) }
|
||||
}
|
||||
|
||||
/// The shape a solid is made from: a path's or a compound path's outline.
|
||||
fn profile(n: &Node) -> Option<(PathData, FillRule)> {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, rule, guide: false, clipping: false, .. } => Some((path.clone(), *rule)),
|
||||
NodeKind::Compound { children, rule } => {
|
||||
Some((PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect()), *rule))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn facets(id: &str, path: &PathData, bounds: Rect, p: &Value) -> Result<Vec<Facet>, String> {
|
||||
let view = view_options(p);
|
||||
match id {
|
||||
EXTRUDE => vectorcraft_three_d::extrude(path, bounds, extrude_options(p), view),
|
||||
INFLATE => vectorcraft_three_d::inflate(path, bounds, inflate_options(p), view),
|
||||
_ => vectorcraft_three_d::plane(path, bounds, view),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate before a command changes the document; the evaluator bounds loaded files too.
|
||||
pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
|
||||
let label = crate::effect_info(id).map_or("This effect", |e| e.label.trim_end_matches('…'));
|
||||
let (path, _) =
|
||||
profile(n).ok_or_else(|| format!("{label} currently requires a path or compound path. Convert type, images and groups to paths first."))?;
|
||||
let b = path.bounds().ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?;
|
||||
if let Some(v) = p.get("bevel")
|
||||
&& !v.as_str().is_some_and(|s| s == "none" || BevelShape::parse(s).is_some())
|
||||
{
|
||||
let names: Vec<_> = BevelShape::ALL.iter().map(|b| b.id()).collect();
|
||||
return Err(format!("bevel must be none or one of {}", names.join(", ")));
|
||||
}
|
||||
facets(id, &path, b, p).map(|_| ())
|
||||
}
|
||||
|
||||
/// One surface: each facet a path shaded from `paint`, as a group with the paint's opacity and
|
||||
/// blend mode (so overlapping facets don't add up their transparency).
|
||||
fn surface(n: &Node, facets: Vec<Facet>, paint: &Paint, opacity: f32, blend: BlendMode) -> Node {
|
||||
let children = facets
|
||||
.into_iter()
|
||||
.map(|face| {
|
||||
let mut items = vec![];
|
||||
if let Some(c) = paint.color() {
|
||||
let [r, g, b] = c.to_rgb();
|
||||
let s = face.brightness as f32;
|
||||
items.push(AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(r * s, g * s, b * s)))));
|
||||
} else {
|
||||
// Gradients and patterns keep their paint, darkened by a black layer.
|
||||
items.push(AppearanceItem::Fill(FillLayer::new(paint.clone())));
|
||||
let mut shadow = FillLayer::new(Paint::solid(Color::BLACK));
|
||||
shadow.opacity = (1.0 - face.brightness) as f32;
|
||||
items.push(AppearanceItem::Fill(shadow));
|
||||
}
|
||||
// Adjacent facets, antialiased on their own, would leave hairline seams: a narrow
|
||||
// stroke of the same paint closes them. No wider than the facet is thick, though: seen
|
||||
// at a grazing angle a facet can be thinner than the stroke, and its stroke would then
|
||||
// paint over its neighbours.
|
||||
let seam = (face.thickness() / 2.0).clamp(0.05, 0.75);
|
||||
let items = items
|
||||
.into_iter()
|
||||
.flat_map(|item| {
|
||||
let AppearanceItem::Fill(fill) = &item else { return vec![item] };
|
||||
let mut edge = StrokeLayer::new(fill.paint.clone(), seam);
|
||||
edge.opacity = fill.opacity;
|
||||
vec![item, AppearanceItem::Stroke(edge)]
|
||||
})
|
||||
.collect();
|
||||
Arc::new(Node::path(n.id, face.path(), Appearance { items, ..Default::default() }))
|
||||
})
|
||||
.collect();
|
||||
let mut group = Node::group(n.id, children);
|
||||
group.opacity = opacity;
|
||||
group.blend = blend;
|
||||
group
|
||||
}
|
||||
|
||||
fn paint_of(item: &AppearanceItem) -> (Paint, f32, BlendMode) {
|
||||
match item {
|
||||
AppearanceItem::Fill(f) => (f.paint.clone(), f.opacity, f.blend),
|
||||
AppearanceItem::Stroke(s) => (s.paint.clone(), s.opacity, s.blend),
|
||||
}
|
||||
}
|
||||
|
||||
/// The area `item` paints on `path`: the path itself for a fill, the stroke's outline for a stroke.
|
||||
fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> PathData {
|
||||
match item {
|
||||
AppearanceItem::Stroke(s) if s.width > 0.0 && s.width.is_finite() => crate::stroke::outline_region(path, rule, s),
|
||||
_ => path.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The 3D art of one effect `fx` on `path` (already through the effects before it).
|
||||
/// Extrude & Bevel and Inflate build a solid from the outline, painted with the top visible fill
|
||||
/// (or, without one, the top stroke); Rotate turns every fill and stroke, each in its own paint.
|
||||
fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&AppearanceItem]) -> Option<Vec<Arc<Node>>> {
|
||||
let b = path.bounds()?;
|
||||
let p = crate::merged_params(&fx.id, &fx.params);
|
||||
if fx.id == ROTATE {
|
||||
// Every part turns about the centre of the object's own bounds, so they stay together.
|
||||
let mut out = vec![];
|
||||
for item in items {
|
||||
let (paint, opacity, blend) = paint_of(item);
|
||||
let f = facets(ROTATE, ®ion(path, rule, item), b, &p).ok()?;
|
||||
out.push(Arc::new(surface(n, f, &paint, opacity, blend)));
|
||||
}
|
||||
return Some(out);
|
||||
}
|
||||
let item = items.iter().rev().find(|i| matches!(i, AppearanceItem::Fill(_))).or_else(|| items.last())?;
|
||||
let (paint, opacity, blend) = paint_of(item);
|
||||
// A loaded file or a shape edited past the limits keeps drawing its source path instead of
|
||||
// failing the frame; commands validate the same evaluator and report its errors.
|
||||
let f = facets(&fx.id, path, b, &p).ok()?;
|
||||
Some(vec![Arc::new(surface(n, f, &paint, opacity, blend))])
|
||||
}
|
||||
|
||||
fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData {
|
||||
apply_geometry_with(fx, path, path.bounds().unwrap_or(Rect::ZERO), ctx)
|
||||
}
|
||||
|
||||
/// `n` drawn with its Extrude & Bevel, Inflate or Rotate effect: a group of shaded faces keeping
|
||||
/// the object's id and transparency, the source path untouched. `None` without such an effect.
|
||||
pub fn solid_art(n: &Node) -> Option<Node> {
|
||||
if !has_solid(n) {
|
||||
return None;
|
||||
}
|
||||
let (path, rule) = profile(n)?;
|
||||
let ctx = GeomContext::of(n);
|
||||
let painted: Vec<&AppearanceItem> = n.appearance.items.iter().filter(|i| crate::paints(i)).collect();
|
||||
let mut children = vec![];
|
||||
let remaining: Vec<Effect>;
|
||||
if let Some((index, fx)) = solid(&n.appearance.effects) {
|
||||
let path = geometry(&path, n.appearance.effects.get(..index)?, &ctx);
|
||||
children.extend(art_for(n, fx, &path, rule, &painted)?);
|
||||
// Later geometry effects act on the projected art as a whole.
|
||||
remaining = n
|
||||
.appearance
|
||||
.effects
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index))
|
||||
.map(|(_, e)| e.clone())
|
||||
.collect();
|
||||
} else {
|
||||
let path = geometry(&path, &n.appearance.effects, &ctx);
|
||||
remaining = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect();
|
||||
for item in &painted {
|
||||
if let Some((index, fx)) = solid(item.effects()) {
|
||||
let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item));
|
||||
for mut art in art_for(n, fx, &p, rule, &[*item])? {
|
||||
Arc::make_mut(&mut art).appearance.effects =
|
||||
item.effects().iter().skip(index + 1).filter(|e| !is_three_d(&e.id)).cloned().collect();
|
||||
children.push(art);
|
||||
}
|
||||
} else {
|
||||
children.push(Arc::new(Node::path(n.id, path.clone(), Appearance { items: vec![(*item).clone()], ..Default::default() })));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut art = n.clone();
|
||||
art.kind = NodeKind::Group { children, clip: false };
|
||||
art.appearance = Appearance { effects: remaining, ..Default::default() }.into();
|
||||
Some(art)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests_solids.rs"]
|
||||
mod tests;
|
||||
@@ -19,7 +19,7 @@ mod width;
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use kurbo::{BezPath, PathEl, PathSeg};
|
||||
use kurbo::{BezPath, ParamCurve, ParamCurveNearest, PathEl, PathSeg, Shape};
|
||||
use vectorcraft_doc::{LineCap, LineJoin, StrokeAlign, StrokeLayer};
|
||||
pub(crate) use vectorcraft_geom::bez::{kink, segments, subpaths, tangent, unit};
|
||||
|
||||
@@ -116,10 +116,58 @@ pub fn line_outline(line: &BezPath, st: &StrokeLayer, width: f64, tol: f64) -> B
|
||||
out.extend(dot_outline(&d.dots, width, st.cap, tol).iter());
|
||||
out
|
||||
}
|
||||
(None, None) => kurbo::stroke(line.iter(), &style(st, width), &kurbo::StrokeOpts::default(), tol),
|
||||
(None, None) => plain_outline(line, &style(st, width), tol),
|
||||
}
|
||||
}
|
||||
|
||||
/// `line` stroked with `style`, to fill with the non-zero rule.
|
||||
///
|
||||
/// kurbo offsets a closed subpath to both sides, as two contours. Once half the weight is more
|
||||
/// than the shape can hold (its inradius), the inner one has crossed the middle and comes out
|
||||
/// the far side: wound against the outer contour it would cut a hole where the pen in fact
|
||||
/// overlaps itself (#1396). Such a contour is left out, so the stroke paints solid.
|
||||
// shortcut: closed subpaths of plain strokes only. An open path that turns back on itself inside
|
||||
// a wider stroke (and dashes on such a shape) can still cancel; fix it in the offsetting if reported.
|
||||
fn plain_outline(line: &BezPath, style: &kurbo::Stroke, tol: f64) -> BezPath {
|
||||
let stroke = |els: &[PathEl]| kurbo::stroke(els.iter().copied(), style, &kurbo::StrokeOpts::default(), tol);
|
||||
let els = line.elements();
|
||||
// Only a stroke at least as wide as a closed subpath's bounds can cross over: everything
|
||||
// else takes the one call it always did.
|
||||
let may_cross = |sub: &[PathEl]| {
|
||||
let b = BezPath::from_vec(sub.to_vec()).bounding_box();
|
||||
style.width >= b.width().min(b.height())
|
||||
};
|
||||
let subs = subpaths(line);
|
||||
if !subs.iter().any(|(r, closed)| *closed && els.get(r.clone()).is_some_and(may_cross)) {
|
||||
return stroke(els);
|
||||
}
|
||||
let reach = style.width / 2.0 - 2.0 * tol;
|
||||
let mut out = BezPath::new();
|
||||
for (r, closed) in subs {
|
||||
let Some(sub) = els.get(r) else { continue };
|
||||
let outline = stroke(sub);
|
||||
if !(closed && may_cross(sub)) {
|
||||
out.extend(outline.iter());
|
||||
continue;
|
||||
}
|
||||
let sub = BezPath::from_vec(sub.to_vec());
|
||||
for (c, _) in subpaths(&outline) {
|
||||
let Some(contour) = outline.elements().get(c) else { continue };
|
||||
if reaches_edge(contour, &sub, reach) {
|
||||
out.extend(contour.iter().copied());
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether some point of `contour` lies at least `reach` from `path`: an offset contour that
|
||||
/// never does is nowhere the edge of the stroke.
|
||||
fn reaches_edge(contour: &[PathEl], path: &BezPath, reach: f64) -> bool {
|
||||
let far = |p: kurbo::Point| path.segments().all(|s| s.nearest(p, 1e-6).distance_sq >= reach * reach);
|
||||
kurbo::segments(contour.iter().copied()).any(|s| far(s.eval(0.0)) || far(s.eval(0.5)))
|
||||
}
|
||||
|
||||
/// Append `seg` to `out` (which must already have a current point at its start).
|
||||
pub(crate) fn push_seg(out: &mut BezPath, seg: &PathSeg) {
|
||||
match seg {
|
||||
|
||||
@@ -37,6 +37,23 @@ fn near(a: f64, b: f64, tol: f64) -> bool {
|
||||
(a - b).abs() <= tol
|
||||
}
|
||||
|
||||
/// #1396: a stroke wider than the shape still covers the interior and reaches half the weight past it.
|
||||
/// A thinner stroke keeps the hole in the middle.
|
||||
#[test]
|
||||
fn a_stroke_wider_than_the_shape_covers_the_interior() {
|
||||
let circle = kurbo::Circle::new(Point::new(0.0, 0.0), 5.0).to_path(0.1);
|
||||
let st = stroke(40.0, |_| {});
|
||||
let outline = line_outline(&circle, &st, 40.0, 0.25);
|
||||
let b = outline.bounding_box();
|
||||
assert!(b.x0 < -24.0 && b.x1 > 24.0, "half the weight past the radius: {b:?}");
|
||||
assert!(kurbo::Shape::contains(&outline, Point::ZERO), "the middle of a fat stroke is painted");
|
||||
let thin = line_outline(&circle, &stroke(4.0, |_| {}), 4.0, 0.25);
|
||||
assert!(!kurbo::Shape::contains(&thin, Point::ZERO), "a narrow stroke stays a ring");
|
||||
assert!(kurbo::Shape::contains(&thin, Point::new(5.0, 0.0)));
|
||||
let square = line_outline(&Rect::new(0.0, 0.0, 8.0, 8.0).to_path(0.1), &st, 40.0, 0.25);
|
||||
assert!(kurbo::Shape::contains(&square, Point::new(4.0, 4.0)), "a fat stroke on a small rectangle covers it");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- dashes and dots
|
||||
|
||||
#[test]
|
||||
@@ -304,3 +321,85 @@ fn degenerate_paths_and_patterns_neither_panic_nor_hang() {
|
||||
}
|
||||
assert!(dash(&line(0.0, 1e-9), &dashed(&[1e-12, 1e-12], 0.0)).is_none(), "too fine to advance: solid");
|
||||
}
|
||||
|
||||
/// Grid points within `reach` of the inside of `path` (not past an open end) that `outline`
|
||||
/// leaves unpainted.
|
||||
fn unpainted_near(path: &BezPath, outline: &BezPath, reach: f64) -> Vec<Point> {
|
||||
let b = path.bounding_box().inflate(reach, reach);
|
||||
let ends: Vec<Point> = if is_closed(path) { vec![] } else { path.segments().flat_map(|s| [s.start(), s.end()]).collect() };
|
||||
let ends = [ends.first().copied(), ends.last().copied()];
|
||||
let mut missed = Vec::new();
|
||||
for i in 0..=60 {
|
||||
for j in 0..=60 {
|
||||
let p = Point::new(b.x0 + b.width() * f64::from(i) / 60.0, b.y0 + b.height() * f64::from(j) / 60.0);
|
||||
let nearest = path
|
||||
.segments()
|
||||
.map(|s| (s, kurbo::ParamCurveNearest::nearest(&s, p, 1e-6)))
|
||||
.min_by(|a, b| a.1.distance_sq.total_cmp(&b.1.distance_sq))
|
||||
.map(|(s, n)| (s.eval(n.t), n.distance_sq.sqrt()));
|
||||
let Some((at, dist)) = nearest else { continue };
|
||||
let past_end = ends.iter().flatten().any(|e| e.distance(at) < 1e-6);
|
||||
if dist <= reach && !past_end && outline.winding(p) == 0 {
|
||||
missed.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
missed
|
||||
}
|
||||
|
||||
/// #1396: whatever the weight is next to the size of the shape, every point within half the
|
||||
/// weight of the path is painted (the stroke is the path swept by a pen that wide).
|
||||
#[test]
|
||||
fn a_fat_stroke_paints_everything_within_half_its_weight() {
|
||||
let mut shapes: Vec<(String, BezPath)> = Vec::new();
|
||||
for r in [3.0, 6.0, 10.0, 15.0, 20.0, 24.0, 40.0] {
|
||||
let circle = kurbo::Circle::new(Point::ZERO, r).to_path(0.01);
|
||||
let mut ellipse = kurbo::Ellipse::new(Point::ZERO, (r, r / 2.0), 0.3).to_path(0.01);
|
||||
ellipse.close_path();
|
||||
let arc = BezPath::from_vec(circle.elements().iter().take(3).copied().collect());
|
||||
let mut l = BezPath::new();
|
||||
l.move_to((0.0, 0.0));
|
||||
for p in [(r * 2.0, 0.0), (r * 2.0, r), (r, r), (r, r * 2.0), (0.0, r * 2.0)] {
|
||||
l.line_to(p);
|
||||
}
|
||||
l.close_path();
|
||||
let mut two = circle.clone();
|
||||
two.extend(kurbo::Circle::new(Point::new(200.0, 0.0), 60.0).to_path(0.01).iter());
|
||||
shapes.extend([
|
||||
(format!("circle r={r}"), circle),
|
||||
(format!("ellipse {r}"), ellipse),
|
||||
(format!("rect {r}"), Rect::new(0.0, 0.0, r, r * 1.5).to_path(0.1)),
|
||||
(format!("open arc r={r}"), arc),
|
||||
(format!("L {r}"), l),
|
||||
(format!("circle r={r} beside a large one"), two),
|
||||
]);
|
||||
}
|
||||
let mut failures = Vec::new();
|
||||
for (name, path) in &shapes {
|
||||
for join in [LineJoin::Miter, LineJoin::Round, LineJoin::Bevel] {
|
||||
let outline = line_outline(path, &stroke(50.0, |s| s.join = join), 50.0, 0.05);
|
||||
// A bevel stops short of the pen's reach at a corner.
|
||||
let reach = if join == LineJoin::Bevel { 16.0 } else { 24.5 };
|
||||
let missed = unpainted_near(path, &outline, reach);
|
||||
if !missed.is_empty() {
|
||||
failures.push(format!("{name} {join:?}: {} unpainted, e.g. {:?} ({} contours)", missed.len(), missed.first(), moves(&outline)));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
/// A stroke the shape has room for keeps its hole, also beside a subpath that has none.
|
||||
#[test]
|
||||
fn a_stroke_narrower_than_the_shape_keeps_its_hole() {
|
||||
let mut path = kurbo::Circle::new(Point::ZERO, 30.0).to_path(0.01);
|
||||
path.extend(kurbo::Circle::new(Point::new(200.0, 0.0), 10.0).to_path(0.01).iter());
|
||||
for join in [LineJoin::Miter, LineJoin::Round, LineJoin::Bevel] {
|
||||
let outline = line_outline(&path, &stroke(50.0, |s| s.join = join), 50.0, 0.05);
|
||||
assert_eq!(outline.winding(Point::ZERO), 0, "{join:?}: the hole of the wide ring");
|
||||
assert_eq!(outline.winding(Point::new(4.0, 0.0)), 0, "{join:?}: up to its inner edge");
|
||||
assert_ne!(outline.winding(Point::new(6.0, 0.0)), 0, "{join:?}");
|
||||
assert_ne!(outline.winding(Point::new(200.0, 0.0)), 0, "{join:?}: the small circle is solid");
|
||||
assert_eq!(moves(&outline), 3, "{join:?}: two contours for the ring, one for the blob");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{Document, NodeId};
|
||||
use vectorcraft_geom::{Affine, BezPath};
|
||||
|
||||
use super::*;
|
||||
|
||||
const RED: Color = Color::Rgb { r: 0.8, g: 0.2, b: 0.1 };
|
||||
|
||||
fn square_node() -> Node {
|
||||
let mut b = BezPath::new();
|
||||
b.move_to((100.0, 100.0));
|
||||
b.line_to((200.0, 100.0));
|
||||
b.line_to((200.0, 200.0));
|
||||
b.line_to((100.0, 200.0));
|
||||
b.close_path();
|
||||
Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(RED), Paint::solid(Color::BLACK), 4.0))
|
||||
}
|
||||
|
||||
fn fx(id: &str, params: Value) -> Effect {
|
||||
crate::new_effect(id, ¶ms).unwrap()
|
||||
}
|
||||
|
||||
/// The shaded faces of every surface of `art`, bottom first.
|
||||
fn faces(art: &Node) -> Vec<&Node> {
|
||||
art.children().unwrap().iter().flat_map(|s| s.children().unwrap().iter().map(|f| &**f)).collect()
|
||||
}
|
||||
|
||||
fn fill_rgb(n: &Node) -> [f32; 3] {
|
||||
match &n.appearance.items[0] {
|
||||
AppearanceItem::Fill(f) => f.paint.color().unwrap().to_rgb(),
|
||||
_ => panic!("a fill first"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_solid_draws_shaded_faces_and_leaves_the_source_editable() {
|
||||
for id in SOLIDS {
|
||||
let mut n = square_node();
|
||||
let source = n.path_data().unwrap().clone();
|
||||
n.appearance.effects.push(fx(id, json!({})));
|
||||
let art = solid_art(&n).unwrap_or_else(|| panic!("{id}"));
|
||||
assert_eq!(n.path_data(), Some(&source), "{id}");
|
||||
assert!(!faces(&art).is_empty(), "{id}");
|
||||
// The faces are the fill's red, shaded: never brighter, never another hue.
|
||||
for f in faces(&art) {
|
||||
let [r, g, b] = fill_rgb(f);
|
||||
assert!(r <= 0.8 + 1e-6 && (g * 4.0 - r).abs() < 1e-4 && (b * 8.0 - r).abs() < 1e-4 || id == ROTATE, "{id}: {r} {g} {b}");
|
||||
}
|
||||
// The art follows the source when it moves.
|
||||
let before = art.visual_bounds().unwrap();
|
||||
n.transform(Affine::translate((50.0, -30.0)), false);
|
||||
let after = solid_art(&n).unwrap().visual_bounds().unwrap();
|
||||
assert!((after.x0 - before.x0 - 50.0).abs() < 1e-6 && (after.y0 - before.y0 + 30.0).abs() < 1e-6, "{id}");
|
||||
// Hidden, the effect draws nothing of its own.
|
||||
n.appearance.effects[0].visible = false;
|
||||
assert!(solid_art(&n).is_none(), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_turns_every_fill_and_stroke_and_the_solids_use_the_top_fill() {
|
||||
let mut n = square_node();
|
||||
n.appearance.effects.push(fx(ROTATE, json!({})));
|
||||
let art = solid_art(&n).unwrap();
|
||||
// The fill's plane and the stroke's outline, each in its own paint.
|
||||
let surfaces = art.children().unwrap();
|
||||
assert_eq!(surfaces.len(), 2);
|
||||
let paint = |s: &Node| fill_rgb(&s.children().unwrap()[0]);
|
||||
assert!(paint(&surfaces[0])[0] > paint(&surfaces[0])[1], "the red fill below");
|
||||
assert!(paint(&surfaces[1]).iter().all(|c| *c < 0.05), "the black stroke above");
|
||||
// Turned 60° about y with no perspective, the stroke's outline spans half its width plus the
|
||||
// stroke's own outset, foreshortened as well.
|
||||
n.appearance.effects[0].params = json!({"rotationX": 0, "rotationY": 60, "rotationZ": 0});
|
||||
let b = solid_art(&n).unwrap().children().unwrap()[1].visual_bounds().unwrap();
|
||||
assert!((b.width() - 104.0 * 0.5 - 0.75).abs() < 1e-6, "{b:?}");
|
||||
// Extrude & Bevel paints one surface with the top fill.
|
||||
let mut n = square_node();
|
||||
n.appearance.effects.push(fx(EXTRUDE, json!({})));
|
||||
assert_eq!(solid_art(&n).unwrap().children().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparency_stays_on_the_whole_surface_and_items_take_their_own_effect() {
|
||||
let mut n = square_node();
|
||||
n.opacity = 0.5;
|
||||
if let AppearanceItem::Fill(f) = &mut n.appearance.items[0] {
|
||||
f.opacity = 0.4;
|
||||
f.effects.push(fx(INFLATE, json!({})));
|
||||
}
|
||||
let art = solid_art(&n).unwrap();
|
||||
assert_eq!(art.opacity, 0.5);
|
||||
let surfaces = art.children().unwrap();
|
||||
// The fill inflated, the stroke drawn flat as it was.
|
||||
assert_eq!(surfaces.len(), 2);
|
||||
assert_eq!(surfaces[0].opacity, 0.4);
|
||||
assert!(surfaces[0].children().unwrap().iter().all(|c| c.opacity == 1.0));
|
||||
assert!(matches!(surfaces[1].kind, NodeKind::Path { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_and_expand_bake_faces_with_their_own_ids() {
|
||||
for id in SOLIDS {
|
||||
let mut d = Document::new(400.0, 400.0);
|
||||
let mut n = square_node();
|
||||
n.id = d.alloc_id();
|
||||
n.appearance.effects.push(fx(id, json!({"bevel": "rounded"})));
|
||||
assert_eq!(crate::bake_appearance(&n), solid_art(&n), "{id}");
|
||||
let mut stroke = |_: &mut Document, _: &PathData, _: FillRule, _: &StrokeLayer| None;
|
||||
let art = crate::expand_leaf(&mut d, &n, &mut stroke).unwrap();
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
fn visit(n: &Node, ids: &mut std::collections::HashSet<NodeId>) {
|
||||
assert!(ids.insert(n.id), "{:?} twice", n.id);
|
||||
assert!(!has_solid(n));
|
||||
for c in n.children().into_iter().flatten() {
|
||||
visit(c, ids);
|
||||
}
|
||||
}
|
||||
visit(&art, &mut ids);
|
||||
assert!(ids.len() > 2, "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effects_before_it_shape_the_solid_and_effects_after_it_bend_the_result() {
|
||||
let mut n = square_node();
|
||||
n.appearance.effects.push(fx("path.offsetPath", json!({"offset": 20})));
|
||||
n.appearance.effects.push(fx(EXTRUDE, json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "perspective": 0})));
|
||||
// Straight on, the extrusion shows its front: the offset square.
|
||||
let b = solid_art(&n).unwrap().visual_bounds().unwrap();
|
||||
assert!((b.width() - 140.0 - 0.75).abs() < 1e-6, "{b:?}");
|
||||
n.appearance.effects.push(fx("distort.twist", json!({"angle": 30})));
|
||||
let art = solid_art(&n).unwrap();
|
||||
assert!(art.appearance.effects.iter().any(|e| e.id == "distort.twist"), "applied to the result as a whole");
|
||||
assert!(art.appearance.effects.iter().all(|e| e.id != "path.offsetPath" && !is_three_d(&e.id)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameters_are_validated_and_lengths_scale_with_the_object() {
|
||||
let n = square_node();
|
||||
assert!(validate_solid(&n, EXTRUDE, &json!({"bevel": "rounded", "depth": 80})).is_ok());
|
||||
assert!(validate_solid(&n, EXTRUDE, &json!({"bevel": "fancy"})).unwrap_err().contains("bevel must be"));
|
||||
let text = Node::group(NodeId(2), vec![]);
|
||||
assert!(validate_solid(&text, INFLATE, &json!({})).unwrap_err().contains("requires a path"));
|
||||
for (id, key) in [(EXTRUDE, "depth"), (EXTRUDE, "bevelWidth"), (EXTRUDE, "bevelHeight"), (INFLATE, "depth")] {
|
||||
assert!(crate::is_length(id, key, false), "{id} {key}");
|
||||
}
|
||||
assert!(!crate::is_length(EXTRUDE, "twist", false));
|
||||
for id in SOLIDS {
|
||||
assert!(!crate::is_geometry(id) && is_three_d(id));
|
||||
assert!(crate::default_params(id).is_some());
|
||||
}
|
||||
// Options read from parameters, with the documented ranges' defaults.
|
||||
let o = extrude_options(&json!({"taper": 40, "bevel": "step", "bevelSpace": 30, "bevelRepeat": 99}));
|
||||
assert_eq!(o.taper, 0.4);
|
||||
let b = o.bevel.unwrap();
|
||||
assert_eq!((b.shape, b.space, b.repeat), (BevelShape::Step, 0.3, 32));
|
||||
assert!(extrude_options(&json!({})).bevel.is_none());
|
||||
}
|
||||
@@ -33,8 +33,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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.arc", "Arc", [], None, "{x1, y1, x2, y2, closed?} → {id}", has_doc, arc),
|
||||
cmd!("shape.rectangularGrid", "Rectangular Grid", [], None, "{x, y, width, height, rows=5, columns=5} → {id}", has_doc, rect_grid),
|
||||
cmd!("shape.polarGrid", "Polar Grid", [], None, "{x, y, width, height, concentric=5, radial=5} → {id}", has_doc, polar_grid),
|
||||
cmd!(
|
||||
"shape.rectangularGrid",
|
||||
"Rectangular Grid",
|
||||
[],
|
||||
None,
|
||||
"{x, y, width, height, rows=5, columns=5, rowSkew=0, columnSkew=0 (−500…500 %: + crowds the horizontal dividers toward the top, the vertical ones toward the right), frame=true (the outside as one rectangle, else its four sides), fill=false (the current fill, else none)} → {id}",
|
||||
has_doc,
|
||||
rect_grid
|
||||
),
|
||||
cmd!(
|
||||
"shape.polarGrid",
|
||||
"Polar Grid",
|
||||
[],
|
||||
None,
|
||||
"{x, y, width, height, concentric=5, radial=5, concentricSkew=0, radialSkew=0 (−500…500 %: + crowds the ellipses toward the outside, the radial dividers clockwise of the top one), compound=false (the ellipses pair up into compound-path rings), fill=false (the current fill, else none)} → {id}",
|
||||
has_doc,
|
||||
polar_grid
|
||||
),
|
||||
cmd!(
|
||||
"path.create",
|
||||
"Create Path",
|
||||
@@ -351,14 +367,24 @@ fn arc(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
add_art(s, "Arc", path_kind(shapes::arc(a, b, 0.0, bool_or(p, "closed", false)), None), None)
|
||||
}
|
||||
|
||||
fn grid_group(s: &mut Session, label: &str, paths: Vec<PathData>) -> Result<Value> {
|
||||
/// The grid `paths` (bottom first) as one selected group in the new-art stroke, filled with the
|
||||
/// current fill when `fill`; a path of several subpaths becomes a compound path of them.
|
||||
fn grid_group(s: &mut Session, label: &str, paths: Vec<PathData>, fill: bool) -> Result<Value> {
|
||||
let parent = s.doc()?.target_parent()?;
|
||||
let look = s.new_art_look(vectorcraft_color::Paint::None, s.paint.stroke.clone(), s.paint.stroke_width);
|
||||
let fill = if fill { s.paint.fill.clone() } else { vectorcraft_color::Paint::None };
|
||||
let look = s.new_art_look(fill, s.paint.stroke.clone(), s.paint.stroke_width);
|
||||
let id = s.edit(label, |d, sel| {
|
||||
let children = paths
|
||||
.into_iter()
|
||||
.map(|pd| {
|
||||
let mut n = Node::path(d.alloc_id(), pd, Appearance::default());
|
||||
let mut n = if pd.subpaths.len() > 1 {
|
||||
let members =
|
||||
pd.subpaths.into_iter().map(|sp| std::sync::Arc::new(Node::path(d.alloc_id(), PathData::single(sp), Appearance::default())));
|
||||
let children = members.collect();
|
||||
Node::new(d.alloc_id(), NodeKind::Compound { children, rule: FillRule::NonZero })
|
||||
} else {
|
||||
Node::path(d.alloc_id(), pd, Appearance::default())
|
||||
};
|
||||
look.apply(d, &mut n);
|
||||
std::sync::Arc::new(n)
|
||||
})
|
||||
@@ -371,18 +397,43 @@ fn grid_group(s: &mut Session, label: &str, paths: Vec<PathData>) -> Result<Valu
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
/// A grid's divider count `count` 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)> {
|
||||
let n = p.get(count).and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
|
||||
let skew = match p.get(key) {
|
||||
None | Some(Value::Null) => 0.0,
|
||||
Some(v) => v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a number")))?,
|
||||
};
|
||||
Ok((n, skew.clamp(-shapes::GRID_SKEW_MAX, shapes::GRID_SKEW_MAX)))
|
||||
}
|
||||
|
||||
/// A grid's on/off option `key` (`default` when absent).
|
||||
fn switch(p: &Value, key: &str, default: bool, cmd: &str) -> Result<bool> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(default),
|
||||
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, format!("`{key}` must be true or false"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn rect_grid(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = rect_of(p, "shape.rectangularGrid")?;
|
||||
let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
|
||||
let cols = p.get("columns").and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
|
||||
grid_group(s, "Rectangular Grid", shapes::rectangular_grid(r, rows, cols, true))
|
||||
const CMD: &str = "shape.rectangularGrid";
|
||||
let r = rect_of(p, CMD)?;
|
||||
let paths =
|
||||
shapes::rectangular_grid(r, dividers(p, "rows", "rowSkew", CMD)?, dividers(p, "columns", "columnSkew", CMD)?, switch(p, "frame", true, CMD)?);
|
||||
grid_group(s, "Rectangular Grid", paths, switch(p, "fill", false, CMD)?)
|
||||
}
|
||||
|
||||
fn polar_grid(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = rect_of(p, "shape.polarGrid")?;
|
||||
let c = p.get("concentric").and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
|
||||
let rad = p.get("radial").and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
|
||||
grid_group(s, "Polar Grid", shapes::polar_grid(r, c, rad))
|
||||
const CMD: &str = "shape.polarGrid";
|
||||
let r = rect_of(p, CMD)?;
|
||||
let paths = shapes::polar_grid(
|
||||
r,
|
||||
dividers(p, "concentric", "concentricSkew", CMD)?,
|
||||
dividers(p, "radial", "radialSkew", CMD)?,
|
||||
switch(p, "compound", false, CMD)?,
|
||||
);
|
||||
grid_group(s, "Polar Grid", paths, switch(p, "fill", false, CMD)?)
|
||||
}
|
||||
|
||||
/// An anchor `{x, y, in?, out?, smooth?}` given to `cmd` (handles `[x, y]`, at the anchor when
|
||||
|
||||
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!("file.close", "Close", ["File"], Some("Cmd+W"), "{index?}", has_doc, file_close),
|
||||
cmd!("document.activate", "Activate Document", [], None, "{index}", always, doc_activate),
|
||||
cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?} → layer tree, artboards, selection, history (a sliced tree reports childCount)", has_doc, |s, p| Ok(inspect::document_opts(s, slice_opts(p, "document.inspect")?))),
|
||||
cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount)", has_doc, |s, p| Ok(inspect::document_opts(s, slice_opts(p, "document.inspect")?))),
|
||||
cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: compact summary, depth?: child levels in the summary (default all), childLimit?: children shown per node (default all; a level that shows fewer reports childCount)} → one object", has_doc, doc_node),
|
||||
cmd!(query "document.find", "Find Objects", [], None, "{name?: substring of the Layers panel name, kind?: panel label such as Group, Path, Type or Image (exact), text?: substring of type content, limit?: max matches (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total}", has_doc, doc_find),
|
||||
cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))),
|
||||
@@ -151,20 +151,22 @@ fn doc_activate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn doc_node(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("document.node", "missing id"))?;
|
||||
let opts = slice_opts(p, "document.node")?;
|
||||
let mut opts = slice_opts(p, "document.node")?;
|
||||
let summary = bool_or(p, "summary", false);
|
||||
if !summary && (opts.depth.is_some() || opts.child_limit.is_some()) {
|
||||
// The full object JSON is never truncated: a slice without `summary` would
|
||||
// otherwise silently return the whole subtree.
|
||||
return Err(bad("document.node", "`depth` and `childLimit` slice the summary: pass `summary: true`"));
|
||||
}
|
||||
let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
let doc = &s.doc()?.doc;
|
||||
let n = doc.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
opts.compound_member = doc.parent_of(id).and_then(|p| doc.node(p)).is_some_and(|p| matches!(p.kind, vectorcraft_doc::NodeKind::Compound { .. }));
|
||||
if summary { Ok(inspect::node_summary_opts(n, opts)) } else { Ok(serde_json::to_value(n).unwrap_or(Value::Null)) }
|
||||
}
|
||||
|
||||
/// `depth` / `childLimit` for a summary read: absent (or null) means no limit.
|
||||
fn slice_opts(p: &Value, cmd: &str) -> Result<inspect::SummaryOpts> {
|
||||
Ok(inspect::SummaryOpts { depth: opt_u64(p, cmd, "depth")?, child_limit: opt_u64(p, cmd, "childLimit")? })
|
||||
Ok(inspect::SummaryOpts { depth: opt_u64(p, cmd, "depth")?, child_limit: opt_u64(p, cmd, "childLimit")?, compound_member: false })
|
||||
}
|
||||
|
||||
/// An optional count: absent (or null) is `None`; a present value that is not a
|
||||
@@ -382,9 +384,7 @@ fn clear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Clear", |d, sel| {
|
||||
for id in &ids {
|
||||
d.remove(*id)?;
|
||||
}
|
||||
d.remove_many(&ids)?;
|
||||
if with_guides {
|
||||
let gone = sel.guides.clone();
|
||||
d.retain_guides(sel, |i, _| !gone.contains(&i));
|
||||
@@ -558,16 +558,35 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// Duplicate `roots` directly above each original, transform the copies, select them.
|
||||
/// Duplicate `roots` directly above each original, transform the copies, select them. The copies
|
||||
/// take new ids in the order of `roots`. Roots listed twice, or inside another root, are copied one
|
||||
/// at a time: each copy is made from the document as the copies before it left it.
|
||||
pub(crate) fn duplicate_in(d: &mut Document, sel: &mut vectorcraft_doc::Selection, roots: &[NodeId], xf: Affine) -> Result<Vec<NodeId>> {
|
||||
let mut out = vec![];
|
||||
for id in roots {
|
||||
let Some((par, idx, _)) = d.position(*id) else { continue };
|
||||
let Some(n) = d.node(*id).cloned() else { continue };
|
||||
let mut c = d.reid(&n);
|
||||
c.transform(xf, true);
|
||||
out.push(d.insert(par, idx + 1, c)?);
|
||||
}
|
||||
let paths = d.index_paths(roots.iter().copied());
|
||||
let mut found: Vec<_> = roots.iter().filter_map(|id| paths.get(id)).collect();
|
||||
found.sort_unstable();
|
||||
let out = if found.windows(2).all(|w| !w[1].starts_with(w[0])) {
|
||||
let mut copies = Vec::with_capacity(roots.len());
|
||||
for id in roots {
|
||||
let Some(n) = paths.get(id).and_then(|p| d.node_at(p)).cloned() else { continue };
|
||||
let mut c = d.reid(&n);
|
||||
c.transform(xf, true);
|
||||
copies.push((*id, vec![c]));
|
||||
}
|
||||
let out: Vec<NodeId> = copies.iter().flat_map(|(_, c)| c.iter().map(|n| n.id)).collect();
|
||||
d.insert_beside(copies, false)?;
|
||||
out
|
||||
} else {
|
||||
let mut out = vec![];
|
||||
for id in roots {
|
||||
let Some((par, idx, _)) = d.position(*id) else { continue };
|
||||
let Some(n) = d.node(*id).cloned() else { continue };
|
||||
let mut c = d.reid(&n);
|
||||
c.transform(xf, true);
|
||||
out.push(d.insert(par, idx + 1, c)?);
|
||||
}
|
||||
out
|
||||
};
|
||||
sel.set(out.iter().copied());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -119,13 +119,18 @@ fn edit_effects(
|
||||
}
|
||||
|
||||
fn check_revolve_count(n: &Node, cmd: &str) -> Result<()> {
|
||||
let count = n.appearance.effects.iter().chain(n.appearance.items.iter().flat_map(|i| i.effects())).filter(|e| e.id == effects::REVOLVE).count();
|
||||
if count > 1 {
|
||||
let all = || n.appearance.effects.iter().chain(n.appearance.items.iter().flat_map(|i| i.effects()));
|
||||
if all().filter(|e| e.id == effects::REVOLVE).count() > 1 {
|
||||
return Err(bad(cmd, "A profile supports one Revolve effect. Edit the existing effect's options instead."));
|
||||
}
|
||||
if all().filter(|e| effects::is_three_d(&e.id)).count() > 1 {
|
||||
return Err(bad(cmd, ONE_3D));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const ONE_3D: &str = "An object takes one 3D effect. Edit the existing effect's options, or remove it first.";
|
||||
|
||||
pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "effect.apply";
|
||||
let id = str_param(p, "effect").or_else(|| str_param(p, "id")).ok_or_else(|| bad(C, "missing `effect` id"))?;
|
||||
@@ -149,12 +154,19 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Ok(json!({ "ids": [gid.0], "index": 0, "item": null, "grouped": true }));
|
||||
}
|
||||
let item = item_target(s, p, C)?;
|
||||
if id == effects::REVOLVE {
|
||||
for id in appearance_targets(s, p)? {
|
||||
let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
effects::validate_revolve(n, &effect.params).map_err(|e| bad(C, e))?;
|
||||
if effects::has_revolve(n) {
|
||||
return Err(bad(C, "A profile supports one Revolve effect. Edit the existing effect's options instead."));
|
||||
if effects::is_three_d(id) {
|
||||
for target in appearance_targets(s, p)? {
|
||||
let n = s.doc()?.doc.node(target).ok_or(EngineError::NoNode(target))?;
|
||||
if id == effects::REVOLVE {
|
||||
effects::validate_revolve(n, &effect.params).map_err(|e| bad(C, e))?;
|
||||
if effects::has_revolve(n) {
|
||||
return Err(bad(C, "A profile supports one Revolve effect. Edit the existing effect's options instead."));
|
||||
}
|
||||
} else {
|
||||
effects::validate_solid(n, id, &effect.params).map_err(|e| bad(C, e))?;
|
||||
}
|
||||
if effects::has_revolve(n) || effects::has_solid(n) {
|
||||
return Err(bad(C, ONE_3D));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -281,14 +293,18 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
let target = item.effects_item(&n.appearance, C)?;
|
||||
if let Some(e) = n.appearance.effects_at(target).and_then(|fx| fx.get(index))
|
||||
&& e.id == effects::REVOLVE
|
||||
&& effects::is_three_d(&e.id)
|
||||
&& visible != Some(false)
|
||||
{
|
||||
let mut prospective = effects::merged_params(&e.id, &e.params);
|
||||
if let (Some(cur), Some(new)) = (prospective.as_object_mut(), params.as_object()) {
|
||||
cur.extend(new.clone());
|
||||
}
|
||||
effects::validate_revolve(n, &prospective).map_err(|e| bad(C, e))?;
|
||||
if e.id == effects::REVOLVE {
|
||||
effects::validate_revolve(n, &prospective).map_err(|e| bad(C, e))?;
|
||||
} else {
|
||||
effects::validate_solid(n, &e.id, &prospective).map_err(|e| bad(C, e))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
let ids = edit_effects(s, p, item, C, "Effect Options", &format!("{C}: no effect at index {index}"), |fx| {
|
||||
@@ -533,6 +549,45 @@ mod tests {
|
||||
assert_eq!(effects_for_test(&s, id), before);
|
||||
}
|
||||
|
||||
/// Extrude & Bevel, Inflate and Rotate: applied by command, saved and reopened, edited,
|
||||
/// exported, expanded and undone; one 3D effect per object.
|
||||
#[test]
|
||||
fn solid_effects_apply_save_edit_export_expand_and_undo() {
|
||||
for effect in effects::SOLIDS {
|
||||
let (mut s, id) = session_with_rect();
|
||||
let source = node(&s, id).path_data().unwrap().clone();
|
||||
s.execute("effect.apply", &json!({"effect": effect, "ids": [id.0], "item": null})).unwrap();
|
||||
let art = effects::solid_art(&node(&s, id)).unwrap();
|
||||
assert_eq!(node(&s, id).path_data(), Some(&source), "{effect}: the source stays editable");
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let restored = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap();
|
||||
assert_eq!(effects::solid_art(restored.node(id).unwrap()).unwrap(), art, "{effect}: reopens the same");
|
||||
// One 3D effect per object, whichever kind.
|
||||
for other in ["threeD.revolve", "threeD.extrude", "threeD.rotate"] {
|
||||
assert!(s.execute("effect.apply", &json!({"effect": other, "ids": [id.0], "item": null})).is_err(), "{effect} + {other}");
|
||||
}
|
||||
s.execute("effect.setParams", &json!({"index": 0, "params": {"rotationY": 50, "depth": 120}, "ids": [id.0], "item": null})).unwrap();
|
||||
assert_ne!(effects::solid_art(&node(&s, id)).unwrap(), art, "{effect}: options change the art");
|
||||
assert!(s.execute("effect.setParams", &json!({"index": 0, "params": {"bevel": "nope"}, "ids": [id.0], "item": null})).is_err());
|
||||
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap();
|
||||
let svg = svg["data"].as_str().or_else(|| svg["text"].as_str()).map(str::to_string).unwrap_or_else(|| svg.to_string());
|
||||
assert!(svg.matches("<path").count() >= 2, "{effect}: exported as vector faces");
|
||||
s.execute("effect.expandAppearance", &json!({"ids": [id.0]})).unwrap();
|
||||
assert!(matches!(node(&s, id).kind, NodeKind::Group { .. }), "{effect}");
|
||||
assert!(!effects::has_solid(&node(&s, id)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, id).appearance.effects.len(), 0, "{effect}: back to the plain rectangle");
|
||||
}
|
||||
// Type has no outline to extrude.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let t = s.execute("text.create", &json!({"x": 50, "y": 50, "text": "3D"})).unwrap();
|
||||
let err = s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [t["id"]], "item": null})).unwrap_err();
|
||||
assert!(err.to_string().contains("requires a path"), "{err}");
|
||||
}
|
||||
|
||||
fn effects_for_test(s: &Session, id: NodeId) -> vectorcraft_doc::Node {
|
||||
effects::revolve_art(&node(s, id)).unwrap()
|
||||
}
|
||||
|
||||
@@ -488,6 +488,10 @@ impl Builder {
|
||||
};
|
||||
if let Some(f) = t.frame {
|
||||
obj.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, (f.x1 - f.x0) * k, (f.y1 - f.y0) * k)) };
|
||||
// Affinity shows a line in a frame shorter than it (#1423); here none of it would draw.
|
||||
if vectorcraft_text::fit_first_line(vectorcraft_text::FontDb::global(), &mut obj) {
|
||||
self.warn("text frames too short for their first line (made taller so the line shows)");
|
||||
}
|
||||
}
|
||||
obj
|
||||
}
|
||||
|
||||
@@ -158,7 +158,7 @@ fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warning
|
||||
}
|
||||
let Some(private) = vectorcraft_pdf::illustrator_data(bytes, opts.password.as_deref()) else { return (doc, warnings) };
|
||||
let outlined = opts.text_as == vectorcraft_pdf::TextAs::Outlines;
|
||||
let (doc, mut warnings, read) = vectorcraft_eps::layered_ai(&private, doc, warnings, outlined);
|
||||
let (doc, mut warnings, read) = vectorcraft_eps::layered_ai(&private, doc, warnings, outlined, opts.trust_editing_data);
|
||||
// The layers weren't read (or couldn't be): the notes of reading the PDF part still hold.
|
||||
if !read {
|
||||
return (doc, warnings);
|
||||
@@ -221,8 +221,13 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
.then(|| vectorcraft_pdf::editing_with(bytes, opts.password.as_deref()))
|
||||
.flatten()
|
||||
.map(|e| (e.intact, move || Some(e.data)));
|
||||
restore_or_import(editing, || match super::pdfimport::import(bytes, opts) {
|
||||
Ok((doc, warnings)) => Ok(through_editing_data(bytes, opts, doc, warnings)),
|
||||
restore_or_import(editing, || match super::pdfimport::import(bytes, opts, format.id != "pdf") {
|
||||
Ok(r) => {
|
||||
// Pages of text alone: the editing copy alone where it reads, as for a file
|
||||
// saved without its PDF part (#1387: else the page, which is the art).
|
||||
let alone = r.only_text.then(|| from_editing_data_alone(bytes, opts)).flatten().and_then(|read| read.ok());
|
||||
Ok(alone.unwrap_or_else(|| through_editing_data(bytes, opts, r.document, r.warnings)))
|
||||
}
|
||||
// Saved without its PDF part: the editing copy is all the file has.
|
||||
Err(e) if e.to_string() == vectorcraft_pdf::PdfError::PlaceholderOnly.to_string() => {
|
||||
from_editing_data_alone(bytes, opts).unwrap_or(Err(e))
|
||||
|
||||
@@ -86,7 +86,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Open Document",
|
||||
[],
|
||||
None,
|
||||
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
|
||||
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
|
||||
always,
|
||||
load::open
|
||||
),
|
||||
|
||||
@@ -36,6 +36,9 @@ pub struct LoadOptions {
|
||||
/// layers, hidden objects and the art outside its artboards); else only what its page or PDF
|
||||
/// part prints comes in.
|
||||
pub editing_data: bool,
|
||||
/// A `.ai` opens from its editing copy even where that draws differently from its PDF part
|
||||
/// (which can be older than it), with a warning, instead of from the PDF part.
|
||||
pub trust_editing_data: bool,
|
||||
}
|
||||
|
||||
impl Default for LoadOptions {
|
||||
@@ -50,6 +53,7 @@ impl Default for LoadOptions {
|
||||
layers: true,
|
||||
dxf: Default::default(),
|
||||
editing_data: true,
|
||||
trust_editing_data: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,7 +61,7 @@ impl Default for LoadOptions {
|
||||
impl LoadOptions {
|
||||
/// From command params: `pages` (`"2-3, 5"`, a page number or `"all"`), `page` (one page, when
|
||||
/// `pages` isn't given), `cropTo` (or `crop`), `password`, `colorMode` (`rgb` | `cmyk`) with `grays`,
|
||||
/// `textAs` (`text` | `outlines`), `layers` (true | false), `editingData` (true | false) and `dxf`
|
||||
/// `textAs` (`text` | `outlines`), `layers` (true | false), `editingData` (true | false | `always`) and `dxf`
|
||||
/// (the DXF import options).
|
||||
pub fn from_params(cmd: &str, p: &Value) -> Result<Self> {
|
||||
let pages = match p.get("pages").filter(|v| !v.is_null()).or_else(|| p.get("page").filter(|v| !v.is_null())) {
|
||||
@@ -81,12 +85,13 @@ impl LoadOptions {
|
||||
None => true,
|
||||
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "layers must be true or false"))?,
|
||||
};
|
||||
let editing_data = match p.get("editingData").filter(|v| !v.is_null()) {
|
||||
None => true,
|
||||
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "editingData must be true or false"))?,
|
||||
let (editing_data, trust_editing_data) = match p.get("editingData").filter(|v| !v.is_null()) {
|
||||
None => (true, false),
|
||||
Some(Value::String(s)) if s.eq_ignore_ascii_case("always") => (true, true),
|
||||
Some(v) => (v.as_bool().ok_or_else(|| bad(cmd, "editingData must be true, false or \"always\""))?, false),
|
||||
};
|
||||
let dxf = super::dxfimport::options(cmd, p.get("dxf"))?;
|
||||
Ok(Self { pages, crop, password, color_mode, grays, text_as, layers, dxf, editing_data })
|
||||
Ok(Self { pages, crop, password, color_mode, grays, text_as, layers, dxf, editing_data, trust_editing_data })
|
||||
}
|
||||
|
||||
/// Does the document read only part of the file, or read it differently (a page range, another
|
||||
@@ -97,9 +102,9 @@ impl LoadOptions {
|
||||
}
|
||||
|
||||
/// The PDF import options: `pages` resolved against the file's page count. A PDF-compatible
|
||||
/// `.ai` asked for its crop box opens on its trim boxes, its editor's artboards, with the bleed
|
||||
/// round them (its crop box takes the bleed in).
|
||||
fn import_options(&self, bytes: &[u8]) -> Result<ImportOptions> {
|
||||
/// `.ai`, or a PDF saved with its editing data, asked for its crop box opens on its trim boxes,
|
||||
/// its editor's artboards, with the bleed round them (its crop box takes the bleed in).
|
||||
fn import_options(&self, bytes: &[u8], editor_document: bool) -> Result<ImportOptions> {
|
||||
let pages = match &self.pages {
|
||||
None => None,
|
||||
Some(range) => {
|
||||
@@ -114,6 +119,7 @@ impl LoadOptions {
|
||||
password: self.password.clone(),
|
||||
text_as: self.text_as,
|
||||
layers: self.layers,
|
||||
uncalibrated_rgb: editor_document,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
@@ -130,10 +136,10 @@ fn choice<T: Choice + Default>(cmd: &str, key: &str, id: Option<&str>) -> Result
|
||||
.ok_or_else(|| bad(cmd, format!("{key} must be one of {}", T::IDS.join(", "))))
|
||||
}
|
||||
|
||||
/// Import the pages `o` pick → the document and import notes.
|
||||
pub(super) fn import(bytes: &[u8], o: &LoadOptions) -> Result<(Document, Vec<String>)> {
|
||||
let r = vectorcraft_pdf::import_with_report(bytes, &o.import_options(bytes)?).map_err(err)?;
|
||||
Ok((r.document, r.warnings))
|
||||
/// Import the pages `o` pick → the document, import notes and what else the import tells.
|
||||
/// `editor_document`: a `.ai` file.
|
||||
pub(super) fn import(bytes: &[u8], o: &LoadOptions, editor_document: bool) -> Result<vectorcraft_pdf::ImportReport> {
|
||||
vectorcraft_pdf::import_with_report(bytes, &o.import_options(bytes, editor_document)?).map_err(err)
|
||||
}
|
||||
|
||||
/// Page `page` (0-based) of a PDF as a document of one artboard (its `o.crop` box) and one
|
||||
|
||||
@@ -154,6 +154,52 @@ fn an_ai_file_saved_without_its_pdf_part_opens_from_its_editing_data() {
|
||||
assert!(e.contains("placeholder"), "{e}");
|
||||
}
|
||||
|
||||
/// A page of text alone, as text-only art has in its PDF part: no path is painted.
|
||||
const TEXT_ART: &str = "BT /F1 36 Tf 10 40 Td (Text only) Tj ET";
|
||||
|
||||
/// The kinds of what the first layer holds: type (or its outlines, without the font) and others.
|
||||
fn text_and_others(s: &Session) -> (usize, usize) {
|
||||
let art = s.doc().unwrap().doc.layers[0].children().unwrap();
|
||||
let text = art.iter().filter(|n| matches!(n.kind, NodeKind::Text(_)) || n.name.as_deref() == Some("<Text Outlines>")).count();
|
||||
(text, art.len() - text)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ai_file_whose_pdf_part_is_only_text_opens_from_it_when_its_editing_data_cant_be_read() {
|
||||
// #1387. The page states where its art is (an art, trim or bleed box), which the notice
|
||||
// page of a file saved without its PDF part has no art to do.
|
||||
let unreadable = b"%AI24_ZStandard_Data\0\0\0\0\0\0\0\0";
|
||||
for boxed in ["/ArtBox [0 0 200 100]", "/TrimBox [0 0 200 100]", "/BleedBox [0 0 200 100]"] {
|
||||
let mut s = Session::new();
|
||||
let r = open(&mut s, "text.ai", &ai::ai_with(unreadable, TEXT_ART, boxed), json!({})).unwrap();
|
||||
// Then a note on the font, where the machine lacks it.
|
||||
assert_eq!(r["warnings"][0], json!(vectorcraft_pdf::OFF_ARTBOARD_NOTE), "{boxed}: {r}");
|
||||
assert_eq!(text_and_others(&s), (1, 0), "{boxed}");
|
||||
// The PDF part alone, as asked.
|
||||
open(&mut s, "text.ai", &ai::ai_with(unreadable, TEXT_ART, boxed), json!({"editingData": false})).unwrap();
|
||||
assert_eq!(text_and_others(&s), (1, 0), "{boxed}");
|
||||
}
|
||||
// The same page without a box is the notice: the file still says why it can't open.
|
||||
let mut s = Session::new();
|
||||
let e = open(&mut s, "text.ai", &ai::ai(unreadable, TEXT_ART), json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("without PDF compatibility") && e.contains("can't be read"), "{e}");
|
||||
let e = open(&mut s, "text.ai", &ai::ai(unreadable, TEXT_ART), json!({"editingData": false})).unwrap_err().to_string();
|
||||
assert!(e.contains("placeholder"), "{e}");
|
||||
// Placed, it is read as its page too.
|
||||
let r = s.execute("file.new", &json!({})).and_then(|_| {
|
||||
let data = vectorcraft_format::base64_encode(&ai::ai_with(unreadable, TEXT_ART, "/ArtBox [0 0 200 100]"));
|
||||
s.execute("file.place", &json!({"name": "text.ai", "dataBase64": data, "link": false}))
|
||||
});
|
||||
assert!(r.is_ok(), "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ai_file_whose_pdf_part_is_only_text_still_opens_from_editing_data_that_reads() {
|
||||
let mut s = Session::new();
|
||||
open(&mut s, "art.ai", &ai::ai_with(&compressed(&ai::sample_data()), TEXT_ART, "/ArtBox [0 0 200 100]"), json!({})).unwrap();
|
||||
check_sample(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_data_false_opens_only_what_the_page_prints() {
|
||||
let mut s = Session::new();
|
||||
@@ -165,7 +211,10 @@ fn editing_data_false_opens_only_what_the_page_prints() {
|
||||
assert_ne!(names(layers), ["Art"], "{name}: the page's layer");
|
||||
}
|
||||
let e = open(&mut s, "art.eps", &ai::eps(&ai::sample_data(), ai::page_ps()), json!({"editingData": "no"})).unwrap_err().to_string();
|
||||
assert!(e.contains("editingData must be true or false"), "{e}");
|
||||
assert!(e.contains("editingData must be true, false or \"always\""), "{e}");
|
||||
// `always`: the editing data, as by default (whether it draws as the page does is the reader's).
|
||||
open(&mut s, "art.ai", &ai::ai(&compressed(&ai::sample_data()), ai::page_pdf()), json!({"editingData": "always"})).unwrap();
|
||||
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art", "Hidden"]);
|
||||
// A file that is only its editing data can't open without it.
|
||||
let e = open(&mut s, "art.ai", &ai::ai(&compressed(&ai::sample_data()), PLACEHOLDER), json!({"editingData": false})).unwrap_err().to_string();
|
||||
assert!(e.contains("placeholder"), "{e}");
|
||||
@@ -205,40 +254,48 @@ fn type_asked_for_as_outlines_opens_a_file_whose_type_shows_from_its_pdf_part()
|
||||
|
||||
/// A `.ai` of a 200 × 100 pt artboard with a 10 mm bleed: its PDF part's page is the artboard
|
||||
/// (its trim box) grown by the bleed, and a red square reaches 20 pt into the bleed, on the page
|
||||
/// as in the editing data.
|
||||
fn bled_ai(bleed: f64) -> Vec<u8> {
|
||||
/// as in the editing data. `key`: `AIPrivateData`, or `AIPDFPrivateData` for a PDF saved with its
|
||||
/// editing data.
|
||||
fn bled_ai(bleed: f64, key: &str) -> Vec<u8> {
|
||||
let art = format!("0 1 1 0 1 0 0 Xa\n{}f\n", ai::rect(-20.0, -20.0, 60.0, 50.0));
|
||||
let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", &art));
|
||||
let (w, h) = (200.0 + 2.0 * bleed, 100.0 + 2.0 * bleed);
|
||||
let page = PdfPage {
|
||||
bleed: Some([0.0, 0.0, w, h]),
|
||||
trim: Some([bleed, bleed, 200.0 + bleed, 100.0 + bleed]),
|
||||
entries: format!("/PieceInfo << /Illustrator << /Private << /AIPrivateData1 {} 0 R /NumBlock 1 >> >> >>", first_extra(1)),
|
||||
entries: format!("/PieceInfo << /Illustrator << /Private << /{key}1 {} 0 R /NumBlock 1 >> >> >>", first_extra(1)),
|
||||
..PdfPage::new(w, h, &format!("1 0 0 rg {0} {0} 80 70 re f", bleed - 20.0))
|
||||
};
|
||||
pdf_with(&[page], &[&format!("<< /Length {} >>\nstream\n{data}\nendstream", data.len())], None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ai_file_with_a_bleed_opens_on_its_artboard_with_the_bleed_round_it() {
|
||||
fn a_file_with_a_bleed_opens_on_its_artboard_with_the_bleed_round_it() {
|
||||
// A `.ai`, and a PDF saved with its editing data (#1345), which has it under other keys.
|
||||
for (name, key) in [("bled.ai", "AIPrivateData"), ("bled.pdf", "AIPDFPrivateData")] {
|
||||
bled_file_opens_on_its_artboard(name, key);
|
||||
}
|
||||
}
|
||||
|
||||
fn bled_file_opens_on_its_artboard(name: &str, key: &str) {
|
||||
let bleed = 10.0 * 72.0 / 25.4;
|
||||
let file = bled_ai(bleed);
|
||||
let file = bled_ai(bleed, key);
|
||||
let mut s = Session::new();
|
||||
// From the editing data, and from the PDF part alone.
|
||||
for params in [json!({}), json!({"editingData": false})] {
|
||||
let r = open(&mut s, "bled.ai", &file, params.clone()).unwrap();
|
||||
let r = open(&mut s, name, &file, params.clone()).unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert_eq!(doc.artboards.len(), 1, "{params}: {r}");
|
||||
assert_eq!(doc.artboards.len(), 1, "{name} {params}: {r}");
|
||||
let ab = doc.artboards[0].rect;
|
||||
assert!((ab.width() - 200.0).abs() < 0.01 && (ab.height() - 100.0).abs() < 0.01, "{params}: {ab:?} {r}");
|
||||
assert!(doc.setup.bleed.iter().all(|b| (b - bleed).abs() < 0.01), "{params}: {:?}", doc.setup.bleed);
|
||||
assert!((ab.width() - 200.0).abs() < 0.01 && (ab.height() - 100.0).abs() < 0.01, "{name} {params}: {ab:?} {r}");
|
||||
assert!(doc.setup.bleed.iter().all(|b| (b - bleed).abs() < 0.01), "{name} {params}: {:?}", doc.setup.bleed);
|
||||
// The square reaches into the bleed, whole.
|
||||
let art = doc.art_bounds().unwrap();
|
||||
let want = [ab.x0 - 20.0, ab.y0 + 50.0, ab.x0 + 60.0, ab.y0 + 120.0];
|
||||
assert!([art.x0, art.y0, art.x1, art.y1].iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01), "{params}: {art:?}");
|
||||
assert!(doc.setup.bleed_rect(ab).inflate(0.01, 0.01).contains_rect(art), "{params}: {art:?}");
|
||||
assert!([art.x0, art.y0, art.x1, art.y1].iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01), "{name} {params}: {art:?}");
|
||||
assert!(doc.setup.bleed_rect(ab).inflate(0.01, 0.01).contains_rect(art), "{name} {params}: {art:?}");
|
||||
}
|
||||
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Page 1"], "editingData: false reads the PDF part");
|
||||
open(&mut s, "bled.ai", &file, json!({})).unwrap();
|
||||
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "the editing data's layers are read");
|
||||
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Page 1"], "{name}: editingData: false reads the PDF part");
|
||||
open(&mut s, name, &file, json!({})).unwrap();
|
||||
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{name}: the editing data's layers are read");
|
||||
}
|
||||
@@ -529,15 +529,19 @@ impl Resolved {
|
||||
|
||||
/// The linked files of document `d`, just read from `doc_path`: those found unchanged are read
|
||||
/// again (their images had the preview), modified ones are read again when `update`, links found
|
||||
/// away from their path take the new path. Nothing here is an undo step.
|
||||
/// 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
|
||||
/// 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 {
|
||||
let mut out = Resolved::default();
|
||||
for g in groups(d, None) {
|
||||
let row = rows(std::iter::once(&g));
|
||||
let unread = g.document.is_none() && g.link.hash.is_none() && g.link.size.is_none() && previewing(d, &g.key);
|
||||
match probe(&g, folder(doc_path)) {
|
||||
(Status::Missing, _) => out.missing.extend(row),
|
||||
(Status::Modified, Some(mut f)) if update => {
|
||||
match read_for(&mut f, &g, "document.open").and_then(|read| install_read(d, &g.ids, read, false)) {
|
||||
(Status::Modified, Some(mut f)) if update || unread => {
|
||||
match read_for(&mut f, &g, "document.open").and_then(|read| install_read(d, &g.ids, read, unread)) {
|
||||
Ok(()) if unread => {}
|
||||
Ok(()) => out.updated.extend(row),
|
||||
Err(_) => out.missing.extend(row),
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Transform",
|
||||
[],
|
||||
None,
|
||||
"{matrix: [a,b,c,d,e,f], copy?: bool, ids?, strokes?: bool, corners?: bool, typeAreas?: bool, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} apply an affine to the selection (or ids); strokes/corners: Scale Strokes & Effects / Scale Corners (default: the preferences); typeAreas: area type among them (not type inside a group) reshapes its frame by the matrix and its text reflows at its size, as a bounding-box handle drag does (default: the type transforms too)",
|
||||
"{matrix: [a,b,c,d,e,f], copy?: bool, ids?, strokes?: bool, corners?: bool, typeAreas?: bool, anchors?: bool, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} apply an affine to the selection (or ids); anchors (without ids or copy): paths with anchors selected with Direct Selection have only those anchors and their handles transformed, as the Transform tools and object.move/rotate/scale/reflect/shear do (default: the whole objects); strokes/corners: Scale Strokes & Effects / Scale Corners (default: the preferences); typeAreas: area type among them (not type inside a group) reshapes its frame by the matrix and its text reflows at its size, as a bounding-box handle drag does (default: the type transforms too)",
|
||||
has_doc,
|
||||
transform
|
||||
),
|
||||
@@ -32,7 +32,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Move…",
|
||||
["Object", "Transform"],
|
||||
Some("Cmd+Shift+M"),
|
||||
"{dx, dy, copy?, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}",
|
||||
"{dx, dy, copy?, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.",
|
||||
has_selection,
|
||||
move_cmd
|
||||
),
|
||||
@@ -41,7 +41,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Rotate…",
|
||||
["Object", "Transform"],
|
||||
None,
|
||||
"{angle: deg (counter-clockwise), absolute?: bool (angle is the bounding box's new angle, not an amount), origin?: [x,y] (default: the bounding box centre), copy?, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} → {ids}",
|
||||
"{angle: deg (counter-clockwise), absolute?: bool (angle is the bounding box's new angle, not an amount), origin?: [x,y] (default: the bounding box centre), copy?, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} → {ids}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.",
|
||||
has_selection,
|
||||
rotate
|
||||
),
|
||||
@@ -50,7 +50,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Scale…",
|
||||
["Object", "Transform"],
|
||||
None,
|
||||
"{sx: %, sy?: %, origin?: [x,y], copy?, strokes?: bool (Scale Strokes & Effects: stroke weights, dashes and effect distances scale; off keeps them, type strokes included), corners?: bool (Scale Corners: live corner radii scale), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} (strokes/corners default to the preferences)",
|
||||
"{sx: %, sy?: %, origin?: [x,y], copy?, strokes?: bool (Scale Strokes & Effects: stroke weights, dashes and effect distances scale; off keeps them, type strokes included), corners?: bool (Scale Corners: live corner radii scale), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} (strokes/corners default to the preferences). With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.",
|
||||
has_selection,
|
||||
scale
|
||||
),
|
||||
@@ -59,7 +59,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reflect…",
|
||||
["Object", "Transform"],
|
||||
None,
|
||||
"{axis: \"vertical\"|\"horizontal\"|deg, origin?, copy?, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}",
|
||||
"{axis: \"vertical\"|\"horizontal\"|deg, origin?, copy?, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.",
|
||||
has_selection,
|
||||
reflect
|
||||
),
|
||||
@@ -68,7 +68,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Shear…",
|
||||
["Object", "Transform"],
|
||||
None,
|
||||
"{angle: deg, axis?: \"horizontal\"|\"vertical\", origin?, copy?, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}",
|
||||
"{angle: deg, axis?: \"horizontal\"|\"vertical\", origin?, copy?, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.",
|
||||
has_selection,
|
||||
shear
|
||||
),
|
||||
@@ -223,6 +223,13 @@ fn origin_of(s: &Session, p: &Value, ids: &[NodeId]) -> Result<Point> {
|
||||
if let Some(o) = point_param(p, "origin") {
|
||||
return Ok(o);
|
||||
}
|
||||
// Direct-selected anchors transform alone (see [`apply_transform`]), about their own centre.
|
||||
let st = s.doc()?;
|
||||
if !bool_or(p, "copy", false)
|
||||
&& let Some(b) = st.selection.anchor_bounds(&st.doc, s.prefs.use_preview_bounds)
|
||||
{
|
||||
return Ok(b.center());
|
||||
}
|
||||
s.transform_box(ids).map(|b| b.center()).ok_or_else(|| EngineError::Other("selection has no bounds".into()))
|
||||
}
|
||||
|
||||
@@ -263,7 +270,10 @@ pub(crate) fn transform_patterns(s: &mut Session, p: &Value, ids: &[NodeId]) ->
|
||||
/// Apply `xf` to `ids` (`copy` param: duplicate first; `strokes`/`corners`: see [`scaling`];
|
||||
/// `patterns`: see [`transform_patterns`]; `typeAreas`: see [`resize_type_area`]). Outside an
|
||||
/// interaction, records `xf` and the options it used for Transform Again.
|
||||
pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec<NodeId>, xf: Affine, p: &Value) -> Result<Value> {
|
||||
///
|
||||
/// `anchors` (`ids` being the selection): with anchors selected with Direct Selection and no
|
||||
/// copy, only those anchors transform, their handles with them, and the objects selected whole.
|
||||
pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec<NodeId>, xf: Affine, p: &Value, anchors: bool) -> Result<Value> {
|
||||
let copy = bool_or(p, "copy", false);
|
||||
let areas = bool_or(p, "typeAreas", false);
|
||||
let mut sc = if Scaling::factor(xf).is_some() { scaling(s, p) } else { Scaling::default() };
|
||||
@@ -273,7 +283,12 @@ pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec<NodeId>, xf
|
||||
// With a copy, the reply lists the copies, and there are none.
|
||||
let d = &s.doc()?.doc;
|
||||
let none = d.index_paths(ids.iter().copied()).is_empty();
|
||||
let ids = if none || (!copy && xf == Affine::IDENTITY) {
|
||||
let ids = if anchors && !copy && !s.doc()?.selection.anchors.is_empty() {
|
||||
if xf != Affine::IDENTITY {
|
||||
super::path::transform_anchors(s, label, xf, sc)?;
|
||||
}
|
||||
ids
|
||||
} else if none || (!copy && xf == Affine::IDENTITY) {
|
||||
if copy { vec![] } else { ids }
|
||||
} else {
|
||||
s.edit(label, |d, sel| {
|
||||
@@ -316,16 +331,16 @@ fn resize_type_area(n: &mut Node, xf: Affine) -> bool {
|
||||
|
||||
fn transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let m = matrix_param(p, "matrix").ok_or_else(|| bad("object.transform", "missing matrix [a,b,c,d,e,f]"))?;
|
||||
let ids = match ids_param(p, "ids") {
|
||||
Some(v) => v,
|
||||
None => selected_roots(s)?,
|
||||
let (ids, anchors) = match ids_param(p, "ids") {
|
||||
Some(v) => (v, false),
|
||||
None => (selected_roots(s)?, bool_or(p, "anchors", false)),
|
||||
};
|
||||
apply_transform(s, "Transform", ids, m, p)
|
||||
apply_transform(s, "Transform", ids, m, p, anchors)
|
||||
}
|
||||
|
||||
fn move_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
apply_transform(s, "Move", ids, Affine::translate((f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0))), p)
|
||||
apply_transform(s, "Move", ids, Affine::translate((f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0))), p, true)
|
||||
}
|
||||
|
||||
fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -348,7 +363,7 @@ fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return super::docmenu::guide_move(s, &json!({ "dx": dx, "dy": dy, "copy": copy }));
|
||||
}
|
||||
let ids = selected_roots(s)?;
|
||||
apply_transform(s, "Move", ids, Affine::translate((dx, dy)), p)
|
||||
apply_transform(s, "Move", ids, Affine::translate((dx, dy)), p, true)
|
||||
}
|
||||
|
||||
fn about(o: Point, a: Affine) -> Affine {
|
||||
@@ -364,7 +379,7 @@ fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// Angles are counter-clockwise; y is down, so negate.
|
||||
let a = about(o, Affine::rotate(-angle.to_radians()));
|
||||
apply_transform(s, "Rotate", ids, a, p)
|
||||
apply_transform(s, "Rotate", ids, a, p, true)
|
||||
}
|
||||
|
||||
fn scale(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -375,7 +390,7 @@ fn scale(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if sx == 0.0 || sy == 0.0 {
|
||||
return Err(bad("object.scale", "scale must be non-zero"));
|
||||
}
|
||||
apply_transform(s, "Scale", ids, about(o, Affine::scale_non_uniform(sx, sy)), p)
|
||||
apply_transform(s, "Scale", ids, about(o, Affine::scale_non_uniform(sx, sy)), p, true)
|
||||
}
|
||||
|
||||
fn reflect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -394,7 +409,7 @@ fn reflect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad("object.reflect", format!("axis must be \"vertical\", \"horizontal\" or an angle in degrees, not `{given}`")));
|
||||
}
|
||||
};
|
||||
apply_transform(s, "Reflect", ids, about(o, m), p)
|
||||
apply_transform(s, "Reflect", ids, about(o, m), p, true)
|
||||
}
|
||||
|
||||
fn shear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -403,7 +418,7 @@ fn shear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = f64_or(p, "angle", 0.0).clamp(-89.0, 89.0).to_radians().tan();
|
||||
let vertical = choice(p, "object.shear", "axis", &["horizontal", "vertical"])? == Some("vertical");
|
||||
let m = if vertical { Affine::new([1.0, t, 0.0, 1.0, 0.0, 0.0]) } else { Affine::new([1.0, 0.0, -t, 1.0, 0.0, 0.0]) };
|
||||
apply_transform(s, "Shear", ids, about(o, m), p)
|
||||
apply_transform(s, "Shear", ids, about(o, m), p, true)
|
||||
}
|
||||
|
||||
/// The last transform's matrix on the selection, with the options `last_transform` keeps for it; an
|
||||
@@ -417,7 +432,7 @@ fn transform_again(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
o.extend(given.iter().filter(|(_, v)| v.is_boolean()).map(|(k, v)| (k.clone(), v.clone())));
|
||||
}
|
||||
let ids = selected_roots(s)?;
|
||||
apply_transform(s, "Transform Again", ids, m, &options)
|
||||
apply_transform(s, "Transform Again", ids, m, &options, true)
|
||||
}
|
||||
|
||||
enum Arrange {
|
||||
@@ -490,9 +505,8 @@ fn send_to_current_layer(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Send to Current Layer", |d, _| {
|
||||
for id in &ids {
|
||||
d.move_node(*id, Some(layer), usize::MAX)?;
|
||||
}
|
||||
let moved = d.remove_many(&ids)?;
|
||||
d.children_mut(Some(layer))?.extend(moved);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
@@ -510,14 +524,11 @@ fn group(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
/// Put `ids` (top-level objects, in paint order) in a new group where the front-most one was.
|
||||
pub(crate) fn group_nodes(d: &mut Document, ids: &[NodeId]) -> Result<NodeId> {
|
||||
let Some(top) = ids.last().copied() else { return Err(EngineError::Other("nothing selected".into())) };
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
let gid = d.alloc_id();
|
||||
d.insert(par, idx + 1, Node::group(gid, vec![]))?;
|
||||
for id in ids {
|
||||
d.move_node(*id, Some(gid), usize::MAX)?;
|
||||
if ids.is_empty() {
|
||||
return Err(EngineError::Other("nothing selected".into()));
|
||||
}
|
||||
Ok(gid)
|
||||
let gid = d.alloc_id();
|
||||
Ok(d.wrap(ids, |children| Node::group(gid, children))?)
|
||||
}
|
||||
|
||||
fn ungroup(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
@@ -529,31 +540,30 @@ fn ungroup(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
s.edit("Ungroup", |d, sel| {
|
||||
let mut new_sel = vec![];
|
||||
let mut ungrouped = vec![];
|
||||
for id in &ids {
|
||||
let Some(n) = d.node(*id) else { continue };
|
||||
let NodeKind::Group { children, clip } = &n.kind else {
|
||||
new_sel.push(*id);
|
||||
continue;
|
||||
};
|
||||
let children = children.clone();
|
||||
let clip = *clip;
|
||||
let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
let opacity = n.opacity;
|
||||
d.remove(*id)?;
|
||||
for (k, c) in children.into_iter().enumerate() {
|
||||
let mut c = (*c).clone();
|
||||
let mut out = Vec::with_capacity(children.len());
|
||||
for (k, c) in children.iter().enumerate() {
|
||||
let mut c = (**c).clone();
|
||||
// The clipping path keeps its paint (none, unless painted after Make).
|
||||
if clip
|
||||
if *clip
|
||||
&& k == 0
|
||||
&& let NodeKind::Path { clipping, .. } = &mut c.kind
|
||||
{
|
||||
*clipping = false;
|
||||
}
|
||||
c.opacity *= opacity;
|
||||
c.opacity *= n.opacity;
|
||||
new_sel.push(c.id);
|
||||
d.insert(par, idx + k, c)?;
|
||||
out.push(c);
|
||||
}
|
||||
ungrouped.push((*id, out));
|
||||
}
|
||||
d.insert_beside(ungrouped, true)?;
|
||||
sel.set(new_sel);
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -651,10 +661,8 @@ fn show_all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
fn compound_make(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
let Some(backmost) = ids.first().copied() else { return Err(EngineError::Other("nothing selected".into())) };
|
||||
let top = ids.last().copied().unwrap_or(backmost);
|
||||
let id = s.edit("Make Compound Path", |d, sel| {
|
||||
let appearance = d.node(backmost).ok_or(EngineError::NoNode(backmost))?.appearance.clone();
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
let mut paths: Vec<Arc<Node>> = vec![];
|
||||
for id in &ids {
|
||||
let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?;
|
||||
@@ -674,11 +682,7 @@ fn compound_make(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let cid = d.alloc_id();
|
||||
let mut c = Node::new(cid, NodeKind::Compound { children: vec![], rule: FillRule::NonZero });
|
||||
c.appearance = appearance;
|
||||
d.insert(par, idx + 1, c)?;
|
||||
for id in &ids {
|
||||
d.remove(*id)?;
|
||||
}
|
||||
let ch = d.node_mut(cid).and_then(|n| n.children_mut()).ok_or(EngineError::NoNode(cid))?;
|
||||
let ch = c.children_mut().ok_or(EngineError::NoNode(cid))?;
|
||||
// The paths in front run against the backmost one, so where they overlap it they cut holes
|
||||
// under the non-zero fill rule (#1197).
|
||||
let direction = |n: &Node| n.path_data().and_then(|pd| pd.subpaths.first()).map_or(0.0, |sp| sp.signed_area().signum());
|
||||
@@ -695,6 +699,7 @@ fn compound_make(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
ch.push(p);
|
||||
}
|
||||
d.wrap(&ids, |_| c)?;
|
||||
sel.set([cid]);
|
||||
Ok(cid)
|
||||
})?;
|
||||
@@ -721,17 +726,17 @@ fn compound_release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
s.edit("Release Compound Path", |d, sel| {
|
||||
let mut out = vec![];
|
||||
let mut released = vec![];
|
||||
for id in &ids {
|
||||
let Some(n) = d.node(*id).cloned().filter(releases) else { continue };
|
||||
let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
d.remove(*id)?;
|
||||
let mut parts = vec![];
|
||||
match &n.kind {
|
||||
NodeKind::Compound { children, .. } => {
|
||||
for (k, c) in children.iter().enumerate() {
|
||||
for c in children {
|
||||
let mut c = (**c).clone();
|
||||
c.appearance = n.appearance.clone();
|
||||
out.push(c.id);
|
||||
d.insert(par, idx + k, c)?;
|
||||
parts.push(c);
|
||||
}
|
||||
}
|
||||
// Each subpath becomes a path of its own with the path's paint, in the same
|
||||
@@ -743,12 +748,14 @@ fn compound_release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
(*path, *live) = (PathData::single(sp.clone()), None);
|
||||
}
|
||||
out.push(c.id);
|
||||
d.insert(par, idx + k, c)?;
|
||||
parts.push(c);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
released.push((*id, parts));
|
||||
}
|
||||
d.insert_beside(released, true)?;
|
||||
sel.set(out);
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -796,13 +803,12 @@ fn clip_make(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let Some(&top) = ids.last() else { return Err(EngineError::Other("nothing selected".into())) };
|
||||
let gid = s.edit("Make Clipping Mask", |d, sel| {
|
||||
make_clipping_path(d, top)?;
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
let gid = d.alloc_id();
|
||||
d.insert(par, idx + 1, Node::new(gid, NodeKind::Group { children: vec![], clip: true }))?;
|
||||
d.move_node(top, Some(gid), 0)?;
|
||||
for id in &ids[..ids.len() - 1] {
|
||||
d.move_node(*id, Some(gid), usize::MAX)?;
|
||||
}
|
||||
// The topmost object, the clipping path, goes first, and the others follow in paint order.
|
||||
d.wrap(&ids, |mut children| {
|
||||
children.rotate_right(1);
|
||||
Node::new(gid, NodeKind::Group { children, clip: true })
|
||||
})?;
|
||||
sel.set([gid]);
|
||||
Ok(gid)
|
||||
})?;
|
||||
@@ -857,8 +863,10 @@ fn isolate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.or_else(|| s.active().and_then(|d| d.selection.objects.first().copied()))
|
||||
.ok_or_else(|| bad("object.isolate", "missing id"))?;
|
||||
let st = s.doc_mut()?;
|
||||
if st.doc.node(id).is_none_or(|n| !n.is_container()) {
|
||||
return Err(bad("object.isolate", "only groups and layers can be isolated"));
|
||||
// Groups, layers and single objects (a path, a compound path, an image…); type isn't
|
||||
// isolated: a double-click on it edits its text.
|
||||
if matches!(st.doc.node(id).ok_or(EngineError::NoNode(id))?.kind, NodeKind::Text(_)) {
|
||||
return Err(bad("object.isolate", "type objects can't be isolated"));
|
||||
}
|
||||
let isolation = st.doc.node(id).filter(|n| n.extras().shaper.is_some()).and_then(|n| n.children()).and_then(|c| c.first()).map_or(id, |n| n.id);
|
||||
st.isolation = Some(isolation);
|
||||
@@ -1309,10 +1317,10 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// leave a rounding error in it on a rotated object or with Use Preview Bounds.
|
||||
let current = |k: &str, now: f64| p.get(k).and_then(Value::as_f64).is_none_or(|x| x == now);
|
||||
if current("x", page.x) && current("y", page.y) && current("width", v.width()) && current("height", v.height()) {
|
||||
return apply_transform(s, "Transform", ids, Affine::IDENTITY, p);
|
||||
return apply_transform(s, "Transform", ids, Affine::IDENTITY, p, false);
|
||||
}
|
||||
let to = frame.to_local(Point::new(f64_or(p, "x", page.x), f64_or(p, "y", page.y)));
|
||||
apply_transform(s, "Transform", ids, frame.conjugate(Affine::translate(to - new_rp) * scale), p)
|
||||
apply_transform(s, "Transform", ids, frame.conjugate(Affine::translate(to - new_rp) * scale), p, false)
|
||||
}
|
||||
|
||||
fn expand_shape(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
@@ -168,35 +168,38 @@ fn close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
pub(crate) fn move_anchors(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
|
||||
let st = s.doc()?;
|
||||
let sel = st.selection.clone();
|
||||
let scale_strokes = s.prefs.scale_strokes;
|
||||
let _ = scale_strokes;
|
||||
// Objects selected whole (with no entry in `sel.anchors`) move as objects, and their pattern
|
||||
// tiles move with them when `patterns` (default: General › Transform Pattern Tiles) is on.
|
||||
let whole: Vec<NodeId> = sel.objects.iter().copied().filter(|id| !sel.anchors.contains_key(id)).collect();
|
||||
// Objects selected whole move as objects, and their pattern tiles move with them when
|
||||
// `patterns` (default: General › Transform Pattern Tiles) is on.
|
||||
let whole = s.doc()?.selection.whole();
|
||||
let sc = Scaling { patterns: super::object::transform_patterns(s, p, &whole)?, ..Scaling::default() };
|
||||
s.edit("Move", |doc, _| {
|
||||
transform_anchors(s, "Move", vectorcraft_geom::Affine::translate(d), sc)?;
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Apply `xf` to the direct-selected anchors, their handles with them, and (with `sc`) to the
|
||||
/// objects selected whole (those with no entry in the selection's `anchors`).
|
||||
pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_geom::Affine, sc: Scaling) -> Result<()> {
|
||||
let sel = s.doc()?.selection.clone();
|
||||
s.edit(label, |doc, _| {
|
||||
for id in &sel.objects {
|
||||
match sel.anchors.get(id) {
|
||||
Some(set) => {
|
||||
let path = path_mut(doc, *id)?;
|
||||
for &(si, ai) in set {
|
||||
if let Some(a) = path.anchor_mut(si, ai) {
|
||||
a.translate(d);
|
||||
*a = a.transform(xf);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if let Some(n) = doc.node_mut(*id) {
|
||||
n.transform(vectorcraft_geom::Affine::translate(d), sc);
|
||||
n.transform(xf, sc);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
})
|
||||
}
|
||||
|
||||
fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -193,7 +193,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
fn selectable(d: &Document, iso: Option<NodeId>) -> Vec<NodeId> {
|
||||
match iso.and_then(|i| d.node(i)) {
|
||||
Some(c) if d.ancestry(c.id).is_some_and(|a| a.iter().all(|i| d.node(*i).is_some_and(|n| n.visible && !n.locked && !n.is_template()))) => {
|
||||
c.layer_art(true)
|
||||
// A single object isolated is all there is to select.
|
||||
if c.is_container() { c.layer_art(true) } else { vec![c.id] }
|
||||
}
|
||||
Some(_) => vec![],
|
||||
None => d.selectable_art(),
|
||||
|
||||
@@ -1022,6 +1022,7 @@ mod area_tests {
|
||||
}
|
||||
|
||||
/// Width of the first line and of the space it can fill, with `tracking` on the first paragraph.
|
||||
/// The third value is where the first line ends in the plain text.
|
||||
fn headline_fit(t: &TextObject, tracking: f64) -> Option<(f64, f64, usize)> {
|
||||
let mut probe = t.clone();
|
||||
let para_end = probe.plain_text().find('\n').unwrap_or(usize::MAX);
|
||||
@@ -1031,7 +1032,7 @@ fn headline_fit(t: &TextObject, tracking: f64) -> Option<(f64, f64, usize)> {
|
||||
let line = lay.lines.first()?;
|
||||
let para = t.para_at(0);
|
||||
let avail = cell.width() - 2.0 * t.area.inset - para.left_indent - para.right_indent;
|
||||
Some((line.x1 - line.x0, avail, lay.lines.iter().filter(|l| l.start < para_end).count()))
|
||||
Some((line.x1 - line.x0, avail, line.end))
|
||||
}
|
||||
|
||||
fn fit_headline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1044,8 +1045,11 @@ fn fit_headline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !matches!(t.kind, vectorcraft_doc::TextKind::Area { .. }) {
|
||||
continue;
|
||||
}
|
||||
// Largest tracking that keeps the first paragraph on one line (bisection).
|
||||
let one_line = |tr: f64| headline_fit(t, tr).is_some_and(|(_, _, lines)| lines == 1);
|
||||
// Largest tracking that keeps the first paragraph on one line (bisection). The layout
|
||||
// drops lines that overflow the frame, so a frame one line tall hides the wrapped rest:
|
||||
// "one line" means the first line reaches the end of the first paragraph.
|
||||
let para_end = t.plain_text().find('\n').unwrap_or(t.plain_text().len());
|
||||
let one_line = |tr: f64| headline_fit(t, tr).is_some_and(|(_, _, end)| end >= para_end);
|
||||
let (mut lo, mut hi) = (-200.0, 2000.0);
|
||||
if !one_line(lo) {
|
||||
continue;
|
||||
@@ -1087,9 +1091,27 @@ mod headline_tests {
|
||||
let tr = s.execute("text.fitHeadline", &json!({})).unwrap()["tracking"].as_f64().unwrap();
|
||||
assert!(tr > 100.0, "a short word spreads out: {tr}");
|
||||
let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!() };
|
||||
let (w, avail, lines) = headline_fit(t, tr).unwrap();
|
||||
assert_eq!(lines, 1);
|
||||
let (w, avail, end) = headline_fit(t, tr).unwrap();
|
||||
assert_eq!(end, 8);
|
||||
assert!(avail - w < 30.0, "{w} of {avail}");
|
||||
assert_eq!(t.runs.last().unwrap().style.tracking, 0.0, "the body keeps its tracking");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_headline_fills_even_when_the_frame_holds_one_line() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id = s
|
||||
.execute("text.create", &json!({"x": 10, "y": 10, "text": "NEON DRIVE", "size": 36, "area": {"width": 240, "height": 50}}))
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
let tr = s.execute("text.fitHeadline", &json!({})).unwrap()["tracking"].as_f64().unwrap();
|
||||
assert!(tr < 1500.0, "a frame one line tall must not push the tracking to the ceiling: {tr}");
|
||||
let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!() };
|
||||
let (w, avail, end) = headline_fit(t, tr).unwrap();
|
||||
assert_eq!(end, 10);
|
||||
assert!(avail - w < 5.0, "{w} of {avail}");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Agent-friendly document summaries and view-models for panels.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::BlendMode;
|
||||
use vectorcraft_doc::{ArrowAlign, Document, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, WidthProfile};
|
||||
|
||||
use crate::Session;
|
||||
@@ -20,6 +21,9 @@ pub struct SummaryOpts {
|
||||
/// Children shown per node (`None`: all, top of the stack first). A level that shows
|
||||
/// fewer children than it has reports `childCount`, so truncation is never silent.
|
||||
pub child_limit: Option<u64>,
|
||||
/// The node is a member of a compound path, which paints it: it has no paint of its own
|
||||
/// to report.
|
||||
pub compound_member: bool,
|
||||
}
|
||||
|
||||
/// Compact tree summary of a node (for `document.inspect`): the whole subtree.
|
||||
@@ -40,6 +44,9 @@ pub fn node_summary_opts(n: &Node, opts: SummaryOpts) -> Value {
|
||||
if n.opacity < 1.0 {
|
||||
v["opacity"] = json!(n.opacity);
|
||||
}
|
||||
if n.blend != BlendMode::Normal {
|
||||
v["blend"] = json!(n.blend.label());
|
||||
}
|
||||
// The angle of its rotated bounding box (counter-clockwise degrees).
|
||||
if n.bbox_angle != 0.0 {
|
||||
v["rotation"] = json!(n.bbox_angle);
|
||||
@@ -58,12 +65,15 @@ pub fn node_summary_opts(n: &Node, opts: SummaryOpts) -> Value {
|
||||
v["objectStroke"] = json!(n.appearance.stroke_paint().label());
|
||||
v["objectStrokeWidth"] = json!(n.appearance.stroke_width());
|
||||
}
|
||||
} else if !n.is_container() {
|
||||
} else if opts.compound_member {
|
||||
v["compoundMember"] = json!(true);
|
||||
} else if !n.is_container() || matches!(n.kind, NodeKind::Compound { .. }) {
|
||||
// A compound path is a container, but its fill and stroke live on it, not on its members.
|
||||
v["fill"] = json!(n.appearance.fill_paint().label());
|
||||
v["stroke"] = json!(n.appearance.stroke_paint().label());
|
||||
v["strokeWidth"] = json!(n.appearance.stroke_width());
|
||||
}
|
||||
if let Some(st) = node_stroke(n) {
|
||||
if let Some(st) = node_stroke(n).filter(|_| !opts.compound_member) {
|
||||
v["strokeOptions"] = stroke_options(&st);
|
||||
}
|
||||
match &n.kind {
|
||||
@@ -99,7 +109,11 @@ pub fn node_summary_opts(n: &Node, opts: SummaryOpts) -> Value {
|
||||
let total = ch.len();
|
||||
let take_n = opts.child_limit.map_or(total, |l| l.min(total as u64) as usize);
|
||||
if opts.depth.is_none_or(|d| d > 0) {
|
||||
let next = SummaryOpts { depth: opts.depth.map(|d| d.saturating_sub(1)), child_limit: opts.child_limit };
|
||||
let next = SummaryOpts {
|
||||
depth: opts.depth.map(|d| d.saturating_sub(1)),
|
||||
child_limit: opts.child_limit,
|
||||
compound_member: matches!(n.kind, NodeKind::Compound { .. }),
|
||||
};
|
||||
v["children"] = Value::Array(ch.iter().rev().take(take_n).map(|c| node_summary_opts(c, next)).collect());
|
||||
}
|
||||
if take_n < total || v.get("children").is_none() {
|
||||
|
||||
@@ -483,6 +483,12 @@ impl DocState {
|
||||
{
|
||||
return Some(i);
|
||||
}
|
||||
// A single object isolated: new art goes beside it, in its group or layer.
|
||||
if let Some(p) = self.isolation.filter(|i| self.doc.node(*i).is_some_and(|n| !n.is_container())).and_then(|i| self.doc.parent_of(i))
|
||||
&& self.doc.node(p).is_some_and(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }))
|
||||
{
|
||||
return Some(p);
|
||||
}
|
||||
// A sublayer takes new art only while it and the layers around it are shown and unlocked.
|
||||
self.active_layer.filter(|l| self.doc.node(*l).is_some_and(|n| n.is_layer()) && self.doc.is_editable(*l)).or_else(|| self.doc.default_layer())
|
||||
}
|
||||
@@ -1734,6 +1740,8 @@ mod tests_knockout;
|
||||
#[cfg(test)]
|
||||
mod tests_labspots;
|
||||
#[cfg(test)]
|
||||
mod tests_large_edits;
|
||||
#[cfg(test)]
|
||||
mod tests_layerclip;
|
||||
#[cfg(test)]
|
||||
mod tests_layers;
|
||||
|
||||
@@ -764,6 +764,60 @@ fn a_transform_that_moves_nothing_records_no_undo_step() {
|
||||
assert_eq!(count(&s), n + 1);
|
||||
}
|
||||
|
||||
/// #1408: with anchors selected with Direct Selection, the Transform commands and tools transform
|
||||
/// those anchors alone, about the centre of their own box; a copy still copies the whole object.
|
||||
#[test]
|
||||
fn transforms_act_on_direct_selected_anchors() {
|
||||
let mut s = session();
|
||||
let square = json!({"anchors": [{"x": 0, "y": 0}, {"x": 100, "y": 0}, {"x": 100, "y": 100}, {"x": 0, "y": 100}], "closed": true});
|
||||
let id = NodeId(s.execute("path.create", &square).unwrap()["id"].as_u64().unwrap());
|
||||
let points = |s: &Session| -> Vec<(f64, f64)> {
|
||||
let NodeKind::Path { path, .. } = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("a path") };
|
||||
path.subpaths[0].anchors.iter().map(|a| (a.p.x.round(), a.p.y.round())).collect()
|
||||
};
|
||||
let top = json!({"id": id.0, "anchors": [[0, 0], [0, 1]]});
|
||||
s.execute("select.anchors", &top).unwrap();
|
||||
let cases = [
|
||||
("object.scale", json!({"sx": 50}), [(25.0, 0.0), (75.0, 0.0)]),
|
||||
("object.scale", json!({"sx": 50, "origin": [0, 100]}), [(0.0, 50.0), (50.0, 50.0)]),
|
||||
("object.rotate", json!({"angle": 90}), [(50.0, 50.0), (50.0, -50.0)]),
|
||||
("object.reflect", json!({"axis": "vertical"}), [(100.0, 0.0), (0.0, 0.0)]),
|
||||
("object.shear", json!({"angle": 45, "axis": "vertical", "origin": [0, 0]}), [(0.0, 0.0), (100.0, 100.0)]),
|
||||
("object.move", json!({"dx": 10, "dy": -10}), [(10.0, -10.0), (110.0, -10.0)]),
|
||||
("object.transform", json!({"matrix": [1, 0, 0, 1, 5, 5], "anchors": true}), [(5.0, 5.0), (105.0, 5.0)]),
|
||||
];
|
||||
for (cmd, p, [a, b]) in cases {
|
||||
s.execute(cmd, &p).unwrap();
|
||||
assert_eq!(points(&s), [a, b, (100.0, 100.0), (0.0, 100.0)], "{cmd} {p}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("select.anchors", &top).unwrap();
|
||||
}
|
||||
// Transform Again repeats it on the anchors.
|
||||
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
|
||||
s.execute("object.transformAgain", &json!({})).unwrap();
|
||||
assert_eq!(points(&s), [(20.0, 0.0), (120.0, 0.0), (100.0, 100.0), (0.0, 100.0)]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("select.anchors", &top).unwrap();
|
||||
// Without `anchors`, object.transform (the Selection tool's) takes the whole path, and so does a copy.
|
||||
s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, 5, 5]})).unwrap();
|
||||
assert_eq!(points(&s), [(5.0, 5.0), (105.0, 5.0), (105.0, 105.0), (5.0, 105.0)]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("select.anchors", &top).unwrap();
|
||||
let copies = s.execute("object.scale", &json!({"sx": 50, "copy": true})).unwrap();
|
||||
assert_eq!(copies["ids"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(points(&s), [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)], "the original stays");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("select.anchors", &top).unwrap();
|
||||
// The Scale tool: a drag from (100, 50) to (75, 50) halves the top edge about its centre (50, 0).
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("scale", v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 50.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 75.0, 50.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 75.0, 50.0), v).unwrap();
|
||||
assert_eq!(points(&s), [(25.0, 0.0), (75.0, 0.0), (100.0, 100.0), (0.0, 100.0)]);
|
||||
}
|
||||
|
||||
/// Arrange commands that leave the stacking order as it is record no undo step: Bring to Front and
|
||||
/// Bring Forward on the front objects, Send to Back and Send Backward on the back ones, and Send to
|
||||
/// Current Layer on objects already on top of the current layer.
|
||||
@@ -1517,6 +1571,28 @@ fn inspect_lists_everything() {
|
||||
assert!(s.commands().len() > 100);
|
||||
}
|
||||
|
||||
/// A compound path reports the paint that lives on it, and any node reports a blend other than Normal.
|
||||
#[test]
|
||||
fn inspect_reports_compound_paint_and_blend() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 40.0, 40.0);
|
||||
let b = rect(&mut s, 10.0, 10.0, 10.0, 10.0);
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
let c = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("object.setProps", &json!({"id": c, "blend": "Multiply"})).unwrap();
|
||||
let v = s.execute("document.inspect", &json!({})).unwrap();
|
||||
let node = &v["layers"][0]["children"][0];
|
||||
assert_eq!(node["id"], c);
|
||||
assert_eq!(node["kind"], "Compound Path");
|
||||
assert!(node["fill"].as_str().is_some_and(|f| f != "None"), "compound paint: {node}");
|
||||
assert!(node.get("stroke").is_some() && node.get("strokeWidth").is_some(), "{node}");
|
||||
assert_eq!(node["blend"], "Multiply");
|
||||
let plain = rect(&mut s, 80.0, 0.0, 10.0, 10.0);
|
||||
let v = s.execute("document.inspect", &json!({})).unwrap();
|
||||
let plain = v["layers"][0]["children"].as_array().unwrap().iter().find(|n| n["id"] == plain.0).unwrap();
|
||||
assert!(plain.get("blend").is_none(), "Normal is omitted: {plain}");
|
||||
}
|
||||
|
||||
/// `document.node {summary: true}` answers the `document.inspect` shape for one node:
|
||||
/// the same fields the full-tree summary carries, without serializing the whole tree.
|
||||
#[test]
|
||||
@@ -1532,6 +1608,55 @@ fn document_node_summary_matches_inspect() {
|
||||
assert!(s.execute("document.node", &json!({"id": 999999, "summary": true})).is_err());
|
||||
}
|
||||
|
||||
/// #1389: the paint of a compound path is on the compound, which reports it; its members report
|
||||
/// that they are members instead of a paint they don't have. Every node reports a blend mode
|
||||
/// that isn't Normal, as it does an opacity below 100%.
|
||||
#[test]
|
||||
fn inspect_reports_a_compound_paths_paint_and_blend_modes() {
|
||||
let mut s = session();
|
||||
let plain = rect(&mut s, 300.0, 0.0, 50.0, 50.0).0;
|
||||
let (a, b) = (rect(&mut s, 0.0, 0.0, 200.0, 200.0), rect(&mut s, 50.0, 50.0, 100.0, 100.0));
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
s.execute("object.compoundPath.make", &json!({})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"color": "#0000ff"})).unwrap();
|
||||
s.execute("stroke.set", &json!({"weight": 3})).unwrap();
|
||||
s.execute("object.setProps", &json!({"opacity": 80, "blend": "Color Burn"})).unwrap();
|
||||
let v = s.execute("document.inspect", &json!({})).unwrap();
|
||||
let nodes = v["layers"][0]["children"].as_array().unwrap();
|
||||
let compound = nodes.iter().find(|n| n["kind"] == "Compound Path").unwrap();
|
||||
assert_eq!((&compound["fill"], &compound["stroke"], &compound["strokeWidth"]), (&json!("#ff0000"), &json!("#0000ff"), &json!(3.0)), "{compound}");
|
||||
assert_eq!(compound["blend"], "Color Burn", "{compound}");
|
||||
// The label is what `object.setProps` takes.
|
||||
s.execute("object.setProps", &json!({"blend": compound["blend"]})).unwrap();
|
||||
let members = compound["children"].as_array().unwrap();
|
||||
assert_eq!(members.len(), 2);
|
||||
for m in members {
|
||||
assert_eq!(m["compoundMember"], true, "{m}");
|
||||
assert!(["fill", "stroke", "strokeWidth", "strokeOptions", "blend"].iter().all(|k| m.get(k).is_none()), "{m}");
|
||||
// One member read on its own says the same.
|
||||
assert_eq!(&s.execute("document.node", &json!({"id": m["id"], "summary": true})).unwrap(), m);
|
||||
}
|
||||
assert_eq!(s.execute("document.node", &json!({"id": compound["id"], "summary": true})).unwrap(), *compound);
|
||||
// A plain object: no blend while it is Normal, nothing about compound paths.
|
||||
let plain = nodes.iter().find(|n| n["id"] == plain).unwrap();
|
||||
assert!(plain.get("blend").is_none() && plain.get("compoundMember").is_none() && plain.get("fill").is_some(), "{plain}");
|
||||
// A group has a blend mode too, and still no paint.
|
||||
s.execute("select.set", &json!({"ids": [plain["id"]]})).unwrap();
|
||||
let g = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("object.setProps", &json!({"ids": [g], "blend": "Multiply"})).unwrap();
|
||||
let group = s.execute("document.node", &json!({"id": g, "summary": true})).unwrap();
|
||||
assert!(group["blend"] == "Multiply" && group.get("fill").is_none(), "{group}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolating_an_object_that_doesnt_exist_says_so() {
|
||||
let mut s = session();
|
||||
let e = s.execute("object.isolate", &json!({"id": 999_999})).unwrap_err();
|
||||
assert_eq!(e.to_string(), EngineError::NoNode(NodeId(999_999)).to_string());
|
||||
assert!(!e.to_string().contains("type"), "{e}");
|
||||
}
|
||||
|
||||
/// `depth` and `childLimit` slice a summary read; a level that shows fewer children
|
||||
/// than it has reports `childCount`, and invalid values are rejected, not reinterpreted.
|
||||
#[test]
|
||||
|
||||
@@ -442,6 +442,132 @@ fn line_family_tools_draw_and_click_asks_dialog() {
|
||||
assert_eq!(ui, vec![UiRequest::Dialog("polarGrid".into(), json!({"x": 10.0, "y": 10.0}))]);
|
||||
}
|
||||
|
||||
/// The children of the grid a `shape.rectangularGrid` / `shape.polarGrid` call made, bottom first.
|
||||
fn grid(s: &mut Session, cmd: &str, p: serde_json::Value) -> Vec<std::sync::Arc<vectorcraft_doc::Node>> {
|
||||
let id = NodeId(s.execute(cmd, &p).unwrap()["id"].as_u64().unwrap());
|
||||
s.doc().unwrap().doc.node(id).unwrap().children().unwrap().to_vec()
|
||||
}
|
||||
|
||||
/// `base` with the keys of `extra` added.
|
||||
fn with(base: &serde_json::Value, extra: serde_json::Value) -> serde_json::Value {
|
||||
let mut p = base.clone();
|
||||
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
p
|
||||
}
|
||||
|
||||
fn is_filled(n: &vectorcraft_doc::Node) -> bool {
|
||||
n.appearance.fill().is_some_and(|f| f.paint != vectorcraft_color::Paint::None)
|
||||
}
|
||||
|
||||
/// #1377: the Rectangular Grid options. By default the frame is one rectangle under evenly spaced
|
||||
/// dividers and nothing is filled; `frame: false` draws its four sides instead, `fill` paints the
|
||||
/// current fill and the skews crowd the dividers to the top / right (positive) or bottom / left.
|
||||
#[test]
|
||||
fn rectangular_grid_options_frame_fill_and_skew() {
|
||||
const CMD: &str = "shape.rectangularGrid";
|
||||
let mut s = session();
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
let base = json!({"x": 0, "y": 0, "width": 100, "height": 100, "rows": 3, "columns": 3});
|
||||
// The y of the three horizontal dividers and the x of the three vertical ones (the last six children).
|
||||
let dividers = |g: &[std::sync::Arc<vectorcraft_doc::Node>]| {
|
||||
let starts: Vec<Point> = g[g.len() - 6..].iter().map(|n| n.path_data().unwrap().subpaths[0].anchors[0].p).collect();
|
||||
starts[..3].iter().map(|p| p.y).chain(starts[3..].iter().map(|p| p.x)).collect::<Vec<_>>()
|
||||
};
|
||||
let plain = grid(&mut s, CMD, base.clone());
|
||||
assert_eq!(plain.len(), 7);
|
||||
assert!(plain[0].path_data().unwrap().is_closed(), "the frame lies under the dividers");
|
||||
assert!(plain.iter().all(|n| !is_filled(n) && n.appearance.stroke().is_some()));
|
||||
assert_eq!(dividers(&plain), [25.0, 50.0, 75.0, 25.0, 50.0, 75.0]);
|
||||
// No frame: its four sides are lines of their own.
|
||||
let sides = grid(&mut s, CMD, with(&base, json!({"frame": false})));
|
||||
assert_eq!(sides.len(), 10);
|
||||
assert!(sides.iter().all(|n| !n.path_data().unwrap().is_closed() && n.path_data().unwrap().anchor_count() == 2));
|
||||
assert_eq!(dividers(&sides), dividers(&plain));
|
||||
let filled = grid(&mut s, CMD, with(&base, json!({"fill": true})));
|
||||
assert!(filled.iter().all(|n| is_filled(n)), "Fill Grid paints the current fill");
|
||||
assert_eq!(filled[0].appearance.fill().unwrap().paint, s.paint.fill);
|
||||
// Skew: toward the top and the right when positive, the bottom and the left when negative;
|
||||
// past ±500 % is ±500 %.
|
||||
let top_right = dividers(&grid(&mut s, CMD, with(&base, json!({"rowSkew": 100, "columnSkew": 100}))));
|
||||
for (got, want) in top_right.iter().zip([6.25, 25.0, 56.25, 43.75, 75.0, 93.75]) {
|
||||
assert!((got - want).abs() < 1e-9, "{top_right:?}");
|
||||
}
|
||||
let bottom_left = dividers(&grid(&mut s, CMD, with(&base, json!({"rowSkew": -100, "columnSkew": -100}))));
|
||||
assert!(bottom_left[1] > 50.0 && bottom_left[4] < 50.0, "{bottom_left:?}");
|
||||
let most = dividers(&grid(&mut s, CMD, with(&base, json!({"rowSkew": 500, "columnSkew": -500}))));
|
||||
let beyond = dividers(&grid(&mut s, CMD, with(&base, json!({"rowSkew": 1e300, "columnSkew": -1e300}))));
|
||||
assert_eq!(most, beyond);
|
||||
assert!(most.iter().all(|v| *v > 0.0 && *v < 100.0), "{most:?}");
|
||||
}
|
||||
|
||||
/// #1377: the Polar Grid options. The ellipses lie under the radial dividers, largest first;
|
||||
/// `compound` pairs them into compound-path rings, `fill` paints the current fill and the skews
|
||||
/// crowd the ellipses outward and the radial dividers clockwise of the top one (positive).
|
||||
#[test]
|
||||
fn polar_grid_options_compound_fill_and_skew() {
|
||||
const CMD: &str = "shape.polarGrid";
|
||||
let mut s = session();
|
||||
s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap();
|
||||
let base = json!({"x": 0, "y": 0, "width": 100, "height": 100, "concentric": 3, "radial": 4});
|
||||
let width = |n: &vectorcraft_doc::Node| n.geometric_bounds().unwrap().width();
|
||||
let tips =
|
||||
|g: &[std::sync::Arc<vectorcraft_doc::Node>]| g[4..].iter().map(|n| n.path_data().unwrap().subpaths[0].anchors[1].p).collect::<Vec<_>>();
|
||||
let plain = grid(&mut s, CMD, base.clone());
|
||||
assert_eq!(plain.len(), 8);
|
||||
assert_eq!(plain[..4].iter().map(|n| width(n).round()).collect::<Vec<_>>(), [100.0, 75.0, 50.0, 25.0], "largest at the bottom");
|
||||
assert!(plain[..4].iter().all(|n| n.path_data().unwrap().is_closed()) && plain[4..].iter().all(|n| !n.path_data().unwrap().is_closed()));
|
||||
assert!(plain.iter().all(|n| !is_filled(n)));
|
||||
assert!(grid(&mut s, CMD, with(&base, json!({"fill": true}))).iter().all(|n| is_filled(n)));
|
||||
// Compound: 4 ellipses make 2 rings, each a compound path of an outside and a hole running
|
||||
// against it that carries the grid's paint itself; with 5, the innermost ellipse stays a path.
|
||||
let rings = grid(&mut s, CMD, with(&base, json!({"compound": true, "fill": true})));
|
||||
assert_eq!(rings.len(), 6);
|
||||
for (ring, sizes) in rings[..2].iter().zip([[100.0, 75.0], [50.0, 25.0]]) {
|
||||
let NodeKind::Compound { children, rule } = &ring.kind else { panic!("{:?}", ring.kind) };
|
||||
assert_eq!(*rule, FillRule::NonZero);
|
||||
assert_eq!(children.iter().map(|n| width(n).round()).collect::<Vec<_>>(), sizes);
|
||||
let areas: Vec<f64> = children.iter().map(|n| n.path_data().unwrap().subpaths[0].signed_area()).collect();
|
||||
assert!(areas[0] * areas[1] < 0.0, "the hole runs against the outside: {areas:?}");
|
||||
assert!(is_filled(ring) && ring.appearance.stroke().is_some());
|
||||
}
|
||||
let five = grid(&mut s, CMD, with(&base, json!({"compound": true, "concentric": 4, "radial": 0})));
|
||||
assert_eq!(five.iter().map(|n| matches!(n.kind, NodeKind::Compound { .. })).collect::<Vec<_>>(), [true, true, false]);
|
||||
// Skew.
|
||||
let out = grid(&mut s, CMD, with(&base, json!({"concentricSkew": 100, "radialSkew": 500})));
|
||||
for (n, want) in out[..4].iter().zip([100.0, 93.75, 75.0, 43.75]) {
|
||||
assert!((width(n) - want).abs() < 1e-6, "{}", width(n));
|
||||
}
|
||||
assert!(tips(&out)[0].distance(Point::new(50.0, 0.0)) < 1e-9, "the top divider stays");
|
||||
assert!(tips(&out)[1..].iter().all(|t| t.x > 50.0), "clockwise of the top: {:?}", tips(&out));
|
||||
let inward = grid(&mut s, CMD, with(&base, json!({"concentricSkew": -100, "radialSkew": -500})));
|
||||
assert!((width(&inward[1]) - 56.25).abs() < 1e-6);
|
||||
assert!(tips(&inward)[1..].iter().all(|t| t.x < 50.0), "counterclockwise of the top: {:?}", tips(&inward));
|
||||
}
|
||||
|
||||
/// The grid options are checked: a skew is a number and a switch true or false, or the command
|
||||
/// fails without an undo step.
|
||||
#[test]
|
||||
fn grid_commands_reject_options_of_the_wrong_type() {
|
||||
let mut s = session();
|
||||
let size = json!({"x": 0, "y": 0, "width": 10, "height": 10});
|
||||
for (cmd, key, value) in [
|
||||
("shape.rectangularGrid", "rowSkew", json!("50")),
|
||||
("shape.rectangularGrid", "columnSkew", json!(true)),
|
||||
("shape.rectangularGrid", "frame", json!(1)),
|
||||
("shape.rectangularGrid", "fill", json!("yes")),
|
||||
("shape.polarGrid", "concentricSkew", json!([1])),
|
||||
("shape.polarGrid", "radialSkew", json!({})),
|
||||
("shape.polarGrid", "compound", json!(0)),
|
||||
("shape.polarGrid", "fill", json!("true")),
|
||||
] {
|
||||
let before = s.doc().unwrap().history.undo.len();
|
||||
let p = with(&size, json!({key: value}));
|
||||
assert!(s.execute(cmd, &p).is_err(), "{cmd} {p}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), before, "{cmd} left an undo step");
|
||||
assert!(s.execute(cmd, &with(&size, json!({key: null}))).is_ok(), "{cmd}: null is the default");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw2_commands_reject_bad_params() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
//! Commands that remove, insert or copy many objects, and an Alt-drag that copies them, each finish
|
||||
//! within 20 s on one layer of 25,000 to 100,000 objects, and objects in two layers and inside a
|
||||
//! group keep their order and ids.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{FillRule, Rect, shapes};
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Squares in a row of [`square_at`].
|
||||
const ROW: usize = 200;
|
||||
|
||||
/// Square `k`: 10 pt wide, in rows of 200 squares 20 pt apart.
|
||||
fn square_at(k: usize) -> Rect {
|
||||
let (x, y) = ((k % ROW) as f64 * 20.0, (k / ROW) as f64 * 20.0);
|
||||
Rect::new(x, y, x + 10.0, y + 10.0)
|
||||
}
|
||||
|
||||
/// Square `k` ([`square_at`]) as a path with a new id.
|
||||
fn square(d: &mut Document, k: usize) -> Node {
|
||||
Node::path(d.alloc_id(), shapes::rectangle(square_at(k)), Appearance::default_art())
|
||||
}
|
||||
|
||||
/// A session whose document holds `make(d, k)` for each `k` below `n` on its one layer, all of it
|
||||
/// selected.
|
||||
fn session(n: usize, mut make: impl FnMut(&mut Document, usize) -> Node) -> Session {
|
||||
let mut d = Document::new(ROW as f64 * 20.0, (n / ROW + 1) as f64 * 20.0);
|
||||
let art: Vec<Arc<Node>> = (0..n).map(|k| Arc::new(make(&mut d, k))).collect();
|
||||
if let Some(ch) = Arc::make_mut(&mut d.layers[0]).children_mut() {
|
||||
*ch = art;
|
||||
}
|
||||
let mut s = Session::new();
|
||||
s.add_document(d, None);
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// The ids of `n`'s children.
|
||||
fn ids_of(n: &Node) -> Vec<NodeId> {
|
||||
n.children().into_iter().flatten().map(|c| c.id).collect()
|
||||
}
|
||||
|
||||
/// The ids of the objects on layer `i`.
|
||||
fn layer(s: &Session, i: usize) -> Vec<NodeId> {
|
||||
ids_of(&s.doc().unwrap().doc.layers[i])
|
||||
}
|
||||
|
||||
/// The ids of the children of `id`.
|
||||
fn children(s: &Session, id: NodeId) -> Vec<NodeId> {
|
||||
ids_of(s.doc().unwrap().doc.node(id).unwrap())
|
||||
}
|
||||
|
||||
/// `n` ids counting up from `first`.
|
||||
fn ids_from(first: u64, n: usize) -> Vec<NodeId> {
|
||||
(first..).take(n).map(NodeId).collect()
|
||||
}
|
||||
|
||||
/// The ids of `a` and `b` in turn: each id of `a` followed by the id of `b` at the same place.
|
||||
fn in_turn(a: &[NodeId], b: &[NodeId]) -> Vec<NodeId> {
|
||||
a.iter().zip(b).flat_map(|(a, b)| [*a, *b]).collect()
|
||||
}
|
||||
|
||||
/// Runs `f` on `s` on a thread of its own and fails the test when it takes more than 20 s.
|
||||
fn soon<T: Send + 'static>(mut s: Session, what: &'static str, f: impl FnOnce(&mut Session) -> T + Send + 'static) -> (Session, T) {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let r = f(&mut s);
|
||||
tx.send((s, r)).ok();
|
||||
});
|
||||
rx.recv_timeout(Duration::from_secs(20)).unwrap_or_else(|_| panic!("{what} did not finish within 20 s"))
|
||||
}
|
||||
|
||||
/// Runs command `cmd` with `params` through [`soon`] and returns its result.
|
||||
fn run_soon(s: Session, cmd: &'static str, params: Value) -> (Session, Value) {
|
||||
let (s, r) = soon(s, cmd, move |s| s.execute(cmd, ¶ms));
|
||||
(s, r.unwrap())
|
||||
}
|
||||
|
||||
/// The ids in the `ids` array of a command's result.
|
||||
fn ids_in(r: &Value) -> Vec<NodeId> {
|
||||
r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect()
|
||||
}
|
||||
|
||||
/// Runs Undo and checks that it gives back `before`.
|
||||
fn undo_gives_back(s: &mut Session, before: &Document) {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(*s.doc().unwrap().doc == *before, "Undo gives back the document");
|
||||
}
|
||||
|
||||
/// Whether the selected objects are `ids`, in that order.
|
||||
fn selected(s: &Session, ids: &[NodeId]) -> bool {
|
||||
s.doc().unwrap().selection.objects == ids
|
||||
}
|
||||
|
||||
/// The geometric bounds of object `id`.
|
||||
fn bounds(s: &Session, id: NodeId) -> Rect {
|
||||
s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_of_100000_objects_finishes_within_20_s() {
|
||||
let s = session(100_000, square);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let (mut s, _) = run_soon(s, "edit.clear", json!({}));
|
||||
assert!(layer(&s, 0).is_empty());
|
||||
assert!(selected(&s, &[]));
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_of_50000_objects_finishes_within_20_s() {
|
||||
let s = session(50_000, square);
|
||||
let (before, ids) = (s.doc().unwrap().doc.clone(), layer(&s, 0));
|
||||
let (mut s, r) = run_soon(s, "object.group", json!({}));
|
||||
let g = NodeId(r["id"].as_u64().unwrap());
|
||||
assert_eq!(layer(&s, 0), [g]);
|
||||
assert!(children(&s, g) == ids, "the group holds the objects in their order");
|
||||
assert!(selected(&s, &[g]));
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipping_mask_of_50000_objects_finishes_within_20_s() {
|
||||
let s = session(50_000, square);
|
||||
let (before, ids) = (s.doc().unwrap().doc.clone(), layer(&s, 0));
|
||||
let (mut s, r) = run_soon(s, "object.clippingMask.make", json!({}));
|
||||
let g = NodeId(r["id"].as_u64().unwrap());
|
||||
assert_eq!(layer(&s, 0), [g]);
|
||||
// The topmost object comes first, as the clipping path, and the others follow in their order.
|
||||
let mut want = ids.clone();
|
||||
want.rotate_right(1);
|
||||
assert!(children(&s, g) == want, "the clip group holds the objects in their order");
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert!(matches!(doc.node(g).unwrap().kind, NodeKind::Group { clip: true, .. }));
|
||||
assert!(matches!(doc.node(want[0]).unwrap().kind, NodeKind::Path { clipping: true, .. }));
|
||||
assert!(selected(&s, &[g]));
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_of_50000_objects_finishes_within_20_s() {
|
||||
let s = session(50_000, square);
|
||||
let (before, ids, first) = (s.doc().unwrap().doc.clone(), layer(&s, 0), s.doc().unwrap().doc.peek_next_id());
|
||||
let (mut s, r) = run_soon(s, "edit.duplicate", json!({"dx": 5}));
|
||||
// Each copy goes directly above its object and takes the next id, in the objects' order.
|
||||
let copies = ids_from(first, ids.len());
|
||||
assert!(ids_in(&r) == copies);
|
||||
assert!(layer(&s, 0) == in_turn(&ids, &copies), "each copy directly above its object");
|
||||
assert!(selected(&s, &copies));
|
||||
assert_eq!(bounds(&s, copies[0]), square_at(0) + vectorcraft_geom::Vec2::new(5.0, 0.0));
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_with_copy_of_50000_objects_finishes_within_20_s() {
|
||||
let s = session(50_000, square);
|
||||
let (before, ids, first) = (s.doc().unwrap().doc.clone(), layer(&s, 0), s.doc().unwrap().doc.peek_next_id());
|
||||
let (mut s, r) = run_soon(s, "object.transform", json!({"matrix": [1, 0, 0, 1, 5, 0], "copy": true}));
|
||||
let copies = ids_from(first, ids.len());
|
||||
assert!(ids_in(&r) == copies);
|
||||
assert!(layer(&s, 0) == in_turn(&ids, &copies), "each copy directly above its object");
|
||||
assert!(selected(&s, &copies));
|
||||
let last = ids.len() - 1;
|
||||
assert_eq!(bounds(&s, copies[last]), square_at(last) + vectorcraft_geom::Vec2::new(5.0, 0.0));
|
||||
assert_eq!(bounds(&s, ids[last]), square_at(last));
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_each_with_copy_of_50000_objects_finishes_within_20_s() {
|
||||
let s = session(50_000, square);
|
||||
let (before, ids, first) = (s.doc().unwrap().doc.clone(), layer(&s, 0), s.doc().unwrap().doc.peek_next_id());
|
||||
let (mut s, r) = run_soon(s, "object.transformEach", json!({"moveH": 5, "copy": true}));
|
||||
let copies = ids_from(first, ids.len());
|
||||
assert!(ids_in(&r) == copies);
|
||||
assert!(layer(&s, 0) == in_turn(&ids, &copies), "each copy directly above its object");
|
||||
assert!(selected(&s, &copies));
|
||||
assert_eq!(bounds(&s, copies[1]), square_at(1) + vectorcraft_geom::Vec2::new(5.0, 0.0));
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_path_of_100000_objects_finishes_within_20_s() {
|
||||
let s = session(100_000, square);
|
||||
let (before, ids) = (s.doc().unwrap().doc.clone(), layer(&s, 0));
|
||||
let (mut s, r) = run_soon(s, "object.compoundPath.make", json!({}));
|
||||
let c = NodeId(r["id"].as_u64().unwrap());
|
||||
assert_eq!(layer(&s, 0), [c]);
|
||||
assert!(children(&s, c) == ids, "the compound path holds the paths in their order");
|
||||
assert!(selected(&s, &[c]));
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_to_current_layer_of_100000_objects_finishes_within_20_s() {
|
||||
let mut s = session(100_000, square);
|
||||
let ids = layer(&s, 0);
|
||||
let to = NodeId(s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
s.execute("layer.setCurrent", &json!({"id": to.0})).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let (mut s, _) = run_soon(s, "object.arrange.sendToCurrentLayer", json!({}));
|
||||
assert!(layer(&s, 0).is_empty());
|
||||
assert!(children(&s, to) == ids, "the layer holds the objects in their order");
|
||||
assert!(selected(&s, &ids));
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ungroup_of_25000_groups_finishes_within_20_s() {
|
||||
// Each group holds two squares, and ungrouping one adds an object to the layer.
|
||||
let s = session(25_000, |d, k| {
|
||||
let pair = [square(d, 2 * k), square(d, 2 * k + 1)].map(Arc::new);
|
||||
Node::group(d.alloc_id(), pair.to_vec())
|
||||
});
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let squares: Vec<NodeId> = layer(&s, 0).into_iter().flat_map(|g| children(&s, g)).collect();
|
||||
let (mut s, _) = run_soon(s, "object.ungroup", json!({}));
|
||||
assert!(layer(&s, 0) == squares, "each group's objects where the group was");
|
||||
assert!(selected(&s, &squares));
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_path_release_of_25000_compound_paths_finishes_within_20_s() {
|
||||
let s = session(25_000, |d, k| {
|
||||
let pair = [square(d, 2 * k), square(d, 2 * k + 1)].map(Arc::new);
|
||||
Node::new(d.alloc_id(), NodeKind::Compound { children: pair.to_vec(), rule: FillRule::NonZero })
|
||||
});
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let paths: Vec<NodeId> = layer(&s, 0).into_iter().flat_map(|c| children(&s, c)).collect();
|
||||
let (mut s, _) = run_soon(s, "object.compoundPath.release", json!({}));
|
||||
assert!(layer(&s, 0) == paths, "each compound path's paths where it was");
|
||||
assert!(selected(&s, &paths));
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
/// With Alt held, a Selection tool press on one of 30,000 selected squares, four moves of 20 pt
|
||||
/// and the release copy the squares once, as one undo step.
|
||||
#[test]
|
||||
fn alt_drag_of_30000_objects_finishes_within_20_s() {
|
||||
let s = session(30_000, square);
|
||||
let (before, ids, first) = (s.doc().unwrap().doc.clone(), layer(&s, 0), s.doc().unwrap().doc.peek_next_id());
|
||||
// The press lands on a square in the middle of the art, away from the handles of the
|
||||
// selection's box.
|
||||
let grab = square_at(15_100).center();
|
||||
let (mut s, r) = soon(s, "the Alt-drag", move |s| {
|
||||
let (v, alt) = (ViewInfo::default(), Mods { alt: true, ..Mods::default() });
|
||||
s.select_tool("selection", v)?;
|
||||
let at = |kind, dx: f64| PointerEvent::new(kind, grab.x + dx, grab.y).with_mods(alt);
|
||||
s.pointer(&at(PointerKind::Down, 0.0), v)?;
|
||||
for dx in [20.0, 40.0, 60.0, 80.0] {
|
||||
s.pointer(&at(PointerKind::Drag, dx), v)?;
|
||||
}
|
||||
s.pointer(&at(PointerKind::Up, 80.0), v)
|
||||
});
|
||||
r.unwrap();
|
||||
let copies = ids_from(first, ids.len());
|
||||
assert!(layer(&s, 0) == in_turn(&ids, &copies), "each copy directly above its square");
|
||||
assert!(selected(&s, &copies));
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Copy"));
|
||||
// The squares stay; their copies move together.
|
||||
let moved = bounds(&s, copies[0]).origin() - square_at(0).origin();
|
||||
assert!(moved.x > 0.0, "{moved:?}");
|
||||
for (k, (id, copy)) in ids.iter().zip(&copies).enumerate() {
|
||||
assert_eq!(bounds(&s, *id), square_at(k));
|
||||
assert_eq!(bounds(&s, *copy), square_at(k) + moved);
|
||||
}
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
/// The ids under `n`: a child that can hold children is a list of its id and theirs.
|
||||
fn outline(n: &Node) -> Value {
|
||||
let ids = n.children().into_iter().flatten().map(|c| match c.children() {
|
||||
Some(_) => {
|
||||
let mut v = vec![json!(c.id.0)];
|
||||
v.extend(outline(c).as_array().cloned().unwrap_or_default());
|
||||
Value::Array(v)
|
||||
}
|
||||
None => json!(c.id.0),
|
||||
});
|
||||
Value::Array(ids.collect())
|
||||
}
|
||||
|
||||
/// [`outline`] of each layer.
|
||||
fn layers(s: &Session) -> Vec<Value> {
|
||||
s.doc().unwrap().doc.layers.iter().map(|l| outline(l)).collect()
|
||||
}
|
||||
|
||||
/// A session whose first layer holds a, b, c and a group of d, e and f, and whose second layer
|
||||
/// holds g and h, with a, c, e, g and h selected. Returns the session and the ids of a to h and of
|
||||
/// the group.
|
||||
fn two_layers() -> (Session, [u64; 9]) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
let rect = |s: &mut Session| {
|
||||
let x = 20.0 * s.doc().unwrap().doc.node_count() as f64;
|
||||
s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap()
|
||||
};
|
||||
let [a, b, c, d, e, f] = [(); 6].map(|()| rect(&mut s));
|
||||
s.execute("select.set", &json!({"ids": [d, e, f]})).unwrap();
|
||||
let group = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("layer.new", &json!({})).unwrap();
|
||||
let [g, h] = [(); 2].map(|()| rect(&mut s));
|
||||
s.execute("select.set", &json!({"ids": [a, c, e, g, h]})).unwrap();
|
||||
assert_eq!(layers(&s), [json!([a, b, c, [group, d, e, f]]), json!([g, h])]);
|
||||
(s, [a, b, c, d, e, f, g, h, group])
|
||||
}
|
||||
|
||||
/// After Group, Delete and Duplicate of objects in two layers and inside a group, the layers hold
|
||||
/// the objects and the new ones in the order, and with the ids, that each check lists, and Undo
|
||||
/// gives back the document.
|
||||
#[test]
|
||||
fn group_delete_and_duplicate_across_layers_and_inside_a_group_keep_order_and_ids() {
|
||||
let (mut s, [a, b, c, d, e, f, g, h, group]) = two_layers();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let next = before.peek_next_id();
|
||||
|
||||
let r = s.execute("object.group", &json!({})).unwrap();
|
||||
assert_eq!(r["id"], json!(next));
|
||||
assert_eq!(layers(&s), [json!([b, [group, d, f]]), json!([[next, a, c, e, g, h]])]);
|
||||
assert!(selected(&s, &[NodeId(next)]));
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
assert_eq!(layers(&s), [json!([b, [group, d, f]]), json!([])]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
let r = s.execute("edit.duplicate", &json!({})).unwrap();
|
||||
let [a2, c2, e2, g2, h2] = [0, 1, 2, 3, 4].map(|k| next + k);
|
||||
assert_eq!(r["ids"], json!([a2, c2, e2, g2, h2]));
|
||||
assert_eq!(layers(&s), [json!([a, a2, b, c, c2, [group, d, e, e2, f]]), json!([g, g2, h, h2])]);
|
||||
assert!(selected(&s, &ids_from(next, 5)));
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
/// `object.transform` copies ids listed twice, and an object listed before a group around it.
|
||||
/// Each copy goes directly above its original, a later copy of the same object goes below an
|
||||
/// earlier one, and the copy of the group holds a copy of the copy made inside the group before it.
|
||||
#[test]
|
||||
fn copies_of_ids_listed_twice_or_inside_another_listed_id_keep_their_order_and_ids() {
|
||||
let (mut s, [a, b, c, d, e, f, g, h, group]) = two_layers();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let n = before.peek_next_id();
|
||||
|
||||
let r = s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, 5, 0], "copy": true, "ids": [e, group]})).unwrap();
|
||||
assert_eq!(r["ids"], json!([n, n + 1]));
|
||||
let copied = json!([n + 1, n + 2, n + 3, n + 4, n + 5]);
|
||||
assert_eq!(layers(&s), [json!([a, b, c, [group, d, e, n, f], copied]), json!([g, h])]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
let r = s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, 5, 0], "copy": true, "ids": [a, a]})).unwrap();
|
||||
assert_eq!(r["ids"], json!([n, n + 1]));
|
||||
assert_eq!(layers(&s), [json!([a, n + 1, n, b, c, [group, d, e, f]]), json!([g, h])]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
@@ -359,3 +359,40 @@ fn a_placed_document_brings_its_links_with_the_files_pixels() {
|
||||
assert_eq!(im.link.as_ref().map(|l| l.path.as_str()), Some(pic.as_str()), "still linked");
|
||||
assert!(!doc.images[&im.key].is_proxy(), "the file's pixels, not the preview");
|
||||
}
|
||||
|
||||
/// The placed (linked) files of a `.ai` file come in with the preview the file keeps of them: one
|
||||
/// not found shows it, one found is read at once into the same bounds, which isn't a change.
|
||||
#[test]
|
||||
fn links_of_an_imported_file_are_read_where_they_are_found() {
|
||||
use vectorcraft_testkit::ai;
|
||||
let dir = Folder::new("imported");
|
||||
let pic = dir.file("photo.png").replace('\\', "/");
|
||||
// A 2×1 preview (premultiplied ARGB: a blue at half opacity) of a file of 600 × 300 px placed at
|
||||
// 60 × 30 pt, which the PDF part draws there too.
|
||||
let place = format!(
|
||||
"%AI5_BeginPlace\n[0.1 0 0 -0.1 20 80] 0 0 600 300 4 4 0\n({pic})`\n%AI26_BeginPlacedObjectPreview\n2 1 8 32 17 \x7f\x10\x10\x60\x7f\x10\x10\x60\n%AI26_EndPlacedObjectPreview\n~\n%AI5_EndPlace\n"
|
||||
);
|
||||
let doc_path = dir.file("poster.ai");
|
||||
write(&doc_path, &ai::ai(ai::editing_data(200.0, 100.0, &ai::layer("Art", &place)).as_bytes(), "0.56 0.56 0.87 rg 20 50 60 30 re f"));
|
||||
let placed = |s: &Session| {
|
||||
let mut found = vec![];
|
||||
s.doc().unwrap().doc.visit_images(|n, im| found.push((n.geometric_bounds().unwrap(), im.clone())));
|
||||
let [one] = &found[..] else { panic!("one image: {found:?}") };
|
||||
one.clone()
|
||||
};
|
||||
let in_place = |b: Rect| [b.x0 - 20.0, b.y0 - 20.0, b.x1 - 80.0, b.y1 - 50.0].iter().all(|d| d.abs() < 1e-6);
|
||||
let mut s = session();
|
||||
|
||||
let r = open(&mut s, &doc_path);
|
||||
assert_eq!((r["missingLinks"][0]["name"].as_str(), r["missingLinks"][0]["path"].as_str()), (Some("photo.png"), Some(pic.as_str())), "{r}");
|
||||
assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("low-resolution preview")), "{r}");
|
||||
let (b, im) = placed(&s);
|
||||
assert!(in_place(b) && im.width == 2 && s.doc().unwrap().doc.images[&im.key].is_proxy(), "{b:?} {im:?}");
|
||||
|
||||
write(&pic, &png(600, 300, RED));
|
||||
let r = open(&mut s, &doc_path);
|
||||
assert_eq!((&r["missingLinks"], &r["modifiedLinks"], &r["updatedLinks"]), (&json!([]), &json!([]), &json!([])), "{r}");
|
||||
let (b, im) = placed(&s);
|
||||
assert!(in_place(b) && (im.width, im.height) == (600, 300) && im.link.as_ref().is_some_and(|l| l.hash.is_some()), "{b:?} {im:?}");
|
||||
assert!(!s.doc().unwrap().doc.images[&im.key].is_proxy());
|
||||
}
|
||||
@@ -298,6 +298,60 @@ fn selection_preferences_drive_the_selection_tool() {
|
||||
assert!(double(&mut s).is_some(), "on: the group is isolated");
|
||||
}
|
||||
|
||||
/// Double Click To Isolate: a double-click with the Selection tool isolates a single object too,
|
||||
/// not only a group. Inside, only it is hit, marqueed and selected by Select All; new art goes
|
||||
/// beside it; a double-click on the empty canvas leaves, with it selected. Type isn't isolated:
|
||||
/// a double-click on it switches to the Type tool.
|
||||
#[test]
|
||||
fn double_clicking_a_single_object_isolates_it() {
|
||||
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id = |v: Value| NodeId(v["id"].as_u64().unwrap());
|
||||
let rect = |s: &mut Session, x: f64| id(s.execute("shape.rectangle", &json!({"x": x, "y": 100, "width": 50, "height": 50})).unwrap());
|
||||
let (a, b) = (rect(&mut s, 100.0), rect(&mut s, 200.0));
|
||||
s.select_tool("selection", ViewInfo::default()).unwrap();
|
||||
let ev = |s: &mut Session, kinds: &[PointerKind], x: f64, y: f64| {
|
||||
for k in kinds {
|
||||
s.pointer(&PointerEvent::new(*k, x, y), ViewInfo::default()).unwrap();
|
||||
}
|
||||
};
|
||||
let click = [PointerKind::Down, PointerKind::Up];
|
||||
let double = [PointerKind::DoubleClick];
|
||||
ev(&mut s, &double, 125.0, 125.0);
|
||||
assert_eq!(s.doc().unwrap().isolation, Some(a));
|
||||
// Only the isolated object is hit: a click on the other selects nothing.
|
||||
ev(&mut s, &click, 225.0, 125.0);
|
||||
assert!(s.doc().unwrap().selection.objects.is_empty());
|
||||
ev(&mut s, &click, 125.0, 125.0);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
|
||||
// A marquee over both and Select All take it alone.
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
ev(&mut s, &[PointerKind::Down], 90.0, 90.0);
|
||||
ev(&mut s, &[PointerKind::Move, PointerKind::Up], 300.0, 300.0);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
|
||||
// A second double-click on it stays in isolation; new art lands on its layer.
|
||||
ev(&mut s, &double, 125.0, 125.0);
|
||||
assert_eq!(s.doc().unwrap().isolation, Some(a));
|
||||
let c = rect(&mut s, 300.0);
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.doc.parent_of(c), st.doc.parent_of(a));
|
||||
// A double-click on the empty canvas leaves isolation, the object selected.
|
||||
ev(&mut s, &double, 50.0, 350.0);
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!((st.isolation, st.selection.objects.clone()), (None, vec![a]));
|
||||
assert!(st.doc.node(b).is_some());
|
||||
// Type is not isolated.
|
||||
let t = id(s.execute("text.create", &json!({"x": 100, "y": 300, "text": "Type"})).unwrap());
|
||||
assert!(s.execute("object.isolate", &json!({"id": t.0})).is_err());
|
||||
s.execute("prefs.set", &json!({"key": "doubleClickToIsolate", "value": false})).unwrap();
|
||||
ev(&mut s, &double, 225.0, 125.0);
|
||||
assert_eq!(s.doc().unwrap().isolation, None, "off: no isolation");
|
||||
}
|
||||
|
||||
/// General › Use Precise Cursors (#394): the Pen's pointer becomes a crosshair; the Selection
|
||||
/// tool's arrow stays.
|
||||
#[test]
|
||||
|
||||
@@ -289,6 +289,42 @@ fn artboard_tool_moves_with_art_creates_and_deletes() {
|
||||
assert_eq!(s.doc().unwrap().doc.artboards.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artboard_drag_keeps_a_flush_background_with_roundoff_and_multiple_texts() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap();
|
||||
let (x, y) = (-35.23064566724116, -107.85371637852609);
|
||||
s.execute("artboard.setProps", &json!({"index": 0, "x": x, "y": y})).unwrap();
|
||||
// The background's top is 2.84e-14 pt above the artboard's top.
|
||||
let background = rect(&mut s, x, -107.85371637852612, 600.0, 400.0);
|
||||
let mut ids = vec![background];
|
||||
for i in 0..3 {
|
||||
let text = s.execute("text.create", &json!({"x": x + 100.0, "y": y + 80.0 + f64::from(i) * 60.0, "text": "Test", "size": 12})).unwrap();
|
||||
ids.push(NodeId(text["id"].as_u64().unwrap()));
|
||||
}
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let before: Vec<Rect> = ids.iter().map(|id| bounds(&s, *id)).collect();
|
||||
let board = s.doc().unwrap().doc.artboards[0].rect;
|
||||
let history = undo_len(&s);
|
||||
gesture(&mut s, "artboard", &[(x + 300.0, y + 200.0), (x + 400.0, y + 240.0), (x + 400.0, y + 240.0)], Mods::default());
|
||||
let moved_board = s.doc().unwrap().doc.artboards[0].rect;
|
||||
let delta = moved_board.origin() - board.origin();
|
||||
assert!(delta.hypot() > 1.0, "the artboard actually moved");
|
||||
for (id, was) in ids.iter().zip(&before) {
|
||||
assert!(close(bounds(&s, *id), *was + delta), "{id:?} must move with the artboard");
|
||||
}
|
||||
assert_eq!(undo_len(&s), history + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.artboards[0].rect, board);
|
||||
for (id, was) in ids.iter().zip(&before) {
|
||||
assert!(close(bounds(&s, *id), *was), "Undo restores {id:?}");
|
||||
}
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
for (id, was) in ids.iter().zip(&before) {
|
||||
assert!(close(bounds(&s, *id), *was + delta), "Redo moves {id:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artboard_resize_via_handle_and_move_command() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -88,6 +88,26 @@ fn frame_type_keeps_dimensions_alignment_and_unit_conversion() {
|
||||
assert_eq!(frame.bounds().unwrap().height(), 50.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_too_short_for_its_first_line_is_made_taller_with_a_warning() {
|
||||
// #1423: a title taller than its frame (60 pt type in a 50 pt frame) drew nothing.
|
||||
let lines = |t: &TextObject| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).lines.len();
|
||||
let title = |size: f64| import(vec![block(utf8("T"), vec![run(1, Some(attrs("Frame", "", 400, false, size)))], &[3])], true, 144.0, IDENTITY);
|
||||
let short = title(120.0);
|
||||
let t = texts(&short.doc)[0];
|
||||
let TextKind::Area { frame } = &t.kind else { panic!("expected area type") };
|
||||
let b = frame.bounds().unwrap();
|
||||
assert!((b.x0, b.y0, b.width()) == (0.0, 0.0, 100.0) && b.height() > 50.0 && b.height() < 100.0, "taller from its top: {b:?}");
|
||||
assert_eq!(lines(t), 1, "the line shows");
|
||||
assert!(short.warnings.iter().any(|w| w.contains("too short for their first line")), "{:?}", short.warnings);
|
||||
// A frame that holds its line keeps its height, and nothing is said.
|
||||
let fits = title(20.0);
|
||||
let t = texts(&fits.doc)[0];
|
||||
let TextKind::Area { frame } = &t.kind else { panic!("expected area type") };
|
||||
assert_eq!((frame.bounds().unwrap().height(), lines(t)), (50.0, 1));
|
||||
assert!(!fits.warnings.iter().any(|w| w.contains("too short")), "{:?}", fits.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_font_weight_and_italic_maps_to_the_expected_style() {
|
||||
let cases = [
|
||||
|
||||
@@ -7,8 +7,11 @@ use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_color::cms;
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::cmd::colormgmt::{PROFILES_KEY, doc_profiles};
|
||||
use vectorcraft_testkit::fixtures::session;
|
||||
use vectorcraft_geom::Affine;
|
||||
use vectorcraft_render::{RenderOptions, Renderer};
|
||||
use vectorcraft_testkit::fixtures::{exec, rect, session};
|
||||
|
||||
/// The active Color Settings: one test at a time.
|
||||
fn settings() -> MutexGuard<'static, ()> {
|
||||
@@ -52,3 +55,32 @@ fn legacy_profile_name_resolves_in_commands_and_files() {
|
||||
assert_eq!(doc_profiles(&back), (Some(cms::WIDE_GAMUT_RGB.to_string()), None));
|
||||
cms::set_active(&before).unwrap();
|
||||
}
|
||||
|
||||
/// Raster effects follow the Color Settings: a blurred CMYK fill, and a white square's drop shadow
|
||||
/// in a CMYK color, are drawn again through the new working CMYK profile, in a frame drawn while
|
||||
/// interacting (as during a dialog's preview) and after it.
|
||||
#[test]
|
||||
fn raster_effects_draw_through_new_color_settings() {
|
||||
let _g = settings();
|
||||
let mut s = session();
|
||||
let blurred = rect(&mut s, 20.0, 20.0, 160.0, 120.0);
|
||||
exec(&mut s, "paint.setFill", json!({"ids": [blurred.0], "color": {"c": 0, "m": 60, "y": 100, "k": 0}}));
|
||||
exec(&mut s, "effect.apply", json!({"ids": [blurred.0], "effect": "blur.gaussian", "params": {"radius": 2}}));
|
||||
let lit = rect(&mut s, 220.0, 20.0, 120.0, 120.0);
|
||||
exec(&mut s, "paint.setFill", json!({"ids": [lit.0], "color": "#ffffff"}));
|
||||
let cyan = json!({"model": "cmyk", "c": 1.0, "m": 0.0, "y": 0.0, "k": 0.0});
|
||||
let shadow = json!({"x": 8, "y": 8, "blur": 1, "opacity": 100, "mode": "normal", "color": cyan});
|
||||
exec(&mut s, "effect.apply", json!({"ids": [lit.0], "effect": "stylize.dropShadow", "params": shadow}));
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let live = RenderOptions { interacting: true, ..opts.clone() };
|
||||
let frame = |r: &mut Renderer, s: &Session, o: &RenderOptions| r.render(&s.doc().unwrap().doc, 400, 300, Affine::IDENTITY, o).pixels;
|
||||
let mut r = Renderer::new();
|
||||
let before = frame(&mut r, &s, &opts);
|
||||
let old = cms::active_settings();
|
||||
s.execute("edit.colorSettings", &json!({"cmyk": cms::DEVICE_CMYK})).unwrap();
|
||||
let fresh = frame(&mut Renderer::new(), &s, &opts);
|
||||
assert!(fresh != before, "the new profile changes the frame");
|
||||
assert!(frame(&mut r, &s, &live) == fresh, "while interacting");
|
||||
assert!(frame(&mut r, &s, &opts) == fresh, "after it");
|
||||
cms::set_active(&old).unwrap();
|
||||
}
|
||||
@@ -325,6 +325,19 @@ fn structured_junk() {
|
||||
("object.path.offsetPath", json!({"offset": 1e308})),
|
||||
("object.path.offsetPath", json!({"offset": -1e308})),
|
||||
("object.path.simplify", json!({"tolerance": -1, "cornerAngle": 1e308})),
|
||||
(
|
||||
"shape.rectangularGrid",
|
||||
json!({"x": 0, "y": 0, "width": 1e308, "height": -1e308, "rows": 999, "columns": u64::MAX, "rowSkew": 1e308, "columnSkew": -1e308, "frame": false, "fill": true}),
|
||||
),
|
||||
("shape.rectangularGrid", json!({"x": 0, "y": 0, "width": 0, "height": 0, "rowSkew": "x", "frame": 3})),
|
||||
(
|
||||
"shape.polarGrid",
|
||||
json!({"x": 0, "y": 0, "width": 1e308, "height": 1e-308, "concentric": 999, "radial": 999, "concentricSkew": -1e308, "radialSkew": 1e308, "compound": true, "fill": true}),
|
||||
),
|
||||
(
|
||||
"shape.polarGrid",
|
||||
json!({"x": 0, "y": 0, "width": 10, "height": 10, "concentric": -1, "radial": 0.5, "radialSkew": null, "compound": "yes"}),
|
||||
),
|
||||
("object.path.splitIntoGrid", json!({"rows": 500, "columns": 1, "gutter": 1e308})),
|
||||
("object.path.splitIntoGrid", json!({"rows": 0.4})),
|
||||
("object.setBounds", json!({"width": 0, "height": 0})),
|
||||
|
||||
@@ -713,7 +713,7 @@ fn rich_resources_pdf(content: &str, config: &str) -> Vec<u8> {
|
||||
];
|
||||
let (a, b, form, pat, mesh, hidden) = (x, x + 1, x + 2, x + 3, x + 4, x + 5);
|
||||
let resources = format!(
|
||||
"/Properties << /MC0 {a} 0 R /MC1 {b} 0 R >> /XObject << /X0 {form} 0 R /X1 {hidden} 0 R >> /Pattern << /P0 {pat} 0 R >> /Shading << /Sh0 {mesh} 0 R /Sh1 << /ShadingType 3 /ColorSpace /DeviceGray /Coords [50 50 0 50 50 40] /Extend [false false] /Function << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> >> >> /ExtGState << /GS0 << /SMask << /Type /Mask /S /Luminosity /G {form} 0 R /BC [1] /TR << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >> >> >> /GS1 << /SMask << /Type /Mask /S /Alpha /G {form} 0 R >> >> >> /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Odd-Name,Bold >> >> /ColorSpace << /CS0 [/Pattern /DeviceRGB] >>"
|
||||
"/Properties << /MC0 {a} 0 R /MC1 {b} 0 R >> /XObject << /X0 {form} 0 R /X1 {hidden} 0 R >> /Pattern << /P0 {pat} 0 R >> /Shading << /Sh0 {mesh} 0 R /Sh1 << /ShadingType 3 /ColorSpace /DeviceGray /Coords [50 50 0 50 50 40] /Extend [false false] /Function << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> >> >> /ExtGState << /GS0 << /OP true /OPM 1 /SMask << /Type /Mask /S /Luminosity /G {form} 0 R /BC [1] /TR << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >> >> >> /GS1 << /OP true /op false /SMask << /Type /Mask /S /Alpha /G {form} 0 R >> >> /GS2 << /OP 7 /op (x) /OPM -3 >> /GS3 << /op true >> >> /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Odd-Name,Bold >> >> /ColorSpace << /CS0 [/Pattern /DeviceRGB] >>"
|
||||
);
|
||||
let catalog = format!("/OCProperties << /OCGs [{a} 0 R {b} 0 R 99 0 R] /D << {} >> >> ", config.replace("{B}", &format!("{b} 0 R")));
|
||||
let extra: Vec<&str> = extra.iter().map(String::as_str).collect();
|
||||
@@ -734,6 +734,9 @@ fn arb_rich_op() -> impl Strategy<Value = String> {
|
||||
"/X1 Do",
|
||||
"/GS0 gs",
|
||||
"/GS1 gs",
|
||||
// Overprint (#1388): set, malformed, and for fills alone.
|
||||
"/GS2 gs",
|
||||
"/GS3 gs",
|
||||
"/CS0 cs 1 0 0 /P0 scn",
|
||||
"/Pattern cs /P0 scn",
|
||||
"/Sh0 sh",
|
||||
@@ -1738,9 +1741,11 @@ fn editing_text() -> String {
|
||||
"1 Xw".into(),
|
||||
"u".into(),
|
||||
"/AI11Text :\n0 /FreeUndo ,\n;".into(),
|
||||
"/AI11Text :\n1 /FreeUndo ,\n;\n1 (Style) XW".into(),
|
||||
"U".into(),
|
||||
"0 Xw".into(),
|
||||
"1 0 0 0 1 0 Bg".into(),
|
||||
"Bb\n1 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg\n1 0 0 1 0 0 Xm\n0 0 0 0 0 0 Bm\n1 0 0 1 5 5 Bm\n0 BB".into(),
|
||||
"0 1 w 2 J 0 j 4 M [3 2]0 d 1 D".into(),
|
||||
"0 0 0 1 (Black global) 0.4 0 Xk".into(),
|
||||
"(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p".into(),
|
||||
@@ -1833,6 +1838,24 @@ fn the_frames_fixture_reads_as_threaded_area_type_and_type_on_a_path() {
|
||||
assert_eq!(l.doc.text_threads.len(), 1);
|
||||
}
|
||||
|
||||
/// The smallest failing input `eps_text_frames_never_panic` found: the first frame's bottom edge
|
||||
/// has a control point at x = 8e115 (`8e0115`). That frame holds no text, and the story flows on
|
||||
/// into the second frame.
|
||||
#[test]
|
||||
fn a_text_frame_with_a_control_point_at_8e115_opens_with_its_story_in_the_next_frame() {
|
||||
let text = mutate_text(&frames_document_text(), 1397, &[(191, '1'), (1586, 'e')]);
|
||||
assert!(text.contains(" 8e0115 "));
|
||||
let l = vectorcraft_engine::cmd::fileio::load("x.eps", &text_eps_of(&text, FRAME_OBJECTS)).unwrap();
|
||||
let mut texts = vec![];
|
||||
l.doc.walk(|n| {
|
||||
if let vectorcraft_doc::NodeKind::Text(t) = &n.kind {
|
||||
texts.push(t.plain_text());
|
||||
}
|
||||
});
|
||||
assert_eq!(texts, ["", "A story in two frames\nand more", "On a path"], "{:?}", l.warnings);
|
||||
survive("a frame with a control point at 8e115 pt", || Some(l.doc)).unwrap();
|
||||
}
|
||||
|
||||
/// An EPS with a hidden text object whose story is in `document`.
|
||||
fn text_eps(document: &str) -> Vec<u8> {
|
||||
text_eps_of(document, "/AI11Text :\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n")
|
||||
@@ -1889,7 +1912,7 @@ proptest! {
|
||||
*x = b;
|
||||
}
|
||||
}
|
||||
survive("mutated .ai editing data", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false).0))?;
|
||||
survive("mutated .ai editing data", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false).0))?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2060,6 +2083,64 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
|
||||
"\n%AI9_ColorModel: 2\n",
|
||||
"\n%AI5_BeginPlace\n",
|
||||
"\n%AI26_BeginPlacedObjectPreview\n",
|
||||
"\n%AI26_BeginPlacedObjectPreview\n2 1 8 32 17 abcdefgh",
|
||||
"\n%AI26_BeginPlacedObjectPreview\n4096 4096 1e9 32 17 ",
|
||||
"\n%AI26_EndPlacedObjectPreview\n",
|
||||
"[1 0 0 -1 0 0] 0 0 2 1 4 4 0 (a.png) `",
|
||||
"[1e308 0 0 1e308 0 0] 0 0 -1e308 1e308 4 4 0 (a.png) `",
|
||||
"`",
|
||||
"~",
|
||||
"\n%AI5_EndPlace\n",
|
||||
"Xm",
|
||||
"50 -50 -100 -100 5000 -2000 Xm",
|
||||
"0 0 0 0 1e308 -1e308 Xm",
|
||||
"Xd",
|
||||
"0 1 Xd",
|
||||
"1 0 Xd",
|
||||
"XD",
|
||||
"(00800080) Xt 0 XD",
|
||||
"X!",
|
||||
"/Mesh X!",
|
||||
"/End X!",
|
||||
"X#",
|
||||
"(DeviceRGB) /CS X#",
|
||||
"[0 0] /P X#",
|
||||
"[1 0 0 1 0 0 10 10 12 10 1 8 10 1] /N X#",
|
||||
"[1e308 -1e308 0] /N X#",
|
||||
"0 /E X#",
|
||||
"\n%AI9_BeginArtStyles\n",
|
||||
"\n%AI9_EndArtStyles\n",
|
||||
"/KnownStyle :",
|
||||
"(style) /Name ,",
|
||||
"/ActiveStyle :",
|
||||
"/CompoundFilter :",
|
||||
"/BasicFilter :",
|
||||
"(Stroke Style Filter) 0 0 /Filter ,",
|
||||
"(Fill Style Filter) 0 0 /Filter ,",
|
||||
"(Adobe Outer Glow) 1 0 /Filter ,",
|
||||
"(Adobe Drop Shadow) 1 0 /Filter ,",
|
||||
"1 /Visible ,",
|
||||
"2 /FillOrStroke ,",
|
||||
"1 /Bool (BSDashAdj) ,",
|
||||
"0.75 /Real (opac) ,",
|
||||
"1e308 /Real (blur) ,",
|
||||
"0 /Real ,",
|
||||
"; (BSVariableWidthTValuesArray) ,",
|
||||
"; (BSCenteredWidthsArray) ,",
|
||||
"/StrokeStyle : 0 R",
|
||||
"/FillStyle : 0 O",
|
||||
"/Def ;",
|
||||
"/Execution ;",
|
||||
"[5 5] 1e308 d",
|
||||
"-0.5 /Real ,",
|
||||
"1.5 /Real ,",
|
||||
"-2 /Real ,",
|
||||
"; (BSLeftWidthsArray) ,",
|
||||
"; (BSRightWidthsArray) ,",
|
||||
"[0.5 1e308 0 0 0 0 1 0 0 1] /N X#",
|
||||
"(DeviceGray) /CS X#",
|
||||
"\n%\n",
|
||||
"\n%AI5_EndPlace\n",
|
||||
"\n%%PageTrailer\n",
|
||||
])
|
||||
.prop_map(str::to_string),
|
||||
@@ -2083,8 +2164,9 @@ proptest! {
|
||||
fn ai_hostile_editing_data_never_panics(tokens in prop::collection::vec(arb_ai_token(), 0..80)) {
|
||||
use vectorcraft_testkit::ai;
|
||||
let data = ai::editing_data(200.0, 100.0, &format!("%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L) Ln\n{}\nLB\n", tokens.join(" ")));
|
||||
survive("hostile editing data", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], false).0))?;
|
||||
survive("hostile editing data, type as outlines", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], true).0))?;
|
||||
survive("hostile editing data", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], false, false).0))?;
|
||||
survive("hostile editing data, type as outlines", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], true, false).0))?;
|
||||
survive("hostile editing data, trusted", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], false, true).0))?;
|
||||
let eps = ai::eps(data.as_bytes(), ai::page_ps());
|
||||
survive("hostile editing data in an EPS", || vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc))?;
|
||||
// An EPS whose page is its art's box, not its artboard: what prints outside it is compared too.
|
||||
|
||||
@@ -25,6 +25,8 @@ kurbo = { workspace = true }
|
||||
image = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
ruzstd = { workspace = true }
|
||||
# Live effects' params, as the art styles that name them give them.
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
vectorcraft-testkit = { workspace = true }
|
||||
@@ -40,6 +40,10 @@ pub(crate) fn text_frames(data: &[u8]) -> HashMap<(u32, usize), BezPath> {
|
||||
continue;
|
||||
}
|
||||
let Some(head) = seg.get(..art) else { continue };
|
||||
// The type in a drawn look is a story of another text document (see `Reader::text_slot`).
|
||||
if number_before(head, b"/FreeUndo") == Some(1.0) {
|
||||
continue;
|
||||
}
|
||||
let (Some(story), frame) = (number_before(head, b"/StoryIndex"), number_before(head, b"/FrameIndex").unwrap_or(0.0)) else { continue };
|
||||
if !(0.0..f64::from(u32::MAX)).contains(&story) || !(0.0..1e6).contains(&frame) {
|
||||
continue;
|
||||
@@ -123,6 +127,12 @@ mod tests {
|
||||
assert_eq!(f.len(), 2, "point type has no frame: {f:?}");
|
||||
assert_eq!(f[&(49, 0)].bounding_box(), kurbo::Rect::new(10.0, 20.0, 110.0, 80.0));
|
||||
assert_eq!(f[&(60, 1)].elements().len(), 2, "a curve, led by %_");
|
||||
// The type in a drawn look counts its stories in another text document: its frame isn't
|
||||
// that of the text object which has the same number in this one.
|
||||
let look = String::from_utf8_lossy(doc).replacen("0 /FreeUndo", "1 /FreeUndo", 1).replacen("10 20 m", "500 20 m", 1);
|
||||
let own = "/AI11Text :\r0 /FreeUndo ,\r0 /FrameIndex ,\r49 /StoryIndex ,\r/Art :\r1 2 m\r3 2 L\r3 4 L\rN\r;\r/ConfiningPath ,\r;\r";
|
||||
let f = text_frames(format!("{look}{own}").as_bytes());
|
||||
assert_eq!(f[&(49, 0)].bounding_box(), kurbo::Rect::new(1.0, 2.0, 3.0, 4.0));
|
||||
assert!(text_frames(b"/AI11Text : garbage").is_empty());
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@
|
||||
//! readers of the printed format skip them as comments).
|
||||
//! - Any other line starting with `%` is a [`Tok::Comment`] (the section markers).
|
||||
//! - `%` later in a line starts a comment that is skipped.
|
||||
//! - The binary image data after `%%BeginData:` is one [`Tok::Data`]; the binary previews of placed
|
||||
//! files are skipped.
|
||||
//! - The binary image data after `%%BeginData:` is one [`Tok::Data`]; the binary preview of a placed
|
||||
//! file is one [`Tok::Preview`].
|
||||
|
||||
use super::super::lex::{find, hex_decode};
|
||||
|
||||
@@ -25,6 +25,8 @@ pub(super) enum Tok<'a> {
|
||||
Comment(&'a [u8]),
|
||||
/// An image's samples: what follows `XI` in a `%%BeginData:` section.
|
||||
Data(&'a [u8]),
|
||||
/// A placed file's preview: what is between `%AI26_BeginPlacedObjectPreview` and its end.
|
||||
Preview(&'a [u8]),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -34,8 +36,10 @@ pub(super) struct Token<'a> {
|
||||
pub hidden: bool,
|
||||
}
|
||||
|
||||
/// Binary sections skipped whole: (begin, end) markers.
|
||||
const SKIPPED: [(&[u8], &[u8]); 2] = [(b"%AI26_BeginPlacedObjectPreview", b"%AI26_EndPlacedObjectPreview"), (b"%%BeginDocument", b"%%EndDocument")];
|
||||
/// A placed file's preview (binary): (begin, end) markers.
|
||||
const PREVIEW: (&[u8], &[u8]) = (b"%AI26_BeginPlacedObjectPreview", b"%AI26_EndPlacedObjectPreview");
|
||||
/// A binary section skipped whole: (begin, end) markers.
|
||||
const DOCUMENT: (&[u8], &[u8]) = (b"%%BeginDocument", b"%%EndDocument");
|
||||
|
||||
fn is_space(b: u8) -> bool {
|
||||
matches!(b, b' ' | b'\t' | b'\r' | b'\n' | b'\x0c' | b'\0')
|
||||
@@ -160,21 +164,27 @@ impl<'a> Lexer<'a> {
|
||||
}
|
||||
|
||||
/// The token a comment line stands for when it starts a section read differently: image data
|
||||
/// (`%%BeginData:` followed by `XI`) and skipped binary sections.
|
||||
/// (`%%BeginData:` followed by `XI`), a placed file's preview and skipped binary sections.
|
||||
fn special(&mut self, line: &'a [u8]) -> Option<Token<'a>> {
|
||||
if let Some((_, end)) = SKIPPED.iter().find(|(b, _)| line.starts_with(b)) {
|
||||
if let Some((_, end)) = [PREVIEW, DOCUMENT].iter().find(|(b, _)| line.starts_with(b)) {
|
||||
let from = self.pos;
|
||||
let after = find(self.rest(), end).map_or(self.src.len(), |i| self.pos + i);
|
||||
self.pos = after;
|
||||
self.line();
|
||||
return Some(Token { tok: Tok::Comment(line.get(1..).unwrap_or_default()), hidden: false });
|
||||
let tok = if *end == PREVIEW.1 { Tok::Preview(self.slice(from, after)) } else { Tok::Comment(line.get(1..).unwrap_or_default()) };
|
||||
return Some(Token { tok, hidden: false });
|
||||
}
|
||||
let n = line.strip_prefix(b"%%BeginData:")?;
|
||||
let n: usize = std::str::from_utf8(n).ok()?.split_whitespace().next()?.parse().ok()?;
|
||||
// The count starts after the comment's line end character (a following `\n` counts).
|
||||
let from = self.pos + 1;
|
||||
let region = self.src.get(from..)?;
|
||||
let skip = region.iter().take_while(|b| is_space(**b)).count();
|
||||
if !region.get(skip..)?.starts_with(b"XI") {
|
||||
let mut skip = region.iter().take_while(|b| is_space(**b)).count();
|
||||
// A hidden object's section is on `%_` lines up to its samples: `%_XI`, then `%_%%EndData`.
|
||||
let hidden = region.get(skip..)?.starts_with(b"%_XI");
|
||||
if hidden {
|
||||
skip += 2;
|
||||
} else if !region.get(skip..)?.starts_with(b"XI") {
|
||||
return None;
|
||||
}
|
||||
let mut data = from + skip + 2;
|
||||
@@ -192,7 +202,9 @@ impl<'a> Lexer<'a> {
|
||||
None => declared,
|
||||
};
|
||||
self.pos = end;
|
||||
Some(Token { tok: Tok::Data(self.src.get(data..end).unwrap_or_default()), hidden: false })
|
||||
let samples = self.src.get(data..end).unwrap_or_default();
|
||||
let samples = if hidden { samples.strip_suffix(b"%_").unwrap_or(samples) } else { samples };
|
||||
Some(Token { tok: Tok::Data(samples), hidden: false })
|
||||
}
|
||||
|
||||
/// A regular token's characters.
|
||||
@@ -321,13 +333,20 @@ mod tests {
|
||||
// A section of hex lines is comments.
|
||||
let toks = all(b"%%BeginData: 9 Hex Bytes\r\n%00FF\r\n%%EndData\r\n1\r\n");
|
||||
assert_eq!(toks.last(), Some(&t(Tok::Num(1.0), false)));
|
||||
// On `%_` lines (an opacity mask's image, say) too: a `(` in the samples starts no string.
|
||||
let toks = all(b"%_XI\r%_%%BeginData: 4\r%_XI\r(\x00\x00\x00%_%%EndData\r%_XH\r1\r");
|
||||
assert!(toks.contains(&t(Tok::Data(b"(\x00\x00\x00"), true)), "{toks:?}");
|
||||
assert_eq!(toks.last(), Some(&t(Tok::Num(1.0), false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_previews_are_skipped() {
|
||||
fn binary_sections_are_one_token() {
|
||||
let toks = all(b"%AI26_BeginPlacedObjectPreview\r\n2 1 (\x00\xff\r\n%AI26_EndPlacedObjectPreview\r\nN\r\n");
|
||||
assert_eq!(toks.last(), Some(&t(Tok::Word(b"N"), false)));
|
||||
assert_eq!(toks.len(), 2);
|
||||
assert_eq!(toks, vec![t(Tok::Preview(b"\r\n2 1 (\x00\xff\r\n"), false), t(Tok::Word(b"N"), false)]);
|
||||
let toks = all(b"%%BeginDocument: a.eps\n(\x00 frobnicate\n%%EndDocument\nN\n");
|
||||
assert_eq!(toks, vec![t(Tok::Comment(b"%BeginDocument: a.eps"), false), t(Tok::Word(b"N"), false)]);
|
||||
// One that doesn't end takes the rest.
|
||||
assert_eq!(all(b"%AI26_BeginPlacedObjectPreview\r1 (").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
//! Gradient meshes: `/Mesh X!`, then on `%_` lines `value /key X#` entries — the colour space
|
||||
//! (`/CS`), and per patch its place (`/P [row col]`) and four corners (`/N`, from the corner nearest
|
||||
//! the origin round to the one beside it), ended by `/E` — and `/End X!`.
|
||||
//!
|
||||
//! A corner is `[colour… opacity x y` then the handle towards the next corner, a weight, the handle
|
||||
//! towards the previous one and a weight `]`, in art space. Each patch becomes a 1×1 mesh.
|
||||
//!
|
||||
//! `X!`/`X#` aren't publicly documented (the AI7 specification predates meshes).
|
||||
|
||||
use kurbo::{Affine, Point, Vec2};
|
||||
use vectorcraft_color::Color;
|
||||
use vectorcraft_doc::live::{GradientMesh, H_DOWN, H_LEFT, H_RIGHT, H_UP, MeshPoint};
|
||||
|
||||
use super::obj::V;
|
||||
|
||||
/// The most patches read from one mesh.
|
||||
const MAX_PATCHES: usize = 4096;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct MeshReader {
|
||||
space: String,
|
||||
corners: Vec<Vec<f64>>,
|
||||
pub patches: Vec<GradientMesh>,
|
||||
/// Something in it this doesn't read.
|
||||
pub unread: bool,
|
||||
}
|
||||
|
||||
impl MeshReader {
|
||||
/// `value /key X#`; `to_doc`: art space → the document.
|
||||
pub fn entry(&mut self, vals: &[V], to_doc: Affine) {
|
||||
let Some(at) = vals.iter().rposition(|v| matches!(v, V::Name(_))) else { return };
|
||||
let key = vals.get(at).and_then(V::text).unwrap_or_default();
|
||||
let value = at.checked_sub(1).and_then(|i| vals.get(i));
|
||||
match key.as_str() {
|
||||
"CS" => self.space = value.and_then(V::text).unwrap_or_default(),
|
||||
"N" => {
|
||||
if let Some(V::Arr(a)) = value {
|
||||
if self.corners.len() < 4 {
|
||||
self.corners.push(a.iter().filter_map(V::num).take(64).collect());
|
||||
} else {
|
||||
self.unread = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
"E" => {
|
||||
let corners = std::mem::take(&mut self.corners);
|
||||
match self.patch(&corners, to_doc) {
|
||||
Some(m) if self.patches.len() < MAX_PATCHES => self.patches.push(m),
|
||||
_ => self.unread = true,
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn color(&self, c: &[f64]) -> Option<Color> {
|
||||
let u = |i: usize| c.get(i).filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 1.0) as f32);
|
||||
Some(match (self.space.as_str(), c.len()) {
|
||||
("DeviceRGB", 3) => Color::rgb(u(0)?, u(1)?, u(2)?),
|
||||
("DeviceCMYK", 4) => Color::cmyk(u(0)?, u(1)?, u(2)?, u(3)?),
|
||||
("DeviceGray", 1) => Color::gray(1.0 - u(0)?),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn patch(&self, corners: &[Vec<f64>], to_doc: Affine) -> Option<GradientMesh> {
|
||||
let [n0, n1, n2, n3] = corners else { return None };
|
||||
// (anchor, towards the next corner, towards the previous one, colour, opacity)
|
||||
let read = |n: &Vec<f64>| -> Option<(Point, Vec2, Vec2, Color, f32)> {
|
||||
let comps = n.len().checked_sub(9)?;
|
||||
let &[opacity, x, y, nx, ny, _, px, py, _] = n.get(comps..)? else { return None };
|
||||
if ![opacity, x, y, nx, ny, px, py].iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let at = to_doc * Point::new(x, y);
|
||||
let color = self.color(n.get(..comps)?)?;
|
||||
Some((at, to_doc * Point::new(nx, ny) - at, to_doc * Point::new(px, py) - at, color, opacity.clamp(0.0, 1.0) as f32))
|
||||
};
|
||||
let (a, b, c, d) = (read(n0)?, read(n1)?, read(n2)?, read(n3)?);
|
||||
// Rows run from the first corner to the second, columns from the first to the fourth.
|
||||
let point = |(p, _, _, color, opacity): (Point, Vec2, Vec2, Color, f32), handles: [(usize, Vec2); 2]| {
|
||||
let mut h = [Vec2::ZERO; 4];
|
||||
for (i, v) in handles {
|
||||
if let Some(slot) = h.get_mut(i) {
|
||||
*slot = v;
|
||||
}
|
||||
}
|
||||
MeshPoint { p, color, opacity, handles: h }
|
||||
};
|
||||
let points = vec![
|
||||
point(a, [(H_DOWN, a.1), (H_RIGHT, a.2)]),
|
||||
point(d, [(H_LEFT, d.1), (H_DOWN, d.2)]),
|
||||
point(b, [(H_RIGHT, b.1), (H_UP, b.2)]),
|
||||
point(c, [(H_UP, c.1), (H_LEFT, c.2)]),
|
||||
];
|
||||
Some(GradientMesh { rows: 1, cols: 1, points })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn corner(values: &[f64]) -> Vec<V> {
|
||||
vec![V::Arr(values.iter().map(|v| V::Num(*v)).collect()), V::Name("N".into())]
|
||||
}
|
||||
|
||||
fn patch(reader: &mut MeshReader, first: &[f64]) {
|
||||
reader.entry(&corner(first), Affine::IDENTITY);
|
||||
for (x, y) in [(0.0, 1.0), (1.0, 1.0), (1.0, 0.0)] {
|
||||
reader.entry(&corner(&[0.5, 1.0, x, y, x, y, 1.0, x, y, 1.0]), Affine::IDENTITY);
|
||||
}
|
||||
reader.entry(&[V::Num(0.0), V::Name("E".into())], Affine::IDENTITY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_meshes_are_capped_and_checked() {
|
||||
let mut reader = MeshReader { space: "DeviceGray".into(), ..MeshReader::default() };
|
||||
for _ in 0..=MAX_PATCHES {
|
||||
patch(&mut reader, &[0.5, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
assert!(reader.unread && reader.patches.len() == MAX_PATCHES);
|
||||
// NaN passes `clamp`: a non-finite opacity or colour leaves the patch unread.
|
||||
for bad in [[0.5, f64::NAN], [f64::INFINITY, 1.0]] {
|
||||
let mut reader = MeshReader { space: "DeviceGray".into(), ..MeshReader::default() };
|
||||
patch(&mut reader, &[bad[0], bad[1], 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0]);
|
||||
assert!(reader.unread && reader.patches.is_empty(), "{bad:?}");
|
||||
}
|
||||
// More corners than a patch has.
|
||||
let mut reader = MeshReader { space: "DeviceGray".into(), ..MeshReader::default() };
|
||||
for _ in 0..1000 {
|
||||
reader.entry(&corner(&[0.5, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0]), Affine::IDENTITY);
|
||||
}
|
||||
assert!(reader.unread && reader.corners.len() == 4);
|
||||
}
|
||||
}
|
||||
@@ -19,9 +19,11 @@
|
||||
|
||||
mod frames;
|
||||
mod lex;
|
||||
mod mesh;
|
||||
mod obj;
|
||||
mod paint;
|
||||
mod read;
|
||||
mod style;
|
||||
|
||||
use std::io::Read as _;
|
||||
|
||||
|
||||
@@ -9,8 +9,15 @@
|
||||
//! - Gradient definitions: `(name) type stops Bd`, its stops as `colour style [opacity 6]
|
||||
//! midpoint ramp Bs` (style 0 grey, 1 CMYK, 2 RGB with its CMYK, 3 and 4 named colours), `BD`.
|
||||
//! - A gradient on an object: `[flag] Bb` (1: the stroke), `flag (name) x y angle length a b c d
|
||||
//! tx ty Bg`, the gradient matrix `a b c d tx ty Bm`, the focal point `x y angle length Bh`,
|
||||
//! `flag BB`.
|
||||
//! tx ty Bg`, the focal point `x y angle length Bh`, `flag BB`. Newer files write the gradient's
|
||||
//! place as matrices after `Bg` (whose own numbers are then not in art space): `a b c d tx ty Xm`
|
||||
//! takes the unit vector (a radial's unit circle) onto the gradient's whole ramp (0 to 100, its
|
||||
//! angle and length in it), and `a b c d tx ty Bm` takes it onto the part between two stops next to each other, one
|
||||
//! `Bm` for each pair in the order of the ramp (`Bc`, around them, are the caps past the first and
|
||||
//! last stops). Without `Xm`, a radial gradient's one `Bm` takes the unit circle onto it.
|
||||
//!
|
||||
//! "AI7 spec": Adobe Illustrator File Format Specification, version 7.0 (1998), listed by PRONOM at
|
||||
//! https://www.nationalarchives.gov.uk/PRONOM/fmt/423; §3.5 documents the gradient operators.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -151,6 +158,8 @@ impl GradientDef {
|
||||
if self.gradient.stops.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Stops come last first: reversed, stops sharing a ramp point keep their order.
|
||||
self.gradient.stops.reverse();
|
||||
self.gradient.stops.sort_by(|a, b| a.offset.total_cmp(&b.offset));
|
||||
Some((self.name, self.gradient))
|
||||
}
|
||||
@@ -166,8 +175,11 @@ pub(super) struct Instance {
|
||||
length: f64,
|
||||
/// `Bg`'s own matrix.
|
||||
bg: Affine,
|
||||
/// The gradient matrix (`Bm`), when there is one.
|
||||
bm: Option<Affine>,
|
||||
/// `Xm`: "the overall matrix applied to the gradient" (AI7 spec §3.5.4), when there is one.
|
||||
xm: Option<Affine>,
|
||||
/// The first and the last gradient matrix (`Bm`), when there are any. After `Xm` they are
|
||||
/// imaging bands (AI7 spec §3.5.6), not the gradient's matrix.
|
||||
bm: Option<(Affine, Affine)>,
|
||||
/// The focal point's offset from the origin (`Bh`).
|
||||
hilight: Vec2,
|
||||
}
|
||||
@@ -197,10 +209,22 @@ impl Instance {
|
||||
}
|
||||
}
|
||||
|
||||
/// `a b c d tx ty Bm`.
|
||||
pub fn bm(&mut self, vals: &[V]) {
|
||||
/// The matrix of `a b c d tx ty Bm` or `Xm`, when it can be inverted.
|
||||
fn matrix(vals: &[V]) -> Option<Affine> {
|
||||
let n: Vec<f64> = vals.iter().filter_map(V::num).collect();
|
||||
self.bm = n.get(n.len().saturating_sub(6)..).and_then(affine).filter(|m| m.determinant().abs() > 1e-12);
|
||||
n.get(n.len().saturating_sub(6)..).and_then(affine).filter(|m| m.determinant().abs() > 1e-12)
|
||||
}
|
||||
|
||||
/// `a b c d tx ty Bm`: one for each pair of stops next to each other.
|
||||
pub fn bm(&mut self, vals: &[V]) {
|
||||
if let Some(m) = Self::matrix(vals) {
|
||||
self.bm = Some((self.bm.map_or(m, |(first, _)| first), m));
|
||||
}
|
||||
}
|
||||
|
||||
/// `a b c d tx ty Xm`: "the overall matrix applied to the gradient" (AI7 spec §3.5.4).
|
||||
pub fn xm(&mut self, vals: &[V]) {
|
||||
self.xm = Self::matrix(vals);
|
||||
}
|
||||
|
||||
/// `x y angle length Bh`.
|
||||
@@ -215,25 +239,69 @@ impl Instance {
|
||||
pub fn paint(&self, defs: &HashMap<String, Gradient>, to_doc: Affine) -> Option<Paint> {
|
||||
let name = self.name.as_ref()?;
|
||||
let gradient = defs.get(name)?.clone();
|
||||
let m = to_doc * self.bm.unwrap_or(Affine::IDENTITY) * self.bg;
|
||||
let a = self.angle.to_radians();
|
||||
let dir = Vec2::new(a.cos(), a.sin()) * self.length;
|
||||
let start = m * self.origin;
|
||||
let end = m * (self.origin + dir);
|
||||
let radial = gradient.kind == GradientKind::Radial;
|
||||
// `Xm` maps the unit circle (or vector) to the art, `Bg`'s origin, angle and length already
|
||||
// in it, and so does a radial's `Bm`: files whose PDF part draws the radial draw it through
|
||||
// that `Bm` alone, whatever `Bg` holds. The highlight is an offset from the centre in art space.
|
||||
let unit = self.xm.or(self.bm.filter(|_| radial).map(|(first, _)| first));
|
||||
// A skewed matrix leaves `along` and `across` oblique: a linear ramp runs along the normal to
|
||||
// its isolines (`across`), and a radial's ellipse is set by the matrix's principal axes.
|
||||
let through = |m: Affine, origin: Point, dir: Vec2| {
|
||||
let start = m * origin;
|
||||
let along = m * (origin + dir) - start;
|
||||
let across = m * (origin + Vec2::new(-dir.y, dir.x)) - start;
|
||||
if radial {
|
||||
let (major, minor) = principal_axes(along, across);
|
||||
(start, start + major, minor.hypot() / major.hypot())
|
||||
} else {
|
||||
let normal = Vec2::new(-across.y, across.x).normalize();
|
||||
(start, start + normal * along.dot(normal), 1.0)
|
||||
}
|
||||
};
|
||||
let (start, end, aspect, focal) = match (unit, self.bm) {
|
||||
(Some(unit), _) => {
|
||||
let (start, end, aspect) = through(to_doc * unit, Point::ORIGIN, Vec2::new(1.0, 0.0));
|
||||
(start, end, aspect, to_doc * (unit * Point::ORIGIN + self.hilight))
|
||||
}
|
||||
// A linear gradient's `Bm`s alone: they run from its first stop to its last.
|
||||
(None, Some((first, last))) => {
|
||||
let (a, b) = (to_doc * first * Point::ORIGIN, to_doc * last * Point::new(1.0, 0.0));
|
||||
let from = gradient.stops.first().map_or(0.0, |s| f64::from(s.offset));
|
||||
let to = gradient.stops.last().map_or(1.0, |s| f64::from(s.offset));
|
||||
let whole = (b - a) / (to - from).max(1e-6);
|
||||
let start = a - whole * from;
|
||||
(start, start + whole, 1.0, start)
|
||||
}
|
||||
// `Bg`'s own numbers, in art space through its matrix.
|
||||
(None, None) => {
|
||||
let m = to_doc * self.bg;
|
||||
let a = self.angle.to_radians();
|
||||
let (start, end, aspect) = through(m, self.origin, Vec2::new(a.cos(), a.sin()) * self.length);
|
||||
(start, end, aspect, m * (self.origin + self.hilight))
|
||||
}
|
||||
};
|
||||
if !(start.is_finite() && end.is_finite()) || start.distance(end) < 1e-9 {
|
||||
return None;
|
||||
}
|
||||
// Across the vector, for an elliptical radial gradient.
|
||||
let across = m * (self.origin + Vec2::new(-dir.y, dir.x));
|
||||
let aspect = (across.distance(start) / start.distance(end)).clamp(0.01, 100.0);
|
||||
let focal = (self.hilight.hypot() > 1e-9).then(|| m * (self.origin + self.hilight)).filter(|p| p.is_finite());
|
||||
let radial = gradient.kind == GradientKind::Radial;
|
||||
let geom = GradientGeom { start, end, aspect: if radial { aspect } else { 1.0 }, focal: if radial { focal } else { None } };
|
||||
let aspect = aspect.clamp(0.01, 100.0);
|
||||
let focal = (self.hilight.hypot() > 1e-9).then_some(focal).filter(|p| p.is_finite());
|
||||
let geom = GradientGeom { start, end, aspect, focal: if radial { focal } else { None } };
|
||||
let angle = -(end - start).atan2().to_degrees();
|
||||
Some(Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), angle, swatch: Some(name.clone()), ..GradientPaint::new(gradient) })))
|
||||
}
|
||||
}
|
||||
|
||||
/// The semi-axes of the ellipse the unit circle maps to under the linear map with columns `u` and `v`,
|
||||
/// the major one first.
|
||||
fn principal_axes(u: Vec2, v: Vec2) -> (Vec2, Vec2) {
|
||||
let (xx, yy, xy) = (u.x * u.x + v.x * v.x, u.y * u.y + v.y * v.y, u.x * u.y + v.x * v.y);
|
||||
let angle = 0.5 * (2.0 * xy).atan2(xx - yy);
|
||||
let spread = (0.25 * (xx - yy).powi(2) + xy * xy).sqrt();
|
||||
let (major, minor) = ((0.5 * (xx + yy) + spread).sqrt(), (0.5 * (xx + yy) - spread).max(0.0).sqrt());
|
||||
let axis = Vec2::new(angle.cos(), angle.sin());
|
||||
(axis * major, Vec2::new(-axis.y, axis.x) * minor)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -298,7 +366,7 @@ mod tests {
|
||||
let defs = HashMap::from([(name, g)]);
|
||||
let mut i = Instance::begin(&[]);
|
||||
let mut bg = vec![V::Num(1.0), V::Str(b"G".to_vec())];
|
||||
bg.extend(nums(&[0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0]));
|
||||
bg.extend(nums(&[100.0, 100.0, 0.0, 50.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0]));
|
||||
i.bg(&bg);
|
||||
i.bm(&nums(&[50.0, 0.0, 0.0, -50.0, 100.0, 100.0]));
|
||||
let Some(Paint::Gradient(gp)) = i.paint(&defs, Affine::IDENTITY) else { panic!() };
|
||||
@@ -307,5 +375,44 @@ mod tests {
|
||||
assert_eq!(geom.end, Point::new(150.0, 100.0));
|
||||
assert!((geom.aspect - 1.0).abs() < 1e-9);
|
||||
assert!(i.paint(&HashMap::new(), Affine::IDENTITY).is_none());
|
||||
// Newer files give the matrix with `Xm`; the `Bm`s after it are bands of a cached ramp.
|
||||
let mut i = Instance::begin(&[]);
|
||||
i.bg(&bg);
|
||||
i.xm(&nums(&[40.0, 0.0, 0.0, -40.0, 10.0, 10.0]));
|
||||
i.bm(&nums(&[5.0, 0.0, 0.0, -40.0, 30.0, 10.0]));
|
||||
let Some(Paint::Gradient(gp)) = i.paint(&defs, Affine::IDENTITY) else { panic!() };
|
||||
let geom = gp.geom.unwrap();
|
||||
assert_eq!((geom.start, geom.end), (Point::new(10.0, 10.0), Point::new(50.0, 10.0)));
|
||||
}
|
||||
|
||||
/// A linear gradient of three stops as newer files place it: `Bg` with numbers that aren't in
|
||||
/// art space, `Xm` for the whole ramp and a `Bm` for each pair of stops.
|
||||
#[test]
|
||||
fn a_linear_gradient_runs_its_whole_ramp_not_its_last_part() {
|
||||
let mut d = GradientDef::begin(&[V::Str(b"L".to_vec()), V::Num(0.0), V::Num(3.0)]).unwrap();
|
||||
for ramp in [100.0, 60.0, 20.0] {
|
||||
d.stop(&nums(&[0.2, 0.4, 0.6, 0.0, 1.0, 1.0, 6.0, 50.0, ramp]), &mut vec![]);
|
||||
}
|
||||
let defs = HashMap::from([d.finish().unwrap()]);
|
||||
let instance = |xm: bool| {
|
||||
let mut i = Instance::begin(&[]);
|
||||
let mut bg = vec![V::Num(1.0), V::Str(b"L".to_vec())];
|
||||
bg.extend(nums(&[0.3, -0.2, -90.0, 0.5, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0]));
|
||||
i.bg(&bg);
|
||||
if xm {
|
||||
i.xm(&nums(&[0.0, -200.0, -30.0, 0.0, 10.0, 300.0]));
|
||||
}
|
||||
// From the stop at 20 to the one at 60, then on to the one at 100.
|
||||
i.bm(&nums(&[0.0, -80.0, -30.0, 0.0, 10.0, 260.0]));
|
||||
i.bm(&nums(&[0.0, -80.0, -30.0, 0.0, 10.0, 180.0]));
|
||||
let Some(Paint::Gradient(gp)) = i.paint(&defs, Affine::IDENTITY) else { panic!() };
|
||||
gp.geom.unwrap()
|
||||
};
|
||||
for xm in [true, false] {
|
||||
let geom = instance(xm);
|
||||
// (The stops' offsets are `f32`.)
|
||||
assert!(geom.start.distance(Point::new(10.0, 300.0)) < 1e-5 && geom.end.distance(Point::new(10.0, 100.0)) < 1e-5, "{xm}: {geom:?}");
|
||||
assert_eq!((geom.aspect, geom.focal), (1.0, None));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,12 @@
|
||||
//! app wrote) the bounding box.
|
||||
//! - Images: `%AI5_BeginRaster`, the colour space `XN`, `[matrix] bounds w h bits type alpha …`
|
||||
//! and the samples after `XI`.
|
||||
//! - Placed (linked) files, as written in files users sent: `%AI5_BeginPlace`, `[matrix] llx lly urx
|
||||
//! ury … (path)` and the operator `` ` ``, the file's preview between `%AI26_BeginPlacedObjectPreview`
|
||||
//! and its end (`w h rowBytes 32 n `, then rows of premultiplied ARGB, the top one first), `~`. The
|
||||
//! box is as large as the file has pixels; the matrix takes them into art space from the canvas
|
||||
//! (y down, its centre the template box's), where they start at the point of art space's origin,
|
||||
//! the first row the top one. Each is a linked image showing the preview until its file is found.
|
||||
//! - Art with an appearance the format can't write as plain art (several fills or strokes,
|
||||
//! effects, brushes) comes first as that look, drawn (a group), then the object itself on `%_`
|
||||
//! lines and `1 (style) XW`: the drawn look is kept, named after the object.
|
||||
@@ -25,8 +31,8 @@
|
||||
//! taking the pattern's space to art space (the other operands are what it is made of).
|
||||
//!
|
||||
//! Sections the reader doesn't need (brushes, symbols, styles, swatches, filters) are skipped;
|
||||
//! type, symbols, fills with a pattern it can't read, placed files, legacy type and operators it
|
||||
//! doesn't know are errors.
|
||||
//! type, symbols, fills with a pattern it can't read, placed files without a preview, legacy type
|
||||
//! and operators it doesn't know are errors.
|
||||
|
||||
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||
use std::rc::Rc;
|
||||
@@ -38,10 +44,11 @@ use vectorcraft_color::swatch::Swatch;
|
||||
use vectorcraft_color::{BlendMode, Paint};
|
||||
use vectorcraft_doc::{
|
||||
Appearance, AppearanceItem, Artboard, ColorMode, Dash, Document, FillLayer, ImageBlob, ImageObject, Knockout, LayerColor, LineCap, LineJoin,
|
||||
Node, NodeId, NodeKind, PatternDef, StrokeLayer,
|
||||
LinkInfo, Node, NodeId, NodeKind, PatternDef, StrokeLayer,
|
||||
};
|
||||
use vectorcraft_geom::{FillRule, PathData};
|
||||
|
||||
use super::super::ate::CANVAS_CENTRE;
|
||||
use super::lex::{Lexer, Tok};
|
||||
use super::obj::{self, Obj, V};
|
||||
use super::paint::{self, GradientDef, Instance, Named};
|
||||
@@ -106,13 +113,60 @@ pub fn read(data: &[u8]) -> Result<Structure, String> {
|
||||
r.finish()
|
||||
}
|
||||
|
||||
/// The artboards (art space) of the editing data `data` that [`read`] can't read as a whole, as far
|
||||
/// as it gets: where to put the pages of its PDF part instead (#1068).
|
||||
pub fn artboards(data: &[u8]) -> Vec<Rect> {
|
||||
/// The artboards (names, and rects in art space) of the editing data `data` that [`read`] can't
|
||||
/// read as a whole, as far as it gets: where to put the pages of its PDF part instead, and what to
|
||||
/// call them (#1068).
|
||||
pub fn artboards(data: &[u8]) -> Vec<(String, Rect)> {
|
||||
let found = artboards_read(data);
|
||||
if !found.is_empty() {
|
||||
return found;
|
||||
}
|
||||
// Reading can stop before the artboards (art kept in the document's data comes first): read
|
||||
// their array on its own.
|
||||
artboard_array(data).map_or(found, |array| {
|
||||
let mut alone = b"%_/Document :\n%_/Dictionary :\n".to_vec();
|
||||
alone.extend_from_slice(array);
|
||||
alone.extend_from_slice(b"\n%_; /NotRecorded ,\n%_;\n");
|
||||
artboards_read(&alone)
|
||||
})
|
||||
}
|
||||
|
||||
/// The `ArtboardArray` entry of the editing data `data`, from its `/Array :` to its key: found
|
||||
/// from the key back, counting the dictionaries its artboards open and close.
|
||||
fn artboard_array(data: &[u8]) -> Option<&[u8]> {
|
||||
const KEY: &[u8] = b"%_; (ArtboardArray) ,";
|
||||
let end = data.windows(KEY.len()).rposition(|w| w == KEY)? + KEY.len();
|
||||
let head = data.get(..end)?;
|
||||
let mut depth = 0usize;
|
||||
let mut line_end = head.len();
|
||||
loop {
|
||||
let line_start = head.get(..line_end)?.iter().rposition(|&b| b == b'\n' || b == b'\r').map_or(0, |at| at + 1);
|
||||
if let Some(text) = head.get(line_start..line_end)?.strip_prefix(b"%_") {
|
||||
if text.starts_with(b";") || text.starts_with(b"/Def ;") {
|
||||
depth += 1;
|
||||
} else if text.starts_with(b"/") && text.windows(2).any(|w| w == b" :") {
|
||||
depth = depth.checked_sub(1)?;
|
||||
if depth == 0 {
|
||||
return head.get(line_start..);
|
||||
}
|
||||
}
|
||||
}
|
||||
line_end = line_start.checked_sub(1)?;
|
||||
}
|
||||
}
|
||||
|
||||
/// [`artboards`] as far as reading `data` from its start gets: those read before an error, and
|
||||
/// those of a `/Document` still open when it stopped (its art can be what stopped it).
|
||||
fn artboards_read(data: &[u8]) -> Vec<(String, Rect)> {
|
||||
let mut r = Reader::new(data);
|
||||
// Artboards read before an error still say where they are.
|
||||
let _ = r.run();
|
||||
r.artboards.into_iter().map(|(_, rect)| rect).collect()
|
||||
let open: Vec<Obj> = r.frames.iter().filter_map(|f| if let Kind::Obj(o) = &f.kind { Some((**o).clone()) } else { None }).collect();
|
||||
for o in &open {
|
||||
if r.artboards.is_empty() {
|
||||
r.document(o);
|
||||
}
|
||||
}
|
||||
r.artboards
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -181,6 +235,11 @@ const BLENDS: [BlendMode; 16] = [
|
||||
BlendMode::Luminosity,
|
||||
];
|
||||
|
||||
/// The name effects take for blend mode `n` of `Xy` (and of effects' `blnd`).
|
||||
pub(super) fn blend_name(n: usize) -> String {
|
||||
format!("{:?}", BLENDS.get(n).copied().unwrap_or(BlendMode::Normal))
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct LayerAttrs {
|
||||
name: String,
|
||||
@@ -234,6 +293,22 @@ struct Raster {
|
||||
space: Option<String>,
|
||||
}
|
||||
|
||||
/// A placed file's operands, from `` ` `` and its preview, until `~`.
|
||||
#[derive(Debug, Default)]
|
||||
struct Place {
|
||||
/// The canvas, where the file's pixels are → art space.
|
||||
matrix: Option<Affine>,
|
||||
/// The file's box (`llx lly urx ury`): its size is the file's, in pixels.
|
||||
bounds: Option<Rect>,
|
||||
path: String,
|
||||
/// The preview as a PNG, and its size in pixels.
|
||||
preview: Option<(Vec<u8>, u32, u32)>,
|
||||
}
|
||||
|
||||
/// What the open result says of placed files: they show the preview the file keeps of them.
|
||||
const LINKED_PREVIEWS: &str =
|
||||
"linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel";
|
||||
|
||||
struct Reader<'a> {
|
||||
lex: Lexer<'a>,
|
||||
stack: Vec<V>,
|
||||
@@ -256,6 +331,8 @@ struct Reader<'a> {
|
||||
pending_stroke: Option<Paint>,
|
||||
spots: Vec<Named>,
|
||||
raster: Option<Raster>,
|
||||
/// The placed file being read (`%AI5_BeginPlace` … `~`).
|
||||
place: Option<Place>,
|
||||
/// The last object read is the last child of the innermost frame, and nothing started since.
|
||||
after_object: bool,
|
||||
nodes: usize,
|
||||
@@ -278,6 +355,16 @@ struct Reader<'a> {
|
||||
drawn_looks: usize,
|
||||
/// Nodes read from `%_` lines.
|
||||
hidden_ids: HashSet<NodeId>,
|
||||
/// A group just ended, its state (`Xy`, `Xd`, `XW`) not yet read.
|
||||
group_ended: bool,
|
||||
/// Painted copies of clipping paths that have a fill (drawn under what they clip).
|
||||
clip_fills: HashSet<NodeId>,
|
||||
/// Groups closed as an object's drawn look (`9 () XW`).
|
||||
looks: HashSet<NodeId>,
|
||||
/// The gradient mesh being read (`/Mesh X!` … `/End X!`), and whether it started on a `%_` line.
|
||||
mesh: Option<(super::mesh::MeshReader, bool)>,
|
||||
/// The art styles (`%AI9_BeginArtStyles`), by name.
|
||||
styles: HashMap<String, super::style::ArtStyle>,
|
||||
/// CMYK samples converted, by value.
|
||||
cmyk_cache: HashMap<[u8; 4], [u8; 3]>,
|
||||
/// A `/ForeignObject`'s `/Data`: its PDF as ASCII85 in comment lines, until the object closes.
|
||||
@@ -313,7 +400,6 @@ const IGNORED: &[&str] = &[
|
||||
"X=",
|
||||
"X+",
|
||||
"Bc",
|
||||
"Xm",
|
||||
"XP",
|
||||
"Np",
|
||||
// Flatness (`i`): how finely curves print.
|
||||
@@ -322,6 +408,8 @@ const IGNORED: &[&str] = &[
|
||||
"TZ",
|
||||
"Xs",
|
||||
"Xt",
|
||||
// A brushed path's width at each anchor (`(…) Xt n XD`), on its hidden source path.
|
||||
"XD",
|
||||
"Xi",
|
||||
"XI",
|
||||
"`",
|
||||
@@ -393,6 +481,7 @@ impl<'a> Reader<'a> {
|
||||
pending_stroke: None,
|
||||
spots: Vec::new(),
|
||||
raster: None,
|
||||
place: None,
|
||||
foreign_data: None,
|
||||
after_object: false,
|
||||
nodes: 0,
|
||||
@@ -408,6 +497,11 @@ impl<'a> Reader<'a> {
|
||||
warnings: Vec::new(),
|
||||
drawn_looks: 0,
|
||||
hidden_ids: HashSet::new(),
|
||||
clip_fills: HashSet::new(),
|
||||
looks: HashSet::new(),
|
||||
group_ended: false,
|
||||
styles: HashMap::new(),
|
||||
mesh: None,
|
||||
cmyk_cache: HashMap::new(),
|
||||
done: false,
|
||||
obj_hidden: false,
|
||||
@@ -436,6 +530,11 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
Tok::Comment(c) => self.comment(c)?,
|
||||
Tok::Data(d) => self.image(d)?,
|
||||
Tok::Preview(d) => {
|
||||
if let Some(p) = self.place.as_mut() {
|
||||
p.preview = preview_png(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.frames.iter().any(|f| matches!(f.kind, Kind::Layer(_))) {
|
||||
@@ -474,6 +573,11 @@ impl<'a> Reader<'a> {
|
||||
self.define_pattern(section);
|
||||
return Ok(());
|
||||
}
|
||||
if m == "BeginArtStyles" {
|
||||
let section = self.section("EndArtStyles")?;
|
||||
self.styles = super::style::art_styles(section);
|
||||
return Ok(());
|
||||
}
|
||||
if let Some((_, end)) = SKIPPED.iter().find(|(b, _)| *b == m) {
|
||||
return self.skip_to(end);
|
||||
}
|
||||
@@ -495,7 +599,12 @@ impl<'a> Reader<'a> {
|
||||
} else if line.starts_with("AI5_EndRaster") {
|
||||
self.raster = None;
|
||||
} else if line.starts_with("AI5_BeginPlace") {
|
||||
self.unreadable("placed files");
|
||||
self.place = Some(Place::default());
|
||||
} else if line.starts_with("AI5_EndPlace") {
|
||||
// One that `~` didn't end isn't read.
|
||||
if self.place.take().is_some() {
|
||||
self.unreadable("placed files");
|
||||
}
|
||||
} else if self.frames.is_empty()
|
||||
&& let Some(v) = line.strip_prefix("%HiResBoundingBox:")
|
||||
{
|
||||
@@ -541,7 +650,7 @@ impl<'a> Reader<'a> {
|
||||
if let Tok::Comment(c) = t.tok {
|
||||
let line = String::from_utf8_lossy(c);
|
||||
let m = marker(&line);
|
||||
if m == begin {
|
||||
if !begin.is_empty() && m == begin {
|
||||
depth += 1;
|
||||
} else if m == end {
|
||||
if depth == 0 {
|
||||
@@ -667,23 +776,23 @@ impl<'a> Reader<'a> {
|
||||
"*U" => self.close(what, |k| matches!(k, Kind::Compound))?,
|
||||
_ => self.close(what, |k| matches!(k, Kind::Clip))?,
|
||||
};
|
||||
let Frame { kind, children: mut kids, gs, clips, hidden: opened_hidden, .. } = f;
|
||||
let Frame { kind, children: kids, gs, clips, hidden: opened_hidden, .. } = f;
|
||||
// A compound path of clipping paths clips its clipping group.
|
||||
let clipping = matches!(kind, Kind::Compound) && !clips.is_empty();
|
||||
self.group_ended = !matches!(kind, Kind::Compound);
|
||||
let mut painted = None;
|
||||
let node = match kind {
|
||||
// A compound path holding a group (its drawn look) is that look: its paths paint apart.
|
||||
Kind::Compound if kids.iter().any(|k| self.looks.contains(&k.id)) => {
|
||||
let (children, clip) = self.clip_first(kids, &clips);
|
||||
self.new_node(NodeKind::Group { children, clip }, &gs)
|
||||
}
|
||||
Kind::Compound => {
|
||||
let Some(first) = kids.first() else { return Ok(()) };
|
||||
let (appearance, opacity, blend, rule) = (first.appearance.clone(), first.opacity, first.blend, path_rule(first));
|
||||
for k in &mut kids {
|
||||
k.opacity = 1.0;
|
||||
k.blend = BlendMode::Normal;
|
||||
k.visible = true;
|
||||
k.locked = false;
|
||||
}
|
||||
let mut n = self.new_node(NodeKind::Compound { children: kids.into_iter().map(Arc::new).collect(), rule }, &gs);
|
||||
n.appearance = appearance;
|
||||
n.opacity = opacity;
|
||||
n.blend = blend;
|
||||
// A group in a compound path (`*u u … U *U`) only holds its paths. Clipping subpaths'
|
||||
// painted copies make a compound of their own, under what it clips.
|
||||
let (fills, kids): (Vec<Node>, Vec<Node>) = ungrouped(kids).into_iter().partition(|n| self.clip_fills.contains(&n.id));
|
||||
painted = self.compound(fills, &gs);
|
||||
let Some(n) = self.compound(kids, &gs) else { return Ok(()) };
|
||||
n
|
||||
}
|
||||
// A group with a clipping path (`q` … `Q`, or a `W` path in a group) is a clipping group.
|
||||
@@ -695,7 +804,31 @@ impl<'a> Reader<'a> {
|
||||
if clipping && let Some(f) = self.frames.last_mut() {
|
||||
f.clips.push(f.children.len());
|
||||
}
|
||||
self.add(node, opened_hidden || hidden)
|
||||
self.add(node, opened_hidden || hidden)?;
|
||||
if let Some(p) = painted {
|
||||
self.clip_fills.insert(p.id);
|
||||
self.add(p, opened_hidden || hidden)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The compound path of `kids`, painted and made transparent as its subpaths say.
|
||||
fn compound(&mut self, mut kids: Vec<Node>, gs: &GState) -> Option<Node> {
|
||||
// The compound path's paint is its last path's (a gradient's instance wraps only
|
||||
// that one); the last painted one, should the last have none.
|
||||
let shown = kids.iter().rev().find(|k| !k.appearance.items.is_empty()).or(kids.last())?;
|
||||
let (appearance, opacity, blend, rule) = (shown.appearance.clone(), shown.opacity, shown.blend, path_rule(shown));
|
||||
for k in &mut kids {
|
||||
k.opacity = 1.0;
|
||||
k.blend = BlendMode::Normal;
|
||||
k.visible = true;
|
||||
k.locked = false;
|
||||
}
|
||||
let mut n = self.new_node(NodeKind::Compound { children: kids.into_iter().map(Arc::new).collect(), rule }, gs);
|
||||
n.appearance = appearance;
|
||||
n.opacity = opacity;
|
||||
n.blend = blend;
|
||||
Some(n)
|
||||
}
|
||||
|
||||
/// `kids` with the clipping paths among them (indexes `clips`) first, several as one compound
|
||||
@@ -712,7 +845,9 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
};
|
||||
let clips = clip_node.is_some();
|
||||
let children = clip_node.into_iter().chain(rest.into_iter().map(|(_, n)| n)).map(Arc::new).collect();
|
||||
// A clipping path's own fill paints under the art it clips.
|
||||
let (fills, rest): (Vec<Indexed>, Vec<Indexed>) = rest.into_iter().partition(|(_, n)| self.clip_fills.contains(&n.id));
|
||||
let children = clip_node.into_iter().chain(fills.into_iter().chain(rest).map(|(_, n)| n)).map(Arc::new).collect();
|
||||
(children, clips)
|
||||
}
|
||||
|
||||
@@ -786,6 +921,7 @@ impl<'a> Reader<'a> {
|
||||
if !matches!(w, "Xy" | "Xd" | "XW" | "BB") {
|
||||
self.after_object = false;
|
||||
}
|
||||
let group_ended = std::mem::take(&mut self.group_ended);
|
||||
let vals = self.operands();
|
||||
let vals = self.take_head(vals);
|
||||
if let Some(d) = self.gradient_def.as_mut() {
|
||||
@@ -800,6 +936,26 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if w == "X!" || self.mesh.is_some() {
|
||||
let name = vals.iter().rev().find_map(|v| if let V::Name(n) = v { Some(n.as_str()) } else { None });
|
||||
match (w, name) {
|
||||
("X!", Some("Mesh")) => {
|
||||
self.end_path_quietly();
|
||||
self.mesh = Some((super::mesh::MeshReader::default(), hidden));
|
||||
return Ok(());
|
||||
}
|
||||
("X!", Some("End")) if self.mesh.is_some() => return self.end_mesh(),
|
||||
("X#", _) => {
|
||||
let to_doc = self.space();
|
||||
if let Some((m, _)) = self.mesh.as_mut() {
|
||||
m.entry(&vals, to_doc);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
_ if self.mesh.is_some() => return Ok(()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
match w {
|
||||
// Containers.
|
||||
"u" => {
|
||||
@@ -868,6 +1024,14 @@ impl<'a> Reader<'a> {
|
||||
"d" => self.dash(&vals),
|
||||
// Transparency and state.
|
||||
"Xy" => self.transparency(&vals),
|
||||
// A group's own state (`Xd`, `6 () XW`) without an `Xy` keeps the current transparency, as
|
||||
// the file's PDF part draws it. A look's group (`9 () XW`) has no state of its own.
|
||||
"Xd" if group_ended => {
|
||||
let gs = self.gs.clone();
|
||||
if let Some(n) = self.last_object().filter(|n| matches!(n.kind, NodeKind::Group { .. })) {
|
||||
(n.opacity, n.blend, n.isolate, n.knockout) = (gs.opacity, gs.blend, gs.isolate, gs.knockout);
|
||||
}
|
||||
}
|
||||
"Xw" => self.gs.hidden = Self::nums(&vals).last() == Some(&1.0),
|
||||
"A" => self.gs.locked = Self::nums(&vals).last() == Some(&1.0),
|
||||
"XW" => self.style_marker(&vals, hidden),
|
||||
@@ -885,13 +1049,22 @@ impl<'a> Reader<'a> {
|
||||
i.bm(&vals);
|
||||
}
|
||||
}
|
||||
"Xm" => {
|
||||
if let Some(i) = self.instance.as_mut() {
|
||||
i.xm(&vals);
|
||||
}
|
||||
}
|
||||
"Bh" => {
|
||||
if let Some(i) = self.instance.as_mut() {
|
||||
i.bh(&vals);
|
||||
}
|
||||
}
|
||||
"BB" => {
|
||||
if let Some(i) = self.instance.take() {
|
||||
// An instance that closes with no path to paint is an object's own note of its
|
||||
// gradient, written after it: it paints nothing (not the next path either).
|
||||
if let Some(i) = self.instance.take()
|
||||
&& !self.path.elements().is_empty()
|
||||
{
|
||||
self.set_instance(&i);
|
||||
}
|
||||
}
|
||||
@@ -901,6 +1074,18 @@ impl<'a> Reader<'a> {
|
||||
r.space = vals.iter().rev().find_map(V::text);
|
||||
}
|
||||
}
|
||||
// Placed files.
|
||||
"`" if self.place.is_some() => {
|
||||
let matrix = vals.iter().find_map(|v| if let V::Arr(a) = v { Some(Self::nums(a)) } else { None });
|
||||
let path = vals.iter().rev().find_map(V::text).unwrap_or_default();
|
||||
if let Some(p) = self.place.as_mut() {
|
||||
p.matrix = matrix.and_then(|m| <[f64; 6]>::try_from(m).ok()).map(Affine::new);
|
||||
p.bounds =
|
||||
Self::nums(&vals).get(..4).and_then(|b| <[f64; 4]>::try_from(b).ok()).map(|[x0, y0, x1, y1]| Rect::new(x0, y0, x1, y1));
|
||||
p.path = path;
|
||||
}
|
||||
}
|
||||
"~" if self.place.is_some() => self.end_place(hidden)?,
|
||||
// Type in the legacy format.
|
||||
"To" | "TO" | "Tp" | "TP" | "Tx" | "TX" | "Tj" | "Tk" => self.unreadable("type in the legacy format"),
|
||||
_ if IGNORED.contains(&w) => {}
|
||||
@@ -967,6 +1152,12 @@ impl<'a> Reader<'a> {
|
||||
if self.pattern.is_some() {
|
||||
self.unreadable("type in a pattern");
|
||||
}
|
||||
// `1 /FreeUndo`: the type in the drawn look of a text object that has an appearance. Its
|
||||
// story is one of another text document (`/AI11UndoFreeTextDocument`), which counts its
|
||||
// stories on its own; the object itself follows the look, on `%_` lines, and is the type.
|
||||
if o.nums("FreeUndo").first() == Some(&1.0) {
|
||||
return Ok(());
|
||||
}
|
||||
let index = |key: &str| o.nums(key).first().copied().filter(|v| (0.0..f64::from(u32::MAX)).contains(v)).map(|v| v as u32);
|
||||
let (story, frame) = (index("StoryIndex"), index("FrameIndex").unwrap_or(0));
|
||||
let gs = self.gs.clone();
|
||||
@@ -1172,10 +1363,14 @@ impl<'a> Reader<'a> {
|
||||
if let Some(n) = named {
|
||||
self.spots.push(n);
|
||||
}
|
||||
// A colour set after a gradient instance that painted nothing (`Bb` … `0 BB` with no
|
||||
// path) is the next object's paint, not the gradient.
|
||||
if w.ends_with(|c: char| c.is_ascii_lowercase()) {
|
||||
self.gs.fill = p;
|
||||
self.pending_fill = None;
|
||||
} else {
|
||||
self.gs.stroke = p;
|
||||
self.pending_stroke = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1216,12 +1411,18 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
|
||||
/// `n (style) XW` after an object: `1 (style)` after the object written on `%_` lines, its
|
||||
/// drawn look before it. Other markers end what follows an object.
|
||||
/// drawn look before it (all on `%_` lines inside another such object). Other markers end what
|
||||
/// follows an object.
|
||||
fn style_marker(&mut self, vals: &[V], hidden: bool) {
|
||||
let code = Self::nums(vals).last().copied().unwrap_or(0.0);
|
||||
let style = vals.iter().rev().find_map(V::text).unwrap_or_default();
|
||||
let after = std::mem::take(&mut self.after_object);
|
||||
if hidden || code != 1.0 || style.is_empty() || !after {
|
||||
if code == 9.0
|
||||
&& let Some(look) = self.frames.last().and_then(|f| f.children.last()).filter(|n| matches!(n.kind, NodeKind::Group { .. }))
|
||||
{
|
||||
self.looks.insert(look.id);
|
||||
}
|
||||
if code != 1.0 || style.is_empty() || !after {
|
||||
return;
|
||||
}
|
||||
let Some(f) = self.frames.last_mut() else { return };
|
||||
@@ -1229,16 +1430,45 @@ impl<'a> Reader<'a> {
|
||||
let (Some(object), Some(look)) = (n.checked_sub(1).and_then(|i| f.children.get(i)), n.checked_sub(2).and_then(|i| f.children.get(i))) else {
|
||||
return;
|
||||
};
|
||||
if !self.hidden_ids.contains(&object.id) || self.hidden_ids.contains(&look.id) || !matches!(look.kind, NodeKind::Group { clip: false, .. }) {
|
||||
if !self.hidden_ids.contains(&object.id)
|
||||
|| self.hidden_ids.contains(&look.id) != hidden
|
||||
|| !matches!(look.kind, NodeKind::Group { clip: false, .. })
|
||||
{
|
||||
return;
|
||||
}
|
||||
// A style read here: the object, painted as it says.
|
||||
if let Some(appearance) = self.styles.get(&style).and_then(|st| styled(object, st)) {
|
||||
let Some(mut object) = f.children.pop() else { return };
|
||||
f.children.pop();
|
||||
object.appearance = appearance.into();
|
||||
// Now the object itself, not a copy an enclosing look's `strip` would remove.
|
||||
self.hidden_ids.remove(&object.id);
|
||||
f.children.push(object);
|
||||
return;
|
||||
}
|
||||
let is_text = slot_of(object.name.as_deref()).is_some();
|
||||
let Some(object) = f.children.pop() else { return };
|
||||
let Some(look) = f.children.last_mut() else { return };
|
||||
strip(look, &self.hidden_ids);
|
||||
// Inside a copy on `%_` lines everything is, so nothing tells the look's own copies apart.
|
||||
if !hidden {
|
||||
strip(look, &self.hidden_ids);
|
||||
}
|
||||
if is_text {
|
||||
// A text object stays the type it is: over what else its look draws, or in place of a
|
||||
// look that was only its type.
|
||||
if look.children().is_some_and(|c| c.is_empty()) {
|
||||
*look = object;
|
||||
} else {
|
||||
f.children.push(object);
|
||||
}
|
||||
return;
|
||||
}
|
||||
look.name = object.name.clone().or(look.name.take());
|
||||
look.visible = object.visible;
|
||||
look.locked = object.locked;
|
||||
self.drawn_looks += 1;
|
||||
if !hidden {
|
||||
self.drawn_looks += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- paths -----------------------------------------------------------------------------
|
||||
@@ -1317,7 +1547,10 @@ impl<'a> Reader<'a> {
|
||||
};
|
||||
let mut items = Vec::new();
|
||||
if fill && !clip {
|
||||
items.push(AppearanceItem::Fill(FillLayer { overprint: gs.overprint_fill, ..FillLayer::new(fill_paint.unwrap_or(gs.fill.clone())) }));
|
||||
items.push(AppearanceItem::Fill(FillLayer {
|
||||
overprint: gs.overprint_fill,
|
||||
..FillLayer::new(fill_paint.clone().unwrap_or(gs.fill.clone()))
|
||||
}));
|
||||
}
|
||||
if stroke && !clip {
|
||||
let mut st = StrokeLayer::new(stroke_paint.unwrap_or(gs.stroke.clone()), gs.width);
|
||||
@@ -1337,6 +1570,45 @@ impl<'a> Reader<'a> {
|
||||
if clip && let Some(f) = self.frames.last_mut() {
|
||||
f.clips.push(f.children.len());
|
||||
}
|
||||
// A clipping path's fill is painted too, under the art it clips, as the file's PDF part draws it.
|
||||
let painted = clip && fill && !matches!(gs.fill, Paint::None);
|
||||
let copy = painted.then(|| {
|
||||
let mut copy = node.clone();
|
||||
copy.id = self.doc.alloc_id();
|
||||
if let NodeKind::Path { clipping, .. } = &mut copy.kind {
|
||||
*clipping = false;
|
||||
}
|
||||
let paint = fill_paint.clone().unwrap_or_else(|| gs.fill.clone());
|
||||
copy.appearance = Appearance {
|
||||
items: vec![AppearanceItem::Fill(FillLayer { overprint: gs.overprint_fill, ..FillLayer::new(paint) })],
|
||||
..Appearance::default()
|
||||
}
|
||||
.into();
|
||||
copy
|
||||
});
|
||||
self.add(node, hidden)?;
|
||||
if let Some(copy) = copy {
|
||||
self.clip_fills.insert(copy.id);
|
||||
self.add(copy, hidden)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `/End X!`: the gradient mesh read, as one object.
|
||||
fn end_mesh(&mut self) -> Result<(), String> {
|
||||
let Some((m, hidden)) = self.mesh.take() else { return Ok(()) };
|
||||
if m.unread || m.patches.is_empty() {
|
||||
self.unreadable("gradient meshes it can't read");
|
||||
return Ok(());
|
||||
}
|
||||
let gs = self.gs.clone();
|
||||
let mut patches: Vec<Node> = m.patches.into_iter().map(|p| self.new_node(NodeKind::Mesh(p), &gs)).collect();
|
||||
let mut node = match patches.len() {
|
||||
1 => patches.pop().unwrap_or_else(|| self.new_node(NodeKind::Group { children: vec![], clip: false }, &gs)),
|
||||
_ => self.new_node(NodeKind::Group { children: patches.into_iter().map(Arc::new).collect(), clip: false }, &gs),
|
||||
};
|
||||
node.opacity = gs.opacity;
|
||||
node.blend = gs.blend;
|
||||
self.add(node, hidden)
|
||||
}
|
||||
|
||||
@@ -1435,12 +1707,44 @@ impl<'a> Reader<'a> {
|
||||
let blob = ImageBlob::new("image/png", png);
|
||||
let key = blob.content_key();
|
||||
self.doc.images.entry(key.clone()).or_insert(blob);
|
||||
self.add_image(ImageObject { key, width: w as u32, height: h as u32, xf, link: None, placement: Default::default() }, false)
|
||||
}
|
||||
|
||||
/// Add an image object with the state's transparency.
|
||||
fn add_image(&mut self, im: ImageObject, hidden: bool) -> Result<(), String> {
|
||||
let gs = self.gs.clone();
|
||||
let im = ImageObject { key, width: w as u32, height: h as u32, xf, link: None, placement: Default::default() };
|
||||
let mut node = self.new_node(NodeKind::Image(Box::new(im)), &gs);
|
||||
node.opacity = gs.opacity;
|
||||
node.blend = gs.blend;
|
||||
self.add(node, false)
|
||||
self.add(node, hidden)
|
||||
}
|
||||
|
||||
/// `~`: the placed file read since `%AI5_BeginPlace` becomes a linked image in its place that
|
||||
/// shows the preview the data keeps of it, as a linked image whose file is missing does. One
|
||||
/// without a preview, a path or a matrix isn't read.
|
||||
fn end_place(&mut self, hidden: bool) -> Result<(), String> {
|
||||
let Some(Place { matrix: Some(m), bounds: Some(b), path, preview: Some((png, w, h)) }) = self.place.take().filter(|p| !p.path.is_empty())
|
||||
else {
|
||||
self.unreadable("placed files");
|
||||
return Ok(());
|
||||
};
|
||||
// The file's pixels (y down) start at the canvas point of art space's origin (the box's own
|
||||
// corner when the file doesn't say where the canvas is); the preview's are spread over them.
|
||||
let centre = self.template.map(|t| ((t[0] + t[2]) / 2.0, (t[1] + t[3]) / 2.0));
|
||||
let origin = centre.map_or(b.origin(), |(x, y)| Point::new(CANVAS_CENTRE - x, CANVAS_CENTRE + y));
|
||||
let over_file = Affine::translate(origin.to_vec2()) * Affine::scale_non_uniform(b.width() / f64::from(w), b.height() / f64::from(h));
|
||||
let xf = self.space() * m * over_file;
|
||||
if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 {
|
||||
self.unreadable("placed files");
|
||||
return Ok(());
|
||||
}
|
||||
// The preview is all the document has of the file: what a missing link shows.
|
||||
let bytes = Arc::new(png);
|
||||
let blob = ImageBlob { mime: "image/png".into(), bytes: bytes.clone(), proxy: Some(bytes) };
|
||||
let key = blob.content_key();
|
||||
self.doc.images.entry(key.clone()).or_insert(blob);
|
||||
self.warn(LINKED_PREVIEWS);
|
||||
self.add_image(ImageObject { key, width: w, height: h, xf, link: Some(LinkInfo::new(path)), placement: Default::default() }, hidden)
|
||||
}
|
||||
|
||||
// ---- the document ----------------------------------------------------------------------
|
||||
@@ -1611,6 +1915,18 @@ fn close(bp: &mut BezPath) {
|
||||
}
|
||||
|
||||
/// The fill rule of a path or compound node.
|
||||
/// `kids` with the members of each plain group among them in the group's place, at any depth.
|
||||
fn ungrouped(kids: Vec<Node>) -> Vec<Node> {
|
||||
let mut out = Vec::with_capacity(kids.len());
|
||||
for k in kids {
|
||||
match k.kind {
|
||||
NodeKind::Group { children, clip: false } => out.extend(ungrouped(children.into_iter().map(Arc::unwrap_or_clone).collect())),
|
||||
_ => out.push(k),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn path_rule(n: &Node) -> FillRule {
|
||||
match &n.kind {
|
||||
NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } => *rule,
|
||||
@@ -1618,6 +1934,28 @@ fn path_rule(n: &Node) -> FillRule {
|
||||
}
|
||||
}
|
||||
|
||||
/// `object`'s appearance with style `st`: `None` when the style isn't one this reads, or asks a
|
||||
/// group for fills or strokes.
|
||||
fn styled(object: &Node, st: &super::style::ArtStyle) -> Option<Appearance> {
|
||||
use super::style::StyleItem;
|
||||
if !st.plain || (object.is_container() && !matches!(object.kind, NodeKind::Compound { .. }) && !st.items.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
let mut items = vec![];
|
||||
for item in &st.items {
|
||||
items.push(match item {
|
||||
StyleItem::Fill(paint) => AppearanceItem::Fill(FillLayer::new(paint.clone())),
|
||||
StyleItem::ObjectFill => AppearanceItem::Fill(object.appearance.fill().filter(|f| matches!(f.paint, Paint::Gradient(_)))?.clone()),
|
||||
StyleItem::Stroke { paint, width, cap, join, miter, dash, profile } => {
|
||||
let mut layer = StrokeLayer::new(paint.clone(), *width);
|
||||
(layer.cap, layer.join, layer.miter_limit, layer.dash, layer.profile) = (*cap, *join, *miter, dash.clone(), profile.clone());
|
||||
AppearanceItem::Stroke(layer)
|
||||
}
|
||||
});
|
||||
}
|
||||
Some(Appearance { items, effects: st.effects.clone(), ..(*object.appearance).clone() })
|
||||
}
|
||||
|
||||
/// Remove from `n` the children read from `%_` lines (`hidden`: the object itself, not its look).
|
||||
fn strip(n: &mut Node, hidden: &HashSet<NodeId>) {
|
||||
if let Some(children) = n.children_mut() {
|
||||
@@ -1636,6 +1974,38 @@ fn find_entry<'o>(o: &'o Obj, key: &str, depth: usize) -> Option<&'o Obj> {
|
||||
o.obj(key).or_else(|| o.entries.iter().flat_map(|(_, v)| v.iter()).filter_map(V::obj).find_map(|c| find_entry(c, key, depth + 1)))
|
||||
}
|
||||
|
||||
/// A placed file's preview as a PNG, with its size: `w h rowBytes 32 n ` (each number followed by
|
||||
/// a space), then `h` rows of `rowBytes` bytes, premultiplied ARGB. `None` when it isn't that, is
|
||||
/// larger than [`MAX_PIXELS`] or is cut short.
|
||||
fn preview_png(data: &[u8]) -> Option<(Vec<u8>, u32, u32)> {
|
||||
let mut rest = data.trim_ascii_start();
|
||||
let mut head = [0usize; 5];
|
||||
for v in &mut head {
|
||||
let n = rest.iter().position(|b| *b == b' ')?;
|
||||
*v = std::str::from_utf8(rest.get(..n)?).ok()?.parse().ok()?;
|
||||
rest = rest.get(n + 1..)?;
|
||||
}
|
||||
let [w, h, row, bits, _] = head;
|
||||
let (width, height) = (u32::try_from(w).ok()?, u32::try_from(h).ok()?);
|
||||
if w == 0 || h == 0 || u64::from(width) * u64::from(height) > MAX_PIXELS || bits != 32 || row < w * 4 {
|
||||
return None;
|
||||
}
|
||||
// Every row is there before anything is allocated for them.
|
||||
if rest.len() < (h - 1).checked_mul(row)?.checked_add(w * 4)? {
|
||||
return None;
|
||||
}
|
||||
let mut rgba = Vec::with_capacity(w * h * 4);
|
||||
for line in rest.chunks(row).take(h) {
|
||||
for &[a, r, g, b] in line.get(..w * 4)?.as_chunks::<4>().0 {
|
||||
let straight = |c: u8| if a == 0 { 0 } else { (u32::from(c) * 255 / u32::from(a)).min(255) as u8 };
|
||||
rgba.extend_from_slice(&[straight(r), straight(g), straight(b), a]);
|
||||
}
|
||||
}
|
||||
let mut png = vec![];
|
||||
image::RgbaImage::from_raw(width, height, rgba)?.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
|
||||
Some((png, width, height))
|
||||
}
|
||||
|
||||
fn sane(r: Rect) -> bool {
|
||||
[r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_SIDE)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
//! Art styles (`%AI9_BeginArtStyles`; not publicly documented): the appearance an object's
|
||||
//! `1 (style) XW` names, a chain of filters — a stack of fills and strokes round the object's
|
||||
//! contents (`Conduit Filter`), then live effects, then its transparency (`Blend Style Filter`):
|
||||
//!
|
||||
//! ```text
|
||||
//! /KnownStyle :
|
||||
//! (name) /Name ,
|
||||
//! /ActiveStyle :
|
||||
//! /CompoundFilter :
|
||||
//! (Chain Style Filter) 0 0 /Filter ,
|
||||
//! … (Stack Style Filter) … (Stroke Style Filter) … (Conduit Filter) … (Fill Style Filter) …
|
||||
//! (Adobe Outer Glow) 1 0 /Filter ,
|
||||
//! 1 /Visible ,
|
||||
//! 2 /Int (blnd) ,
|
||||
//! 0.75 /Real (opac) ,
|
||||
//! 5 /Real (blur) ,
|
||||
//! /FillStyle : 0 O
|
||||
//! c m y k r g b Xa
|
||||
//! /Def ;
|
||||
//! (sclr) ,
|
||||
//! ```
|
||||
//!
|
||||
//! A style is *plain* when it is one fill and one stroke at most, in solid colours (a fill's gradient
|
||||
//! is the object's own), and only effects read here (Outer Glow, Drop Shadow) after them. Such
|
||||
//! objects are read as themselves with that fill, stroke and those effects; others keep their
|
||||
//! drawn look. A fill's or stroke's style says whether it paints with `stroke fill Xd`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{Dash, Effect, LineCap, LineJoin, WidthProfile};
|
||||
|
||||
use super::lex::{Lexer, Tok};
|
||||
use super::obj::V;
|
||||
use super::paint;
|
||||
|
||||
/// The filters a plain style may chain.
|
||||
const PLAIN: [&str; 7] =
|
||||
["Chain Style Filter", "Stack Style Filter", "Stroke Style Filter", "Fill Style Filter", "Conduit Filter", "Blend Style Filter", GLOW];
|
||||
const GLOW: &str = "Adobe Outer Glow"; // brand-ok: the filter's name in the files
|
||||
const SHADOW: &str = "Adobe Drop Shadow"; // brand-ok: the filter's name in the files
|
||||
|
||||
/// A plain style's paint, as the object comes in.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(super) struct ArtStyle {
|
||||
/// The style reads as [`Self::items`] and [`Self::effects`].
|
||||
pub plain: bool,
|
||||
/// Its fill and stroke, bottom first.
|
||||
pub items: Vec<StyleItem>,
|
||||
/// Its live effects, in the order they apply.
|
||||
pub effects: Vec<Effect>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) enum StyleItem {
|
||||
Fill(Paint),
|
||||
/// A fill with a gradient: the object's own.
|
||||
ObjectFill,
|
||||
Stroke {
|
||||
paint: Paint,
|
||||
width: f64,
|
||||
cap: LineCap,
|
||||
join: LineJoin,
|
||||
miter: f64,
|
||||
dash: Option<Dash>,
|
||||
profile: Option<WidthProfile>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Filter {
|
||||
name: String,
|
||||
visible: bool,
|
||||
params: HashMap<String, f64>,
|
||||
/// Its dictionary's arrays (`/Array : v /Real , … ; (key) ,`), by key.
|
||||
arrays: HashMap<String, Vec<f64>>,
|
||||
fill: Option<Paint>,
|
||||
stroke: Option<Paint>,
|
||||
width: f64,
|
||||
cap: LineCap,
|
||||
join: LineJoin,
|
||||
miter: f64,
|
||||
dash: Option<Dash>,
|
||||
/// `stroke fill Xd`.
|
||||
paints: (bool, bool),
|
||||
gradient: bool,
|
||||
}
|
||||
|
||||
impl Default for Filter {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: String::new(),
|
||||
visible: true,
|
||||
params: HashMap::new(),
|
||||
arrays: HashMap::new(),
|
||||
fill: None,
|
||||
stroke: None,
|
||||
width: 1.0,
|
||||
cap: LineCap::Butt,
|
||||
join: LineJoin::Miter,
|
||||
miter: 10.0,
|
||||
dash: None,
|
||||
paints: (false, false),
|
||||
gradient: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The styles of the section `data` (between its markers), by name.
|
||||
pub(super) fn art_styles(data: &[u8]) -> HashMap<String, ArtStyle> {
|
||||
let mut styles = HashMap::new();
|
||||
let mut name: Option<String> = None;
|
||||
let mut filters: Vec<Filter> = vec![];
|
||||
let mut operands: Vec<V> = vec![];
|
||||
let mut finish = |name: Option<String>, filters: &mut Vec<Filter>| {
|
||||
if let Some(name) = name {
|
||||
styles.insert(name, style(filters));
|
||||
}
|
||||
filters.clear();
|
||||
};
|
||||
let mut array: Option<Vec<V>> = None;
|
||||
let mut dict_array: Option<Vec<f64>> = None;
|
||||
let mut lex = Lexer::new(data);
|
||||
while let Some(t) = lex.next_token() {
|
||||
if let (Some(a), Tok::Num(v)) = (array.as_mut(), &t.tok) {
|
||||
a.push(V::Num(*v));
|
||||
continue;
|
||||
}
|
||||
match t.tok {
|
||||
Tok::Num(v) => operands.push(V::Num(v)),
|
||||
Tok::Str(s) | Tok::Hex(s) => operands.push(V::Str(s)),
|
||||
Tok::Name(n) => match n {
|
||||
b"KnownStyle" => {
|
||||
finish(name.take(), &mut filters);
|
||||
operands.clear();
|
||||
}
|
||||
b"Name" if name.is_none() && filters.is_empty() => name = operands.iter().rev().find_map(V::text),
|
||||
b"Filter" => filters.push(Filter { name: operands.iter().rev().find_map(V::text).unwrap_or_default(), ..Filter::default() }),
|
||||
b"Visible" => {
|
||||
if let Some(f) = filters.last_mut() {
|
||||
f.visible = operands.iter().rev().find_map(V::num) != Some(0.0);
|
||||
}
|
||||
}
|
||||
b"Array" => dict_array = Some(vec![]),
|
||||
b"Int" | b"Real" | b"Bool" => {
|
||||
let value = operands.iter().rev().find_map(V::num);
|
||||
operands.clear();
|
||||
if let Some(a) = dict_array.as_mut() {
|
||||
a.extend(value);
|
||||
continue;
|
||||
}
|
||||
// The key follows: `value /Real (key) ,`.
|
||||
if let (Some(value), Some(Tok::Str(key))) = (value, lex.next_token().map(|t| t.tok))
|
||||
&& let Some(f) = filters.last_mut()
|
||||
{
|
||||
f.params.insert(String::from_utf8_lossy(&key).into_owned(), value);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Tok::Word(w) => {
|
||||
let op = std::str::from_utf8(w).unwrap_or_default();
|
||||
if op == "[" {
|
||||
array = Some(vec![]);
|
||||
continue;
|
||||
}
|
||||
if op == "]" {
|
||||
let items = array.take().unwrap_or_default();
|
||||
operands.push(V::Arr(items));
|
||||
continue;
|
||||
}
|
||||
if op == ";"
|
||||
&& let Some(values) = dict_array.take()
|
||||
{
|
||||
if let (Some(Tok::Str(key)), Some(f)) = (lex.next_token().map(|t| t.tok), filters.last_mut()) {
|
||||
f.arrays.insert(String::from_utf8_lossy(&key).into_owned(), values);
|
||||
}
|
||||
operands.clear();
|
||||
continue;
|
||||
}
|
||||
if let Some(f) = filters.last_mut() {
|
||||
paint_op(f, op, &operands);
|
||||
}
|
||||
if op == "," || op == ";" || op.starts_with('X') || op.starts_with('B') || op.len() <= 2 {
|
||||
operands.clear();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Tok::Comment(_) | Tok::Data(_) | Tok::Preview(_) => {}
|
||||
}
|
||||
if operands.len() > 64 {
|
||||
operands.clear();
|
||||
}
|
||||
}
|
||||
finish(name, &mut filters);
|
||||
styles
|
||||
}
|
||||
|
||||
/// A paint operator of a fill's or stroke's style.
|
||||
fn paint_op(f: &mut Filter, op: &str, vals: &[V]) {
|
||||
let last = vals.iter().rev().find_map(V::num);
|
||||
match op {
|
||||
"Xa" | "k" | "g" | "x" | "Xk" | "XA" | "K" | "G" | "X" | "XK" => {
|
||||
let Some((p, _)) = paint::color_op(op, vals) else { return };
|
||||
if op.ends_with(|c: char| c.is_ascii_lowercase()) {
|
||||
f.fill = Some(p);
|
||||
} else {
|
||||
f.stroke = Some(p);
|
||||
}
|
||||
}
|
||||
"w" => f.width = last.filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(f.width),
|
||||
"J" => {
|
||||
f.cap = match last.map(|v| v as i64) {
|
||||
Some(1) => LineCap::Round,
|
||||
Some(2) => LineCap::Square,
|
||||
_ => LineCap::Butt,
|
||||
}
|
||||
}
|
||||
"j" => {
|
||||
f.join = match last.map(|v| v as i64) {
|
||||
Some(1) => LineJoin::Round,
|
||||
Some(2) => LineJoin::Bevel,
|
||||
_ => LineJoin::Miter,
|
||||
}
|
||||
}
|
||||
"M" => f.miter = last.filter(|v| v.is_finite() && *v >= 1.0).unwrap_or(f.miter).min(500.0),
|
||||
"d" => {
|
||||
let pattern: Vec<f64> = vals
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|v| if let V::Arr(a) = v { Some(a.iter().filter_map(V::num).collect()) } else { None })
|
||||
.unwrap_or_default();
|
||||
f.dash = (!pattern.is_empty() && pattern.len() <= 64 && pattern.iter().all(|v| v.is_finite() && *v >= 0.0)).then(|| Dash {
|
||||
pattern,
|
||||
offset: last.filter(|v| v.is_finite()).unwrap_or(0.0),
|
||||
..Dash::default()
|
||||
});
|
||||
}
|
||||
"Xd" => {
|
||||
let n: Vec<f64> = vals.iter().filter_map(V::num).collect();
|
||||
if let [.., stroke, fill] = n.as_slice() {
|
||||
f.paints = (*stroke != 0.0, *fill != 0.0);
|
||||
}
|
||||
}
|
||||
"Bg" => f.gradient = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// A variable-width stroke's profile: its widths (fractions of the stroke's) at positions along the
|
||||
/// path, the same both sides unless it gives each side's.
|
||||
fn width_profile(arrays: &HashMap<String, Vec<f64>>) -> Option<WidthProfile> {
|
||||
let at = arrays.get("BSVariableWidthTValuesArray")?;
|
||||
let side = |key: &str| arrays.get(key).filter(|w| w.len() == at.len());
|
||||
let (left, right) = match (side("BSLeftWidthsArray"), side("BSRightWidthsArray")) {
|
||||
(Some(left), Some(right)) => (left, right),
|
||||
_ => (side("BSCenteredWidthsArray")?, side("BSCenteredWidthsArray")?),
|
||||
};
|
||||
if at.len() > 1024 {
|
||||
return None;
|
||||
}
|
||||
// The stroker expects the positions sorted over 0..=1 and no negative widths.
|
||||
let mut points: Vec<(f64, f64, f64)> = at
|
||||
.iter()
|
||||
.zip(left)
|
||||
.zip(right)
|
||||
.filter(|((t, l), r)| t.is_finite() && l.is_finite() && r.is_finite())
|
||||
.map(|((t, l), r)| (t.clamp(0.0, 1.0), l.max(0.0), r.max(0.0)))
|
||||
.collect();
|
||||
points.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let uniform = points.iter().all(|(_, l, r)| (l - 1.0).abs() < 1e-9 && (r - 1.0).abs() < 1e-9);
|
||||
(!uniform).then_some(WidthProfile { points })
|
||||
}
|
||||
|
||||
fn style(filters: &[Filter]) -> ArtStyle {
|
||||
let count = |name: &str| filters.iter().filter(|f| f.name == name).count();
|
||||
let painted = filters.iter().rposition(|f| matches!(f.name.as_str(), "Stroke Style Filter" | "Fill Style Filter" | "Conduit Filter"));
|
||||
let mut plain = count("Stroke Style Filter") <= 1 && count("Fill Style Filter") <= 1;
|
||||
let mut effects = vec![];
|
||||
let mut items = vec![];
|
||||
// The stack lists its fills and strokes top first.
|
||||
for f in filters.iter().rev().filter(|f| f.visible) {
|
||||
match f.name.as_str() {
|
||||
"Fill Style Filter" if f.paints.1 && f.gradient => items.push(StyleItem::ObjectFill),
|
||||
"Fill Style Filter" if f.paints.1 => match &f.fill {
|
||||
Some(p) => items.push(StyleItem::Fill(p.clone())),
|
||||
None => plain = false,
|
||||
},
|
||||
// Dashes aligned to corners and path ends draw as the look's pieces: a solid end at each end
|
||||
// and the pattern, fitted, between them.
|
||||
"Stroke Style Filter" if f.paints.0 && f.params.get("BSDashAdj").is_some_and(|v| *v != 0.0) => plain = false,
|
||||
"Stroke Style Filter" if f.paints.0 => match &f.stroke {
|
||||
Some(p) if !f.gradient => items.push(StyleItem::Stroke {
|
||||
paint: p.clone(),
|
||||
width: f.width,
|
||||
cap: f.cap,
|
||||
join: f.join,
|
||||
miter: f.miter,
|
||||
dash: f.dash.clone(),
|
||||
profile: width_profile(&f.arrays),
|
||||
}),
|
||||
_ => plain = false,
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for (i, f) in filters.iter().enumerate() {
|
||||
let effect = f.name == GLOW || f.name == SHADOW;
|
||||
if !PLAIN.contains(&f.name.as_str()) && f.name != SHADOW {
|
||||
plain = false;
|
||||
}
|
||||
// An effect inside the stack is a fill's or a stroke's, not the object's.
|
||||
if effect && painted.is_some_and(|p| i < p) {
|
||||
plain = false;
|
||||
}
|
||||
if !f.visible {
|
||||
// A hidden fill or stroke leaves the object painting differently; a hidden effect, nothing.
|
||||
plain &= effect;
|
||||
continue;
|
||||
}
|
||||
let param = |k: &str| f.params.get(k).copied();
|
||||
let mode = |default: usize| super::read::blend_name(param("blnd").map_or(default, |v| v.max(0.0) as usize));
|
||||
let color = f.fill.as_ref().and_then(Paint::color).unwrap_or(vectorcraft_color::Color::BLACK);
|
||||
match f.name.as_str() {
|
||||
// `blur`'s scale isn't publicly documented: read as the Gaussian's σ, an effect's blur being 2σ.
|
||||
GLOW => effects.push(Effect {
|
||||
id: "stylize.outerGlow".into(),
|
||||
params: json!({"mode": mode(2), "opacity": param("opac").unwrap_or(0.75) * 100.0, "blur": 2.0 * param("blur").unwrap_or(5.0), "color": color}),
|
||||
visible: true,
|
||||
}),
|
||||
SHADOW if param("csrc").unwrap_or(0.0) == 0.0 => effects.push(Effect {
|
||||
id: "stylize.dropShadow".into(),
|
||||
params: json!({
|
||||
"mode": mode(1),
|
||||
"opacity": param("opac").unwrap_or(0.75) * 100.0,
|
||||
"blur": 2.0 * param("blur").unwrap_or(5.0),
|
||||
// Undocumented; read as the dialog's offsets, not flipped like art-space y: its default 7/7 pt is
|
||||
// right and down (https://helpx.adobe.com/illustrator/using/drop-shadows-glows-feathering.html).
|
||||
"x": param("horz").unwrap_or(7.0),
|
||||
"y": param("vert").unwrap_or(7.0),
|
||||
"color": color,
|
||||
}),
|
||||
visible: true,
|
||||
}),
|
||||
// A shadow darkening the object's own colours isn't one effects describe.
|
||||
SHADOW => plain = false,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
ArtStyle { plain, items, effects }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_variable_width_stroke_keeps_its_profile() {
|
||||
let data = b"/KnownStyle :\n(Tapered) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
|
||||
/CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/BasicFilter :\n(Stroke Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
|
||||
2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n/StrokeStyle : 0 R\n0 0 0 0 0 0 1 XA\n0 J 0 j 30 w 10 M []0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n\
|
||||
/Dictionary : /NotRecorded ,\n2 /Int (BSWidthSource) ,\n/Array :\n0 /Real ,\n1 /Real ,\n; (BSVariableWidthTValuesArray) ,\n\
|
||||
/Array :\n1 /Real ,\n0.1 /Real ,\n; (BSCenteredWidthsArray) ,\n; (kAIBeautifulStrokesParamsDictKey) ,\n; /Dict ;\n /Part ,\n\
|
||||
/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n";
|
||||
let styles = art_styles(data);
|
||||
let style = &styles["Tapered"];
|
||||
assert!(style.plain);
|
||||
let [StyleItem::Stroke { width, profile, .. }] = style.items.as_slice() else { panic!("{:?}", style.items) };
|
||||
assert_eq!(*width, 30.0);
|
||||
assert_eq!(profile.as_ref().map(|p| p.points.clone()), Some(vec![(0.0, 1.0, 1.0), (1.0, 0.1, 0.1)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dashes_aligned_to_corners_keep_the_drawn_look() {
|
||||
// GUARDS: the look holds the fitted pattern and the solid ends the style's dash array doesn't.
|
||||
let data = b"/KnownStyle :\n(Stitches) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
|
||||
/BasicFilter :\n(Stroke Style Filter) 0 0 /Filter ,\n1 /Visible ,\n2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n\
|
||||
/Dictionary : /NotRecorded ,\n1 /Bool (BSDashAdj) ,\n; (kAIBeautifulStrokesParamsDictKey) ,\n\
|
||||
/StrokeStyle : 0 R\n0 0 0 0 0 0 1 XA\n1 J 0 j 2 w 10 M [5 10 ]0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n; /Dict ;\n /Part ,\n;\n";
|
||||
assert!(!art_styles(data)["Stitches"].plain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drop_shadow_offset_down_points_down() {
|
||||
// GUARDS: the document has y down; flipping `vert` like an art-space y would turn the dialog's
|
||||
// default shadow (7 pt right, 7 pt down) upwards.
|
||||
let data = b"/KnownStyle :\n(Shade) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
|
||||
/BasicFilter :\n(Adobe Drop Shadow) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n7 /Real (horz) ,\n7 /Real (vert) ,\n; /Dict ;\n /Part ,\n;\n";
|
||||
let styles = art_styles(data);
|
||||
let [shadow] = styles["Shade"].effects.as_slice() else { panic!("{:?}", styles["Shade"]) };
|
||||
assert_eq!((shadow.params["x"].as_f64(), shadow.params["y"].as_f64()), (Some(7.0), Some(7.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_dash_offsets_and_width_profiles_are_tamed() {
|
||||
let mut f = Filter::default();
|
||||
paint_op(&mut f, "d", &[V::Arr(vec![V::Num(5.0)]), V::Num(f64::NAN)]);
|
||||
assert_eq!(f.dash.map(|d| d.offset), Some(0.0));
|
||||
// Positions outside 0..=1 and out of order, a negative width, a non-finite one.
|
||||
let arrays = HashMap::from([
|
||||
("BSVariableWidthTValuesArray".to_string(), vec![1.5, -0.2, 0.5, 0.7]),
|
||||
("BSCenteredWidthsArray".to_string(), vec![-1.0, 2.0, 0.5, f64::INFINITY]),
|
||||
]);
|
||||
assert_eq!(width_profile(&arrays).map(|p| p.points), Some(vec![(0.0, 2.0, 2.0), (0.5, 0.5, 0.5), (1.0, 0.0, 0.0)]));
|
||||
}
|
||||
}
|
||||
@@ -238,12 +238,51 @@ fn compound_paths_and_clipping() {
|
||||
// The clipping path goes first.
|
||||
assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. }));
|
||||
assert!(matches!(children[1].kind, NodeKind::Path { clipping: false, .. }));
|
||||
// A clipping path with a fill paints it too, under the art it clips; without one, nothing.
|
||||
let filled = format!("0 Ae\nq\n{RED}{}f\n0 0 1 0 0 0 1 Xa\n{}W\nf\nQ\n", rect_path(0.0, 0.0, 30.0, 30.0), rect_path(5.0, 5.0, 25.0, 25.0));
|
||||
let doc = read_ok(&layer(&filled)).doc;
|
||||
let NodeKind::Group { children, clip: true } = &art(&doc)[0].kind else { panic!("a clipping group") };
|
||||
let kinds: Vec<_> = children.iter().map(|c| (matches!(c.kind, NodeKind::Path { clipping: true, .. }), fill(c).color())).collect();
|
||||
assert_eq!(kinds, [(true, None), (false, Some(Color::rgb(0.0, 0.0, 1.0))), (false, Some(Color::rgb(1.0, 0.0, 0.0)))]);
|
||||
// A clipping path in a layer clips the layer.
|
||||
let doc = read_ok(&layer(&format!("{}f\n{}W\nn\n", rect_path(0.0, 0.0, 9.0, 9.0), rect_path(0.0, 0.0, 5.0, 5.0)))).doc;
|
||||
let NodeKind::Layer { clip, children, .. } = &doc.layers[0].kind else { panic!() };
|
||||
assert!(*clip && matches!(children[0].kind, NodeKind::Path { clipping: true, .. }));
|
||||
}
|
||||
|
||||
/// #1410: a compound path shows its last path's paint, and paths grouped in it stay its paths.
|
||||
#[test]
|
||||
fn compound_path_takes_its_last_paths_paint() {
|
||||
let paths = format!("0 g\n{}f\n{RED}{}f\n", rect_path(0.0, 0.0, 30.0, 30.0), rect_path(10.0, 10.0, 20.0, 20.0));
|
||||
let bodies = [
|
||||
format!("0 Ae\n*u\n{paths}*U\n"),
|
||||
// The paths in a group.
|
||||
format!("0 Ae\n*u\n0 Ae\nu\n{paths}U\n*U\n"),
|
||||
// The last path unpainted.
|
||||
format!("*u\n{paths}{}n\n*U\n", rect_path(1.0, 1.0, 2.0, 2.0)),
|
||||
];
|
||||
for body in bodies {
|
||||
let a = art(&read_ok(&layer(&body)).doc);
|
||||
let NodeKind::Compound { children, .. } = &a[0].kind else { panic!("{:?}", a[0].kind) };
|
||||
assert!(children.len() >= 2 && children.iter().all(|c| matches!(c.kind, NodeKind::Path { .. })), "{body}");
|
||||
assert_eq!(fill(&a[0]).color(), Some(Color::rgb(1.0, 0.0, 0.0)), "{body}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_whose_state_has_no_transparency_takes_the_current_one() {
|
||||
// GUARDS: a group's own state block keeps the last `Xy`, as the PDF part draws it; a drawn look's group stays opaque.
|
||||
let art_ = format!(
|
||||
"{RED}0 Ae\nu\n0 0.5 0 0 0 Xy\n{}f\nU\n0 0 Xd\n6 () XW\n0 1 0 0 0 Xy\n0 Ae\nu\n0 0.5 0 0 0 Xy\n{}f\nU\n9 () XW\n",
|
||||
rect_path(0.0, 0.0, 10.0, 10.0),
|
||||
rect_path(20.0, 0.0, 30.0, 10.0)
|
||||
);
|
||||
let doc = read_ok(&layer(&art_)).doc;
|
||||
let a = art(&doc);
|
||||
assert_eq!((a[0].opacity, a[0].children().unwrap()[0].opacity), (0.5, 0.5));
|
||||
assert_eq!((a[1].opacity, a[1].children().unwrap()[0].opacity), (1.0, 0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparency_of_objects_and_groups() {
|
||||
let art_ = format!(
|
||||
@@ -266,9 +305,13 @@ fn gradients() {
|
||||
let defs = "%AI5_BeginGradient: (G)\n(G) 1 2 Bd\n[\n<00FF>\n0\n0 0 0 0 1 0 0 2 1 6 %_BS\n\
|
||||
%_0 0 0 0 1 0 0 2 1 6 40 0 Bs\n%_0 0 0 0 0 0 1 2 0.5 6 50 100 Bs\nBD\n%AI5_EndGradient\n";
|
||||
let art_ = format!(
|
||||
"{RED}{}Bb\n0 0 0 0 Bh\n1 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg\n50 0 0 -50 100 50 Bm\nf\n0 BB\n{}f\n",
|
||||
"{RED}{}Bb\n0 0 0 0 Bh\n1 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg\n50 0 0 -50 100 50 Bm\nf\n0 BB\n{}f\n\
|
||||
Bb\n2 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg\n0 BB\n0 0 0 0 0 0 1 Xa\n{}f\n\
|
||||
{}Bb\n-20 -10 144 0.77 Bh\n1 (G) -0.1 0.4 0 1.35 1 0 0 -1 0 16384 1 Bg\n50 0 0 -50 100 50 Bm\nf\n0 BB\n",
|
||||
rect_path(50.0, 0.0, 150.0, 100.0),
|
||||
rect_path(0.0, 0.0, 10.0, 10.0),
|
||||
rect_path(20.0, 0.0, 30.0, 10.0),
|
||||
rect_path(50.0, 0.0, 150.0, 100.0),
|
||||
);
|
||||
let doc = read_ok(&format!("{defs}{}", layer(&art_))).doc;
|
||||
let a = art(&doc);
|
||||
@@ -281,6 +324,96 @@ fn gradients() {
|
||||
assert_eq!((geom.start, geom.end), (Point::new(100.0, 50.0), Point::new(150.0, 50.0)));
|
||||
// The gradient was the one object's.
|
||||
assert_eq!(fill(&a[1]).color(), Some(Color::rgb(1.0, 0.0, 0.0)));
|
||||
// A gradient instance that paints nothing doesn't stick to the next object, which sets its colour.
|
||||
assert_eq!(fill(&a[2]).color(), Some(Color::rgb(0.0, 0.0, 1.0)));
|
||||
// A radial gradient is its matrix's unit circle whatever `Bg`'s origin and length, and its
|
||||
// highlight an offset from the centre in art space.
|
||||
let Paint::Gradient(g) = fill(&a[3]) else { panic!("{:?}", fill(&a[3])) };
|
||||
let geom = g.geom.unwrap();
|
||||
assert_eq!((geom.start, geom.end), (Point::new(100.0, 50.0), Point::new(150.0, 50.0)));
|
||||
let focal = geom.focal.unwrap();
|
||||
assert!((focal - Point::new(80.0, 60.0)).hypot() < 1e-6, "{focal:?}");
|
||||
// A linear gradient with a stop in the middle: `Xm` places its whole ramp, a `Bm` each of its
|
||||
// two parts. The object's own note of its gradient after it paints nothing.
|
||||
let defs = "(L) 0 3 Bd\n%_1 0 50 100 Bs\n%_0.5 0 50 40 Bs\n%_0 0 50 0 Bs\nBD\n";
|
||||
let art_ = format!(
|
||||
"{RED}{}Bb\n1 (L) 0 0 -90 1 1 0 0 1 0 0 1 Bg\n0 -100 -100 0 150 100 Xm\n0 -9000 -100 0 150 9100 Bc\n0 -40 -100 0 150 100 Bm\n\
|
||||
0 -60 -100 0 150 60 Bm\n0 -9000 -100 0 150 0 Bc\nf\n0 BB\n0 1 Xd\nBb\n2 (L) 0 0 -90 1 1 0 0 1 0 0 1 Bg\n0 BB\n6 () XW\n{}f\n",
|
||||
rect_path(50.0, 0.0, 150.0, 100.0),
|
||||
rect_path(0.0, 0.0, 10.0, 10.0),
|
||||
);
|
||||
let doc = read_ok(&format!("{defs}{}", layer(&art_))).doc;
|
||||
let a = art(&doc);
|
||||
let Paint::Gradient(g) = fill(&a[0]) else { panic!("{:?}", fill(&a[0])) };
|
||||
assert_eq!(g.gradient.stops.len(), 3);
|
||||
// Art space has y up from the crop marks' bottom.
|
||||
let geom = g.geom.unwrap();
|
||||
assert!(geom.start.distance(Point::new(150.0, 0.0)) < 1e-9 && geom.end.distance(Point::new(150.0, 100.0)) < 1e-9, "{geom:?}");
|
||||
assert_eq!(fill(&a[1]).color(), Some(Color::rgb(1.0, 0.0, 0.0)), "the note after the object isn't the next path's paint");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_compound_path_holding_its_drawn_look_paints_its_paths_apart() {
|
||||
// GUARDS: made one compound path, the look's paths take a single fill and the inner one punches a hole.
|
||||
let art_ = format!(
|
||||
"0 Ae\n*u\n0 Ae\nu\n{RED}{}f\n0 0 0 0 0 0 1 Xa\n{}f\nU\n9 () XW\n*U\n",
|
||||
rect_path(0.0, 0.0, 20.0, 20.0),
|
||||
rect_path(5.0, 5.0, 15.0, 15.0)
|
||||
);
|
||||
let a = art(&read_ok(&layer(&art_)).doc);
|
||||
fn leaves(n: &Node, out: &mut Vec<Paint>) {
|
||||
match n.children() {
|
||||
Some(children) if !matches!(n.kind, NodeKind::Compound { .. }) => children.iter().for_each(|c| leaves(c, out)),
|
||||
_ => out.push(fill(n)),
|
||||
}
|
||||
}
|
||||
let mut painted = vec![];
|
||||
a.iter().for_each(|n| leaves(n, &mut painted));
|
||||
let fills: Vec<_> = painted.iter().map(Paint::color).collect();
|
||||
assert_eq!(fills, [Some(Color::rgb(1.0, 0.0, 0.0)), Some(Color::rgb(0.0, 0.0, 1.0))], "{a:#?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradients_through_a_skewed_matrix() {
|
||||
// GUARDS: a sheared `Xm` leaves the matrix's columns oblique. A linear ramp runs along the
|
||||
// normal to its isolines, not along its first column; a radial's ellipse is the one the unit
|
||||
// circle maps to, set by the principal axes.
|
||||
let defs = |name: &str, kind: u8| {
|
||||
format!(
|
||||
"%AI5_BeginGradient: ({name})\n({name}) {kind} 2 Bd\n[\n<00FF>\n0\n%_0 0 0 0 1 0 0 2 1 6 50 0 Bs\n%_0 0 0 0 0 0 1 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n"
|
||||
)
|
||||
};
|
||||
let instance = |name: &str| format!("Bb\n1 ({name}) 0 0 0 1 1 0 0 1 0 0 1 Bg\n10 0 10 10 100 50 Xm\nf\n0 BB\n");
|
||||
let art_ = format!("{}{}{}{}", rect_path(0.0, 0.0, 200.0, 100.0), instance("Lin"), rect_path(0.0, 0.0, 200.0, 100.0), instance("Rad"));
|
||||
let doc = read_ok(&format!("{}{}{}", defs("Lin", 0), defs("Rad", 1), layer(&art_))).doc;
|
||||
let a = art(&doc);
|
||||
let Paint::Gradient(g) = fill(&a[0]) else { panic!("{:?}", fill(&a[0])) };
|
||||
let geom = g.geom.unwrap();
|
||||
assert!((geom.start - Point::new(100.0, 50.0)).hypot() < 1e-9 && (geom.end - Point::new(105.0, 55.0)).hypot() < 1e-9, "{geom:?}");
|
||||
let Paint::Gradient(g) = fill(&a[1]) else { panic!("{:?}", fill(&a[1])) };
|
||||
let geom = g.geom.unwrap();
|
||||
let golden = (1.0 + 5f64.sqrt()) / 2.0;
|
||||
assert!((geom.start.distance(geom.end) - 10.0 * golden).abs() < 1e-9, "{geom:?}");
|
||||
assert!((geom.aspect - 1.0 / golden.powi(2)).abs() < 1e-9, "{geom:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stops_sharing_a_ramp_point_keep_their_order() {
|
||||
// GUARDS: stops come last first, so a sort alone puts the two at 100 the wrong way round and
|
||||
// the ramp blends towards the colour meant only for past its end.
|
||||
let defs = "%AI5_BeginGradient: (S)\n(S) 0 3 Bd\n[\n<00FF>\n0\n%_0 0 0 0 1 0 0 2 1 6 50 100 Bs\n\
|
||||
%_0 0 0 0 0 1 0 2 1 6 50 100 Bs\n%_0 0 0 0 0 0 1 2 1 6 50 0 Bs\nBD\n%AI5_EndGradient\n";
|
||||
let art_ = format!("{}Bb\n1 (S) 0 0 0 1 1 0 0 1 0 0 1 Bg\n10 0 0 10 100 50 Xm\nf\n0 BB\n", rect_path(0.0, 0.0, 200.0, 100.0));
|
||||
let doc = read_ok(&format!("{defs}{}", layer(&art_))).doc;
|
||||
let Paint::Gradient(g) = fill(&art(&doc)[0]) else { panic!() };
|
||||
let colors: Vec<_> = g.gradient.stops.iter().map(|s| s.color).collect();
|
||||
assert_eq!(colors, [Color::rgb(0.0, 0.0, 1.0), Color::rgb(0.0, 1.0, 0.0), Color::rgb(1.0, 0.0, 0.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_anchor_data_reads() {
|
||||
let art_ = format!("{RED}{}f\n(00800080) Xt 0 XD\n%_(00004000) Xt 1 XD\n", rect_path(0.0, 0.0, 10.0, 10.0));
|
||||
assert_eq!(art(&read_ok(&layer(&art_)).doc).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -371,6 +504,81 @@ fn images_with_alpha_channels() {
|
||||
assert!(art(&r.doc).is_empty() && r.warnings.iter().any(|w| w.contains("cut short")));
|
||||
}
|
||||
|
||||
/// Editing data with one layer (showing or not) holding a placed file: its matrix, the file at
|
||||
/// `path` in the box 10 20 14 22, and `preview` (what its preview section holds), then `after`.
|
||||
fn placed(visible: bool, matrix: &str, path: &str, preview: Option<&[u8]>, after: &str) -> Vec<u8> {
|
||||
let text = stream(&layer("@@").replacen("1 1 1 1", if visible { "1 1 1 1" } else { "0 1 1 1" }, 1));
|
||||
let (head, tail) = text.split_once("@@").unwrap();
|
||||
let mut s = format!(
|
||||
"{head}%AI5_BeginPlace\r%%FileType: 1\r{matrix} 10 20 14 22 4 4 0\r({path})`\r%%IncludeFile:{path}\r\
|
||||
%AI17_Begin_Content_if_version_gt:24 12\r%AI28_BeginPlacedRelativePath\r()\r%AI28_EndPlacedRelativePath\r%AI17_Alternate_Content\r%AI17_End_Versioned_Content\r\
|
||||
%AI17_Begin_Content_if_version_gt:24 5\r"
|
||||
)
|
||||
.into_bytes();
|
||||
if let Some(p) = preview {
|
||||
s.extend_from_slice(b"%AI26_BeginPlacedObjectPreview\r");
|
||||
s.extend_from_slice(p);
|
||||
s.extend_from_slice(b"\r%AI26_EndPlacedObjectPreview\r");
|
||||
}
|
||||
s.extend_from_slice(format!("%AI17_End_Versioned_Content\r~\r%AI5_EndPlace\r{after}{tail}").as_bytes());
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placed_files_are_linked_images_showing_their_preview() {
|
||||
// A 2×1 preview (premultiplied ARGB, a padded row) of a file of 4×2 pixels.
|
||||
let preview = [&b"2 1 12 32 17 "[..], &[255, 255, 0, 0, 128, 0, 64, 0, 9, 9, 9, 9]].concat();
|
||||
let name = "%_/ArtDictionary :\r%_(Photo) /String (AIArtName) ,\r%_;\r";
|
||||
let r = read(&placed(true, "[5 0 0 -5 0 200]", "/pics/photo.png", Some(&preview), name)).unwrap();
|
||||
let a = art(&r.doc);
|
||||
let NodeKind::Image(im) = &a[0].kind else { panic!("{:?}", a[0].kind) };
|
||||
assert_eq!((im.link.as_ref().map(|l| l.path.as_str()), im.width, im.height), (Some("/pics/photo.png"), 2, 1));
|
||||
assert_eq!(a[0].name.as_deref(), Some("Photo"));
|
||||
assert!(r.doc.images[&im.key].is_proxy(), "the preview is what a missing link shows");
|
||||
let (img, xf) = pixels(&r.doc);
|
||||
assert_eq!((img.get_pixel(0, 0).0, img.get_pixel(1, 0).0), ([255, 0, 0, 255], [0, 127, 0, 128]));
|
||||
// Without a template box the pixels start at the box's corner, the first row the top one:
|
||||
// (10, 20) is at (50, 100) in art space, the page's top.
|
||||
assert_eq!((xf * Point::ZERO, xf * Point::new(2.0, 1.0)), (Point::new(50.0, 0.0), Point::new(70.0, 10.0)));
|
||||
assert!(r.warnings.iter().any(|w| w.contains("low-resolution preview")), "{:?}", r.warnings);
|
||||
// With a template box, the pixels start at the canvas point of art space's origin (8091.5,
|
||||
// 8241.5 here), wherever the box is.
|
||||
let mut data = placed(true, "[5 0 0 -5 -40407.5 41307.5]", "a.png", Some(&preview), "");
|
||||
let at = data.windows(15).position(|w| w == b"%AI5_BeginLayer").unwrap();
|
||||
data.splice(at..at, *b"%AI3_TemplateBox: 100 50 100 50\n");
|
||||
let xf = pixels(&read(&data).unwrap().doc).1;
|
||||
assert_eq!((xf * Point::ZERO, xf * Point::new(2.0, 1.0)), (Point::new(50.0, 0.0), Point::new(70.0, 10.0)));
|
||||
// Turned a quarter: the box's rows run along x.
|
||||
let r = read(&placed(true, "[0 5 5 0 0 0]", "a.png", Some(&preview), "")).unwrap();
|
||||
let xf = pixels(&r.doc).1;
|
||||
assert_eq!((xf * Point::ZERO, xf * Point::new(2.0, 1.0)), (Point::new(100.0, 50.0), Point::new(110.0, 30.0)));
|
||||
|
||||
// Without a preview it can read (none, cut short, not 32 bits, absurd sizes), without a path or
|
||||
// a matrix, the file isn't read from its editing data.
|
||||
let cut = &preview[..preview.len() - 6];
|
||||
let huge = b"70000 70000 280000 32 17 \0\0\0\0";
|
||||
let overflow = b"1 2 18446744073709551615 32 17 \0\0\0\0\0\0\0\0";
|
||||
for (matrix, path, preview) in [
|
||||
("[5 0 0 -5 0 200]", "a.png", None),
|
||||
("[5 0 0 -5 0 200]", "a.png", Some(cut)),
|
||||
("[5 0 0 -5 0 200]", "a.png", Some(&b"2 1 2 8 17 \0\0"[..])),
|
||||
("[5 0 0 -5 0 200]", "a.png", Some(&huge[..])),
|
||||
("[5 0 0 -5 0 200]", "a.png", Some(&overflow[..])),
|
||||
("[5 0 0 -5 0 200]", "a.png", Some(&b"2 1"[..])),
|
||||
("[5 0 0 -5 0 200]", "", Some(&preview[..])),
|
||||
("[0 0 0 0 0 0]", "a.png", Some(&preview[..])),
|
||||
("[1 2 3]", "a.png", Some(&preview[..])),
|
||||
] {
|
||||
let e = read(&placed(true, matrix, path, preview, "")).unwrap_err();
|
||||
assert!(e.contains("placed files"), "{matrix} {path} {preview:?}: {e}");
|
||||
}
|
||||
// On a layer that doesn't show, such a file is left out of it.
|
||||
let r = read(&placed(false, "[5 0 0 -5 0 200]", "a.png", None, "")).unwrap();
|
||||
assert!(r.hidden_unread.contains("placed files") && art(&r.doc).is_empty());
|
||||
// `~` ends nothing else.
|
||||
assert!(read(stream(&layer("~\n")).as_bytes()).unwrap_err().contains("`~`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_objects_are_slots_named_after_their_story() {
|
||||
let words = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n3 /StoryIndex ,\n;\n%_/ArtDictionary :\n%_/XMLUID : (Title) ; (AI10_ArtUID) ,\n%_;\n";
|
||||
@@ -384,6 +592,20 @@ fn text_objects_are_slots_named_after_their_story() {
|
||||
let r = read_ok(&layer(&format!("/AI11Text :\n0 /StoryIndex ,\n;\n{}{}", commented(0), commented(1))));
|
||||
let stories: Vec<_> = art(&r.doc).iter().map(|n| slot_of(n.name.as_deref())).collect();
|
||||
assert_eq!(stories, [Some(Some(0)), Some(Some(1))]);
|
||||
// A text object with an appearance: its drawn look (a group whose type is a story of the
|
||||
// undo-free text document, which counts its own), then the object on `%_` lines, which is
|
||||
// the type.
|
||||
let look = "0 Ae\nu\n/AI11Text :\n1 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\nU\n0 0 Xd\n6 () XW\n";
|
||||
let own: String = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n7 /StoryIndex ,\n;\n".lines().map(|l| format!("%_{l}\n")).collect();
|
||||
let plain = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n";
|
||||
let r = read_ok(&layer(&format!("{look}{own}1 (Style) XW\n{plain}")));
|
||||
let stories: Vec<_> = art(&r.doc).iter().map(|n| slot_of(n.name.as_deref())).collect();
|
||||
assert_eq!(stories, [Some(Some(7)), Some(Some(0))], "the look's story 0 isn't the text document's");
|
||||
// What else the look draws stays, under the type.
|
||||
let look = look.replace("U\n", &format!("{RED}{}f\nU\n", rect_path(0.0, 0.0, 10.0, 10.0)));
|
||||
let r = read_ok(&layer(&format!("{look}{own}1 (Style) XW\n")));
|
||||
let a = art(&r.doc);
|
||||
assert_eq!((a.len(), a[0].children().map(Vec::len), slot_of(a[1].name.as_deref())), (2, Some(1), Some(Some(7))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -613,3 +835,143 @@ fn closing_brackets_without_their_opening_dont_rescan_the_stack() {
|
||||
let _ = read(stream(&layer(&body)).as_bytes());
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(20), "{:?}", started.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artboards_after_what_stops_the_reading() {
|
||||
let board = |name: &str, x: i32| {
|
||||
format!(
|
||||
"%_/Dictionary :\n%_/FillStyle : 0 1 0 0 0 Xy\n%_/Def ; \n%_ (EachArtboardColor) ,\n%_{x} 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_({name}) /UnicodeString (Name) ,\n%_{} 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n",
|
||||
x + 100
|
||||
)
|
||||
};
|
||||
let array = format!("%_/Array :\n{}{}%_; (ArtboardArray) ,\n", board("one", 0), board("two", 200));
|
||||
// Art in the document's own data that the reader can't follow comes before the artboards.
|
||||
let data = format!("%_/Document :\n%_/Dictionary :\n%_/Array :\n%_/Dictionary :\nLB\n{array}%_; /NotRecorded ,\n%_;\n");
|
||||
let boards: Vec<_> = super::artboards(data.as_bytes()).into_iter().map(|(n, r)| (n, r.x0)).collect();
|
||||
assert_eq!(boards, [("one".to_string(), 0.0), ("two".to_string(), 200.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_compound_clipping_path_with_a_fill_paints_once_under_its_clip() {
|
||||
// GUARDS: the painted copies of a compound's clipping subpaths went into the compound itself,
|
||||
// doubling each subpath, so under even-odd the clip cancelled to nothing.
|
||||
let art_ = format!(
|
||||
"0 Ae\nq\n{RED}{}f\n1 XR\n*u\n0 0 0 0 0 0 1 Xa\n{}W\nf\n{}W\nf\n*U\nQ\n",
|
||||
rect_path(0.0, 0.0, 100.0, 100.0),
|
||||
rect_path(10.0, 10.0, 40.0, 40.0),
|
||||
rect_path(50.0, 10.0, 80.0, 40.0),
|
||||
);
|
||||
let doc = read_ok(&layer(&art_)).doc;
|
||||
let NodeKind::Group { children, clip: true } = &art(&doc)[0].kind else { panic!("a clipping group") };
|
||||
assert_eq!(children.len(), 3, "{children:#?}");
|
||||
let NodeKind::Compound { children: clip, rule } = &children[0].kind else { panic!("{:?}", children[0].kind) };
|
||||
assert_eq!(*rule, vectorcraft_geom::FillRule::EvenOdd);
|
||||
assert_eq!(clip.len(), 2);
|
||||
assert!(clip.iter().all(|c| matches!(c.kind, NodeKind::Path { clipping: true, .. })));
|
||||
let NodeKind::Compound { children: painted, .. } = &children[1].kind else { panic!("{:?}", children[1].kind) };
|
||||
assert_eq!((painted.len(), fill(&children[1]).color()), (2, Some(Color::rgb(0.0, 0.0, 1.0))));
|
||||
assert_eq!(fill(&children[2]).color(), Some(Color::rgb(1.0, 0.0, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_radial_gradient_without_xm_is_its_bm_circle_else_its_bg_circle() {
|
||||
// GUARDS: files write `Bg` as the unit circle and place the radial with `Bm`; reading `Bg` alone collapses it.
|
||||
let defs =
|
||||
"%AI5_BeginGradient: (R)\n(R) 1 2 Bd\n[\n<00FF>\n0\n%_0 0 0 0 1 0 0 2 1 6 50 0 Bs\n%_0 0 0 0 0 0 1 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n";
|
||||
let geom = |instance: &str| {
|
||||
let doc = read_ok(&format!("{defs}{}", layer(&format!("{}Bb\n10 0 0 0.5 Bh\n{instance}f\n0 BB\n", rect_path(0.0, 0.0, 200.0, 100.0))))).doc;
|
||||
let Paint::Gradient(g) = fill(&art(&doc)[0]) else { panic!() };
|
||||
g.geom.unwrap()
|
||||
};
|
||||
let placed = geom("1 (R) 0 0 0 1 1 0 0 1 0 0 Bg\n50 0 0 -50 100 50 Bm\n");
|
||||
assert_eq!((placed.start, placed.end, placed.focal), (Point::new(100.0, 50.0), Point::new(150.0, 50.0), Some(Point::new(110.0, 50.0))));
|
||||
let own = geom("1 (R) 60 50 0 20 1 0 0 1 0 0 Bg\n");
|
||||
assert_eq!((own.start, own.end, own.focal), (Point::new(60.0, 50.0), Point::new(80.0, 50.0), Some(Point::new(70.0, 50.0))));
|
||||
}
|
||||
|
||||
/// Two art styles: `Blue`, a blue fill and a drop shadow; `Lit`, the object's gradient and an outer glow.
|
||||
const ART_STYLES: &str = "%AI9_BeginArtStyles\n\
|
||||
/KnownStyle :\n(Blue) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
|
||||
/CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
|
||||
/BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n0 0 0 0 0 0 1 Xa\n0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\
|
||||
/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n\
|
||||
/BasicFilter :\n(Adobe Drop Shadow) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n3 /Real (horz) ,\n4 /Real (vert) ,\n; /Dict ;\n /Part ,\n;\n\
|
||||
/KnownStyle :\n(Lit) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
|
||||
/CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
|
||||
/BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n1 (G) 0 0 0 1 1 0 0 1 0 0 Bg\n0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\
|
||||
/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n\
|
||||
/BasicFilter :\n(Adobe Outer Glow) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n0.5 /Real (opac) ,\n2 /Real (blur) ,\n; /Dict ;\n /Part ,\n;\n\
|
||||
%AI9_EndArtStyles\n";
|
||||
|
||||
#[test]
|
||||
fn an_object_with_a_style_this_reads_comes_in_as_itself() {
|
||||
let defs =
|
||||
"%AI5_BeginGradient: (G)\n(G) 0 2 Bd\n[\n<00FF>\n0\n%_0 0 0 0 1 0 0 2 1 6 50 0 Bs\n%_0 0 0 0 0 0 1 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n";
|
||||
let hidden_rect = rect_path(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_");
|
||||
let art_ = format!(
|
||||
"0 Ae\nu\n{RED}{}f\nU\n%_Bb\n%_1 (G) 0 0 0 1 1 0 0 1 0 0 Bg\n%_10 0 0 10 5 5 Xm\n%_{hidden_rect}f\n%_0 BB\n1 (Lit) XW\n",
|
||||
rect_path(0.0, 0.0, 10.0, 10.0)
|
||||
);
|
||||
let r = read_ok(&format!("{ART_STYLES}{defs}{}", layer(&art_)));
|
||||
let a = art(&r.doc);
|
||||
assert_eq!(a.len(), 1, "{a:#?}");
|
||||
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look");
|
||||
assert!(matches!(fill(&a[0]), Paint::Gradient(g) if g.swatch.as_deref() == Some("G")));
|
||||
let effects: Vec<_> = a[0].appearance.effects.iter().map(|e| (e.id.as_str(), e.params["opacity"].as_f64())).collect();
|
||||
assert_eq!(effects, [("stylize.outerGlow", Some(50.0))]);
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_comment_line_in_a_prolog_leaves_its_end_alone() {
|
||||
// GUARDS: a bare `%` line's empty marker matched the empty begin marker of a section that has none.
|
||||
let r = read_ok(&format!("%%BeginProlog\n%\n%%EndProlog\n{}", layer(&format!("{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0)))));
|
||||
assert_eq!(art(&r.doc).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_styled_object_inside_a_drawn_look_survives_it() {
|
||||
// GUARDS: an adopted styled object was read on `%_` lines; while its id stayed among those, the
|
||||
// enclosing look's strip removed it.
|
||||
let hidden_rect = rect_path(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_");
|
||||
let art_ = format!(
|
||||
"0 Ae\nu\n0 Ae\nu\n{RED}{}f\nU\n%_{hidden_rect}f\n1 (Blue) XW\nU\n%_0 Ae\n%_u\n%_{hidden_rect}f\n%_U\n1 (Outer) XW\n",
|
||||
rect_path(0.0, 0.0, 10.0, 10.0)
|
||||
);
|
||||
let r = read_ok(&format!("{ART_STYLES}{}", layer(&art_)));
|
||||
let a = art(&r.doc);
|
||||
assert_eq!(a.len(), 1, "{a:#?}");
|
||||
let inner = a[0].children().unwrap();
|
||||
assert_eq!(inner.len(), 1, "{inner:#?}");
|
||||
assert_eq!(fill(&inner[0]).color(), Some(Color::rgb(0.0, 0.0, 1.0)));
|
||||
let shadow = &inner[0].appearance.effects[0];
|
||||
assert_eq!((shadow.id.as_str(), shadow.params["x"].as_f64(), shadow.params["y"].as_f64()), ("stylize.dropShadow", Some(3.0), Some(4.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gradient_mesh_reads_as_a_patch() {
|
||||
let corner = |rgb: &str, x: i32, y: i32, next: (i32, i32), prev: (i32, i32)| {
|
||||
format!("%_[{rgb} 1 {x} {y} {} {} 1 {} {} 1] /N X#\n", next.0, next.1, prev.0, prev.1)
|
||||
};
|
||||
let mesh = |space: &str| {
|
||||
format!(
|
||||
"/Mesh X!\n%_({space}) /CS X#\n%_[0 0] /P X#\n{}{}{}{}%_0 /E X#\n/End X!\n",
|
||||
corner("1 0 0", 0, 0, (0, 10), (10, 0)),
|
||||
corner("0 1 0", 0, 40, (10, 40), (0, 30)),
|
||||
corner("0 0 1", 40, 40, (40, 30), (30, 40)),
|
||||
corner("1 1 1", 40, 0, (30, 0), (40, 10)),
|
||||
)
|
||||
};
|
||||
let doc = read_ok(&layer(&mesh("DeviceRGB"))).doc;
|
||||
let NodeKind::Mesh(m) = &art(&doc)[0].kind else { panic!("{:?}", art(&doc)[0].kind) };
|
||||
let colors: Vec<_> = m.points.iter().map(|p| p.color).collect();
|
||||
assert_eq!(colors, [Color::rgb(1.0, 0.0, 0.0), Color::rgb(1.0, 1.0, 1.0), Color::rgb(0.0, 1.0, 0.0), Color::rgb(0.0, 0.0, 1.0)]);
|
||||
use vectorcraft_doc::live::{H_DOWN, H_RIGHT};
|
||||
let first = &m.points[0];
|
||||
assert_eq!(
|
||||
(first.p, first.handles[H_DOWN], first.handles[H_RIGHT]),
|
||||
(Point::new(0.0, 100.0), kurbo::Vec2::new(0.0, -10.0), kurbo::Vec2::new(10.0, 0.0))
|
||||
);
|
||||
let e = read(stream(&layer(&mesh("Separation"))).as_bytes()).unwrap_err();
|
||||
assert!(e.contains("gradient meshes"), "{e}");
|
||||
}
|
||||
@@ -34,7 +34,7 @@ const MAX_DEPTH: usize = 64;
|
||||
/// Most values read.
|
||||
const MAX_VALUES: usize = 4_000_000;
|
||||
/// The centre of the app's canvas, in the units of its text document.
|
||||
const CANVAS_CENTRE: f64 = 8191.5;
|
||||
pub(super) const CANVAS_CENTRE: f64 = 8191.5;
|
||||
|
||||
/// A value of the text document.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -666,6 +666,14 @@ impl Story {
|
||||
Some(Node::new(id, NodeKind::Text(Box::new(t))))
|
||||
}
|
||||
|
||||
/// Area type that the file lays lines out in, in a frame too short for its first line as it is
|
||||
/// laid out here: a font that isn't available shows in the fallback font, whose lines may be
|
||||
/// taller, and type that doesn't fit its frame isn't drawn. The frame is made taller, from its
|
||||
/// top, by what the first line needs (`vectorcraft_text::fit_first_line`). Whether it was.
|
||||
pub(super) fn fit_first_line(&self, t: &mut TextObject) -> bool {
|
||||
!self.lines.is_empty() && vectorcraft_text::fit_first_line(vectorcraft_text::FontDb::global(), t)
|
||||
}
|
||||
|
||||
/// The leading the file's own line positions imply: the distance from one line to the next, less
|
||||
/// the space between paragraphs that is part of it (a paragraph's space after and the next one's
|
||||
/// space before; a layout's lines are where that space has put them, and the text object adds it
|
||||
@@ -860,4 +868,30 @@ mod tests {
|
||||
// One line has no distance to read.
|
||||
assert_eq!(story("one\r", &[0.0], &Para::default()).line_leading(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_too_short_for_its_first_line_is_made_taller() {
|
||||
// A heading in a frame as tall as its size: a line of the font it shows in here is taller.
|
||||
let s = story("Heading\r", &[0.0], &Para::default());
|
||||
let area = |height: f64| {
|
||||
let mut t = TextObject::point(Point::ZERO, "Heading", CharStyle { size: 13.0, ..CharStyle::default() });
|
||||
t.kind = TextKind::Area { frame: vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 20.0, 210.0, 20.0 + height)) };
|
||||
t
|
||||
};
|
||||
let height =
|
||||
|t: &TextObject| if let TextKind::Area { frame } = &t.kind { frame.bounds().map(|b| (b.x0, b.y0, b.x1, b.height())) } else { None };
|
||||
let lines = |t: &TextObject| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).lines.len();
|
||||
let mut t = area(13.0);
|
||||
assert_eq!(lines(&t), 0, "nothing of it shows");
|
||||
assert!(s.fit_first_line(&mut t));
|
||||
let (x0, y0, x1, h) = height(&t).unwrap();
|
||||
assert!((x0, y0, x1) == (10.0, 20.0, 210.0) && h > 13.0 && h < 26.0, "taller from its top: {h}");
|
||||
assert_eq!(lines(&t), 1);
|
||||
// A frame that holds the line stays as it is; so does one the file lays no line out in
|
||||
// (its type is overset in the file too).
|
||||
let mut t = area(40.0);
|
||||
assert!(!s.fit_first_line(&mut t) && height(&t).unwrap().3 == 40.0);
|
||||
let mut t = area(13.0);
|
||||
assert!(!story("Heading\r", &[], &Para::default()).fit_first_line(&mut t) && height(&t).unwrap().3 == 13.0);
|
||||
}
|
||||
}
|
||||
@@ -453,10 +453,11 @@ fn plan(
|
||||
Some(s.line_starts(t).into_iter().map(|(text, (x, y))| (text, to_doc * Point::new(x, y))).collect())
|
||||
})
|
||||
.collect();
|
||||
// The page's objects that are where a shown slot's lines are, nearest first.
|
||||
// The page's objects that are where a slot's lines are, nearest first. A slot that doesn't show
|
||||
// has them too: a file's page may draw what its layers have hidden since.
|
||||
let mut pairs: Vec<(f64, usize, usize)> = vec![];
|
||||
for (k, info) in infos.iter().enumerate() {
|
||||
let Some(l) = lines.get(k).and_then(Option::as_ref).filter(|_| info.shown) else { continue };
|
||||
for (k, _) in infos.iter().enumerate() {
|
||||
let Some(l) = lines.get(k).and_then(Option::as_ref) else { continue };
|
||||
for (j, object) in shown.iter().enumerate() {
|
||||
if let Some(d) = object.iter().filter_map(|n| same_type(n, l)).min_by(f64::total_cmp) {
|
||||
pairs.push((d, k, j));
|
||||
@@ -472,6 +473,9 @@ fn plan(
|
||||
}
|
||||
// Type the text document makes, shown or not: the page's pieces of it aren't needed.
|
||||
let mut content: Vec<Content> = infos.iter().map(|_| Content::Empty).collect();
|
||||
// Where the made type that doesn't show lies, by slot.
|
||||
let mut hidden_at: Vec<(usize, Rect)> = vec![];
|
||||
let mut taller = 0;
|
||||
if let Some(t) = template {
|
||||
for (k, info) in infos.iter().enumerate() {
|
||||
let Some(Some(story)) = stories.get(k) else { continue };
|
||||
@@ -493,11 +497,24 @@ fn plan(
|
||||
TextKind::Point => {}
|
||||
}
|
||||
}
|
||||
if let (false, Some(r)) = (info.shown, region) {
|
||||
hidden_at.push((k, r));
|
||||
}
|
||||
if let NodeKind::Text(text) = &mut node.kind
|
||||
&& story.fit_first_line(text)
|
||||
{
|
||||
taller += 1;
|
||||
}
|
||||
if let Some(c) = content.get_mut(k) {
|
||||
*c = Content::Made(Box::new(node), region.filter(|_| info.shown), false, vec![]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if taller > 0 {
|
||||
warn.push(format!(
|
||||
"area type in frames too short for a line of the font it shows in got taller frames ({taller}): their type wouldn't show otherwise"
|
||||
));
|
||||
}
|
||||
// The file's text document is trusted where it agrees with the page about something (its lines
|
||||
// start where the page's type does, or the page has type where it lays type out), or where the
|
||||
// page has no type to disagree with.
|
||||
@@ -506,9 +523,26 @@ fn plan(
|
||||
if !trusted {
|
||||
content.iter_mut().for_each(|c| *c = Content::Empty);
|
||||
}
|
||||
// Is `object` the type of a text object that doesn't show, as the page draws it: in its frame,
|
||||
// with its words? (A copied object's lines are where its original's are, so they don't say.)
|
||||
let hidden_type = |object: &[Arc<Node>]| {
|
||||
let Some(r) = extent(object) else { return false };
|
||||
let words: Vec<String> =
|
||||
object.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(letters(&t.plain_text())) } else { None }).collect();
|
||||
hidden_at.iter().any(|(k, place)| {
|
||||
place.intersect(r).area() >= 0.5 * r.area().max(1e-9)
|
||||
&& lines
|
||||
.get(*k)
|
||||
.and_then(Option::as_ref)
|
||||
.is_some_and(|l| !words.is_empty() && words.iter().all(|w| l.iter().any(|(text, _)| letters(text).contains(w.as_str()))))
|
||||
})
|
||||
};
|
||||
let mut spare: Vec<Vec<Arc<Node>>> = vec![];
|
||||
for (j, object) in shown.iter().enumerate() {
|
||||
match owner.get(j).copied().flatten().filter(|_| trusted) {
|
||||
// The type of a text object the layers hide: the layers have it, hidden, so the page's
|
||||
// isn't another text object's.
|
||||
Some(k) if infos.get(k).is_some_and(|i| !i.shown) && matches!(content.get(k), Some(Content::Made(..))) => {}
|
||||
Some(k) => match content.get_mut(k) {
|
||||
Some(Content::Made(_, _, seen, _)) => *seen = true,
|
||||
Some(Content::Page(p)) => p.extend(object.iter().cloned()),
|
||||
@@ -526,6 +560,7 @@ fn plan(
|
||||
}
|
||||
}
|
||||
}
|
||||
None if trusted && hidden_type(object) => {}
|
||||
None => spare.push(object.clone()),
|
||||
}
|
||||
}
|
||||
@@ -836,7 +871,7 @@ struct Frame<'a> {
|
||||
/// (a `.ai` saved without its PDF part), its type is made from the file's text document alone and
|
||||
/// nothing is compared. `outlined`: the page's type was read as outlines, so it has none to put in
|
||||
/// the text objects.
|
||||
fn build(data: &[u8], visible: Option<&Document>, page: Page, outlined: bool) -> Result<(Document, Vec<String>), String> {
|
||||
fn build(data: &[u8], visible: Option<&Document>, page: Page, outlined: bool, trust: bool) -> Result<(Document, Vec<String>), String> {
|
||||
let Structure { mut doc, to_doc, bbox, artboard, template, hidden_unread, slot_names, warnings } = ai::read(data)?;
|
||||
let (mut shown, mut hidden) = (vec![], vec![]);
|
||||
let mut frame = None;
|
||||
@@ -857,7 +892,22 @@ fn build(data: &[u8], visible: Option<&Document>, page: Page, outlined: bool) ->
|
||||
text_objects(c, l.visible, &mut shown, &mut hidden);
|
||||
}
|
||||
}
|
||||
// A `.ai` read as its PDF part has a page for each artboard, in a row: the type on each
|
||||
// goes where the layers have that artboard.
|
||||
let pages: Vec<Rect> = visible.artboards.iter().map(|a| a.rect).collect();
|
||||
let same =
|
||||
|(a, p): (&vectorcraft_doc::Artboard, &Rect)| (a.rect.width() - p.width()).abs() <= 0.5 && (a.rect.height() - p.height()).abs() <= 0.5;
|
||||
let boards = (pages.len() == doc.artboards.len() && doc.artboards.iter().zip(&pages).all(same)).then_some(&doc.artboards);
|
||||
for object in shown.iter_mut().chain(hidden.iter_mut()) {
|
||||
// The page with most of it, the first for what is on none.
|
||||
let on = extent(object).and_then(|r| {
|
||||
let inside = |p: &Rect| p.intersect(r).area().max(0.0);
|
||||
pages.iter().enumerate().skip(1).filter(|(_, p)| inside(p) > inside(&page_rect)).max_by(|a, b| inside(a.1).total_cmp(&inside(b.1)))
|
||||
});
|
||||
let shift = match on.and_then(|(i, page)| Some((boards?.get(i)?, page))) {
|
||||
Some((board, page)) => Affine::translate(board.rect.origin() - page.origin()),
|
||||
None => shift,
|
||||
};
|
||||
for n in object.iter_mut() {
|
||||
Arc::make_mut(n).transform(shift, false);
|
||||
}
|
||||
@@ -935,7 +985,11 @@ fn build(data: &[u8], visible: Option<&Document>, page: Page, outlined: bool) ->
|
||||
if unseen > 0 {
|
||||
notes.push(format!("{unseen} {UNSEEN_TEXT}"));
|
||||
}
|
||||
notes.extend(compare(&frame, &mut view, &doc)?);
|
||||
match compare(&frame, &mut view, &doc) {
|
||||
Ok(note) => notes.extend(note),
|
||||
Err(why) if trust => notes.push(format!("{why}: they come from its editing data all the same")),
|
||||
Err(why) => return Err(why),
|
||||
}
|
||||
}
|
||||
None => unmark(&mut doc.layers, true, &mut vec![]),
|
||||
}
|
||||
@@ -1062,7 +1116,7 @@ fn compare(frame: &Frame<'_>, view: &mut PageView, layered: &Document) -> Result
|
||||
/// has one that can't be used, `visible` with a warning that says why.
|
||||
pub(super) fn layered(ps: &[u8], visible: Imported) -> Imported {
|
||||
let Some(data) = editing_copy(ps) else { return visible };
|
||||
let result = data.and_then(|data| build(&data, Some(&visible.document), Page::Art, false));
|
||||
let result = data.and_then(|data| build(&data, Some(&visible.document), Page::Art, false, false));
|
||||
match result {
|
||||
Ok((document, mut warnings)) => {
|
||||
for w in &visible.warnings {
|
||||
@@ -1088,12 +1142,13 @@ pub(super) fn layered(ps: &[u8], visible: Imported) -> Imported {
|
||||
/// The flag returned is `true` when the document is the one read from the layers; else the document
|
||||
/// is `visible`, its pages moved by `place_pages` when the layers can't be read. `outlined`: the
|
||||
/// PDF part's type was read as outlines (a file whose type shows then comes in as its PDF part).
|
||||
pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>, outlined: bool) -> (Document, Vec<String>, bool) {
|
||||
/// `trust`: layers that draw differently from the PDF part are used all the same, with a warning.
|
||||
pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>, outlined: bool, trust: bool) -> (Document, Vec<String>, bool) {
|
||||
let Ok(data) = ai::decode_private(private) else { return (visible, warnings, false) };
|
||||
if !has_layers(&data) {
|
||||
return (visible, warnings, false);
|
||||
}
|
||||
match build(&data, Some(&visible), Page::Artboard, outlined) {
|
||||
match build(&data, Some(&visible), Page::Artboard, outlined, trust) {
|
||||
Ok((mut done, notes)) => {
|
||||
done.setup.bleed = visible.setup.bleed;
|
||||
let mut all = warnings;
|
||||
@@ -1110,19 +1165,25 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>, outl
|
||||
}
|
||||
}
|
||||
|
||||
/// The pages of `doc` (a `.ai` read as its PDF part: a page per artboard, in a row) moved, with
|
||||
/// the art on each, to where `boards` (the artboards of its editing data, in art space) are, so
|
||||
/// the artboards keep their layout when the layers can't be read (#1068). Left as they are unless
|
||||
/// there are as many artboards as pages, of the same sizes.
|
||||
fn place_pages(boards: &[Rect], doc: &mut Document) {
|
||||
let (Some(first), true) = (boards.first(), boards.len() == doc.artboards.len() && boards.len() > 1) else { return };
|
||||
if boards.iter().zip(&doc.artboards).any(|(b, a)| (b.width() - a.rect.width()).abs() > 0.5 || (b.height() - a.rect.height()).abs() > 0.5) {
|
||||
/// The pages of `doc` (a `.ai` read as its PDF part: a page per artboard, in a row) named as
|
||||
/// `boards` (the artboards of its editing data, in art space) name them, and moved, with the art on
|
||||
/// each, to where they are, so the artboards keep their names and layout when the layers can't be
|
||||
/// read (#1068). Left as they are unless there are as many artboards as pages, of the same sizes.
|
||||
fn place_pages(boards: &[(String, Rect)], doc: &mut Document) {
|
||||
if boards.is_empty() || boards.len() != doc.artboards.len() {
|
||||
return;
|
||||
}
|
||||
if boards.iter().zip(&doc.artboards).any(|((_, b), a)| (b.width() - a.rect.width()).abs() > 0.5 || (b.height() - a.rect.height()).abs() > 0.5) {
|
||||
return;
|
||||
}
|
||||
for ((name, _), a) in boards.iter().zip(&mut doc.artboards) {
|
||||
a.name.clone_from(name);
|
||||
}
|
||||
let [(_, first), _, ..] = boards else { return };
|
||||
// Art space (y up) → the document, the first artboard's top left at the origin, as `read` has it.
|
||||
let to_doc = Affine::new([1.0, 0.0, 0.0, -1.0, -first.x0, first.y1]);
|
||||
let pages: Vec<Rect> = doc.artboards.iter().map(|a| a.rect).collect();
|
||||
let moves: Vec<Vec2> = boards.iter().zip(&pages).map(|(b, page)| to_doc.transform_rect_bbox(*b).origin() - page.origin()).collect();
|
||||
let moves: Vec<Vec2> = boards.iter().zip(&pages).map(|((_, b), page)| to_doc.transform_rect_bbox(*b).origin() - page.origin()).collect();
|
||||
for layer in &mut doc.layers {
|
||||
place_art(Arc::make_mut(layer), &pages, &moves);
|
||||
}
|
||||
@@ -1154,7 +1215,7 @@ fn place_art(layer: &mut Node, pages: &[Rect], moves: &[Vec2]) {
|
||||
/// alone (`private`, the joined `AIPrivateData` streams) → the document and the import's notes.
|
||||
pub fn ai_alone(private: &[u8]) -> Result<(Document, Vec<String>), String> {
|
||||
let data = ai::decode_private(private)?;
|
||||
build(&data, None, Page::Artboard, false)
|
||||
build(&data, None, Page::Artboard, false, false)
|
||||
}
|
||||
|
||||
/// Does the editing data (or a program in the legacy format) have layers?
|
||||
@@ -1175,7 +1236,7 @@ fn editing_copy(ps: &[u8]) -> Option<Result<Cow<'_, [u8]>, String>> {
|
||||
/// prolog without including it) from its layers alone (`ps`: its program, which has them) → the
|
||||
/// document and the import's notes. `None` when the layers aren't in the program.
|
||||
pub(super) fn program_alone(ps: &[u8]) -> Option<Result<(Document, Vec<String>), String>> {
|
||||
(!ai::has_eps_data(ps) && has_layers(ps)).then(|| build(ps, None, Page::Art, false))
|
||||
(!ai::has_eps_data(ps) && has_layers(ps)).then(|| build(ps, None, Page::Art, false, false))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -113,7 +113,7 @@ fn sublayers_groups_compounds_clip_groups_and_hidden_objects_come_in() {
|
||||
square(20, 10),
|
||||
square(30, 10),
|
||||
"*U".into(),
|
||||
// A clipping group: its objects, then its clip path.
|
||||
// A clipping group: its objects, then its clip path (filled, so it paints too).
|
||||
"q".into(),
|
||||
square(40, 10),
|
||||
"50 10 m 60 10 L 60 20 L 50 20 L h W f".into(),
|
||||
@@ -128,11 +128,12 @@ fn sublayers_groups_compounds_clip_groups_and_hidden_objects_come_in() {
|
||||
.join("\n");
|
||||
let sub = layer("Inner", true, &square(80, 10));
|
||||
let art = native(&layer("Outer", true, &format!("{body}\n{sub}")));
|
||||
// The page has what shows: the clip group's square is outside its clip path.
|
||||
let page = [10, 20, 30, 80].map(|x| page_square(x, 10)).join("\n");
|
||||
// The page has what shows: the clip group's square is outside its clip path, which paints.
|
||||
let clip_fill = "0 0 1 0 setcmykcolor 50 10 moveto 60 10 lineto 60 20 lineto 50 20 lineto closepath fill";
|
||||
let page = [10, 20, 30, 80].map(|x| page_square(x, 10)).join("\n") + "\n" + clip_fill;
|
||||
let r = import(&eps(&page, &art)).unwrap();
|
||||
let outer = &r.document.layers[0];
|
||||
assert_eq!(kinds(outer), ["group[1]", "compound[2]", "clip[2]", "group[1]", "other"]);
|
||||
assert_eq!(kinds(outer), ["group[1]", "compound[2]", "clip[3]", "group[1]", "other"]);
|
||||
let children = outer.children().unwrap();
|
||||
assert!(children[0].visible && !children[3].visible, "the hidden object is hidden, not left out");
|
||||
// The clip path comes first in its group.
|
||||
@@ -215,7 +216,7 @@ fn pdf_part(x: f64, y: f64) -> Document {
|
||||
fn an_ai_file_gets_its_layers_and_the_art_outside_its_artboard() {
|
||||
// A square on the artboard, and one above it (the artboard is y 0 to 100 here).
|
||||
let art = native(&(layer("Jig", true, &square(10, 10)) + &layer("Parts", true, &square(30, 110)) + &layer("Old", false, &square(60, 60))));
|
||||
let (d, warnings, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec!["a note".into()], false);
|
||||
let (d, warnings, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec!["a note".into()], false, false);
|
||||
assert_eq!(names(&d), ["Jig", "Parts", "Old"], "{warnings:?}");
|
||||
assert!(read);
|
||||
assert_eq!(warnings, ["a note"], "nothing else to say");
|
||||
@@ -228,24 +229,24 @@ fn an_ai_file_gets_its_layers_and_the_art_outside_its_artboard() {
|
||||
fn an_ai_file_that_cant_be_read_through_its_editing_data_keeps_its_pdf_part() {
|
||||
let pdf = pdf_part(10.0, 86.0);
|
||||
// No editing data this reads.
|
||||
let (d, w, read) = crate::layered_ai(b"nothing", pdf.clone(), vec![], false);
|
||||
let (d, w, read) = crate::layered_ai(b"nothing", pdf.clone(), vec![], false, false);
|
||||
assert_eq!(d.layers.len(), pdf.layers.len());
|
||||
assert!(w.is_empty() && !read);
|
||||
// Editing data that isn't for this page.
|
||||
let other = native(&layer("Jig", true, &square(10, 10))).replace("%AI3_Cropmarks: 0 0 100 100", "%AI3_Cropmarks: 0 0 50 50");
|
||||
let (d, w, _) = crate::layered_ai(&private(&other), pdf.clone(), vec![], false);
|
||||
let (d, w, _) = crate::layered_ai(&private(&other), pdf.clone(), vec![], false, false);
|
||||
assert_eq!(names(&d), names(&pdf));
|
||||
assert!(w.iter().any(|w| w.contains("different page")), "{w:?}");
|
||||
}
|
||||
|
||||
/// A `.ai` whose layers can't be read comes in as its PDF part, a page per artboard in a row; its
|
||||
/// pages still go where its editing data puts its artboards, with their art (#1068).
|
||||
/// pages still go where its editing data puts its artboards, with their art, and keep their names (#1068).
|
||||
#[test]
|
||||
fn an_ai_file_read_as_its_pdf_part_keeps_its_artboard_layout() {
|
||||
// Two 100-point artboards, the second below the first (art space, y up).
|
||||
let board = |x0: i32, y0: i32| {
|
||||
format!(
|
||||
"%_/Dictionary :\n%_{x0} {} /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_{} {y0} /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n",
|
||||
"%_/Dictionary :\n%_{x0} {} /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_{} {y0} /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_(at {x0} {y0}) /UnicodeString (Name) ,\n%_; ,\n",
|
||||
y0 + 100,
|
||||
x0 + 100
|
||||
)
|
||||
@@ -267,17 +268,20 @@ fn an_ai_file_read_as_its_pdf_part_keeps_its_artboard_layout() {
|
||||
(page.id, page.rect) = (2, vectorcraft_geom::Rect::new(120.0, 0.0, 220.0, 100.0));
|
||||
pdf.artboards.push(page);
|
||||
let squares = |d: &Document| d.layers[0].children().unwrap().iter().map(|n| n.visual_bounds().unwrap().origin()).collect::<Vec<_>>();
|
||||
let (d, w, read) = crate::layered_ai(&private(&unread(&[(0, 0), (0, -150)])), pdf.clone(), vec![], false);
|
||||
let (d, w, read) = crate::layered_ai(&private(&unread(&[(0, 0), (0, -150)])), pdf.clone(), vec![], false, false);
|
||||
assert!(!read && w.iter().any(|w| w.contains("layers weren't read")), "{w:?}");
|
||||
let rects: Vec<_> = d.artboards.iter().map(|a| a.rect).collect();
|
||||
assert_eq!(rects, [vectorcraft_geom::Rect::new(0.0, 0.0, 100.0, 100.0), vectorcraft_geom::Rect::new(0.0, 150.0, 100.0, 250.0)]);
|
||||
let board_names: Vec<_> = d.artboards.iter().map(|a| a.name.as_str()).collect();
|
||||
assert_eq!(board_names, ["at 0 0", "at 0 -150"]);
|
||||
let moved = squares(&d);
|
||||
assert!((moved[0] - squares(&pdf)[0]).hypot() < 1e-6, "the first page's art stays: {moved:?}");
|
||||
assert!((moved[1] - vectorcraft_geom::Point::new(10.0, 160.0)).hypot() < 1e-6, "the second's goes with its page: {moved:?}");
|
||||
// Artboards that don't match the pages leave them in a row.
|
||||
for list in [&[(0, 0)][..], &[(0, 0), (0, -150), (200, 0)]] {
|
||||
let (d, _, _) = crate::layered_ai(&private(&unread(list)), pdf.clone(), vec![], false);
|
||||
let (d, _, _) = crate::layered_ai(&private(&unread(list)), pdf.clone(), vec![], false, false);
|
||||
assert_eq!(d.artboards[1].rect, pdf.artboards[1].rect, "{list:?}");
|
||||
assert_eq!(d.artboards[1].name, pdf.artboards[1].name, "{list:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,6 +495,20 @@ fn type_of_the_page_that_no_story_places_stays_with_the_nearest_text_object() {
|
||||
assert!(all.contains(&"Extra".to_string()), "{all:?} {:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_the_page_draws_of_a_text_object_on_a_hidden_layer_stays_hidden() {
|
||||
// A file's page may draw what its layers have hidden since: story 1's type is on the page at
|
||||
// (58.5, 30), and its text object is on a layer that doesn't show.
|
||||
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Extra\r", 0, (8200.0, 8211.5), (0.0, 0.0), 0.0)]);
|
||||
let art = native_with_text(&(layer("Art", true, &text_object(0)) + &layer("Spare", false, &text_object(1))), &doc);
|
||||
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show 58.5 30 moveto (Extra) show";
|
||||
let r = import(&eps(page, &art)).unwrap();
|
||||
assert_eq!(names(&r.document), ["Art", "Spare"], "{:?}", r.warnings);
|
||||
let texts = |l: usize| text_of(&r.document.layers[l]).iter().map(|t| t.plain_text()).collect::<Vec<_>>();
|
||||
assert_eq!((texts(0), texts(1)), (vec!["Hi there".to_string()], vec!["Extra".to_string()]), "{:?}", r.warnings);
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("with another's")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_commented_copy_of_a_story_that_has_a_plain_text_object_is_not_another_object() {
|
||||
// The file writes some stories twice (plain, then as comments) and others only as comments.
|
||||
@@ -587,6 +605,37 @@ fn an_ai_file_without_its_pdf_part_reads_its_layers_and_makes_its_type() {
|
||||
assert!(crate::ai_alone(b"%AI24_ZStandard_Data nothing").is_err());
|
||||
}
|
||||
|
||||
/// A `.ai`'s PDF part has a page for each artboard, in a row: the type on a later page goes to
|
||||
/// its text object where the layers have that artboard, not by the first page's place.
|
||||
#[test]
|
||||
fn type_on_a_later_artboard_of_an_ai_file_goes_to_its_text_object() {
|
||||
// Two 100-point artboards, the second 50 below the first; the type is 50 down the second
|
||||
// (art space: (58.5, -100); the canvas centre 8191.5 is the template box's centre, (50, 50)).
|
||||
let boards = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\
|
||||
%_/Dictionary :\n%_0 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_100 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\
|
||||
%_/Dictionary :\n%_0 -50 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_100 -150 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\
|
||||
%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n";
|
||||
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8341.5), (0.0, 0.0), 0.0)]);
|
||||
let art = native_with_text(&(boards.to_string() + &layer("Art", true, &(square(10, 10) + &text_object(0)))), &doc);
|
||||
// The PDF part: the second page 20 points right of the first, the type 50 down it.
|
||||
let mut pdf = pdf_part(10.0, 86.0);
|
||||
let mut page = pdf.artboards[0].clone();
|
||||
(page.id, page.rect) = (2, vectorcraft_geom::Rect::new(120.0, 0.0, 220.0, 100.0));
|
||||
pdf.artboards.push(page);
|
||||
let style = vectorcraft_doc::CharStyle { size: 14.0, ..Default::default() };
|
||||
let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(178.5, 50.0), "Hi there", style);
|
||||
let (id, layer_id) = (pdf.alloc_id(), pdf.layers[0].id);
|
||||
pdf.insert(Some(layer_id), 1, Node::new(id, NodeKind::Text(Box::new(text)))).unwrap();
|
||||
let (d, w, read) = crate::layered_ai(&private(&art), pdf, vec![], false, false);
|
||||
assert!(read, "{w:?}");
|
||||
assert_eq!(d.artboards[1].rect, vectorcraft_geom::Rect::new(0.0, 150.0, 100.0, 250.0));
|
||||
let texts = text_of(&d.layers[0]);
|
||||
assert_eq!(texts.len(), 1, "{w:?}");
|
||||
let [.., e, f] = texts[0].xf.as_coeffs();
|
||||
assert!((e - 58.5).abs() < 1e-6 && (f - 200.0).abs() < 1e-6, "{:?}", texts[0].xf);
|
||||
assert!(!w.iter().any(|w| w.contains("with another's") || w.contains("doesn't draw")), "{w:?}");
|
||||
}
|
||||
|
||||
/// A global process colour (`Xk`) or a pattern fill on a sublayer no longer sends a `.ai` to its
|
||||
/// PDF part, which has only the top-level layers (#1025).
|
||||
#[test]
|
||||
@@ -598,7 +647,7 @@ fn a_global_colour_or_a_pattern_fill_keeps_the_sublayers_of_an_ai_file() {
|
||||
let patterned = "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n50 50 m\n70 50 L\n70 70 L\n50 70 L\nf";
|
||||
let ground = layer("Ground", true, &(layer("Lawn", true, yellow) + &layer("Paving", false, patterned)));
|
||||
let art = native(&format!("{dots}{ground}"));
|
||||
let (d, warnings, _) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false);
|
||||
let (d, warnings, _) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, false);
|
||||
assert_eq!(names(&d), ["Ground"], "{warnings:?}");
|
||||
let subs: Vec<_> = d.layers[0].children().unwrap().iter().map(|l| (l.name.clone().unwrap_or_default(), kinds(l))).collect();
|
||||
assert_eq!(subs, [("Lawn".to_string(), vec!["path"]), ("Paving".to_string(), vec!["path"])]);
|
||||
@@ -614,6 +663,20 @@ fn a_global_colour_or_a_pattern_fill_keeps_the_sublayers_of_an_ai_file() {
|
||||
assert!(matches!(&fill, Some(vectorcraft_color::Paint::Pattern { pattern, .. }) if pattern == "Dots"), "{fill:?}");
|
||||
}
|
||||
|
||||
/// A `.ai` whose PDF part is older than its editing copy draws less than its layers: trusted, the
|
||||
/// layers are used anyway, and the difference is noted.
|
||||
#[test]
|
||||
fn a_trusted_editing_copy_is_used_where_it_draws_differently_from_the_pdf_part() {
|
||||
let extra = "0 0 1 0 k\n50 50 m\n70 50 L\n70 70 L\n50 70 L\nf";
|
||||
let art = native(&layer("Back", true, &(square(10, 10) + "\n" + extra)));
|
||||
let (d, w, _) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, false);
|
||||
assert_ne!(names(&d), ["Back"], "untrusted, the PDF part is used: {w:?}");
|
||||
let (d, w, _) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, true);
|
||||
assert_eq!(names(&d), ["Back"], "{w:?}");
|
||||
assert_eq!(d.layers[0].children().unwrap().len(), 2);
|
||||
assert!(w.iter().any(|w| w.contains("differs from the page's") && w.contains("all the same")), "{w:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_object_the_page_doesnt_draw_makes_the_file_come_in_as_its_page() {
|
||||
// A 20-point square that the page doesn't draw: 4% of the page, and an object all the same.
|
||||
@@ -683,12 +746,12 @@ fn type_asked_for_as_outlines_leaves_a_file_whose_type_shows_as_its_pdf_part() {
|
||||
let doc = text_document(&[("Hi\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
|
||||
// Type that shows: the PDF part's outlines are its type, which the text objects can't hold.
|
||||
let shown = native_with_text(&layer("Art", true, &(square(10, 10) + "\n" + &text_object(0))), &doc);
|
||||
let (d, w, _) = crate::layered_ai(&private(&shown), pdf_part(10.0, 86.0), vec![], true);
|
||||
let (d, w, _) = crate::layered_ai(&private(&shown), pdf_part(10.0, 86.0), vec![], true, false);
|
||||
assert_eq!(names(&d), ["Layer 1"], "{w:?}");
|
||||
assert!(w.iter().any(|w| w.contains("layers weren't read") && w.contains("outlines")), "{w:?}");
|
||||
// Type only on a hidden layer: the layers come in.
|
||||
let hidden = native_with_text(&(layer("Art", true, &square(10, 10)) + &layer("Notes", false, &text_object(0))), &doc);
|
||||
let (d, w, _) = crate::layered_ai(&private(&hidden), pdf_part(10.0, 86.0), vec![], true);
|
||||
let (d, w, _) = crate::layered_ai(&private(&hidden), pdf_part(10.0, 86.0), vec![], true, false);
|
||||
assert_eq!(names(&d), ["Art", "Notes"], "{w:?}");
|
||||
}
|
||||
|
||||
|
||||
+129
-18
@@ -208,36 +208,70 @@ pub fn spiral(center: Point, radius: f64, decay_percent: f64, segments: u32, clo
|
||||
PathData::single(sp)
|
||||
}
|
||||
|
||||
/// Rectangular grid as separate open lines (plus an optional frame rectangle).
|
||||
pub fn rectangular_grid(r: Rect, h_dividers: u32, v_dividers: u32, frame: bool) -> Vec<PathData> {
|
||||
/// The largest grid divider skew, in percent either way.
|
||||
pub const GRID_SKEW_MAX: f64 = 500.0;
|
||||
|
||||
/// Where a divider at fraction `t` (0..1) of a grid's extent lands under `skew` percent: evenly
|
||||
/// spaced at 0, crowded toward 1 by a positive skew and toward 0 by a negative one (`t` raised to
|
||||
/// 1 + |skew| / 100 from the far end).
|
||||
fn skewed(t: f64, skew: f64) -> f64 {
|
||||
// NaN reads as no skew.
|
||||
let k = 1.0 + skew.abs().min(GRID_SKEW_MAX) / 100.0;
|
||||
if skew > 0.0 {
|
||||
1.0 - (1.0 - t).powf(k)
|
||||
} else if skew < 0.0 {
|
||||
t.powf(k)
|
||||
} else {
|
||||
t
|
||||
}
|
||||
}
|
||||
|
||||
/// Rectangular grid, bottom object first: the outside as one rectangle (`frame`) or as its four
|
||||
/// sides (top, bottom, left, right), then the horizontal and the vertical dividers as open lines.
|
||||
/// A positive `h_skew` crowds the horizontal dividers toward the top and a positive `v_skew` the
|
||||
/// vertical ones toward the right (percent, see [`GRID_SKEW_MAX`]).
|
||||
pub fn rectangular_grid(r: Rect, (h_dividers, h_skew): (u32, f64), (v_dividers, v_skew): (u32, f64), frame: bool) -> Vec<PathData> {
|
||||
let r = r.abs();
|
||||
let mut out = Vec::new();
|
||||
let (tl, tr, br, bl) = (Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1));
|
||||
let mut out = if frame { vec![rectangle(r)] } else { vec![line(tl, tr), line(bl, br), line(tl, bl), line(tr, br)] };
|
||||
for i in 1..=h_dividers {
|
||||
let y = r.y0 + r.height() * i as f64 / (h_dividers + 1) as f64;
|
||||
// From the top down: toward the top is toward 0.
|
||||
let y = r.y0 + r.height() * skewed(i as f64 / (h_dividers + 1) as f64, -h_skew);
|
||||
out.push(line(Point::new(r.x0, y), Point::new(r.x1, y)));
|
||||
}
|
||||
for i in 1..=v_dividers {
|
||||
let x = r.x0 + r.width() * i as f64 / (v_dividers + 1) as f64;
|
||||
let x = r.x0 + r.width() * skewed(i as f64 / (v_dividers + 1) as f64, v_skew);
|
||||
out.push(line(Point::new(x, r.y0), Point::new(x, r.y1)));
|
||||
}
|
||||
if frame {
|
||||
out.push(rectangle(r));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Polar grid: concentric ellipses plus radial dividers.
|
||||
pub fn polar_grid(r: Rect, concentric: u32, radial: u32) -> Vec<PathData> {
|
||||
/// Polar grid, bottom object first: the concentric ellipses from the outside in, then the radial
|
||||
/// dividers from the top one clockwise. A positive `c_skew` crowds the ellipses toward the outside
|
||||
/// and a positive `r_skew` the radial dividers clockwise of the top one (percent, see
|
||||
/// [`GRID_SKEW_MAX`]). With `compound`, the ellipses pair up from the outside in into rings (two
|
||||
/// subpaths, the inner one reversed so that it is a hole under either fill rule); an odd one out
|
||||
/// is the innermost ellipse alone.
|
||||
pub fn polar_grid(r: Rect, (concentric, c_skew): (u32, f64), (radial, r_skew): (u32, f64), compound: bool) -> Vec<PathData> {
|
||||
let r = r.abs();
|
||||
let c = r.center();
|
||||
let mut out = Vec::new();
|
||||
for i in 1..=concentric + 1 {
|
||||
let f = i as f64 / (concentric + 1) as f64;
|
||||
out.push(ellipse(Rect::from_center_size(c, (r.width() * f, r.height() * f))));
|
||||
let mut out: Vec<PathData> = Vec::new();
|
||||
for i in (1..=concentric + 1).rev() {
|
||||
let f = skewed(i as f64 / (concentric + 1) as f64, c_skew);
|
||||
let mut e = ellipse(Rect::from_center_size(c, (r.width() * f, r.height() * f)));
|
||||
// Every second ellipse from the outside is the hole of the one before it.
|
||||
match out.last_mut().filter(|_| compound && (concentric + 1 - i) % 2 == 1) {
|
||||
Some(ring) => {
|
||||
e.reverse();
|
||||
ring.subpaths.append(&mut e.subpaths);
|
||||
}
|
||||
None => out.push(e),
|
||||
}
|
||||
}
|
||||
// No dividers at 0 (the loop never divides by it).
|
||||
for i in 0..radial {
|
||||
let a = -PI / 2.0 + TAU * i as f64 / radial as f64;
|
||||
// The turn runs clockwise on screen: clockwise of the top is toward its start.
|
||||
let a = -PI / 2.0 + TAU * skewed(i as f64 / radial as f64, -r_skew);
|
||||
out.push(line(c, Point::new(c.x + r.width() / 2.0 * a.cos(), c.y + r.height() / 2.0 * a.sin())));
|
||||
}
|
||||
out
|
||||
@@ -401,11 +435,88 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn grids() {
|
||||
assert_eq!(rectangular_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 5, 5, true).len(), 11);
|
||||
assert_eq!(polar_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 5, 5).len(), 11);
|
||||
let r = Rect::new(0.0, 0.0, 10.0, 10.0);
|
||||
assert_eq!(rectangular_grid(r, (5, 0.0), (5, 0.0), true).len(), 11);
|
||||
assert_eq!(polar_grid(r, (5, 0.0), (5, 0.0), false).len(), 11);
|
||||
// #1008: no radial dividers is none, only the outer ellipse without concentric ones.
|
||||
for (radial, len) in [(0, 1), (1, 2), (2, 3)] {
|
||||
assert_eq!(polar_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 0, radial).len(), len, "{radial}");
|
||||
assert_eq!(polar_grid(r, (0, 0.0), (radial, 0.0), false).len(), len, "{radial}");
|
||||
}
|
||||
}
|
||||
|
||||
fn close(got: &[f64], want: &[f64]) -> bool {
|
||||
got.len() == want.len() && got.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-9)
|
||||
}
|
||||
|
||||
/// The first anchor of each of `paths`.
|
||||
fn starts(paths: &[PathData]) -> Vec<Point> {
|
||||
paths.iter().map(|p| p.subpaths[0].anchors[0].p).collect()
|
||||
}
|
||||
|
||||
/// #1377: the frame is one rectangle or its four sides, at the bottom; a skew moves the
|
||||
/// dividers toward the top / right (positive) or the bottom / left, in order and inside the
|
||||
/// grid, and none leaves them evenly spaced.
|
||||
#[test]
|
||||
fn a_rectangular_grid_frames_and_skews_its_dividers() {
|
||||
let r = Rect::new(0.0, 0.0, 100.0, 100.0);
|
||||
let framed = rectangular_grid(r, (3, 0.0), (1, 0.0), true);
|
||||
assert!(framed[0].is_closed() && framed[1..].iter().all(|p| !p.is_closed()));
|
||||
assert_eq!(starts(&framed[1..]), [Point::new(0.0, 25.0), Point::new(0.0, 50.0), Point::new(0.0, 75.0), Point::new(50.0, 0.0)]);
|
||||
let sides = rectangular_grid(r, (3, 0.0), (1, 0.0), false);
|
||||
assert_eq!(sides.len(), 8);
|
||||
assert!(sides.iter().all(|p| !p.is_closed() && p.anchor_count() == 2));
|
||||
assert_eq!(sides[4..], framed[1..]);
|
||||
for (skew, toward_start) in [(100.0, true), (-100.0, false), (GRID_SKEW_MAX, true), (-1e9, false), (f64::INFINITY, true)] {
|
||||
let g = rectangular_grid(r, (3, skew), (3, -skew), true);
|
||||
let ys: Vec<f64> = starts(&g[1..4]).iter().map(|p| p.y).collect();
|
||||
let xs: Vec<f64> = starts(&g[4..]).iter().map(|p| p.x).collect();
|
||||
for v in [&ys, &xs] {
|
||||
assert!(v.windows(2).all(|w| w[0] < w[1]) && v[0] > 0.0 && v[2] < 100.0, "{skew}: {v:?}");
|
||||
// Toward the top (y) and the left (x) is toward 0: the middle divider passes the middle.
|
||||
assert_eq!(v[1] < 50.0, toward_start, "{skew}: {v:?}");
|
||||
}
|
||||
assert_eq!(ys, xs, "one curve for both axes");
|
||||
}
|
||||
// 100 % squares the fraction from the side the dividers crowd to: 1/4, 1/2 and 3/4 from the top.
|
||||
let g = rectangular_grid(r, (3, 100.0), (0, 0.0), true);
|
||||
assert!(close(&starts(&g[1..]).iter().map(|p| p.y).collect::<Vec<_>>(), &[6.25, 25.0, 56.25]), "{g:?}");
|
||||
assert_eq!(rectangular_grid(r, (3, f64::NAN), (1, f64::NAN), true), framed);
|
||||
}
|
||||
|
||||
/// #1377: the ellipses come from the outside in and skew toward the outside (positive) or the
|
||||
/// centre; the radial dividers keep the top one and skew clockwise (positive) of it or
|
||||
/// counterclockwise; as compound paths the ellipses pair up into rings.
|
||||
#[test]
|
||||
fn a_polar_grid_skews_its_dividers_and_pairs_ellipses_into_rings() {
|
||||
let r = Rect::new(0.0, 0.0, 100.0, 100.0);
|
||||
let widths = |g: &[PathData], n: usize| g[..n].iter().map(|p| p.bounds().unwrap().width()).collect::<Vec<_>>();
|
||||
for (skew, want) in [(0.0, [100.0, 75.0, 50.0, 25.0]), (100.0, [100.0, 93.75, 75.0, 43.75]), (-100.0, [100.0, 56.25, 25.0, 6.25])] {
|
||||
let got = widths(&polar_grid(r, (3, skew), (0, 0.0), false), 4);
|
||||
assert!(close(&got, &want), "{skew}: {got:?}");
|
||||
}
|
||||
// Radial: four dividers point up, right, down and left; skewed, the first stays up and the
|
||||
// other three all point to the right (clockwise of it) or to the left.
|
||||
let ends = |skew: f64| polar_grid(r, (0, 0.0), (4, skew), false)[1..].iter().map(|p| p.subpaths[0].anchors[1].p).collect::<Vec<_>>();
|
||||
let even = ends(0.0);
|
||||
for (p, want) in even.iter().zip([(50.0, 0.0), (100.0, 50.0), (50.0, 100.0), (0.0, 50.0)]) {
|
||||
assert!(p.distance(Point::new(want.0, want.1)) < 1e-9, "{even:?}");
|
||||
}
|
||||
for (skew, right) in [(GRID_SKEW_MAX, true), (-GRID_SKEW_MAX, false)] {
|
||||
let e = ends(skew);
|
||||
assert!(e[0].distance(even[0]) < 1e-9);
|
||||
assert!(e[1..].iter().all(|p| (p.x > 50.0) == right && (p.x - 50.0).abs() > 1e-6), "{skew}: {e:?}");
|
||||
}
|
||||
// Compound: 4 ellipses are 2 rings, 5 are 2 rings and the innermost ellipse; each ring's
|
||||
// hole runs against its outside.
|
||||
for (concentric, shapes) in [(3, vec![2, 2]), (4, vec![2, 2, 1]), (0, vec![1])] {
|
||||
let g = polar_grid(r, (concentric, 0.0), (2, 0.0), true);
|
||||
assert_eq!(g.len(), shapes.len() + 2);
|
||||
assert_eq!(g[..shapes.len()].iter().map(|p| p.subpaths.len()).collect::<Vec<_>>(), shapes);
|
||||
for ring in g.iter().filter(|p| p.subpaths.len() == 2) {
|
||||
assert!(ring.is_closed());
|
||||
assert!(ring.subpaths[0].signed_area() * ring.subpaths[1].signed_area() < 0.0);
|
||||
assert!(ring.subpaths[0].signed_area().abs() > ring.subpaths[1].signed_area().abs());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+82
-19
@@ -21,13 +21,18 @@ use vectorcraft_geom::{FillRule, PathData};
|
||||
|
||||
use crate::import_color::{Colors, Native};
|
||||
use crate::import_mask::{MaskSpec, contains, is_rectangle, luminance, mask_spec, white_cover};
|
||||
use crate::import_scan::{FontNames, MAX_NESTING, Ocgs, Scan, all_on, hides_forms, scan_page, tag_key};
|
||||
use crate::import_scan::{FontNames, MAX_NESTING, Ocgs, Scan, TextOverprint, all_on, hides_forms, scan_page, tag_key};
|
||||
use crate::import_shading::{clipped, extend_clip, fold_stop_opacity, gradient_shape, mesh_shading, shading_gradient};
|
||||
use crate::import_text::{Families, LineFacts, Look, Placement, TextLine, Upright};
|
||||
use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs};
|
||||
|
||||
/// The name of the paths text imports as.
|
||||
const TEXT_OUTLINES: &str = "<Text Outlines>";
|
||||
/// A page whose overprint (ISO 32000-1 §8.6.7) is left out rather than given to the wrong objects.
|
||||
const OVERPRINT_UNREAD: &str = "the overprint of a page's objects couldn't be read: they knock out";
|
||||
/// The document overprints as overprint mode 1 does.
|
||||
const OVERPRINT_ZERO_MODE: &str =
|
||||
"overprint in the mode where a process colour's zero tints knock out (/OPM 0) is imported as overprint, where they don't";
|
||||
/// The deepest soft masks, patterns and type 3 glyphs are read within each other.
|
||||
const MAX_NESTED: u32 = 8;
|
||||
/// A tiling pattern read this many times with the same art is taken to always draw it.
|
||||
@@ -189,10 +194,12 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
||||
b.unsure = route && ocgs.list.iter().any(|g| (all_on && !g.on) || !g.print);
|
||||
b.taken = taken;
|
||||
b.warnings = notes;
|
||||
b.colors.uncalibrated_rgb = opts.uncalibrated_rgb && picked.iter().filter_map(|&n| pages.get(n)).any(crate::pages::has_private_data);
|
||||
let cache = InterpreterCache::new();
|
||||
let mut x = 0.0;
|
||||
// Every page only a placeholder (text) over private data: the file's art isn't in its PDF part.
|
||||
let mut placeholder = true;
|
||||
// Every page only text over private data: a file saved without its PDF part shows a notice
|
||||
// there, not its art. A page that states where its art is isn't that notice.
|
||||
let (mut only_text, mut art_boxes) = (true, false);
|
||||
let mut slots: Vec<Slot> = vec![];
|
||||
let mut group_art: HashMap<usize, Vec<Arc<Node>>> = HashMap::new();
|
||||
let mut nesting = Nesting::new();
|
||||
@@ -261,7 +268,8 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
||||
b.warn(PAST_PAGE_NOTE);
|
||||
}
|
||||
let children: Vec<Arc<Node>> = parts.iter().flat_map(|(_, v)| v.iter().cloned()).collect();
|
||||
placeholder &= ai && only_text(&children);
|
||||
only_text &= ai && is_only_text(&children);
|
||||
art_boxes |= crate::pages::states_art(page);
|
||||
let mut right = ab.x1;
|
||||
if opts.crop == CropTo::Bounding
|
||||
&& let Some(art) = vectorcraft_doc::live::nodes_bounds(&children)
|
||||
@@ -303,7 +311,7 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
||||
}
|
||||
}
|
||||
}
|
||||
if placeholder {
|
||||
if only_text && !art_boxes {
|
||||
return Err(PdfError::PlaceholderOnly);
|
||||
}
|
||||
// A group the pages mark but draw nothing in is an empty layer, as the editor had it.
|
||||
@@ -375,7 +383,7 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(ImportReport { document: doc, warnings, native: crate::editing::editing_in(&pdf) })
|
||||
Ok(ImportReport { document: doc, warnings, native: crate::editing::editing_in(&pdf), only_text })
|
||||
}
|
||||
|
||||
/// The characters of a CID-keyed CFF font embedded without a ToUnicode map (as macOS writes
|
||||
@@ -599,7 +607,7 @@ fn drop_page_fill(parts: &mut Vec<(Option<usize>, Vec<Arc<Node>>)>, page: Rect)
|
||||
}
|
||||
|
||||
/// Is this art text and nothing else (in groups and clips), with some text?
|
||||
fn only_text(nodes: &[Arc<Node>]) -> bool {
|
||||
fn is_only_text(nodes: &[Arc<Node>]) -> bool {
|
||||
fn walk(nodes: &[Arc<Node>], text: &mut bool) -> bool {
|
||||
nodes.iter().all(|n| match &n.kind {
|
||||
NodeKind::Path { clipping: true, .. } => true,
|
||||
@@ -666,6 +674,7 @@ struct GlyphRun {
|
||||
opacity: f32,
|
||||
stroke: Option<StrokeProps>,
|
||||
scale: f64,
|
||||
overprint: bool,
|
||||
}
|
||||
|
||||
/// How often a tiling pattern was read, and the art (as its paints) each reading drew with the
|
||||
@@ -734,6 +743,8 @@ struct Builder<'p> {
|
||||
scan: Scan,
|
||||
tag_at: usize,
|
||||
group_at: usize,
|
||||
/// The fills and strokes of paths drawn so far ([`Scan::paints`]).
|
||||
paint_at: usize,
|
||||
aligned: bool,
|
||||
/// Whether optional content groups become layers.
|
||||
route: bool,
|
||||
@@ -880,6 +891,7 @@ impl<'p> Builder<'p> {
|
||||
scan: Scan::default(),
|
||||
tag_at: 0,
|
||||
group_at: 0,
|
||||
paint_at: 0,
|
||||
aligned: true,
|
||||
route,
|
||||
unsure: false,
|
||||
@@ -930,12 +942,49 @@ impl<'p> Builder<'p> {
|
||||
self.masked.clear();
|
||||
self.marked.clear();
|
||||
self.pending = false;
|
||||
(self.scan, self.tag_at, self.group_at, self.aligned) = (scan, 0, 0, true);
|
||||
(self.scan, self.tag_at, self.group_at, self.aligned, self.paint_at) = (scan, 0, 0, true, 0);
|
||||
if self.scan.overprints && self.scan.cut {
|
||||
self.scan.overprints = false;
|
||||
self.warn(OVERPRINT_UNREAD);
|
||||
}
|
||||
if self.scan.overprints && self.scan.zero_mode {
|
||||
self.warn(OVERPRINT_ZERO_MODE);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the path the interpreter draws now (a fill or a stroke, each counted) overprints.
|
||||
fn path_overprint(&mut self) -> bool {
|
||||
if self.nested > 0 || !self.scan.overprints {
|
||||
return false;
|
||||
}
|
||||
self.paint_at += 1;
|
||||
self.scan.paints.get(self.paint_at - 1).copied().unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Whether the glyph the interpreter draws now (its fill, or its `stroke`) overprints.
|
||||
fn glyph_overprint(&mut self, stroke: bool) -> bool {
|
||||
if self.nested > 0 || !self.scan.overprints {
|
||||
return false;
|
||||
}
|
||||
match self.scan.texts.get(self.paint_at) {
|
||||
Some(TextOverprint::All { fill, stroke: s }) => *if stroke { s } else { fill },
|
||||
Some(TextOverprint::Mixed) => {
|
||||
self.warn("some text is set to overprint in a way that can't be read: it knocks out");
|
||||
false
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The page's art, split by optional content group (in the order they first paint).
|
||||
fn end_page(&mut self) -> Vec<(Option<usize>, Vec<Arc<Node>>)> {
|
||||
let children = self.close_frames();
|
||||
let mut children = self.close_frames();
|
||||
// The interpreter drew another number of paths than the file was read to paint: which
|
||||
// of them overprint can't be trusted.
|
||||
if self.scan.overprints && self.paint_at != self.scan.paints.len() {
|
||||
children.iter_mut().for_each(vectorcraft_doc::overprint::clear_overprints);
|
||||
self.warn(OVERPRINT_UNREAD);
|
||||
}
|
||||
let groups = std::mem::take(&mut self.root_groups);
|
||||
let mut parts: Vec<(Option<usize>, Vec<Arc<Node>>)> = vec![];
|
||||
// Thousands of groups: find each one's part by index.
|
||||
@@ -1137,10 +1186,14 @@ impl<'p> Builder<'p> {
|
||||
match &run.stroke {
|
||||
None => {
|
||||
let mut f = FillLayer::new(run.paint);
|
||||
f.opacity = run.opacity;
|
||||
(f.opacity, f.overprint) = (run.opacity, run.overprint);
|
||||
ap.items.push(AppearanceItem::Fill(f));
|
||||
}
|
||||
Some(p) => ap.items.push(AppearanceItem::Stroke(stroke_layer(run.paint, run.opacity, p, run.scale))),
|
||||
Some(p) => {
|
||||
let mut st = stroke_layer(run.paint, run.opacity, p, run.scale);
|
||||
st.overprint = run.overprint;
|
||||
ap.items.push(AppearanceItem::Stroke(st));
|
||||
}
|
||||
}
|
||||
let id = self.id();
|
||||
let mut n = Node::path(id, PathData::from_bezpath(&run.path), ap);
|
||||
@@ -1398,6 +1451,7 @@ impl<'p> Builder<'p> {
|
||||
scale: f64,
|
||||
paint: &hayro_interpret::Paint<'_>,
|
||||
stroke: Option<&StrokeProps>,
|
||||
overprint: bool,
|
||||
) -> bool {
|
||||
if self.text_as != TextAs::Text || self.nested > 0 {
|
||||
return false;
|
||||
@@ -1489,6 +1543,8 @@ impl<'p> Builder<'p> {
|
||||
version,
|
||||
size: at.size,
|
||||
h_scale: at.h_scale,
|
||||
overprint_fill: stroke.is_none() && overprint,
|
||||
overprint_stroke: stroke.is_some() && overprint,
|
||||
fill: stroke.is_none().then_some(paint),
|
||||
stroke,
|
||||
};
|
||||
@@ -1500,7 +1556,7 @@ impl<'p> Builder<'p> {
|
||||
// A stroke over the glyph just filled (fill and stroke rendering).
|
||||
if let (Some(s), Some(line)) = (&look.stroke, &mut self.text)
|
||||
&& look.fill.is_none()
|
||||
&& line.stroke_last(key, at, s.clone())
|
||||
&& line.stroke_last(key, at, s.clone(), overprint)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -1566,6 +1622,7 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
self.flush_glyphs();
|
||||
self.flush_text();
|
||||
self.pending = false;
|
||||
let overprint = self.path_overprint();
|
||||
let mut bp = path.clone();
|
||||
bp.apply_affine(transform);
|
||||
if bp.elements().is_empty() {
|
||||
@@ -1605,7 +1662,7 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
match draw_mode {
|
||||
PathDrawMode::Fill(rule) => {
|
||||
let mut f = FillLayer::new(paint);
|
||||
f.opacity = opacity;
|
||||
(f.opacity, f.overprint) = (opacity, overprint);
|
||||
let mut n = Node::path(self.id(), pd, Appearance { items: vec![AppearanceItem::Fill(f)], ..Default::default() });
|
||||
if let NodeKind::Path { rule: r, .. } = &mut n.kind {
|
||||
*r = fill_rule(*rule);
|
||||
@@ -1615,10 +1672,10 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
self.push_node(n);
|
||||
}
|
||||
PathDrawMode::Stroke(props) => {
|
||||
let st = stroke_layer(paint, opacity, props, mean_scale(transform));
|
||||
// Fill-then-stroke of the same path (the `B` operator, or a fill and a stroke of
|
||||
// the outline as Illustrator writes an object) becomes one object: a path with a
|
||||
// fill and a live stroke.
|
||||
let mut st = stroke_layer(paint, opacity, props, mean_scale(transform));
|
||||
st.overprint = overprint;
|
||||
// Fill-then-stroke of the same path (the `B` operator, or a fill and then a stroke
|
||||
// of the same outline) becomes one object: a path with a fill and a live stroke.
|
||||
let blend = self.blend;
|
||||
let fill = self.last_fill.take();
|
||||
let last = match self.mask {
|
||||
@@ -1630,6 +1687,10 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
&& let Some((id, outline)) = fill
|
||||
&& last.id == id
|
||||
&& last.blend == blend
|
||||
// Opacity or a mask of its own came from a transparency group the fill was
|
||||
// drawn in and this stroke wasn't.
|
||||
&& last.opacity == 1.0
|
||||
&& last.extras().mask.is_none()
|
||||
&& last.appearance.stroke().is_none()
|
||||
&& same_outline(&outline, &bp)
|
||||
{
|
||||
@@ -1699,7 +1760,8 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
match glyph {
|
||||
Glyph::Outline(o) => {
|
||||
let scale = mean_scale(transform);
|
||||
if self.type_glyph(o, transform * glyph_transform, scale, paint, stroke.as_ref()) {
|
||||
let overprint = self.glyph_overprint(stroke.is_some());
|
||||
if self.type_glyph(o, transform * glyph_transform, scale, paint, stroke.as_ref(), overprint) {
|
||||
return;
|
||||
}
|
||||
self.flush_text();
|
||||
@@ -1709,12 +1771,13 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
let same = self.glyphs.as_ref().is_some_and(|r| {
|
||||
r.paint == paint
|
||||
&& r.opacity == opacity
|
||||
&& r.overprint == overprint
|
||||
&& r.stroke.as_ref().map(|s| s.line_width) == stroke.as_ref().map(|s| s.line_width)
|
||||
&& (r.scale - scale).abs() < 1e-9
|
||||
});
|
||||
if !same {
|
||||
self.flush_glyphs();
|
||||
self.glyphs = Some(GlyphRun { path: BezPath::new(), paint, opacity, stroke, scale });
|
||||
self.glyphs = Some(GlyphRun { path: BezPath::new(), paint, opacity, stroke, scale, overprint });
|
||||
}
|
||||
if let Some(r) = &mut self.glyphs {
|
||||
r.path.extend(bp.iter());
|
||||
|
||||
@@ -90,6 +90,8 @@ struct Ink {
|
||||
pub(crate) enum Space {
|
||||
Gray,
|
||||
Rgb,
|
||||
/// An `ICCBased` RGB space whose numbers are taken as they are (see `Colors::uncalibrated_rgb`).
|
||||
IccRgb,
|
||||
Cmyk,
|
||||
/// A Separation or DeviceN space (index into `Colors::spaces`).
|
||||
Inks(usize),
|
||||
@@ -119,6 +121,10 @@ pub(crate) struct Colors<'p> {
|
||||
rgb: usize,
|
||||
/// A DeviceN colour mixed several inks (imported as RGB).
|
||||
pub mixed: bool,
|
||||
/// RGB colours in an `ICCBased` space keep their numbers instead of going through the profile:
|
||||
/// on for an editor's own document (`.ai`), so its art keeps the RGB numbers the file has; off
|
||||
/// for PDFs, whose profiles stand.
|
||||
pub uncalibrated_rgb: bool,
|
||||
}
|
||||
|
||||
/// `v`'s `Debug` text, or `None` past [`DEBUG_LIMIT`] (formatting stops there).
|
||||
@@ -152,7 +158,7 @@ pub(crate) fn split_color(t: &str) -> Option<(&str, Vec<f32>)> {
|
||||
|
||||
impl<'p> Colors<'p> {
|
||||
pub fn new(pdf: &'p Pdf, taken: Vec<String>) -> Self {
|
||||
Self { pdf, taken, scanned: false, spaces: vec![], inks: vec![], cmyk: 0, rgb: 0, mixed: false }
|
||||
Self { pdf, taken, scanned: false, spaces: vec![], inks: vec![], cmyk: 0, rgb: 0, mixed: false, uncalibrated_rgb: false }
|
||||
}
|
||||
|
||||
/// A solid colour in its own model; `None`: use its RGB.
|
||||
@@ -178,7 +184,7 @@ impl<'p> Colors<'p> {
|
||||
fn count(&mut self, space: Space) {
|
||||
match space {
|
||||
Space::Cmyk => self.cmyk += 1,
|
||||
Space::Rgb | Space::Other => self.rgb += 1,
|
||||
Space::Rgb | Space::IccRgb | Space::Other => self.rgb += 1,
|
||||
Space::Gray | Space::Inks(_) => {}
|
||||
}
|
||||
}
|
||||
@@ -200,6 +206,7 @@ impl<'p> Colors<'p> {
|
||||
match (kind, n) {
|
||||
("DeviceGray" | "ICCBased", 1) => Space::Gray,
|
||||
("DeviceRgb", 3) => Space::Rgb,
|
||||
("ICCBased", 3) if self.uncalibrated_rgb => Space::IccRgb,
|
||||
("DeviceCmyk" | "ICCBased", 4) => Space::Cmyk,
|
||||
("Separation", 1) | ("DeviceN", _) => {
|
||||
self.scan();
|
||||
@@ -223,6 +230,7 @@ impl<'p> Colors<'p> {
|
||||
match space {
|
||||
Space::Gray => Some(Native::plain(Color::gray(round3(1.0 - c(0))))),
|
||||
Space::Cmyk => Some(Native::plain(Color::cmyk(c(0), c(1), c(2), c(3)))),
|
||||
Space::IccRgb => Some(Native::plain(Color::rgb(c(0), c(1), c(2)))),
|
||||
Space::Rgb | Space::Other => None,
|
||||
Space::Inks(i) => self.inks_color(i, comps),
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
//! What the interpreter doesn't tell the import, read from the file itself: the optional content
|
||||
//! groups (layers) with their states and nesting, which group each marked-content sequence belongs
|
||||
//! to, the isolate and knockout flags of transparency groups, and the names of the fonts.
|
||||
//! to, the isolate and knockout flags of transparency groups, the overprint parameters each path
|
||||
//! and each stretch of text is painted with, and the names of the fonts.
|
||||
//!
|
||||
//! hayro-interpret reports a marked-content sequence by its tag alone and a transparency group
|
||||
//! without its flags, and skips the content of groups that are off. So each page's content is
|
||||
//! walked here in the interpreter's order (page content, then form XObjects as they are drawn,
|
||||
//! skipping what it skips): the k-th `BDC`/`BMC` it reports is the k-th one found here (its tag
|
||||
//! checks that), and the k-th form transparency group is the k-th group found here. To import
|
||||
//! checks that), the k-th form transparency group is the k-th group found here, and the k-th
|
||||
//! path it fills or strokes is the k-th one painted here (the page's count checks that). To import
|
||||
//! content that is off as hidden layers, [`all_on`] gives the file an update that turns every
|
||||
//! group on.
|
||||
|
||||
@@ -219,6 +221,39 @@ pub(crate) struct Scan {
|
||||
pub groups: Vec<(bool, bool)>,
|
||||
/// The walk stopped at [`MAX_OPS`] or [`MAX_TAGS`]: the rest of the page isn't told apart.
|
||||
pub cut: bool,
|
||||
/// Each fill and each stroke of a path the interpreter draws (a fill-and-stroke operator is
|
||||
/// a fill, then a stroke; `sh` is a fill): whether it overprints.
|
||||
pub paints: Vec<bool>,
|
||||
/// How the text shown before each of [`Self::paints`] (since the one before it) overprints,
|
||||
/// and last the text after them all: one more entry than `paints`.
|
||||
pub texts: Vec<TextOverprint>,
|
||||
/// Something on the page overprints (when nothing does, `paints` and `texts` aren't used).
|
||||
pub overprints: bool,
|
||||
/// Something overprints with overprint mode 0, where a `DeviceCMYK` colour's zero components
|
||||
/// knock out; the document's overprint is the nonzero one (mode 1).
|
||||
pub zero_mode: bool,
|
||||
}
|
||||
|
||||
/// How the text shown between two painted paths overprints.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub(crate) enum TextOverprint {
|
||||
/// No text is shown there.
|
||||
#[default]
|
||||
None,
|
||||
/// All of it is shown with these parameters.
|
||||
All { fill: bool, stroke: bool },
|
||||
/// Text is shown with different parameters, which can't be told apart by glyph.
|
||||
Mixed,
|
||||
}
|
||||
|
||||
/// The overprint parameters of the graphics state (ISO 32000-1 §8.6.7; `OP`, `op` and `OPM` in
|
||||
/// Table 58).
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct Overprint {
|
||||
stroke: bool,
|
||||
fill: bool,
|
||||
/// Overprint mode 1 (nonzero overprint).
|
||||
nonzero: bool,
|
||||
}
|
||||
|
||||
/// A marked-content tag as [`Scan::tags`] keeps it: its FNV-1a hash (the tags are only compared,
|
||||
@@ -255,6 +290,11 @@ struct Walker<'w, 'p> {
|
||||
out: Scan,
|
||||
/// A form XObject hidden by its own optional content was met.
|
||||
hidden_form: bool,
|
||||
/// The saved graphics states' overprint parameters, the current one last, pushed and popped
|
||||
/// as the interpreter does its states.
|
||||
states: Vec<Overprint>,
|
||||
/// The text shown since the last painted path.
|
||||
text: TextOverprint,
|
||||
}
|
||||
|
||||
impl Walker<'_, '_> {
|
||||
@@ -291,6 +331,49 @@ impl Walker<'_, '_> {
|
||||
self.begin(on);
|
||||
}
|
||||
|
||||
fn state(&self) -> Overprint {
|
||||
self.states.last().copied().unwrap_or_default()
|
||||
}
|
||||
|
||||
fn save(&mut self) {
|
||||
self.states.push(self.state());
|
||||
}
|
||||
|
||||
/// The interpreter never pops its first state.
|
||||
fn restore(&mut self) {
|
||||
if self.states.len() > 1 {
|
||||
self.states.pop();
|
||||
}
|
||||
}
|
||||
|
||||
fn overprinting(&mut self, on: bool) {
|
||||
self.out.overprints |= on;
|
||||
self.out.zero_mode |= on && !self.state().nonzero;
|
||||
}
|
||||
|
||||
/// The interpreter draws a path: a fill (or a `stroke`) of the current one.
|
||||
fn painted(&mut self, stroke: bool) {
|
||||
if !self.is_visible() {
|
||||
return;
|
||||
}
|
||||
let s = self.state();
|
||||
let on = if stroke { s.stroke } else { s.fill };
|
||||
self.overprinting(on);
|
||||
self.out.paints.push(on);
|
||||
self.out.texts.push(std::mem::take(&mut self.text));
|
||||
}
|
||||
|
||||
/// The interpreter shows text.
|
||||
fn shown(&mut self) {
|
||||
if !self.is_visible() {
|
||||
return;
|
||||
}
|
||||
let s = self.state();
|
||||
self.overprinting(s.fill || s.stroke);
|
||||
let now = TextOverprint::All { fill: s.fill, stroke: s.stroke };
|
||||
self.text = if self.text == TextOverprint::None || self.text == now { now } else { TextOverprint::Mixed };
|
||||
}
|
||||
|
||||
fn note_fonts(&mut self, res: &Resources<'_>) {
|
||||
for (key, _) in res.fonts.entries() {
|
||||
if let Some(f) = res.fonts.get::<Dict<'_>>(key.as_ref())
|
||||
@@ -303,6 +386,15 @@ impl Walker<'_, '_> {
|
||||
|
||||
fn walk(&mut self, mut iter: TypedIter<'_>, res: &Resources<'_>, depth: u32) {
|
||||
self.note_fonts(res);
|
||||
// The interpreter saves the state for each content stream, and restores what the stream
|
||||
// left saved when it ends.
|
||||
let saved = self.states.len();
|
||||
self.save();
|
||||
self.walk_ops(&mut iter, res, depth);
|
||||
self.states.truncate(saved);
|
||||
}
|
||||
|
||||
fn walk_ops(&mut self, iter: &mut TypedIter<'_>, res: &Resources<'_>, depth: u32) {
|
||||
while let Some(op) = iter.next() {
|
||||
self.ops += 1;
|
||||
if self.ops > self.max_ops || self.out.tags.len() >= MAX_TAGS {
|
||||
@@ -338,6 +430,40 @@ impl Walker<'_, '_> {
|
||||
TypedInstruction::EndMarkedContent(_) => {
|
||||
self.visible.pop();
|
||||
}
|
||||
TypedInstruction::SaveState(_) => self.save(),
|
||||
TypedInstruction::RestoreState(_) => self.restore(),
|
||||
TypedInstruction::SetGraphicsState(gs) => {
|
||||
if let Some(d) = res.get_ext_g_state(gs.0)
|
||||
&& let Some(s) = self.states.last_mut()
|
||||
{
|
||||
let stroke = d.get::<bool>(b"OP");
|
||||
s.stroke = stroke.unwrap_or(s.stroke);
|
||||
// Without `/op`, `/OP` sets both.
|
||||
s.fill = d.get::<bool>(b"op").or(stroke).unwrap_or(s.fill);
|
||||
s.nonzero = d.get::<u8>(b"OPM").map_or(s.nonzero, |m| m != 0);
|
||||
}
|
||||
}
|
||||
TypedInstruction::FillPathEvenOdd(_) | TypedInstruction::FillPathNonZero(_) | TypedInstruction::FillPathNonZeroCompatibility(_) => {
|
||||
self.painted(false);
|
||||
}
|
||||
TypedInstruction::StrokePath(_) | TypedInstruction::CloseAndStrokePath(_) => self.painted(true),
|
||||
TypedInstruction::FillAndStrokeEvenOdd(_)
|
||||
| TypedInstruction::FillAndStrokeNonZero(_)
|
||||
| TypedInstruction::CloseFillAndStrokeEvenOdd(_)
|
||||
| TypedInstruction::CloseFillAndStrokeNonZero(_) => {
|
||||
self.painted(false);
|
||||
self.painted(true);
|
||||
}
|
||||
// Drawn when the interpreter can read the shading: the page's count tells.
|
||||
TypedInstruction::Shading(s) => {
|
||||
if res.get_shading(s.0).is_some() {
|
||||
self.painted(false);
|
||||
}
|
||||
}
|
||||
TypedInstruction::ShowText(_)
|
||||
| TypedInstruction::ShowTexts(_)
|
||||
| TypedInstruction::NextLineAndShowText(_)
|
||||
| TypedInstruction::ShowTextWithParameters(_) => self.shown(),
|
||||
TypedInstruction::XObject(x) => {
|
||||
if !self.is_visible() || depth >= MAX_DEPTH {
|
||||
continue;
|
||||
@@ -360,7 +486,9 @@ impl Walker<'_, '_> {
|
||||
self.out.groups.push((g.get::<bool>(b"I").unwrap_or(false), g.get::<bool>(b"K").unwrap_or(false)));
|
||||
}
|
||||
let inner = Resources::from_parent(d.get::<Dict<'_>>(b"Resources").unwrap_or_default(), res.clone());
|
||||
self.save();
|
||||
self.walk(TypedIter::new(data.as_ref()), &inner, depth + 1);
|
||||
self.restore();
|
||||
}
|
||||
if oc.is_some() {
|
||||
self.visible.pop();
|
||||
@@ -399,8 +527,21 @@ pub(crate) fn scan_page(page: &Page<'_>, ocgs: &mut Ocgs, all_on: bool, fonts: &
|
||||
|
||||
fn walk_page<'w, 'p>(page: &Page<'p>, ocgs: &'w mut Ocgs, all_on: bool, fonts: &'w mut FontNames) -> Walker<'w, 'p> {
|
||||
let off = if all_on { HashSet::new() } else { ocgs.off.clone() };
|
||||
let mut w = Walker { ocgs, off, xref: page.xref(), fonts, visible: vec![], ops: 0, max_ops: max_ops(), out: Scan::default(), hidden_form: false };
|
||||
let mut w = Walker {
|
||||
ocgs,
|
||||
off,
|
||||
xref: page.xref(),
|
||||
fonts,
|
||||
visible: vec![],
|
||||
ops: 0,
|
||||
max_ops: max_ops(),
|
||||
out: Scan::default(),
|
||||
hidden_form: false,
|
||||
states: vec![Overprint::default()],
|
||||
text: TextOverprint::None,
|
||||
};
|
||||
w.walk(page.typed_operations(), page.resources(), 0);
|
||||
w.out.texts.push(w.text);
|
||||
w
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +81,9 @@ pub(crate) struct Look {
|
||||
pub h_scale: f64,
|
||||
pub fill: Option<Paint>,
|
||||
pub stroke: Option<(Paint, f64)>,
|
||||
/// The fill, the stroke overprints.
|
||||
pub overprint_fill: bool,
|
||||
pub overprint_stroke: bool,
|
||||
}
|
||||
|
||||
impl Look {
|
||||
@@ -91,7 +94,8 @@ impl Look {
|
||||
&& (self.size - other.size).abs() <= self.size * 0.01
|
||||
&& (self.h_scale - other.h_scale).abs() < 0.5
|
||||
&& self.fill == other.fill
|
||||
&& (other.stroke.is_none() || self.stroke == other.stroke)
|
||||
&& self.overprint_fill == other.overprint_fill
|
||||
&& (other.stroke.is_none() || (self.stroke == other.stroke && self.overprint_stroke == other.overprint_stroke))
|
||||
}
|
||||
|
||||
fn style(&self) -> CharStyle {
|
||||
@@ -105,6 +109,8 @@ impl Look {
|
||||
fill: self.fill.clone().unwrap_or(Paint::None),
|
||||
stroke,
|
||||
stroke_width,
|
||||
overprint_fill: self.overprint_fill,
|
||||
overprint_stroke: self.overprint_stroke,
|
||||
..CharStyle::default()
|
||||
}
|
||||
}
|
||||
@@ -301,12 +307,13 @@ impl TextLine {
|
||||
}
|
||||
|
||||
/// A stroke (`stroke`: paint and width) over the glyph just drawn at `at` in `font` (fill
|
||||
/// and stroke rendering): the run is stroked. `false`: it isn't that glyph.
|
||||
pub fn stroke_last(&mut self, font: u128, at: Placement, stroke: (Paint, f64)) -> bool {
|
||||
/// and stroke rendering), which `overprint`s or not: the run is stroked. `false`: it isn't
|
||||
/// that glyph.
|
||||
pub fn stroke_last(&mut self, font: u128, at: Placement, stroke: (Paint, f64), overprint: bool) -> bool {
|
||||
let Some((look, _)) = self.runs.last_mut().filter(|(l, _)| l.font == font && (at.origin - self.last).hypot() < self.at.size * 1e-3) else {
|
||||
return false;
|
||||
};
|
||||
look.stroke = Some(stroke);
|
||||
(look.stroke, look.overprint_stroke) = (Some(stroke), overprint);
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
+18
-1
@@ -122,11 +122,23 @@ pub struct ImportOptions {
|
||||
/// Optional content groups become layers (else each page is one layer, without the art
|
||||
/// that is off).
|
||||
pub layers: bool,
|
||||
/// The file is an editor's own document (`.ai`): RGB in its `ICCBased` spaces keeps its numbers
|
||||
/// (see `Colors::uncalibrated_rgb`). Off for PDFs, whose profiles stand.
|
||||
pub uncalibrated_rgb: bool,
|
||||
}
|
||||
|
||||
impl Default for ImportOptions {
|
||||
fn default() -> Self {
|
||||
Self { max_pages: None, artboard_gap: 36.0, pages: None, crop: CropTo::default(), password: None, text_as: TextAs::default(), layers: true }
|
||||
Self {
|
||||
max_pages: None,
|
||||
artboard_gap: 36.0,
|
||||
pages: None,
|
||||
crop: CropTo::default(),
|
||||
password: None,
|
||||
text_as: TextAs::default(),
|
||||
layers: true,
|
||||
uncalibrated_rgb: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +175,9 @@ pub struct ImportReport {
|
||||
pub warnings: Vec<String>,
|
||||
/// The native document the PDF carries (written with Preserve Editing), if any.
|
||||
pub native: Option<Editing>,
|
||||
/// Every page shows only text, over an editor's private data: that data, where it reads,
|
||||
/// is the file's art (a file saved without its PDF part shows a notice there instead).
|
||||
pub only_text: bool,
|
||||
}
|
||||
|
||||
/// Result of an export with non-fatal warnings (features approximated or dropped).
|
||||
@@ -237,6 +252,8 @@ mod tests_import_lines;
|
||||
#[cfg(test)]
|
||||
mod tests_import_options;
|
||||
#[cfg(test)]
|
||||
mod tests_import_overprint;
|
||||
#[cfg(test)]
|
||||
mod tests_layers;
|
||||
#[cfg(test)]
|
||||
mod tests_live_text;
|
||||
|
||||
+23
-6
@@ -162,10 +162,11 @@ pub fn illustrator_data(bytes: &[u8], password: Option<&str>) -> Option<Vec<u8>>
|
||||
(!data.is_empty()).then_some(data)
|
||||
}
|
||||
|
||||
/// Does the PDF's first page carry an editor's private data (a PDF-compatible `.ai`)? Without
|
||||
/// reading the data.
|
||||
/// Does the PDF's first page carry an editor's copy of the art: a PDF-compatible `.ai`
|
||||
/// (`AIPrivateData` keys) or a PDF saved with its editing data (`AIPDFPrivateData`, #1345)? Without
|
||||
/// reading the data. Either's trim boxes are its editor's artboards.
|
||||
pub fn has_illustrator_data(bytes: &[u8], password: Option<&str>) -> bool {
|
||||
open(bytes, password).is_ok_and(|pdf| pdf.pages().first().is_some_and(has_private_data))
|
||||
open(bytes, password).is_ok_and(|pdf| pdf.pages().first().is_some_and(|p| piece_info_has(p, &[b"AIPrivateData", b"AIPDFPrivateData"])))
|
||||
}
|
||||
|
||||
/// `stream` decoded, up to `most` bytes: the decoder is run here rather than by the PDF reader's
|
||||
@@ -193,8 +194,24 @@ fn inflated(stream: &Stream<'_>, most: usize) -> Option<Vec<u8>> {
|
||||
/// Does `page` carry an editor's private data (`PieceInfo` keys starting with `AIPrivateData`)?
|
||||
/// A file saved without PDF compatibility keeps its art there and shows a placeholder page.
|
||||
pub(crate) fn has_private_data(page: &Page<'_>) -> bool {
|
||||
fn walk(d: &Dict<'_>, depth: u32) -> bool {
|
||||
depth < 4 && d.keys().any(|k| k.starts_with(b"AIPrivateData") || d.get::<Dict<'_>>(k.as_ref()).is_some_and(|sub| walk(&sub, depth + 1)))
|
||||
piece_info_has(page, &[b"AIPrivateData"])
|
||||
}
|
||||
|
||||
/// Does `page` state where its art is: an art, trim or bleed box (ISO 32000-1 §14.11.2, the
|
||||
/// extent of the page's meaningful content and of the finished page)? A page written from an
|
||||
/// artboard has them; the notice a file saved without its PDF part shows instead of its art is
|
||||
/// the same page whatever the document, with no art to state the place of.
|
||||
pub(crate) fn states_art(page: &Page<'_>) -> bool {
|
||||
[&b"ArtBox"[..], b"TrimBox", b"BleedBox"].iter().any(|k| page.raw().get::<[f32; 4]>(*k).is_some())
|
||||
}
|
||||
|
||||
/// Does `page`'s `PieceInfo` hold a key starting with one of `prefixes`, up to four dictionaries in?
|
||||
fn piece_info_has(page: &Page<'_>, prefixes: &[&[u8]]) -> bool {
|
||||
fn walk(d: &Dict<'_>, prefixes: &[&[u8]], depth: u32) -> bool {
|
||||
depth < 4
|
||||
&& d.keys().any(|k| {
|
||||
prefixes.iter().any(|p| k.starts_with(p)) || d.get::<Dict<'_>>(k.as_ref()).is_some_and(|sub| walk(&sub, prefixes, depth + 1))
|
||||
})
|
||||
}
|
||||
page.raw().get::<Dict<'_>>(b"PieceInfo").is_some_and(|d| walk(&d, 0))
|
||||
page.raw().get::<Dict<'_>>(b"PieceInfo").is_some_and(|d| walk(&d, prefixes, 0))
|
||||
}
|
||||
@@ -245,3 +245,20 @@ fn spot_colours_with_an_icc_lab_alternate_keep_their_lab_values() {
|
||||
// An RGB profile stays RGB.
|
||||
assert!(near(d.swatch("Leafy").unwrap().paint.color().unwrap(), Color::rgb(0.5, 0.9, 0.1)), "{:?}", d.swatch("Leafy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_numbers_stand_in_editor_documents_only() {
|
||||
// GUARDS: an editor's document keeps its `ICCBased` RGB numbers; a PDF's profile stands.
|
||||
let f = first_extra(1);
|
||||
let icc = vectorcraft_color::cms::icc_bytes(vectorcraft_color::cms::PROPHOTO_RGB).unwrap();
|
||||
let hex: String = icc.iter().map(|b| format!("{b:02x}")).collect();
|
||||
let profile = format!("<< /N 3 /Filter /ASCIIHexDecode /Length {} >>\nstream\n{hex}>\nendstream", hex.len() + 1);
|
||||
let mut page = page_with(&format!("/CS0 [/ICCBased {f} 0 R]"), &[("CS0", "0.2 0.6 0.4")]);
|
||||
page.entries = "/PieceInfo << /Illustrator << /Private << /AIPrivateData1 7 /NumBlock 1 >> >> >>".into();
|
||||
let bytes = pdf_with(&[page], &[&profile], None);
|
||||
let color = |uncalibrated_rgb| {
|
||||
solid(&fills(&import_with_report(&bytes, &ImportOptions { uncalibrated_rgb, ..Default::default() }).unwrap().document)[0]).0
|
||||
};
|
||||
assert!(near(color(true), Color::rgb(0.2, 0.6, 0.4)), "{:?}", color(true));
|
||||
assert!(!near(color(false), Color::rgb(0.2, 0.6, 0.4)), "{:?}", color(false));
|
||||
}
|
||||
@@ -432,8 +432,8 @@ fn objects(d: &Document) -> Vec<Node> {
|
||||
|
||||
#[test]
|
||||
fn an_object_written_as_a_fill_and_a_closed_stroke_stays_one_path_with_a_live_stroke() {
|
||||
// As Illustrator writes an object: the fill of the outline left open, then the stroke of it
|
||||
// closed, each in its own transform. Two subpaths: one ending on its start, one not.
|
||||
// The fill of the outline left open, then the stroke of it closed, each in its own
|
||||
// transform. Two subpaths: one ending on its start, one not.
|
||||
let outline = "0 0 m -20 1 l -30 -12 l -30 30 l 0 0 l 5 5 m 10 5 l 10 10 l";
|
||||
let content = format!(
|
||||
"0.2 0.2 0.2 rg q 1 0 0 1 50 50 cm {outline} f Q 1 1 1 RG 1.5 w 1 J 1 j 4 M [3 2] 1 d q 1 0 0 1 50 50 cm {} S Q",
|
||||
@@ -457,6 +457,45 @@ fn an_object_written_as_a_fill_and_a_closed_stroke_stays_one_path_with_a_live_st
|
||||
assert!(p.is_closed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fill_in_a_translucent_group_doesnt_take_the_stroke_drawn_after_it() {
|
||||
// A fill at 75 % under an opaque stroke of the same outline: the fill in a transparency group
|
||||
// drawn at 75 %, the stroke after the group.
|
||||
let x = first_extra(1);
|
||||
let outline = "10 10 m 60 10 l 60 60 l 10 60 l h";
|
||||
let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency >>", &format!("1 0 0 rg {outline} f"));
|
||||
let content = format!("q /G gs /X0 Do Q 0 0 1 RG 2 w {outline} S");
|
||||
let d = open(&one_page(&content, &format!("/ExtGState << /G << /ca 0.75 /CA 0.75 >> >> /XObject << /X0 {x} 0 R >>"), &[&form]));
|
||||
let objs = objects(&d);
|
||||
let stroke = objs.iter().find(|n| n.appearance.stroke().is_some()).expect("a stroke");
|
||||
let alpha = all(&d)
|
||||
.iter()
|
||||
.filter(|n| n.appearance.stroke().is_some() || n.children().is_some_and(|c| c.iter().any(|k| k.id == stroke.id)))
|
||||
.map(|n| n.opacity)
|
||||
.product::<f32>();
|
||||
assert_eq!(alpha, 1.0, "the stroke stays opaque: {objs:#?}");
|
||||
let fill = objs.iter().find(|n| n.appearance.fill().is_some()).expect("a fill");
|
||||
assert!((fill.opacity * fill.appearance.fill().unwrap().opacity - 0.75).abs() < 1e-3, "{fill:#?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fill_in_a_soft_masked_group_doesnt_take_the_stroke_drawn_after_it() {
|
||||
// ISO 32000-1 §11.6.5.2: the soft mask in the graphics state applies to the group drawn under
|
||||
// it, not to the stroke after `Q`.
|
||||
let (m, x) = (first_extra(1), first_extra(1) + 1);
|
||||
let outline = "10 10 m 60 10 l 60 60 l 10 60 l h";
|
||||
let mask = stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /CS /DeviceRGB >>", "1 1 1 rg 0 0 35 100 re f");
|
||||
let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency >>", &format!("1 0 0 rg {outline} f"));
|
||||
let content = format!("q /GS gs /X0 Do Q 0 0 1 RG 2 w {outline} S");
|
||||
let resources = format!("/ExtGState << /GS << /SMask << /Type /Mask /S /Luminosity /G {m} 0 R >> >> >> /XObject << /X0 {x} 0 R >>");
|
||||
let d = open(&one_page(&content, &resources, &[&mask, &form]));
|
||||
let nodes = all(&d);
|
||||
let stroke = nodes.iter().find(|n| n.appearance.stroke().is_some()).expect("a stroke");
|
||||
assert!(stroke.appearance.fill().is_none() && stroke.extras().mask.is_none(), "the stroke is its own unmasked object: {stroke:#?}");
|
||||
let masked = nodes.iter().filter(|n| n.extras().mask.is_some()).collect::<Vec<_>>();
|
||||
assert!(!masked.is_empty() && masked.iter().all(|n| n.appearance.stroke().is_none()), "{masked:#?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_outlines_filled_and_stroked_stay_apart() {
|
||||
let d = open(&one_page("1 0 0 rg 10 10 m 40 10 l 40 40 l f 0 0 1 RG 10 10 m 40 10 l 40 40 l 10 40 l h S 0 g 50 50 m 60 60 l S", "", &[]));
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
//! Overprint on import (#1388): the graphics state's `/OP` (strokes), `/op` (fills, `/OP` when
|
||||
//! absent) and `/OPM` (ISO 32000-1 §8.6.7, Table 58) become the fills' and strokes' overprint.
|
||||
//! Every file here is written by hand from the specification.
|
||||
|
||||
use vectorcraft_doc::{Document, Node, NodeKind};
|
||||
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with};
|
||||
|
||||
use crate::tests_import_fidelity::stream;
|
||||
use crate::*;
|
||||
|
||||
const ON: &str = "/ExtGState << /Off << /OP false /op false /OPM 0 >> /On << /OP true /op true /OPM 1 >> /Stroke << /OP true /op false /OPM 1 >> /Both << /OP true /OPM 1 >> /Zero << /OP true >> >>";
|
||||
|
||||
fn open(content: &str, resources: &str, extra: &[&str]) -> ImportReport {
|
||||
let page = PdfPage { resources: format!("{ON} {resources}"), ..PdfPage::new(400.0, 400.0, content) };
|
||||
import_with_report(&pdf_with(&[page], extra, None), &ImportOptions::default()).unwrap()
|
||||
}
|
||||
|
||||
/// Each painted object in paint order: whether its fill and its stroke overprint (`None`: it has
|
||||
/// none), type by its first run.
|
||||
fn overprints(d: &Document) -> Vec<(Option<bool>, Option<bool>)> {
|
||||
let mut out = vec![];
|
||||
d.walk(|n: &Node| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
out.extend(t.runs.first().map(|r| (Some(r.style.overprint_fill), (!r.style.stroke.is_none()).then_some(r.style.overprint_stroke))));
|
||||
} else if !n.appearance.items.is_empty() {
|
||||
out.push((n.appearance.fill().map(|f| f.overprint), n.appearance.stroke().map(|s| s.overprint)));
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
fn about_overprint(r: &ImportReport) -> Vec<&String> {
|
||||
r.warnings.iter().filter(|w| w.contains("overprint")).collect()
|
||||
}
|
||||
|
||||
const SPOT: &str = "/ColorSpace << /CS0 [/Separation /Spot1 /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 1 0 0] /N 1 >>] >>";
|
||||
|
||||
#[test]
|
||||
fn a_spot_colour_overprinting_a_process_colour_stays_overprinting_through_export() {
|
||||
// The file of the report: a cyan square that knocks out, under a spot square that overprints.
|
||||
let r = open("/Off gs 1 0 0 0 k 50 50 200 200 re f /On gs /CS0 cs 1 scn 150 150 200 200 re f", SPOT, &[]);
|
||||
assert_eq!(overprints(&r.document), [(Some(false), None), (Some(true), None)]);
|
||||
assert!(about_overprint(&r).is_empty(), "{:?}", r.warnings);
|
||||
let spot = r.document.layers[0].children().unwrap().iter().find(|n| n.appearance.fill().is_some_and(|f| f.overprint)).unwrap();
|
||||
assert!(format!("{:?}", spot.appearance.fill().unwrap().paint).contains("Spot1"), "the spot square is the one that overprints");
|
||||
// Written again it overprints, and comes back the same.
|
||||
let out = export(&r.document, &PdfOptions::uncompressed()).unwrap();
|
||||
assert!(String::from_utf8_lossy(&out).contains("/OP true/op true/OPM 1"));
|
||||
let again = import_with_report(&out, &ImportOptions::default()).unwrap();
|
||||
assert_eq!(overprints(&again.document).iter().filter(|o| o.0 == Some(true)).count(), 1);
|
||||
assert_eq!(overprints(&again.document).iter().filter(|o| o.0 == Some(false)).count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn op_sets_strokes_and_fills_unless_the_state_has_its_own_fill_entry() {
|
||||
// `/OP` alone is both; with `/op false` only strokes; `B` is one object with both paints.
|
||||
let r = open("1 0 0 RG 0 0 1 rg /Both gs 10 10 50 50 re B /Stroke gs 100 10 50 50 re B 200 10 50 50 re f 200 100 50 50 re S", "", &[]);
|
||||
assert_eq!(overprints(&r.document), [(Some(true), Some(true)), (Some(false), Some(true)), (Some(false), None), (None, Some(true))]);
|
||||
assert!(about_overprint(&r).is_empty(), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_states_and_forms_keep_their_own_overprint() {
|
||||
let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 400 400]", "200 10 50 50 re f /Off gs 200 100 50 50 re f");
|
||||
let x = first_extra(1);
|
||||
let r = open(
|
||||
"0 0 1 rg q /On gs 10 10 50 50 re f Q 100 10 50 50 re f /On gs /X0 Do 300 10 50 50 re f",
|
||||
&format!("/XObject << /X0 {x} 0 R >>"),
|
||||
&[&form],
|
||||
);
|
||||
// In `q … Q`; after it; in the form (the page's state, then its own); after the form.
|
||||
assert_eq!(overprints(&r.document).iter().map(|o| o.0).collect::<Vec<_>>(), [true, false, true, false, true].map(Some));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overprint_mode_0_is_reported() {
|
||||
// No `/OPM`: mode 0, where a DeviceCMYK colour's zero components knock out.
|
||||
let r = open("/Zero gs 0 0 0 1 k 10 10 50 50 re f", "", &[]);
|
||||
assert_eq!(overprints(&r.document), [(Some(true), None)]);
|
||||
assert_eq!(about_overprint(&r).len(), 1, "{:?}", r.warnings);
|
||||
assert!(about_overprint(&r)[0].contains("/OPM 0"));
|
||||
// Nothing overprints: nothing to say.
|
||||
assert!(about_overprint(&open("0 0 0 1 k 10 10 50 50 re f", "", &[])).is_empty());
|
||||
}
|
||||
|
||||
const FONT: &str = "/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >>";
|
||||
|
||||
#[test]
|
||||
fn text_overprints_as_its_graphics_state_says() {
|
||||
// Type or its outlines, as the fonts of the machine allow.
|
||||
let r = open("0 0 1 rg 10 300 50 50 re f /On gs BT /F1 36 Tf 40 200 Td (Ink) Tj ET /Off gs 100 300 50 50 re f", FONT, &[]);
|
||||
assert_eq!(overprints(&r.document).iter().map(|o| o.0).collect::<Vec<_>>(), [false, true, false].map(Some), "{:?}", r.warnings);
|
||||
assert!(about_overprint(&r).is_empty(), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_of_two_overprint_states_between_two_paths_knocks_out_with_a_warning() {
|
||||
let r = open("0 0 1 rg BT /F1 36 Tf 40 200 Td /On gs (Ink) Tj /Off gs 0 -50 Td (Paper) Tj ET", FONT, &[]);
|
||||
assert!(overprints(&r.document).iter().all(|o| o.0 == Some(false)), "{:?}", overprints(&r.document));
|
||||
assert_eq!(about_overprint(&r).len(), 1, "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_whose_paths_cant_be_counted_knocks_out_with_a_warning() {
|
||||
// A shading the interpreter can't read draws nothing: the paths after it would take the
|
||||
// overprint of the ones before them.
|
||||
let r = open("0 0 1 rg /Sh0 sh /On gs 10 10 50 50 re f /Off gs 100 10 50 50 re f", "/Shading << /Sh0 << /ShadingType 99 >> >>", &[]);
|
||||
assert_eq!(overprints(&r.document), [(Some(false), None), (Some(false), None)]);
|
||||
assert_eq!(about_overprint(&r).len(), 1, "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_optional_content_and_pattern_cells_dont_shift_the_count() {
|
||||
let ocg = "<< /Type /OCG /Name (Hidden) >>";
|
||||
let cell = stream(
|
||||
"/Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >>",
|
||||
"1 0 0 rg 0 0 5 5 re f 5 5 5 5 re f",
|
||||
);
|
||||
let x = first_extra(1);
|
||||
let page = PdfPage {
|
||||
resources: format!("{ON} /Properties << /MC0 {x} 0 R >> /Pattern << /P0 {} 0 R >>", x + 1),
|
||||
..PdfPage::new(
|
||||
400.0,
|
||||
400.0,
|
||||
"/OC /MC0 BDC 0 0 1 rg 10 10 50 50 re f EMC /Pattern cs /P0 scn 100 10 50 50 re f /On gs 0 0 1 rg 200 10 50 50 re f",
|
||||
)
|
||||
};
|
||||
let catalog = format!("/OCProperties << /OCGs [{x} 0 R] /D << /OFF [{x} 0 R] >> >>");
|
||||
let bytes = vectorcraft_testkit::pdf::pdf_with_catalog(&[page], &[ocg, &cell], &catalog, None);
|
||||
for layers in [false, true] {
|
||||
let r = import_with_report(&bytes, &ImportOptions { layers, ..Default::default() }).unwrap();
|
||||
let on: Vec<_> = overprints(&r.document).into_iter().filter(|o| o.0 == Some(true)).collect();
|
||||
assert_eq!(on.len(), 1, "layers {layers}: {:?} {:?}", overprints(&r.document), r.warnings);
|
||||
assert!(about_overprint(&r).is_empty(), "layers {layers}: {:?}", r.warnings);
|
||||
}
|
||||
}
|
||||
@@ -265,6 +265,8 @@ fn a_single_upright_glyph_comes_in_as_vertical_type_and_takes_no_horizontal_glyp
|
||||
h_scale: 100.0,
|
||||
fill: Some(vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 0.0))),
|
||||
stroke: None,
|
||||
overprint_fill: false,
|
||||
overprint_stroke: false,
|
||||
};
|
||||
let at = |x: f64, y: f64| Placement { origin: Point::new(x, y), dir: Vec2::new(1.0, 0.0), size: 20.0, h_scale: 100.0, slant: 0.0 };
|
||||
let mut line = TextLine::new(at(100.0, 100.0), 1.0);
|
||||
@@ -288,6 +290,8 @@ fn letter_spaced_vertical_type_keeps_its_characters_together() {
|
||||
h_scale: 100.0,
|
||||
fill: Some(vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 0.0))),
|
||||
stroke: None,
|
||||
overprint_fill: false,
|
||||
overprint_stroke: false,
|
||||
};
|
||||
// Glyphs set down a column 1.5 em apart (tracking 500): no spaces come in between them.
|
||||
let at = |y: f64| Placement { origin: Point::new(100.0, y), dir: Vec2::new(1.0, 0.0), size: 20.0, h_scale: 100.0, slant: 0.0 };
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//! any of them regenerate the art and nothing else does.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_brush::Brush;
|
||||
@@ -27,6 +28,9 @@ pub(crate) struct Entry {
|
||||
pub(crate) struct BrushCache {
|
||||
/// The document value the library was parsed from (`None` = defaults).
|
||||
src: Option<Option<vectorcraft_brush::serde_json::Value>>,
|
||||
/// A hash of that value (0 for the defaults), which rasters drawing brushed strokes record
|
||||
/// ([`crate::fx::Inputs`]).
|
||||
key: u64,
|
||||
/// (frame stamp, document address) the library was last validated for.
|
||||
checked: (u64, usize),
|
||||
lib: HashMap<String, Arc<Brush>>,
|
||||
@@ -35,7 +39,7 @@ pub(crate) struct BrushCache {
|
||||
|
||||
impl Default for BrushCache {
|
||||
fn default() -> Self {
|
||||
Self { src: None, checked: (0, 0), lib: HashMap::new(), entries: FrameCache::new(lru::BRUSH_BUDGET, 512) }
|
||||
Self { src: None, key: 0, checked: (0, 0), lib: HashMap::new(), entries: FrameCache::new(lru::BRUSH_BUDGET, 512) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,8 +55,19 @@ impl BrushCache {
|
||||
return;
|
||||
}
|
||||
self.src = Some(cur.cloned());
|
||||
self.key = cur.map_or(0, |v| {
|
||||
let mut h = DefaultHasher::new();
|
||||
vectorcraft_brush::serde_json::to_string(v).unwrap_or_default().hash(&mut h);
|
||||
h.finish()
|
||||
});
|
||||
self.lib = vectorcraft_brush::library(doc).into_iter().map(|b| (b.name.clone(), Arc::new(b))).collect();
|
||||
}
|
||||
|
||||
/// [`Self::key`] for the brush library `doc` holds (checked once a frame, `stamp`).
|
||||
pub(crate) fn library_key(&mut self, doc: &Document, stamp: u64) -> u64 {
|
||||
self.library(doc, stamp);
|
||||
self.key
|
||||
}
|
||||
}
|
||||
|
||||
/// Cull bounds that allow for brush art reaching past the stroke (scatter, wide nibs).
|
||||
|
||||
+657
-35
@@ -18,6 +18,15 @@
|
||||
//! ([`Renderer::pixel_content`]). The result is cached per object while it and the zoom don't
|
||||
//! change.
|
||||
//!
|
||||
//! While [`crate::RenderOptions::interacting`], an object whose content only moved draws its
|
||||
//! cached shadows, glows and filtered content at its new place instead of running the effects
|
||||
//! again ([`Renderer::move_shadow`], [`Renderer::move_pixel_content`]).
|
||||
//!
|
||||
//! A cached raster is made again when something its content reads from outside the object
|
||||
//! changes ([`Inputs`]): the pattern definitions, symbols, images and brushes it uses, the
|
||||
//! document's raster resolution, color mode and substitution highlights, the knockout its object
|
||||
//! inherits, the color settings and fonts, and the options and layer settings it is drawn with.
|
||||
//!
|
||||
//! The same applies to one fill or stroke's own raster effects (around that item alone) and to
|
||||
//! type, images, symbol instances and live objects ([`Renderer::draw_object_fx`]): their art is
|
||||
//! drawn offscreen in the filter layers, reshaped first by their geometry effects. Groups and
|
||||
@@ -25,11 +34,14 @@
|
||||
//! ([`effects::evaluate_container`]: their fills and strokes painting the members, their geometry
|
||||
//! effects reshaping them), so their raster effects apply to the composite (one combined shadow).
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeKind};
|
||||
use vectorcraft_color::cms::Cms;
|
||||
use vectorcraft_doc::{AppearanceItem, Document, Effect, Node, NodeId, NodeKind};
|
||||
use vectorcraft_effects::pixel::{Channels, PixelFx};
|
||||
use vectorcraft_effects::{self as effects, GeomContext, RasterFx};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, Rect, Shape, Vec2};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathEl, Point, Rect, Shape, Size, Vec2};
|
||||
use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive};
|
||||
use vello_cpu::RenderContext;
|
||||
use vello_cpu::peniko;
|
||||
@@ -62,6 +74,7 @@ pub(crate) fn has_object_fx(n: &Node) -> bool {
|
||||
|| effects::has_crop_marks(n)
|
||||
|| effects::has_adjustment(n)
|
||||
|| effects::has_revolve(n)
|
||||
|| effects::has_solid(n)
|
||||
}
|
||||
|
||||
/// Is `n` a group or layer (whose evaluated art keeps its knockout setting)?
|
||||
@@ -126,7 +139,7 @@ fn item_effects(item: &AppearanceItem) -> &[Effect] {
|
||||
|
||||
/// Visual bounds including geometry effects, stroke outsets and shadows/glows.
|
||||
pub(crate) fn visual_bounds(n: &Node) -> Option<Rect> {
|
||||
if let Some(art) = effects::revolve_art(n) {
|
||||
if let Some(art) = effects::revolve_art(n).or_else(|| effects::solid_art(n)) {
|
||||
return cull_bounds(&art).map(|b| {
|
||||
let o = effects::outset(&n.appearance.effects, b);
|
||||
b.inflate(o, o)
|
||||
@@ -230,9 +243,24 @@ pub(crate) struct PixelEntry {
|
||||
rel: (f64, f64),
|
||||
k: f64,
|
||||
image: std::sync::Arc<vello_cpu::Pixmap>,
|
||||
made: Made,
|
||||
/// What the content read when the raster was made, with the channels the effects ran on and,
|
||||
/// under De-Interlace, the raster resolution.
|
||||
inputs: Inputs,
|
||||
/// Made for the object before it moved ([`Renderer::move_pixel_content`]), or made over
|
||||
/// rasters made before their objects moved: only a frame rendered while interacting draws
|
||||
/// it.
|
||||
moved: bool,
|
||||
pub(crate) stamp: u64,
|
||||
}
|
||||
|
||||
impl PixelEntry {
|
||||
/// The bytes it holds, for [`FX_RASTER_BUDGET`].
|
||||
pub(crate) fn bytes(&self) -> usize {
|
||||
raster_bytes(&self.image) + self.made.bytes()
|
||||
}
|
||||
}
|
||||
|
||||
/// The four linear coefficients of `view` (zoom and rotation, not the pan), as cache keys.
|
||||
fn linear_key(view: Affine) -> [u64; 4] {
|
||||
let c = view.as_coeffs();
|
||||
@@ -260,9 +288,89 @@ pub(crate) struct ShadowEntry {
|
||||
/// Pixel offset of the raster's top-left from the anchor's view position.
|
||||
rel: (f64, f64),
|
||||
image: std::sync::Arc<vello_cpu::Pixmap>,
|
||||
made: Made,
|
||||
/// What the silhouette read when the raster was made.
|
||||
inputs: Inputs,
|
||||
/// Made for the object before it moved ([`Renderer::move_shadow`]), or made over rasters
|
||||
/// made before their objects moved: only a frame rendered while interacting draws it.
|
||||
moved: bool,
|
||||
pub(crate) stamp: u64,
|
||||
}
|
||||
|
||||
impl ShadowEntry {
|
||||
/// The bytes it holds, for [`FX_RASTER_BUDGET`].
|
||||
pub(crate) fn bytes(&self) -> usize {
|
||||
raster_bytes(&self.image) + self.made.bytes()
|
||||
}
|
||||
}
|
||||
|
||||
/// What a cached shadow, glow or filtered raster was made from. While
|
||||
/// [`crate::RenderOptions::interacting`], an object whose content only moved after the raster
|
||||
/// was made draws from that raster ([`Renderer::move_shadow`], [`Renderer::move_pixel_content`]).
|
||||
pub(crate) struct Made {
|
||||
/// The content's pixels as drawn into the raster before the effects ran: premultiplied RGBA,
|
||||
/// or their alpha alone under a shadow or glow.
|
||||
pixels: Vec<u8>,
|
||||
/// The effects run on them.
|
||||
fx: Vec<RasterFx>,
|
||||
/// The size of the content's document-space bounds.
|
||||
size: Size,
|
||||
/// The color channels the Photoshop-style filters were told of (none for shadows and glows).
|
||||
channels: Option<Channels>,
|
||||
/// Feather's clip outline, moved to put the raster's anchor at the origin.
|
||||
outline: Option<(BezPath, FillRule)>,
|
||||
}
|
||||
|
||||
/// How far (document units) the size of a moved object's content and the points of its Feather
|
||||
/// outline may lie from those of the content a raster was made from.
|
||||
const MOVED_SLACK: f64 = 1e-6;
|
||||
|
||||
impl Made {
|
||||
/// The bytes it holds beyond the raster.
|
||||
fn bytes(&self) -> usize {
|
||||
self.pixels.len() + self.outline.as_ref().map_or(0, |(g, _)| crate::lru::path_bytes(g))
|
||||
}
|
||||
|
||||
/// Whether content of size `size` under the effects `fx`, told of `channels`, with Feather's
|
||||
/// `outline` (moved to put its anchor at the origin) may be this content moved: the same
|
||||
/// effects, channels and outline rule, and the size and outline within [`MOVED_SLACK`].
|
||||
fn fits(&self, fx: &[&RasterFx], size: Size, channels: Option<Channels>, outline: Option<&(BezPath, FillRule)>) -> bool {
|
||||
let near = |a: f64, b: f64| (a - b).abs() <= MOVED_SLACK;
|
||||
self.fx.len() == fx.len()
|
||||
&& self.fx.iter().zip(fx).all(|(a, b)| a == *b)
|
||||
&& near(self.size.width, size.width)
|
||||
&& near(self.size.height, size.height)
|
||||
&& self.channels == channels
|
||||
&& match (&self.outline, outline) {
|
||||
(None, None) => true,
|
||||
(Some((a, ra)), Some((b, rb))) => ra == rb && same_path(a, b),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `a` and `b` have the same elements with points within [`MOVED_SLACK`] of each other.
|
||||
fn same_path(a: &BezPath, b: &BezPath) -> bool {
|
||||
let near = |p: &Point, q: &Point| (*p - *q).hypot() <= MOVED_SLACK;
|
||||
a.elements().len() == b.elements().len()
|
||||
&& a.elements().iter().zip(b.elements()).all(|pair| match pair {
|
||||
(PathEl::MoveTo(p), PathEl::MoveTo(q)) | (PathEl::LineTo(p), PathEl::LineTo(q)) => near(p, q),
|
||||
(PathEl::QuadTo(p0, p1), PathEl::QuadTo(q0, q1)) => near(p0, q0) && near(p1, q1),
|
||||
(PathEl::CurveTo(p0, p1, p2), PathEl::CurveTo(q0, q1, q2)) => near(p0, q0) && near(p1, q1) && near(p2, q2),
|
||||
(PathEl::ClosePath, PathEl::ClosePath) => true,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Feather's clip outline of `content` when `chain` feathers it, moved to put `anchor` at the
|
||||
/// origin ([`Made::outline`]).
|
||||
fn feather_outline(chain: &[&RasterFx], content: &Content, anchor: Point) -> Option<(BezPath, FillRule)> {
|
||||
let (g, rule) = content.outline.filter(|_| chain.iter().any(|fx| matches!(fx, RasterFx::Feather { .. })))?;
|
||||
let mut g = g.clone();
|
||||
g.apply_affine(Affine::translate(-anchor.to_vec2()));
|
||||
Some((g, rule))
|
||||
}
|
||||
|
||||
/// The object a cached raster was made for: the drawn `Arc` itself when known (kept alive, so its
|
||||
/// address, the cache key, names it alone and a key match is exact), else a copy compared by value.
|
||||
pub(crate) enum Drawn {
|
||||
@@ -285,10 +393,123 @@ impl Drawn {
|
||||
Self::Copy(c) => **c == *n,
|
||||
}
|
||||
}
|
||||
|
||||
/// The id of the object drawn.
|
||||
fn id(&self) -> NodeId {
|
||||
match self {
|
||||
Self::Shared(a) => a.id,
|
||||
Self::Copy(c) => c.id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Most bytes the shadow and filtered-content rasters of one renderer hold together: the least
|
||||
/// recently drawn go beyond it.
|
||||
/// What an object's content reads from outside its own nodes, in it or in the opacity masks of the
|
||||
/// objects in it ([`Reads::of`]): which parts of [`Inputs`] can change what its rasters show.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct Reads {
|
||||
/// Pattern paints: the pattern definitions.
|
||||
patterns: bool,
|
||||
/// Symbol instances and type with inline graphics: the symbols' art. Symbol art can hold
|
||||
/// anything, so content that shows a symbol reads everything else too.
|
||||
symbols: bool,
|
||||
/// Images and placed documents: their pixels and files, and the options and the layer's Dim
|
||||
/// Images they are drawn with.
|
||||
images: bool,
|
||||
/// Brushed strokes: the brush library.
|
||||
brushes: bool,
|
||||
/// Type: the fonts, whether overprinting shows ([`crate::proof::overprints`]) and Document
|
||||
/// Setup's substitution highlights.
|
||||
text: bool,
|
||||
}
|
||||
|
||||
impl Reads {
|
||||
/// What the content of `n` reads.
|
||||
pub(crate) fn of(n: &Node) -> Self {
|
||||
let mut reads = Self::default();
|
||||
reads.add(n);
|
||||
if reads.symbols {
|
||||
return Self { patterns: true, symbols: true, images: true, brushes: true, text: true };
|
||||
}
|
||||
reads
|
||||
}
|
||||
|
||||
fn add(&mut self, n: &Node) {
|
||||
let pattern = |p: &vectorcraft_color::Paint| matches!(p, vectorcraft_color::Paint::Pattern { .. });
|
||||
n.walk(&mut |c| {
|
||||
for item in &c.appearance.items {
|
||||
self.patterns |= pattern(item.paint());
|
||||
self.brushes |= matches!(item, AppearanceItem::Stroke(s) if s.brush.is_some());
|
||||
}
|
||||
match &c.kind {
|
||||
NodeKind::SymbolInstance { .. } => self.symbols = true,
|
||||
NodeKind::Image(_) | NodeKind::PlacedDocument(_) => self.images = true,
|
||||
NodeKind::Text(t) => {
|
||||
self.text = true;
|
||||
self.symbols |= t.runs.iter().any(|r| r.inline.is_some());
|
||||
self.patterns |= t.runs.iter().any(|r| pattern(&r.style.fill) || pattern(&r.style.stroke));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(m) = c.extras().mask.as_deref() {
|
||||
self.add(&m.art);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// What a cached shadow, glow or filtered raster was drawn from besides its object, its effects
|
||||
/// and the view's zoom and rotation ([`Renderer::raster_inputs`]). A raster is drawn from only
|
||||
/// while its inputs equal the frame's.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Inputs {
|
||||
/// What the content reads.
|
||||
reads: Reads,
|
||||
/// A hash of the inputs as found when the raster was made: those every raster reads, those
|
||||
/// `reads` names, and what the effects read themselves ([`Inputs::with`]).
|
||||
key: u64,
|
||||
/// The document's resources and the color settings `key` was found with: holding them keeps
|
||||
/// the addresses they hashed for their values ([`DocInputs`]).
|
||||
_doc: Arc<DocInputs>,
|
||||
}
|
||||
|
||||
impl PartialEq for Inputs {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
(self.reads, self.key) == (other.reads, other.key)
|
||||
}
|
||||
}
|
||||
|
||||
impl Inputs {
|
||||
/// These inputs with `extra`, something the effects read themselves.
|
||||
fn with(mut self, extra: impl Hash) -> Self {
|
||||
let mut h = DefaultHasher::new();
|
||||
(self.key, extra).hash(&mut h);
|
||||
self.key = h.finish();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// What raster contents read from the document by name or key, and the color settings, as a frame
|
||||
/// finds them ([`Renderer::doc_inputs`]): hashes of the patterns' tilings
|
||||
/// ([`crate::pattern::hash_tiling`]), of the symbols' names and art and of the images' keys and
|
||||
/// pixels, and the address of the color settings. Art, pixels and previews are hashed by the
|
||||
/// addresses of their shared values, and names, keys, tiles, tile types and overlaps are hashed by
|
||||
/// value.
|
||||
pub(crate) struct DocInputs {
|
||||
patterns: u64,
|
||||
symbols: u64,
|
||||
images: u64,
|
||||
cms: usize,
|
||||
/// Weak references to the values whose addresses the hashes hold: a weak reference keeps the
|
||||
/// allocation, not the value, so no other value gets one of those addresses while these
|
||||
/// inputs are kept.
|
||||
_held: Held,
|
||||
}
|
||||
|
||||
/// The pattern and symbol art, the image pixels and the color settings, held weakly.
|
||||
type Held = (Vec<Weak<Node>>, Vec<Weak<Vec<u8>>>, Weak<Cms>);
|
||||
|
||||
/// Most bytes the shadow and filtered-content rasters of one renderer hold together, with what each
|
||||
/// was made from ([`Made`]): the least recently drawn go beyond it.
|
||||
pub(crate) const FX_RASTER_BUDGET: usize = 512 << 20;
|
||||
|
||||
fn raster_bytes(pm: &vello_cpu::Pixmap) -> usize {
|
||||
@@ -308,8 +529,10 @@ pub(crate) struct FxGeom {
|
||||
impl Renderer {
|
||||
/// Drop shadow / outer glow `fx` (shadow-cache slot `i` of `n`) from a cached raster:
|
||||
/// the silhouette is rendered once into a small crop, blurred and tinted on the CPU, and reused
|
||||
/// while the object and the zoom/rotation are unchanged (pans only move it). Returns false when
|
||||
/// the effect isn't a shadow/glow or the raster would be too large (caller falls back).
|
||||
/// while the object, the zoom/rotation and what the silhouette reads ([`Inputs`]) are unchanged
|
||||
/// (pans only move it), and while interacting after the object only moved
|
||||
/// ([`Self::move_shadow`]). Returns false when the effect isn't a shadow/glow or the raster
|
||||
/// would be too large (caller falls back).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw_shadow_cached(
|
||||
&mut self,
|
||||
@@ -330,10 +553,19 @@ impl Renderer {
|
||||
let linear = linear_key(f.view);
|
||||
let key = (n as *const Node as usize, i, f.ink);
|
||||
let stamp = self.stamp;
|
||||
let hit = self.shadows.get_mut(&key).filter(|e| e.linear == linear && e.node.is(n));
|
||||
let interacting = f.opts.interacting;
|
||||
// What the silhouette reads: as the raster kept for `n` found it, else found now.
|
||||
let reads = self.shadows.get(&key).filter(|e| e.node.is(n)).map_or_else(|| Reads::of(n), |e| e.inputs.reads);
|
||||
let inputs = self.raster_inputs(f, n, reads);
|
||||
let exact = |e: &ShadowEntry| e.linear == linear && e.node.is(n) && e.inputs == inputs && (interacting || !e.moved);
|
||||
if interacting && !self.shadows.get(&key).is_some_and(exact) {
|
||||
self.move_shadow(f, n, key, fx, reach + Vec2::new(dx, dy), owner, &inputs, paint);
|
||||
}
|
||||
let hit = self.shadows.get_mut(&key).filter(|e| exact(e));
|
||||
let (image, x, y) = match hit {
|
||||
Some(e) => {
|
||||
e.stamp = stamp;
|
||||
self.stats.moved += usize::from(e.moved);
|
||||
let p = f.view * e.anchor;
|
||||
(e.image.clone(), (p.x + e.rel.0).round(), (p.y + e.rel.1).round())
|
||||
}
|
||||
@@ -350,10 +582,16 @@ impl Renderer {
|
||||
// Silhouette (the object's painted alpha), offset by the shadow distance.
|
||||
let mut off = f.offscreen_context(w, h);
|
||||
let crop = Frame { mt: false, view: Affine::translate((-x0, -y0)) * f.view, ..*f };
|
||||
let before = self.stats.moved;
|
||||
self.inside_layer(|r| paint(r, &mut off, &shifted(&crop, dx, dy)));
|
||||
// A silhouette drawn over moved rasters makes a moved shadow.
|
||||
let moved = self.stats.moved > before;
|
||||
off.flush();
|
||||
let mut pm = vello_cpu::Pixmap::new(w, h);
|
||||
off.render(&mut pm, &mut self.resources);
|
||||
// What the raster is made from, for its object once it moves (`move_shadow`).
|
||||
let made =
|
||||
Made { pixels: pm.data().iter().map(|p| p.a).collect(), fx: vec![fx.clone()], size: reach.size(), channels: None, outline: None };
|
||||
let (wu, hu) = (w as usize, h as usize);
|
||||
let mut a: Vec<f32> = pm.data().iter().map(|p| p.a as f32).collect();
|
||||
effects::pixel::blur_plane(&mut a, wu, hu, blur / 2.0 / f.px);
|
||||
@@ -367,7 +605,8 @@ impl Renderer {
|
||||
let anchor = reach.origin();
|
||||
let p = f.view * anchor;
|
||||
let node = Drawn::of(n, owner);
|
||||
self.shadows.insert(key, ShadowEntry { node, linear, anchor, rel: (x0 - p.x, y0 - p.y), image: image.clone(), stamp });
|
||||
let rel = (x0 - p.x, y0 - p.y);
|
||||
self.shadows.insert(key, ShadowEntry { node, linear, anchor, rel, image: image.clone(), made, inputs, moved, stamp });
|
||||
self.trim_fx_rasters(FX_RASTER_BUDGET);
|
||||
(image, x0, y0)
|
||||
}
|
||||
@@ -385,6 +624,60 @@ impl Renderer {
|
||||
true
|
||||
}
|
||||
|
||||
/// While interacting, give `n` (shadow-cache key `key`) the shadow or glow raster an earlier
|
||||
/// frame made for its object (the same id, effect slot and ink, at this zoom and rotation, with
|
||||
/// the same `inputs`) when only the silhouette's place changed after that frame: made by the
|
||||
/// same effect `fx` from a silhouette whose bounds have the size of `reach` (the content's
|
||||
/// bounds moved by the shadow's offset), and whose alpha is the alpha of `n`'s silhouette drawn
|
||||
/// into a raster placed as that one was against its anchor. The raster is kept as `n`'s, marked
|
||||
/// moved, and is drawn at the nearest whole pixel.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn move_shadow(
|
||||
&mut self,
|
||||
f: &Frame,
|
||||
n: &Node,
|
||||
key: (usize, usize, Ink),
|
||||
fx: &RasterFx,
|
||||
reach: Rect,
|
||||
owner: Option<&Arc<Node>>,
|
||||
inputs: &Inputs,
|
||||
paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame),
|
||||
) {
|
||||
let (dx, dy) = match fx {
|
||||
RasterFx::DropShadow { dx, dy, .. } => (*dx, *dy),
|
||||
_ => (0.0, 0.0),
|
||||
};
|
||||
let (linear, stamp, size) = (linear_key(f.view), self.stamp, reach.size());
|
||||
let fits = |k: &(usize, usize, Ink), e: &ShadowEntry| {
|
||||
(k.1, k.2) == (key.1, key.2)
|
||||
&& e.stamp < stamp
|
||||
&& e.linear == linear
|
||||
&& e.node.id() == n.id
|
||||
&& e.inputs == *inputs
|
||||
&& e.made.fits(&[fx], size, None, None)
|
||||
};
|
||||
let Some((old, rel, w, h)) = self.shadows.iter().find(|(k, e)| fits(k, e)).map(|(k, e)| (*k, e.rel, e.image.width(), e.image.height()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// The silhouette drawn where the old one lies in its raster.
|
||||
let anchor = reach.origin();
|
||||
let p = f.view * anchor;
|
||||
let mut off = f.offscreen_context(w, h);
|
||||
let crop = Frame { mt: false, view: Affine::translate((-(p.x + rel.0), -(p.y + rel.1))) * f.view, ..*f };
|
||||
self.inside_layer(|r| paint(r, &mut off, &shifted(&crop, dx, dy)));
|
||||
off.flush();
|
||||
let mut pm = vello_cpu::Pixmap::new(w, h);
|
||||
off.render(&mut pm, &mut self.resources);
|
||||
if !self.shadows.get(&old).is_some_and(|e| pm.data().iter().map(|p| p.a).eq(e.made.pixels.iter().copied())) {
|
||||
return;
|
||||
}
|
||||
if let Some(mut e) = self.shadows.remove(&old) {
|
||||
(e.node, e.anchor, e.moved) = (Drawn::of(n, owner), anchor, true);
|
||||
self.shadows.insert(key, e);
|
||||
}
|
||||
}
|
||||
|
||||
/// `draw_shape` for nodes with live effects.
|
||||
pub(crate) fn draw_shape_fx(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, bp: &BezPath, rule: FillRule) {
|
||||
// The node `draw_arc` is drawing gets its geometry from the cache: `bp` is its own path,
|
||||
@@ -494,13 +787,17 @@ impl Renderer {
|
||||
_ => None,
|
||||
};
|
||||
let container = is_container(a);
|
||||
let art = match effects::crop_marks_art(a).or_else(|| effects::revolve_art(a)).or_else(|| effects::reshape(a, symbol.as_ref())) {
|
||||
let art = match effects::crop_marks_art(a)
|
||||
.or_else(|| effects::revolve_art(a))
|
||||
.or_else(|| effects::solid_art(a))
|
||||
.or_else(|| effects::reshape(a, symbol.as_ref()))
|
||||
{
|
||||
Some(r) => r,
|
||||
None if container => effects::evaluate_container(a).unwrap_or_else(|| (**a).clone()),
|
||||
None if matches!(a.kind, NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::Path { .. } | NodeKind::Compound { .. }) => (**a).clone(),
|
||||
None => effects::outline_art(a, symbol.as_ref()).unwrap_or_else(|| Node::group(a.id, vec![])),
|
||||
};
|
||||
let art = if effects::has_revolve(a) { effects::evaluate_container(&art).unwrap_or(art) } else { art };
|
||||
let art = if effects::has_revolve(a) || effects::has_solid(a) { effects::evaluate_container(&art).unwrap_or(art) } else { art };
|
||||
let mut art = self.adjusted_art(art, effects::color_map(&a.appearance.effects).as_ref());
|
||||
// A path's geometry effects still apply when it is drawn.
|
||||
let path = matches!(art.kind, NodeKind::Path { .. } | NodeKind::Compound { .. });
|
||||
@@ -627,8 +924,9 @@ impl Renderer {
|
||||
/// Photoshop-style filters) in stack order on its pixels: rendered offscreen over all it can
|
||||
/// reach, filtered on the CPU and drawn back. The raster holds at most [`PIXEL_BUDGET`] pixels:
|
||||
/// larger content is cropped to the view when every effect only looks nearby, else rendered
|
||||
/// at a lower resolution. A whole raster at full resolution is cached while the object and the
|
||||
/// zoom stay the same (panning only moves it).
|
||||
/// at a lower resolution. A whole raster at full resolution is cached while the object, the
|
||||
/// zoom and what the content reads ([`Inputs`]) stay the same (panning only moves it), and
|
||||
/// while interacting after the content only moved ([`Self::move_pixel_content`]).
|
||||
fn pixel_content(
|
||||
&mut self,
|
||||
ctx: &mut RenderContext,
|
||||
@@ -664,8 +962,33 @@ impl Renderer {
|
||||
let linear = linear_key(f.view);
|
||||
let key = content.cache.filter(|_| fits && k >= 1.0).map(|slot| (content.node as *const Node as usize, slot, f.ink));
|
||||
let stamp = self.stamp;
|
||||
if let Some(e) = key.and_then(|key| self.pixel_fx.get_mut(&key)).filter(|e| e.linear == linear && e.node.is(content.node)) {
|
||||
let channels = match f.ink {
|
||||
Ink::Cmy => effects::pixel::Channels::CmyPlane,
|
||||
Ink::K => effects::pixel::Channels::KPlane,
|
||||
Ink::Display | Ink::RichBlack if f.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk => effects::pixel::Channels::Cmyk,
|
||||
Ink::Display | Ink::RichBlack => effects::pixel::Channels::Rgb,
|
||||
};
|
||||
// De-Interlace's field lines: the rows of the document's raster grid (1 to 2400 ppi).
|
||||
let ppi = Some(f.doc.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0);
|
||||
// What the content reads (as the raster kept for it found it, else found now), with the
|
||||
// channels the effects run on and, under De-Interlace, the resolution.
|
||||
let inputs = key.map(|key| {
|
||||
let reads = self.pixel_fx.get(&key).filter(|e| e.node.is(content.node)).map_or_else(|| Reads::of(content.node), |e| e.inputs.reads);
|
||||
let interlaced = chain.iter().any(|fx| matches!(fx, RasterFx::Pixel(PixelFx::DeInterlace { .. })));
|
||||
self.raster_inputs(f, content.node, reads).with((std::mem::discriminant(&channels), interlaced.then_some(ppi.to_bits())))
|
||||
});
|
||||
let interacting = f.opts.interacting;
|
||||
let exact = |e: &PixelEntry| e.linear == linear && e.node.is(content.node) && Some(&e.inputs) == inputs.as_ref() && (interacting || !e.moved);
|
||||
if interacting
|
||||
&& let Some(key) = key
|
||||
&& let Some(inputs) = &inputs
|
||||
&& !self.pixel_fx.get(&key).is_some_and(exact)
|
||||
{
|
||||
self.move_pixel_content(f, content, key, &chain, channels, inputs, paint);
|
||||
}
|
||||
if let Some(e) = key.and_then(|key| self.pixel_fx.get_mut(&key)).filter(|e| exact(e)) {
|
||||
e.stamp = stamp;
|
||||
self.stats.moved += usize::from(e.moved);
|
||||
let p = f.view * e.anchor;
|
||||
let (image, at, k) = (e.image.clone(), ((p.x + e.rel.0).round(), (p.y + e.rel.1).round()), e.k);
|
||||
draw_scaled(ctx, image, at, k);
|
||||
@@ -677,18 +1000,21 @@ impl Renderer {
|
||||
let px = f.px / k;
|
||||
let frame = Frame { mt: false, view, visible: to_doc.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)), px, ..*f };
|
||||
let mut off = f.offscreen_context(wu, hu);
|
||||
let before = self.stats.moved;
|
||||
self.inside_layer(|r| paint(r, &mut off, &frame));
|
||||
// Content drawn over moved rasters makes a moved raster.
|
||||
let moved = self.stats.moved > before;
|
||||
off.flush();
|
||||
let mut pm = vello_cpu::Pixmap::new(wu, hu);
|
||||
off.render(&mut pm, &mut self.resources);
|
||||
let channels = match f.ink {
|
||||
Ink::Cmy => effects::pixel::Channels::CmyPlane,
|
||||
Ink::K => effects::pixel::Channels::KPlane,
|
||||
Ink::Display | Ink::RichBlack if f.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk => effects::pixel::Channels::Cmyk,
|
||||
Ink::Display | Ink::RichBlack => effects::pixel::Channels::Rgb,
|
||||
};
|
||||
// De-Interlace's field lines: the rows of the document's raster grid (1 to 2400 ppi).
|
||||
let ppi = Some(f.doc.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0);
|
||||
// What the raster is made from, for its object once it moves (`move_pixel_content`).
|
||||
let made = key.map(|_| Made {
|
||||
pixels: pm.data_as_u8_slice().to_vec(),
|
||||
fx: chain.iter().map(|fx| (*fx).clone()).collect(),
|
||||
size: content.reach.size(),
|
||||
channels: Some(channels),
|
||||
outline: feather_outline(&chain, content, content.reach.origin()),
|
||||
});
|
||||
let (center, extent) = (content.reach.center(), 0.5 * content.reach.width().hypot(content.reach.height()));
|
||||
let space = effects::PixelSpace { to_doc, px, center, channels, line: 72.0 / ppi, extent };
|
||||
let (wz, hz) = (wu as usize, hu as usize);
|
||||
@@ -706,23 +1032,153 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
let image = std::sync::Arc::new(pm);
|
||||
if let Some(key) = key {
|
||||
if let Some(((key, made), inputs)) = key.zip(made).zip(inputs) {
|
||||
let anchor = content.reach.origin();
|
||||
let p = f.view * anchor;
|
||||
let rel = (x0 - p.x, y0 - p.y);
|
||||
let owner = content.owner.cloned().or_else(|| self.fx_owner.clone().filter(|o| std::ptr::eq(&**o, content.node)));
|
||||
let node = Drawn::of(content.node, owner.as_ref());
|
||||
self.pixel_fx.insert(key, PixelEntry { node, linear, anchor, rel, k, image: image.clone(), stamp });
|
||||
self.pixel_fx.insert(key, PixelEntry { node, linear, anchor, rel, k, image: image.clone(), made, inputs, moved, stamp });
|
||||
self.trim_fx_rasters(FX_RASTER_BUDGET);
|
||||
}
|
||||
draw_scaled(ctx, image, (x0, y0), k);
|
||||
}
|
||||
|
||||
/// While interacting, give `content` (cache key `key`) the filtered raster an earlier frame
|
||||
/// made at full size for its object (the same id, slot and ink, at this zoom and rotation, with
|
||||
/// the same `inputs`) when only the content's place changed after that frame: made by the same
|
||||
/// effects `chain`, told of the same `channels`, from content whose bounds have the same size
|
||||
/// (and the same Feather outline against its anchor), and whose pixels are the pixels of
|
||||
/// `content` drawn into a raster placed as that one was against its anchor. The raster is kept
|
||||
/// as `content`'s, marked moved, and is drawn at the nearest whole pixel. Content under
|
||||
/// De-Interlace takes none.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn move_pixel_content(
|
||||
&mut self,
|
||||
f: &Frame,
|
||||
content: &Content,
|
||||
key: (usize, usize, Ink),
|
||||
chain: &[&RasterFx],
|
||||
channels: Channels,
|
||||
inputs: &Inputs,
|
||||
paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame),
|
||||
) {
|
||||
if chain.iter().any(|fx| matches!(fx, RasterFx::Pixel(PixelFx::DeInterlace { .. }))) {
|
||||
return;
|
||||
}
|
||||
let (linear, stamp, anchor) = (linear_key(f.view), self.stamp, content.reach.origin());
|
||||
let outline = feather_outline(chain, content, anchor);
|
||||
let fits = |k: &(usize, usize, Ink), e: &PixelEntry| {
|
||||
(k.1, k.2) == (key.1, key.2)
|
||||
&& e.stamp < stamp
|
||||
&& e.linear == linear
|
||||
&& e.k >= 1.0
|
||||
&& e.node.id() == content.node.id
|
||||
&& e.inputs == *inputs
|
||||
&& e.made.fits(chain, content.reach.size(), Some(channels), outline.as_ref())
|
||||
};
|
||||
let Some((old, rel, w, h)) = self.pixel_fx.iter().find(|(k, e)| fits(k, e)).map(|(k, e)| (*k, e.rel, e.image.width(), e.image.height()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// The content drawn where the old content lies in its raster.
|
||||
let p = f.view * anchor;
|
||||
let view = Affine::translate((-(p.x + rel.0), -(p.y + rel.1))) * f.view;
|
||||
let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(w), f64::from(h)));
|
||||
let frame = Frame { mt: false, view, visible, ..*f };
|
||||
let mut off = f.offscreen_context(w, h);
|
||||
self.inside_layer(|r| paint(r, &mut off, &frame));
|
||||
off.flush();
|
||||
let mut pm = vello_cpu::Pixmap::new(w, h);
|
||||
off.render(&mut pm, &mut self.resources);
|
||||
if !self.pixel_fx.get(&old).is_some_and(|e| e.made.pixels.as_slice() == pm.data_as_u8_slice()) {
|
||||
return;
|
||||
}
|
||||
let owner = content.owner.cloned().or_else(|| self.fx_owner.clone().filter(|o| std::ptr::eq(&**o, content.node)));
|
||||
if let Some(mut e) = self.pixel_fx.remove(&old) {
|
||||
(e.node, e.anchor, e.moved) = (Drawn::of(content.node, owner.as_ref()), anchor, true);
|
||||
self.pixel_fx.insert(key, e);
|
||||
}
|
||||
}
|
||||
|
||||
/// The inputs a raster of `n`'s content, which reads `reads`, is drawn with in frame `f`: the
|
||||
/// color settings, the edge smoothing, the knockout `n` inherits and, while `n` holds the
|
||||
/// isolated group, isolation mode; the pattern definitions, symbols and brush library it
|
||||
/// reads; for images and placed documents their pixels and files, the layer's Dim Images and
|
||||
/// the options they are drawn with; for type the fonts, whether overprinting shows
|
||||
/// ([`crate::proof::overprints`]) and Document Setup's substitution highlights.
|
||||
pub(crate) fn raster_inputs(&mut self, f: &Frame, n: &Node, reads: Reads) -> Inputs {
|
||||
let doc = self.doc_inputs(f.doc);
|
||||
let mut h = DefaultHasher::new();
|
||||
(doc.cms, f.opts.anti_alias.threshold(), self.knockout).hash(&mut h);
|
||||
if let Some((isolated, path)) = self.isolation_path(f)
|
||||
&& n.id != isolated
|
||||
&& path.contains(&n.id)
|
||||
{
|
||||
isolated.hash(&mut h);
|
||||
}
|
||||
if reads.patterns {
|
||||
doc.patterns.hash(&mut h);
|
||||
}
|
||||
if reads.symbols {
|
||||
doc.symbols.hash(&mut h);
|
||||
}
|
||||
if reads.images {
|
||||
let o = f.opts;
|
||||
(doc.images, self.dim_images.map(f32::to_bits), o.smooth_images, o.image_mips, o.trace_views, o.progressive_placed).hash(&mut h);
|
||||
}
|
||||
if reads.brushes {
|
||||
self.brushes.library_key(f.doc, self.stamp).hash(&mut h);
|
||||
}
|
||||
if reads.text {
|
||||
let setup = &f.doc.setup;
|
||||
let highlights = f.opts.highlight_substitutions.then_some((setup.highlight_substituted_fonts, setup.highlight_substituted_glyphs));
|
||||
(vectorcraft_text::FontDb::global().generation(), crate::proof::overprints(f.opts), highlights).hash(&mut h);
|
||||
}
|
||||
Inputs { reads, key: h.finish(), _doc: doc }
|
||||
}
|
||||
|
||||
/// [`DocInputs`] for document `doc`: found once a frame (and again for another document drawn
|
||||
/// in it, such as a placed document's own), and kept while they stay the same.
|
||||
fn doc_inputs(&mut self, doc: &Document) -> Arc<DocInputs> {
|
||||
let at = (self.stamp, std::ptr::from_ref(doc) as usize);
|
||||
if let Some((seen, d)) = &self.doc_inputs
|
||||
&& *seen == at
|
||||
{
|
||||
return d.clone();
|
||||
}
|
||||
let cms = vectorcraft_color::cms::active();
|
||||
let hash = |each: &mut dyn FnMut(&mut DefaultHasher)| {
|
||||
let mut h = DefaultHasher::new();
|
||||
each(&mut h);
|
||||
h.finish()
|
||||
};
|
||||
let address = |a: &Arc<Node>| Arc::as_ptr(a) as usize;
|
||||
let patterns = hash(&mut |h| doc.patterns.iter().for_each(|p| crate::pattern::hash_tiling(p, h)));
|
||||
let symbols = hash(&mut |h| doc.symbols.iter().for_each(|s| (&s.name, address(&s.art)).hash(h)));
|
||||
let images = hash(&mut |h| {
|
||||
for (key, blob) in &doc.images {
|
||||
(key, Arc::as_ptr(&blob.bytes) as usize, blob.proxy.as_ref().map(|p| Arc::as_ptr(p) as usize)).hash(h);
|
||||
}
|
||||
});
|
||||
let found = (patterns, symbols, images, Arc::as_ptr(&cms) as usize);
|
||||
let d = match &self.doc_inputs {
|
||||
Some((_, d)) if (d.patterns, d.symbols, d.images, d.cms) == found => d.clone(),
|
||||
_ => {
|
||||
let art = doc.patterns.iter().flat_map(|p| &p.art).chain(doc.symbols.iter().map(|s| &s.art)).map(Arc::downgrade).collect();
|
||||
let pixels = doc.images.values().flat_map(|b| std::iter::once(&b.bytes).chain(&b.proxy)).map(Arc::downgrade).collect();
|
||||
Arc::new(DocInputs { patterns, symbols, images, cms: found.3, _held: (art, pixels, Arc::downgrade(&cms)) })
|
||||
}
|
||||
};
|
||||
self.doc_inputs = Some((at, d.clone()));
|
||||
d
|
||||
}
|
||||
|
||||
/// Drop the least recently drawn shadow and filtered-content rasters beyond `budget` bytes
|
||||
/// ([`FX_RASTER_BUDGET`]).
|
||||
fn trim_fx_rasters(&mut self, budget: usize) {
|
||||
let shadows = self.shadows.iter().map(|(k, e)| (e.stamp, false, *k, raster_bytes(&e.image)));
|
||||
let pixels = self.pixel_fx.iter().map(|(k, e)| (e.stamp, true, *k, raster_bytes(&e.image)));
|
||||
let shadows = self.shadows.iter().map(|(k, e)| (e.stamp, false, *k, e.bytes()));
|
||||
let pixels = self.pixel_fx.iter().map(|(k, e)| (e.stamp, true, *k, e.bytes()));
|
||||
let mut all: Vec<_> = shadows.chain(pixels).collect();
|
||||
let mut total: usize = all.iter().map(|e| e.3).sum();
|
||||
if total <= budget {
|
||||
@@ -891,7 +1347,7 @@ mod tests {
|
||||
use vectorcraft_doc::{Appearance, Document, Effect, Node};
|
||||
use vectorcraft_geom::{Affine, Rect, shapes};
|
||||
|
||||
use super::{Drawn, raster_bytes};
|
||||
use super::Drawn;
|
||||
use crate::{RenderOptions, Rendered, Renderer};
|
||||
|
||||
fn doc_with(r: Rect, fill: Color, effects: Vec<(&str, serde_json::Value)>) -> Document {
|
||||
@@ -976,8 +1432,8 @@ mod tests {
|
||||
// A frame of other objects makes rasters of its own; the older ones go first.
|
||||
let _ = r.render(&effects_page(), 400, 400, Affine::scale(2.0), &opts);
|
||||
let newest = r.stamp;
|
||||
let bytes = |r: &Renderer| r.shadows.values().map(|e| raster_bytes(&e.image)).sum::<usize>();
|
||||
let latest = r.shadows.values().filter(|e| e.stamp == newest).map(|e| raster_bytes(&e.image)).sum::<usize>();
|
||||
let bytes = |r: &Renderer| r.shadows.values().map(|e| e.bytes()).sum::<usize>();
|
||||
let latest = r.shadows.values().filter(|e| e.stamp == newest).map(|e| e.bytes()).sum::<usize>();
|
||||
assert!(bytes(&r) > latest);
|
||||
r.trim_fx_rasters(latest);
|
||||
assert_eq!(bytes(&r), latest);
|
||||
@@ -1202,7 +1658,163 @@ mod tests {
|
||||
assert_eq!(b.pixels, c.pixels);
|
||||
}
|
||||
|
||||
/// Reproduce #1257's moving, filtered rectangle without reusing a static frame's raster.
|
||||
/// While interacting, an object that only moved draws its drop shadow and filtered content
|
||||
/// from the rasters of an earlier frame, at the nearest whole pixel, without running the
|
||||
/// effects again (#1257). The frame after the interaction runs them again: it is what a fresh
|
||||
/// renderer draws.
|
||||
#[test]
|
||||
fn moved_objects_draw_their_effect_rasters_while_interacting() {
|
||||
let effects =
|
||||
vec![("stylize.dropShadow", json!({"x": 4, "y": 3, "blur": 2, "opacity": 80})), ("texture.craquelure", json!({"crackSpacing": 6}))];
|
||||
let base = doc_with(Rect::new(20.0, 20.0, 50.0, 46.0), Color::rgb(0.9, 0.4, 0.1), effects);
|
||||
let id = base.layers[0].children().unwrap()[0].id;
|
||||
// The object moved from where it started, as each frame of a drag moves it.
|
||||
let moved = |dx: f64, dy: f64| {
|
||||
let mut d = base.clone();
|
||||
d.node_mut(id).unwrap().transform(Affine::translate((dx, dy)), false);
|
||||
d
|
||||
};
|
||||
let (view, still) = (Affine::scale(2.0), RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() });
|
||||
let live = RenderOptions { interacting: true, ..still.clone() };
|
||||
let fresh = |d: &Document| Renderer::new().render(d, 200, 200, view, &still).pixels;
|
||||
let mut r = Renderer::new();
|
||||
assert!(r.render(&base, 200, 200, view, &still).pixels == fresh(&base));
|
||||
assert_eq!(r.stats.moved, 0);
|
||||
let (mut d, mut last) = (base.clone(), vec![]);
|
||||
for (dx, dy) in [(3.0, 0.0), (3.3, 1.2), (5.15, 2.5), (6.3, 2.2)] {
|
||||
d = moved(dx, dy);
|
||||
last = r.render(&d, 200, 200, view, &live).pixels;
|
||||
assert_eq!(r.stats.moved, 2, "the shadow and the filtered content moved by ({dx}, {dy})");
|
||||
// At the nearest whole pixel (2 a point): what a fresh renderer draws there.
|
||||
let whole = moved((dx * 2.0).round() / 2.0, (dy * 2.0).round() / 2.0);
|
||||
assert!(last == fresh(&whole), "moved by ({dx}, {dy})");
|
||||
}
|
||||
// The frame after the drag, of the same document: the effects run again where the object is.
|
||||
let after = r.render(&d, 200, 200, view, &still).pixels;
|
||||
assert_eq!(r.stats.moved, 0);
|
||||
assert!(after == fresh(&d), "the frame after the drag");
|
||||
assert!(after != last, "6.3 is not on a whole pixel");
|
||||
}
|
||||
|
||||
/// While interacting, a raster moves with its object only when the content's pixels (for a
|
||||
/// shadow, their alpha) and the effects stayed the same (#1257): a new fill color under
|
||||
/// Craquelure, other effect settings, a pattern fill that stays on the page, a resized object,
|
||||
/// art added beyond the old raster, a changed Feather outline and De-Interlace run the effects
|
||||
/// again. A shadow keeps its raster under a new fill color.
|
||||
#[test]
|
||||
fn moved_rasters_need_the_same_content_and_effects() {
|
||||
let (view, still) = (Affine::scale(2.0), RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() });
|
||||
let live = RenderOptions { interacting: true, ..still.clone() };
|
||||
// `before` drawn, then `after` while interacting: the rasters drawn moved, and whether the
|
||||
// frame is what a fresh renderer draws.
|
||||
let drag = |before: &Document, after: &Document| {
|
||||
let mut r = Renderer::new();
|
||||
let _ = r.render(before, 200, 200, view, &still);
|
||||
let frame = r.render(after, 200, 200, view, &live).pixels;
|
||||
(r.stats.moved, frame == Renderer::new().render(after, 200, 200, view, &still).pixels)
|
||||
};
|
||||
// `d`'s object moved 5, 3 (10, 6 pixels), then changed by `edit`.
|
||||
let moved = |d: &Document, patterns: bool, edit: &dyn Fn(&mut Node)| {
|
||||
let mut d = d.clone();
|
||||
let id = d.layers[0].children().unwrap()[0].id;
|
||||
let n = d.node_mut(id).unwrap();
|
||||
n.transform(Affine::translate((5.0, 3.0)), vectorcraft_doc::Scaling { patterns, ..Default::default() });
|
||||
edit(n);
|
||||
d
|
||||
};
|
||||
let cracks = || vec![("texture.craquelure", json!({"crackSpacing": 6}))];
|
||||
let base = doc_with(Rect::new(20.0, 20.0, 50.0, 46.0), Color::rgb(0.9, 0.4, 0.1), cracks());
|
||||
assert_eq!(drag(&base, &moved(&base, false, &|_| {})), (1, true), "moved");
|
||||
let recolor = |n: &mut Node| n.appearance.fill_mut().unwrap().paint = Paint::solid(Color::rgb(0.1, 0.4, 0.9));
|
||||
assert_eq!(drag(&base, &moved(&base, false, &recolor)), (0, true), "recolored");
|
||||
let spacing = |n: &mut Node| n.appearance.effects[0].params = json!({"crackSpacing": 9});
|
||||
assert_eq!(drag(&base, &moved(&base, false, &spacing)), (0, true), "other effect settings");
|
||||
let resize = |n: &mut Node| n.transform(Affine::scale_about(1.5, vectorcraft_geom::Point::new(25.0, 23.0)), false);
|
||||
assert_eq!(drag(&base, &moved(&base, false, &resize)), (0, true), "resized");
|
||||
// A square added beyond the old raster leaves the content inside it as it was.
|
||||
let beyond = |n: &mut Node| {
|
||||
if let vectorcraft_doc::NodeKind::Path { path, .. } = &mut n.kind {
|
||||
path.subpaths.extend(shapes::rectangle(Rect::new(75.0, 30.0, 80.0, 35.0)).subpaths);
|
||||
}
|
||||
};
|
||||
assert_eq!(drag(&base, &moved(&base, false, &beyond)), (0, true), "art added beyond the old raster");
|
||||
// At 72 ppi, De-Interlace works on lines 1 point tall. The object moves 3 points down, so
|
||||
// De-Interlace makes its other field again.
|
||||
let fields = doc_with(Rect::new(20.0, 20.0, 50.0, 46.0), Color::rgb(0.9, 0.4, 0.1), vec![("video.deinterlace", json!({}))]);
|
||||
assert_eq!(drag(&fields, &moved(&fields, false, &|_| {})), (0, true), "De-Interlace");
|
||||
// A pattern fill: its tiles stay on the page unless they move with the object.
|
||||
let mut tiled = base.clone();
|
||||
let red = Node::path(
|
||||
vectorcraft_doc::NodeId(900),
|
||||
shapes::rectangle(Rect::new(0.0, 0.0, 3.0, 3.0)),
|
||||
Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0),
|
||||
);
|
||||
let mut def = vectorcraft_doc::pattern::PatternDef::new("Dots", vec![std::sync::Arc::new(red)]);
|
||||
def.tile = Rect::new(0.0, 0.0, 7.0, 7.0);
|
||||
tiled.patterns.push(def);
|
||||
let id = tiled.layers[0].children().unwrap()[0].id;
|
||||
tiled.node_mut(id).unwrap().appearance.fill_mut().unwrap().paint = vectorcraft_doc::pattern::pattern_paint("Dots");
|
||||
assert_eq!(drag(&tiled, &moved(&tiled, false, &|_| {})), (0, true), "tiles that stay on the page");
|
||||
assert_eq!(drag(&tiled, &moved(&tiled, true, &|_| {})), (1, true), "tiles that move with the object");
|
||||
// Feather clips to the outline: a notch in the top edge where the tiles paint nothing
|
||||
// (x 29 to 33 and y 20 to 24 once moved) leaves the content's pixels as they were.
|
||||
let mut feathered = tiled.clone();
|
||||
feathered.node_mut(id).unwrap().appearance.effects =
|
||||
vec![Effect { id: "stylize.feather".into(), params: json!({"radius": 3}), visible: true }];
|
||||
assert_eq!(drag(&feathered, &moved(&feathered, true, &|_| {})), (1, true), "feathered");
|
||||
let notch = |n: &mut Node| {
|
||||
let mut bp = vectorcraft_geom::BezPath::new();
|
||||
bp.move_to((25.0, 23.0));
|
||||
for p in [(30.0, 23.0), (30.5, 23.6), (31.5, 23.6), (32.0, 23.0), (55.0, 23.0), (55.0, 49.0), (25.0, 49.0)] {
|
||||
bp.line_to(p);
|
||||
}
|
||||
bp.close_path();
|
||||
if let vectorcraft_doc::NodeKind::Path { path, .. } = &mut n.kind {
|
||||
*path = vectorcraft_geom::PathData::from_bezpath(&bp);
|
||||
}
|
||||
};
|
||||
assert_eq!(drag(&feathered, &moved(&feathered, true, ¬ch)), (0, true), "a notch in the feathered outline");
|
||||
// A shadow is made from the silhouette's alpha: recolored, the object takes its shadow
|
||||
// along; notched, the object makes its shadow again.
|
||||
let shadowed =
|
||||
doc_with(Rect::new(20.0, 20.0, 50.0, 46.0), Color::rgb(0.9, 0.4, 0.1), vec![("stylize.dropShadow", json!({"x": 4, "y": 3, "blur": 2}))]);
|
||||
assert_eq!(drag(&shadowed, &moved(&shadowed, false, &recolor)), (1, true), "a shadow under a new color");
|
||||
assert_eq!(drag(&shadowed, &moved(&shadowed, false, ¬ch)), (0, true), "a shadow under a notch");
|
||||
}
|
||||
|
||||
/// A raster made over moved rasters counts as moved itself (#1257): a group with a drop shadow
|
||||
/// and Gaussian Blur of its own, whose filtered member moves by parts of a pixel while the
|
||||
/// other member stays, draws after the drag what a fresh renderer draws.
|
||||
#[test]
|
||||
fn rasters_made_over_moved_rasters_count_as_moved() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let fill = |c: Color| Appearance::basic(Paint::solid(c), Paint::None, 0.0);
|
||||
let (sid, mid, gid) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
|
||||
let still = Node::path(sid, shapes::rectangle(Rect::new(60.0, 60.0, 80.0, 80.0)), fill(Color::rgb(0.1, 0.5, 0.2)));
|
||||
let mut member = Node::path(mid, shapes::rectangle(Rect::new(20.0, 20.0, 50.0, 46.0)), fill(Color::rgb(0.9, 0.4, 0.1)));
|
||||
member.appearance.effects = vec![Effect { id: "texture.craquelure".into(), params: json!({"crackSpacing": 6}), visible: true }];
|
||||
let mut group = Node::group(gid, vec![std::sync::Arc::new(still), std::sync::Arc::new(member)]);
|
||||
group.appearance.effects = vec![
|
||||
Effect { id: "stylize.dropShadow".into(), params: json!({"x": 4, "y": 3, "blur": 2}), visible: true },
|
||||
Effect { id: "blur.gaussian".into(), params: json!({"radius": 1}), visible: true },
|
||||
];
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, group).unwrap();
|
||||
let (view, opts) = (Affine::scale(2.0), RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() });
|
||||
let mut r = Renderer::new();
|
||||
let _ = r.render(&d, 200, 200, view, &opts);
|
||||
let mut moved = d.clone();
|
||||
moved.node_mut(mid).unwrap().transform(Affine::translate((3.3, 1.2)), false);
|
||||
let _ = r.render(&moved, 200, 200, view, &RenderOptions { interacting: true, ..opts.clone() });
|
||||
assert!(r.stats.moved > 0, "the member's filtered content moved");
|
||||
let after = r.render(&moved, 200, 200, view, &opts).pixels;
|
||||
assert!(after == Renderer::new().render(&moved, 200, 200, view, &opts).pixels, "the frame after the drag");
|
||||
}
|
||||
|
||||
/// Reproduce #1257's moving, filtered rectangle: the first frame renders as before a drag,
|
||||
/// and the next eight render as the canvas renders a drag's frames (`interacting`). Then the
|
||||
/// eighth position renders as the canvas renders the frame after the drag, and that frame
|
||||
/// renders once more with every raster cached.
|
||||
/// Run with `cargo test -p vectorcraft-render texture_drag_perf -- --ignored --nocapture`.
|
||||
#[test]
|
||||
#[ignore = "manual timing probe; wall-clock performance depends on machine load"]
|
||||
@@ -1222,23 +1834,33 @@ mod tests {
|
||||
];
|
||||
base.insert(Some(layer), 0, n).unwrap();
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let live = RenderOptions { interacting: true, ..opts.clone() };
|
||||
let view = Affine::scale(1.9007 * 1.5);
|
||||
let mut r = Renderer::new();
|
||||
r.threads = crate::default_threads();
|
||||
let mut ms = vec![];
|
||||
let (mut ms, mut doc) = (vec![], base.clone());
|
||||
for frame in 0..9 {
|
||||
let mut doc = base.clone();
|
||||
doc = base.clone();
|
||||
doc.node_mut(id).unwrap().transform(Affine::translate((3.0 + f64::from(frame) * 0.75, f64::from(frame) * 0.5)), false);
|
||||
let start = std::time::Instant::now();
|
||||
let image = r.render(&doc, 1800, 1500, view, &opts);
|
||||
let image = r.render(&doc, 1800, 1500, view, if frame > 0 { &live } else { &opts });
|
||||
let time = start.elapsed().as_secs_f64() * 1000.0;
|
||||
if frame > 0 {
|
||||
ms.push(time);
|
||||
}
|
||||
std::hint::black_box(image);
|
||||
}
|
||||
let mut time = |o: &RenderOptions| {
|
||||
let start = std::time::Instant::now();
|
||||
std::hint::black_box(r.render(&doc, 1800, 1500, view, o));
|
||||
start.elapsed().as_secs_f64() * 1000.0
|
||||
};
|
||||
let (after, again) = (time(&opts), time(&opts));
|
||||
ms.sort_by(f64::total_cmp);
|
||||
println!("{effect}: moved CMYK rounded rectangle at 190.07%, DPI 1.5, 1800x1500: median {:.2} ms; samples {ms:?}", (ms[3] + ms[4]) * 0.5);
|
||||
println!(
|
||||
"{effect}: moved CMYK rounded rectangle at 190.07%, DPI 1.5, 1800x1500: median {:.2} ms; samples {ms:?}; the frame after the drag {after:.2} ms, that frame again {again:.2} ms",
|
||||
(ms[3] + ms[4]) * 0.5
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+123
-4
@@ -127,6 +127,12 @@ pub struct RenderOptions {
|
||||
/// (box-filtered from the full image, kept with the decoded images), so the full image needn't
|
||||
/// stay in memory or be sampled. Off, images always sample their full pixels (exports).
|
||||
pub image_mips: bool,
|
||||
/// Screen views while an interaction is in progress (a drag, a dialog's preview): an object
|
||||
/// whose content only moved since an earlier frame draws its cached drop shadows, outer glows
|
||||
/// and filtered content from the rasters made then, at the nearest whole pixel, without
|
||||
/// running the effects again (see `fx`). Off, no raster made that way, or made over one, is
|
||||
/// drawn: the effects run again.
|
||||
pub interacting: bool,
|
||||
/// Appearance of Black's Rich Black: pure CMYK black (black ink alone at 100%) shows as RGB
|
||||
/// black. Off, it shows through the CMYK profile, a dark grey, as other colours do.
|
||||
pub rich_black: bool,
|
||||
@@ -204,6 +210,7 @@ impl Default for RenderOptions {
|
||||
precise: false,
|
||||
supersample: false,
|
||||
image_mips: false,
|
||||
interacting: false,
|
||||
rich_black: false,
|
||||
}
|
||||
}
|
||||
@@ -352,6 +359,21 @@ struct GeomEntry {
|
||||
type ClipEntry = (Arc<Node>, Option<Arc<(BezPath, FillRule)>>);
|
||||
/// A clipping path (kept alive) and what it paints: its fills and its strokes ([`Node::clip_paint`]).
|
||||
type ClipPaintEntry = (Arc<Node>, Option<Arc<Node>>, Option<Arc<Node>>);
|
||||
/// What symbol art placed by [`Renderer::placed_art`] is kept under: the art's address, the bits
|
||||
/// of the transform's coefficients, whether strokes scale with it, and the stain ([`stain_bits`]).
|
||||
type PlacedKey = (usize, [u64; 6], bool, [u32; 6]);
|
||||
|
||||
/// Symbol art placed by [`Renderer::placed_art`].
|
||||
struct PlacedEntry {
|
||||
/// The symbol's art, held weakly: the reference keeps the art's allocation, so no other art can
|
||||
/// get its address while the entry exists, and does not keep the art's content.
|
||||
art: std::sync::Weak<Node>,
|
||||
/// The art placed, or `None` for art placed again in every frame ([`depends_on_document`]).
|
||||
placed: Option<Arc<Node>>,
|
||||
/// The font generation ([`vectorcraft_text::FontDb::generation`]) the art was placed in, when
|
||||
/// it holds type: the art is placed again when the fonts change.
|
||||
fonts: Option<u64>,
|
||||
}
|
||||
|
||||
/// Reusable renderer (keeps the render context, geometry and glyph caches between frames; decoded
|
||||
/// images are shared by all renderers, see [`set_image_cache_budget`]).
|
||||
@@ -393,6 +415,9 @@ pub struct Renderer {
|
||||
/// Objects' content run through Photoshop-style effects, per object and content slot (see
|
||||
/// `fx`): kept only while drawn.
|
||||
pixel_fx: PtrMap<(usize, usize, Ink), fx::PixelEntry>,
|
||||
/// What the contents of those rasters read from the document, and the color settings, as last
|
||||
/// found for a frame (`stamp`) and document (address) (see [`Self::doc_inputs`]).
|
||||
doc_inputs: Option<((u64, usize), Arc<fx::DocInputs>)>,
|
||||
/// Whether the group being drawn is a knockout group (what its neutral children inherit).
|
||||
knockout: bool,
|
||||
/// Address of the knockout-group element being drawn as its knockout shape: at full object
|
||||
@@ -400,6 +425,8 @@ pub struct Renderer {
|
||||
shape_of: usize,
|
||||
/// The art drawn for objects with effects that apply through it (see `fx::has_object_fx`).
|
||||
fx_arts: FrameCache<usize, (Arc<Node>, Arc<Node>)>,
|
||||
/// Symbol art as instances and inline graphics draw it (see [`Self::placed_art`]).
|
||||
placed_arts: FrameCache<PlacedKey, PlacedEntry>,
|
||||
/// Layers open around the drawing point in the current context: a non-isolated group can copy
|
||||
/// its backdrop only when there are none (see [`Self::group`]).
|
||||
nested: u32,
|
||||
@@ -444,6 +471,9 @@ static MISSING_INLINE_LOGGED: std::sync::atomic::AtomicBool = std::sync::atomic:
|
||||
pub struct FrameStats {
|
||||
pub drawn: usize,
|
||||
pub culled: usize,
|
||||
/// The drop shadows, outer glows and filtered contents the frame drew from rasters made before
|
||||
/// their object moved, or made over such rasters ([`RenderOptions::interacting`]).
|
||||
pub moved: usize,
|
||||
pub micros: u64,
|
||||
}
|
||||
|
||||
@@ -504,10 +534,12 @@ impl Renderer {
|
||||
brushes: Default::default(),
|
||||
shadows: PtrMap::default(),
|
||||
pixel_fx: PtrMap::default(),
|
||||
doc_inputs: None,
|
||||
live: live::LiveCache::default(),
|
||||
knockout: false,
|
||||
shape_of: 0,
|
||||
fx_arts: FrameCache::new(lru::FX_ART_BUDGET, 256),
|
||||
placed_arts: FrameCache::new(lru::PLACED_ART_BUDGET, 256),
|
||||
nested: 0,
|
||||
backdrop: None,
|
||||
blends: PtrMap::default(),
|
||||
@@ -611,6 +643,7 @@ impl Renderer {
|
||||
self.clips.sweep(g);
|
||||
self.clip_paints.sweep(g);
|
||||
self.fx_arts.sweep(g);
|
||||
self.placed_arts.sweep(g);
|
||||
self.brushes.entries.sweep(g);
|
||||
self.live.sweep(g);
|
||||
}
|
||||
@@ -1136,8 +1169,7 @@ impl Renderer {
|
||||
NodeKind::Image(im) => self.draw_image(ctx, f, im),
|
||||
NodeKind::SymbolInstance { symbol, xf } => {
|
||||
if let Some(sym) = f.doc.symbols.iter().find(|s| &s.name == symbol) {
|
||||
let mut art = brush_fx::instance_art(&sym.art, n);
|
||||
art.transform(*xf, false);
|
||||
let art = self.placed_art(&sym.art, *xf, false, Some(n));
|
||||
self.draw_node(ctx, f, &art, true);
|
||||
}
|
||||
}
|
||||
@@ -1471,14 +1503,46 @@ impl Renderer {
|
||||
for ig in &g.inlines {
|
||||
let Some(art) = t.runs.get(ig.run).and_then(|r| r.inline.as_ref()) else { continue };
|
||||
let Some(sym) = f.doc.symbols.iter().find(|s| s.name == art.symbol) else { continue };
|
||||
let mut node = (*sym.art).clone();
|
||||
// Strokes scale with the art (it is sized to the type, as in the SVG `<use>`).
|
||||
node.transform(t.xf * ig.xf * f.doc.symbol_natural_xf(&art.symbol), true);
|
||||
let node = self.placed_art(&sym.art, t.xf * ig.xf * f.doc.symbol_natural_xf(&art.symbol), true, None);
|
||||
self.draw_node(ctx, f, &node, true);
|
||||
}
|
||||
self.inline_depth -= 1;
|
||||
}
|
||||
|
||||
/// Symbol art `art` as an instance or an inline graphic draws it ([`place`]). Art that is a
|
||||
/// group or a layer without an appearance of its own is kept between frames under the art's
|
||||
/// `Arc`, `xf`, `scale_strokes` and the stain of instance `inst`: its members keep their `Arc`s
|
||||
/// from frame to frame, and the caches keyed by a node's address (geometry, glyphs, live-effect
|
||||
/// geometry) hit for them. Other art is placed again in every frame, and so is art that holds
|
||||
/// symbol instances, type with inline graphics, images, placed documents, live blends,
|
||||
/// envelopes or repeats, or raster effects ([`depends_on_document`]). Art that holds type is
|
||||
/// placed again when the fonts change.
|
||||
fn placed_art(&mut self, art: &Arc<Node>, xf: Affine, scale_strokes: bool, inst: Option<&Node>) -> Arc<Node> {
|
||||
if !matches!(art.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) || fx::has_object_fx(art) {
|
||||
return place(art, xf, scale_strokes, inst);
|
||||
}
|
||||
let stain = stain_bits(inst.and_then(vectorcraft_brush::stain));
|
||||
let key = (Arc::as_ptr(art) as usize, xf.as_coeffs().map(f64::to_bits), scale_strokes, stain);
|
||||
if let Some(e) = self.placed_arts.get(&key, self.stamp)
|
||||
&& std::ptr::eq(e.art.as_ptr(), Arc::as_ptr(art))
|
||||
&& e.fonts.is_none_or(|g| g == vectorcraft_text::FontDb::global().generation())
|
||||
{
|
||||
return match &e.placed {
|
||||
Some(placed) => placed.clone(),
|
||||
None => place(art, xf, scale_strokes, inst),
|
||||
};
|
||||
}
|
||||
let placed = place(art, xf, scale_strokes, inst);
|
||||
let kept = (!depends_on_document(art)).then(|| placed.clone());
|
||||
let fonts = (kept.is_some() && holds_type(art)).then(|| vectorcraft_text::FontDb::global().generation());
|
||||
// The bytes count the key and the entry, the art's allocation that the weak reference
|
||||
// keeps, and the kept art.
|
||||
let bytes = size_of::<(PlacedKey, PlacedEntry, Node)>() + kept.as_deref().map_or(0, lru::node_bytes);
|
||||
self.placed_arts.insert(key, PlacedEntry { art: Arc::downgrade(art), placed: kept, fonts }, bytes, self.stamp);
|
||||
placed
|
||||
}
|
||||
|
||||
/// Cached glyph geometry for a text node (keyed by Arc identity like paths).
|
||||
fn text_geom_of(&mut self, a: &Arc<Node>, t: &TextObject) -> Arc<TextGeom> {
|
||||
let fonts = vectorcraft_text::FontDb::global().generation();
|
||||
@@ -1844,6 +1908,61 @@ fn painted_items(n: &Node) -> usize {
|
||||
.max(if n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if f.visible && !matches!(f.paint, vectorcraft_color::Paint::Solid { .. } | vectorcraft_color::Paint::None)) || matches!(i, AppearanceItem::Stroke(s) if s.visible && !matches!(s.paint, vectorcraft_color::Paint::Solid { .. } | vectorcraft_color::Paint::None))) { 2 } else { 0 })
|
||||
}
|
||||
|
||||
/// Symbol art `art` stained by instance `inst` ([`instance_art`]) and placed by `xf`, with
|
||||
/// strokes scaled by `xf` when `scale_strokes`.
|
||||
fn place(art: &Node, xf: Affine, scale_strokes: bool, inst: Option<&Node>) -> Arc<Node> {
|
||||
let mut placed = match inst {
|
||||
Some(i) => brush_fx::instance_art(art, i),
|
||||
None => art.clone(),
|
||||
};
|
||||
placed.transform(xf, scale_strokes);
|
||||
Arc::new(placed)
|
||||
}
|
||||
|
||||
/// Does `n` hold, in it or in the opacity masks of the objects in it, a symbol instance, type
|
||||
/// with an inline graphic, an image, a placed document, a blend, an envelope, a repeat, or an
|
||||
/// object or a fill or stroke with a visible raster effect?
|
||||
fn depends_on_document(n: &Node) -> bool {
|
||||
let raster = |fx: &[vectorcraft_doc::Effect]| fx.iter().any(|e| e.visible && effects::is_raster(&e.id));
|
||||
let mut depends = false;
|
||||
n.walk(&mut |c| {
|
||||
depends |= match &c.kind {
|
||||
NodeKind::SymbolInstance { .. }
|
||||
| NodeKind::Image(_)
|
||||
| NodeKind::PlacedDocument(_)
|
||||
| NodeKind::Blend { .. }
|
||||
| NodeKind::Envelope { .. }
|
||||
| NodeKind::Repeat(_) => true,
|
||||
NodeKind::Text(t) => t.runs.iter().any(|r| r.inline.is_some()),
|
||||
_ => false,
|
||||
} || raster(&c.appearance.effects)
|
||||
|| c.appearance.items.iter().any(|i| raster(i.effects()))
|
||||
|| c.extras().mask.as_ref().is_some_and(|m| depends_on_document(&m.art));
|
||||
});
|
||||
depends
|
||||
}
|
||||
|
||||
/// Does `n` hold type, in it or in the opacity masks of the objects in it?
|
||||
fn holds_type(n: &Node) -> bool {
|
||||
let mut holds = false;
|
||||
n.walk(&mut |c| holds |= matches!(c.kind, NodeKind::Text(_)) || c.extras().mask.as_ref().is_some_and(|m| holds_type(&m.art)));
|
||||
holds
|
||||
}
|
||||
|
||||
/// A symbol instance's stain ([`vectorcraft_brush::stain`]) as bits for a cache key: its color
|
||||
/// model, the color's components and the amount (all 0 without a stain).
|
||||
fn stain_bits(stain: Option<(vectorcraft_color::Color, f32)>) -> [u32; 6] {
|
||||
use vectorcraft_color::Color;
|
||||
let Some((color, amount)) = stain else { return [0; 6] };
|
||||
let (model, [p, q, r, s]) = match color {
|
||||
Color::Rgb { r, g, b } => (1, [r, g, b, 0.0]),
|
||||
Color::Cmyk { c, m, y, k } => (2, [c, m, y, k]),
|
||||
Color::Gray { k } => (3, [k, 0.0, 0.0, 0.0]),
|
||||
Color::Lab { l, a, b } => (4, [l, a, b, 0.0]),
|
||||
};
|
||||
[model, p.to_bits(), q.to_bits(), r.to_bits(), s.to_bits(), amount.to_bits()]
|
||||
}
|
||||
|
||||
/// Preferred rasterizer thread count set by the app (Preferences → Performance); negative = automatic.
|
||||
static THREADS_OVERRIDE: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1);
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ pub(crate) const TEXT_BUDGET: usize = 128 << 20;
|
||||
pub(crate) const CLIP_BUDGET: usize = 32 << 20;
|
||||
/// The art drawn for objects with effects that apply through it (`Renderer::fx_art`).
|
||||
pub(crate) const FX_ART_BUDGET: usize = 64 << 20;
|
||||
/// Symbol art placed for instances and inline graphics (`Renderer::placed_art`).
|
||||
pub(crate) const PLACED_ART_BUDGET: usize = 64 << 20;
|
||||
/// Brush art per brushed stroke (`Renderer::draw_brush`).
|
||||
pub(crate) const BRUSH_BUDGET: usize = 64 << 20;
|
||||
/// Evaluated blends, envelopes and repeats (`Renderer::live_expanded`).
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
//! dimmed copies of it at the neighbouring tile positions and the tile edge.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::pattern::{Overlap, PatternDef, TileType};
|
||||
@@ -57,6 +58,19 @@ fn same_art(a: &[Arc<Node>], b: &[Arc<Node>]) -> bool {
|
||||
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| Arc::ptr_eq(x, y))
|
||||
}
|
||||
|
||||
/// Hash what pattern `def`'s tiling is drawn from, as the tile cache checks it: its name, the
|
||||
/// addresses of its art, its tile, tile type and overlap.
|
||||
pub(crate) fn hash_tiling(def: &PatternDef, h: &mut impl Hasher) {
|
||||
def.name.hash(h);
|
||||
def.art.iter().for_each(|a| (Arc::as_ptr(a) as usize).hash(h));
|
||||
[def.tile.x0, def.tile.y0, def.tile.x1, def.tile.y1].map(f64::to_bits).hash(h);
|
||||
std::mem::discriminant(&def.tile_type).hash(h);
|
||||
if let TileType::BrickByRow { offset } | TileType::BrickByColumn { offset } = def.tile_type {
|
||||
offset.to_bits().hash(h);
|
||||
}
|
||||
(def.overlap.right_in_front, def.overlap.bottom_in_front).hash(h);
|
||||
}
|
||||
|
||||
/// Power-of-two raster scale for a pattern seen at device scale `s`, capped so the super-tile
|
||||
/// stays under [`MAX_TILE_PX`].
|
||||
pub(crate) fn raster_scale(s: f64, period: (f64, f64)) -> f64 {
|
||||
|
||||
@@ -50,6 +50,50 @@ fn stroke_draws_outline_only() {
|
||||
assert_eq!(r.pixel(50, 50), [255, 255, 255, 255]);
|
||||
}
|
||||
|
||||
/// #1396: a stroke wider than its ellipse is a solid blob, pixel for pixel the disc a renderer
|
||||
/// with a native stroke (the PDF and SVG exports) draws, at every alignment and zoom.
|
||||
#[test]
|
||||
fn a_stroke_wider_than_its_ellipse_paints_solid() {
|
||||
let doc = |r: f64, fill: Paint, stroke: Paint, w: f64, align: StrokeAlign| {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let id = d.alloc_id();
|
||||
let mut n = Node::path(id, shapes::ellipse(Rect::new(50.0 - r, 50.0 - r, 50.0 + r, 50.0 + r)), Appearance::basic(fill, stroke, w));
|
||||
if let Some(st) = n.appearance.stroke_mut() {
|
||||
st.align = align;
|
||||
}
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
d
|
||||
};
|
||||
let black = || Paint::solid(Color::BLACK);
|
||||
let render = |d: &Document, zoom: f64| {
|
||||
let view = Affine::translate((50.0, 50.0)) * Affine::scale(zoom) * Affine::translate((-50.0, -50.0));
|
||||
Renderer::new().render(d, 100, 100, view, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
|
||||
};
|
||||
// (ellipse radius, weight, alignment, radius of the disc it paints)
|
||||
for (r, w, align, disc) in [
|
||||
(10.0, 60.0, StrokeAlign::Center, 40.0),
|
||||
(6.0, 50.0, StrokeAlign::Center, 31.0),
|
||||
(15.0, 50.0, StrokeAlign::Center, 40.0),
|
||||
(10.0, 30.0, StrokeAlign::Outside, 40.0),
|
||||
(10.0, 30.0, StrokeAlign::Inside, 10.0),
|
||||
] {
|
||||
for zoom in [0.5, 1.0, 2.0] {
|
||||
let got = render(&doc(r, Paint::None, black(), w, align), zoom);
|
||||
if align == StrokeAlign::Outside {
|
||||
// The shape itself stays unpainted, and the stroke around it has no gap.
|
||||
assert_eq!(got.pixel(50, 50), [255, 255, 255, 255], "{r} {w} {align:?} at {zoom}");
|
||||
assert_eq!(got.pixel(50 + (12.0 * zoom) as u32, 50), [0, 0, 0, 255], "{r} {w} {align:?} at {zoom}");
|
||||
continue;
|
||||
}
|
||||
let want = render(&doc(disc, black(), Paint::None, 0.0, StrokeAlign::Center), zoom);
|
||||
let off =
|
||||
(0..100).flat_map(|y| (0..100).map(move |x| (x, y))).filter(|&(x, y)| got.pixel(x, y)[0].abs_diff(want.pixel(x, y)[0]) > 40).count();
|
||||
assert!(off <= 8, "{r} {w} {align:?} at {zoom}: {off} pixels differ from the disc");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inside_stroke_stays_inside() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
|
||||
@@ -39,3 +39,49 @@ fn type_redraws_when_its_font_becomes_available() {
|
||||
assert_ne!(before, after, "drawn in the font that arrived");
|
||||
assert_eq!(after, Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, "as a new renderer draws it");
|
||||
}
|
||||
|
||||
/// Type with raster effects redraws in a font that becomes available: its drop shadow and its
|
||||
/// blurred content are made again.
|
||||
#[test]
|
||||
fn type_with_raster_effects_redraws_when_its_font_becomes_available() {
|
||||
const FAMILY: &str = "Raster Serif 4";
|
||||
let mut d = Document::new(300.0, 150.0);
|
||||
let l = d.layers[0].id;
|
||||
let effects =
|
||||
[("stylize.dropShadow", serde_json::json!({"x": 4, "y": 4, "blur": 1, "opacity": 100})), ("blur.gaussian", serde_json::json!({"radius": 1}))];
|
||||
for (i, (id, params)) in effects.into_iter().enumerate() {
|
||||
let style = CharStyle { size: 50.0, font_family: FAMILY.into(), ..Default::default() };
|
||||
let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 60.0 + 70.0 * i as f64), "Hamburg", style))));
|
||||
n.appearance.effects.push(vectorcraft_doc::Effect { id: id.into(), params, visible: true });
|
||||
d.insert(Some(l), i, n).unwrap();
|
||||
}
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let mut r = Renderer::new();
|
||||
let before = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
|
||||
assert!(vectorcraft_text::FontDb::global().add_font(serif_as(FAMILY)) > 0);
|
||||
let after = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
|
||||
assert_ne!(before, after, "drawn in the font that arrived");
|
||||
assert_eq!(after, Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, "as a new renderer draws it");
|
||||
}
|
||||
|
||||
/// Type with a geometry effect in a symbol's art redraws in a font that becomes available: the art
|
||||
/// kept for the instance is placed again when the fonts change.
|
||||
#[test]
|
||||
fn symbol_type_redraws_when_its_font_becomes_available() {
|
||||
const FAMILY: &str = "Placed Serif 4";
|
||||
let mut d = Document::new(300.0, 150.0);
|
||||
let style = CharStyle { size: 60.0, font_family: FAMILY.into(), ..Default::default() };
|
||||
let mut t = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(-140.0, 25.0), "Hamburg", style))));
|
||||
t.appearance.effects.push(vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 0.0}), visible: true });
|
||||
let art = Node::group(d.alloc_id(), vec![Arc::new(t)]);
|
||||
d.symbols.push(vectorcraft_doc::Symbol { name: "Word".into(), art: Arc::new(art) });
|
||||
let (l, id) = (d.layers[0].id, d.alloc_id());
|
||||
d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Word".into(), xf: Affine::translate((150.0, 75.0)) })).unwrap();
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let mut r = Renderer::new();
|
||||
let before = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
|
||||
assert!(vectorcraft_text::FontDb::global().add_font(serif_as(FAMILY)) > 0);
|
||||
let after = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
|
||||
assert_ne!(before, after, "drawn in the font that arrived");
|
||||
assert_eq!(after, Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, "as a new renderer draws it");
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! What renderers keep between frames: decoded images shared by all of them, image mips, and
|
||||
//! caches swept by every kind of render.
|
||||
//! What renderers keep between frames: decoded images shared by all of them, image mips, symbol
|
||||
//! art placed for instances and inline graphics, raster effects made again when what they read
|
||||
//! changes, and caches swept by every kind of render.
|
||||
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
|
||||
@@ -174,6 +175,549 @@ fn swept_type_entries_give_back_their_bytes() {
|
||||
assert_eq!((r.texts.len(), r.texts.bytes()), (0, 0));
|
||||
}
|
||||
|
||||
/// A symbol's art inside the 20 × 20 square around the origin: a group of a square filled blue
|
||||
/// with a 2 pt black stroke and a black line.
|
||||
fn symbol_art(d: &mut Document) -> Node {
|
||||
use vectorcraft_doc::Appearance;
|
||||
let square = vectorcraft_geom::shapes::rectangle(Rect::new(-8.0, -8.0, 8.0, 8.0));
|
||||
let square = Node::path(d.alloc_id(), square, Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::solid(Color::BLACK), 2.0));
|
||||
let line = vectorcraft_geom::shapes::line((-8.0, 4.0).into(), (8.0, 4.0).into());
|
||||
let line = Node::path(d.alloc_id(), line, Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.5));
|
||||
Node::group(d.alloc_id(), vec![Arc::new(square), Arc::new(line)])
|
||||
}
|
||||
|
||||
/// Three instances of symbol `S` ([`symbol_art`]): two in a row, the second stained red, and a
|
||||
/// third turned and scaled by 1.5.
|
||||
fn symbol_doc() -> Document {
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
let art = symbol_art(&mut d);
|
||||
d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) });
|
||||
let l = d.layers[0].id;
|
||||
let xfs = [
|
||||
Affine::translate((30.0, 30.0)),
|
||||
Affine::translate((80.0, 30.0)),
|
||||
Affine::translate((140.0, 60.0)) * Affine::rotate(0.5) * Affine::scale(1.5),
|
||||
];
|
||||
for (i, xf) in xfs.into_iter().enumerate() {
|
||||
let mut inst = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf });
|
||||
if i == 1 {
|
||||
inst.appearance.set_fill(Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
|
||||
}
|
||||
d.insert(Some(l), i, inst).unwrap();
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// The addresses of the nodes `r` keeps geometry for that are not the own nodes of any version
|
||||
/// of the document drawn (`docs`): the symbol art it placed for instances and inline graphics.
|
||||
fn placed_copies(r: &Renderer, docs: &[&Document]) -> std::collections::BTreeSet<usize> {
|
||||
let mut own = std::collections::HashSet::new();
|
||||
for d in docs {
|
||||
d.walk(|n| {
|
||||
own.insert(n as *const Node as usize);
|
||||
});
|
||||
}
|
||||
r.geom.values().map(|e| Arc::as_ptr(&e.node) as usize).filter(|p| !own.contains(p)).collect()
|
||||
}
|
||||
|
||||
/// A second frame of an unchanged document draws the symbol art placed for its instances in the
|
||||
/// first: it copies no art, and the geometry kept for the art's paths is used again. The renderer
|
||||
/// holds the symbol's art itself only weakly.
|
||||
#[test]
|
||||
fn symbol_instances_keep_their_placed_art_between_frames() {
|
||||
let d = symbol_doc();
|
||||
let opts = RenderOptions::default();
|
||||
let mut r = Renderer::new();
|
||||
let first = r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels;
|
||||
let placed = placed_copies(&r, &[&d]);
|
||||
assert_eq!(placed.len(), 6, "the square and the line of three instances");
|
||||
assert!(r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels == first);
|
||||
assert_eq!(placed_copies(&r, &[&d]), placed, "the second frame draws the art the first placed");
|
||||
assert_eq!(Arc::strong_count(&d.symbols[0].art), 1, "only the document holds the symbol's art");
|
||||
}
|
||||
|
||||
/// Moving an instance places its art again, and editing a symbol's art places the art of its
|
||||
/// instances again: the other instances keep theirs, and each frame draws what a new renderer
|
||||
/// draws.
|
||||
#[test]
|
||||
fn editing_an_instance_or_a_symbol_places_only_its_art_again() {
|
||||
let mut d = symbol_doc();
|
||||
// Symbol `T` is a green square, and one instance of it stands below the others.
|
||||
let green = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(0.0, 1.0, 0.0)), Paint::None, 0.0);
|
||||
let square = Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(-5.0, -5.0, 5.0, 5.0)), green);
|
||||
let t = Node::group(d.alloc_id(), vec![Arc::new(square)]);
|
||||
d.symbols.push(vectorcraft_doc::Symbol { name: "T".into(), art: Arc::new(t) });
|
||||
let (l, id) = (d.layers[0].id, d.alloc_id());
|
||||
d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "T".into(), xf: Affine::translate((30.0, 80.0)) })).unwrap();
|
||||
let opts = RenderOptions::default();
|
||||
let frame = |r: &mut Renderer, d: &Document| r.render(d, 200, 100, Affine::IDENTITY, &opts).pixels;
|
||||
let mut r = Renderer::new();
|
||||
frame(&mut r, &d);
|
||||
let before = placed_copies(&r, &[&d]);
|
||||
assert_eq!(before.len(), 7);
|
||||
// The first instance of `S` moves 5 pt right.
|
||||
let unmoved = d.clone();
|
||||
let first = d.layers[0].children().unwrap()[1].id;
|
||||
if let Some(NodeKind::SymbolInstance { xf, .. }) = d.node_mut(first).map(|n| &mut n.kind) {
|
||||
*xf = Affine::translate((5.0, 0.0)) * *xf;
|
||||
}
|
||||
assert!(frame(&mut r, &d) == frame(&mut Renderer::new(), &d));
|
||||
let moved = placed_copies(&r, &[&unmoved, &d]);
|
||||
assert!(before.is_subset(&moved) && moved.len() == before.len() + 2, "{} copies, then {}", before.len(), moved.len());
|
||||
// `S`'s square is repainted red: its instances show it, and `T`'s instance keeps its art.
|
||||
let mut art = (*d.symbols[0].art).clone();
|
||||
if let Some(square) = art.children_mut().and_then(|c| c.first_mut()) {
|
||||
Arc::make_mut(square).appearance.set_fill(Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
|
||||
}
|
||||
d.symbols[0].art = Arc::new(art);
|
||||
let red = frame(&mut r, &d);
|
||||
assert!(red == frame(&mut Renderer::new(), &d));
|
||||
let img = Rendered { width: 200, height: 100, pixels: red };
|
||||
assert_eq!(img.pixel(140, 60), [255, 0, 0, 255], "the turned instance's square");
|
||||
let edited = placed_copies(&r, &[&unmoved, &d]);
|
||||
assert!(moved.is_subset(&edited) && edited.len() == moved.len() + 6, "{} copies, then {}", moved.len(), edited.len());
|
||||
}
|
||||
|
||||
/// Draws `d` (200 × 100 points, on white), applies `edit` and draws it again with the same
|
||||
/// renderer: the frame changed, a new renderer draws the same, and only the document holds the
|
||||
/// symbols' art.
|
||||
#[track_caller]
|
||||
fn edit_draws_as_new(mut d: Document, edit: impl FnOnce(&mut Document)) {
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let _warm = draws_as_new(&mut d, &opts, &opts, edit);
|
||||
assert!(d.symbols.iter().all(|s| Arc::strong_count(&s.art) == 1), "only the document holds the symbols' art");
|
||||
}
|
||||
|
||||
/// Draws `d` (200 × 100 points) with `before`, applies `edit` and draws it with `after` and the
|
||||
/// same renderer, which it returns: the frame changed, and a new renderer draws the same.
|
||||
#[track_caller]
|
||||
fn draws_as_new(d: &mut Document, before: &RenderOptions, after: &RenderOptions, edit: impl FnOnce(&mut Document)) -> Renderer {
|
||||
let mut r = Renderer::new();
|
||||
let first = r.render(d, 200, 100, Affine::IDENTITY, before).pixels;
|
||||
edit(d);
|
||||
let second = r.render(d, 200, 100, Affine::IDENTITY, after).pixels;
|
||||
assert!(second != first, "the edit changes the frame");
|
||||
assert!(second == Renderer::new().render(d, 200, 100, Affine::IDENTITY, after).pixels, "a new renderer draws the same");
|
||||
r
|
||||
}
|
||||
|
||||
/// `d` with symbol `Holder` (a group of `members`) shown by one instance at (100, 50).
|
||||
fn holder_doc(mut d: Document, members: Vec<Node>) -> Document {
|
||||
let holder = Node::group(d.alloc_id(), members.into_iter().map(Arc::new).collect());
|
||||
d.symbols.push(vectorcraft_doc::Symbol { name: "Holder".into(), art: Arc::new(holder) });
|
||||
let (l, id) = (d.layers[0].id, d.alloc_id());
|
||||
d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Holder".into(), xf: Affine::translate((100.0, 50.0)) })).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// Symbol art that shows another symbol (type with an inline graphic of it, an instance of it with
|
||||
/// a Transform effect) is placed again in every frame: when that symbol's art changes, the next
|
||||
/// frame draws what a new renderer draws.
|
||||
#[test]
|
||||
fn art_showing_another_symbol_draws_its_edits() {
|
||||
use vectorcraft_doc::text::{CharStyle, InlineArt, TextObject, TextRun};
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
let art = symbol_art(&mut d);
|
||||
d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) });
|
||||
let style = CharStyle { size: 24.0, ..Default::default() };
|
||||
let mut t = TextObject::point(vectorcraft_geom::Point::new(-60.0, 10.0), "A", style.clone());
|
||||
t.runs.push(TextRun::inline(InlineArt::new("S"), style.clone()));
|
||||
t.runs.push(TextRun::new("B", style));
|
||||
let text = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
|
||||
let mut moved = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((40.0, 0.0)) });
|
||||
moved.appearance.effects.push(vectorcraft_doc::Effect {
|
||||
id: "distort.transform".into(),
|
||||
params: serde_json::json!({"moveH": 4.0}),
|
||||
visible: true,
|
||||
});
|
||||
let d = holder_doc(d, vec![text, moved]);
|
||||
// `S` becomes a wide red bar: the inline graphic grows, and the type after it moves.
|
||||
edit_draws_as_new(d, |d| {
|
||||
let bar = vectorcraft_geom::shapes::rectangle(Rect::new(-20.0, -4.0, 20.0, 4.0));
|
||||
let red = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
d.symbols[0].art = Arc::new(Node::path(d.alloc_id(), bar, red));
|
||||
});
|
||||
}
|
||||
|
||||
/// Symbol art with raster effects is placed again in every frame: when a pattern under a blur or
|
||||
/// the document's raster resolution under De-Interlace changes, the next frame draws what a new
|
||||
/// renderer draws.
|
||||
#[test]
|
||||
fn art_with_raster_effects_draws_pattern_and_resolution_edits() {
|
||||
use vectorcraft_doc::pattern::{PatternDef, pattern_paint};
|
||||
let effect = |id: &str, params: serde_json::Value| vectorcraft_doc::Effect { id: id.into(), params, visible: true };
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
d.raster_effects_ppi = 72.0;
|
||||
// Pattern `Checks` is a red 10 × 10 square in a 20 × 20 tile.
|
||||
let tile = |c: Color| {
|
||||
let square = vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
vec![Arc::new(Node::path(NodeId(900), square, vectorcraft_doc::Appearance::basic(Paint::solid(c), Paint::None, 0.0)))]
|
||||
};
|
||||
let mut def = PatternDef::new("Checks", tile(Color::rgb(1.0, 0.0, 0.0)));
|
||||
def.tile = Rect::new(0.0, 0.0, 20.0, 20.0);
|
||||
d.patterns.push(def);
|
||||
let band = vectorcraft_geom::shapes::rectangle(Rect::new(-90.0, -45.0, -20.0, -25.0));
|
||||
let mut blurred = Node::path(d.alloc_id(), band, vectorcraft_doc::Appearance::basic(pattern_paint("Checks"), Paint::None, 0.0));
|
||||
blurred.appearance.effects.push(effect("blur.gaussian", serde_json::json!({"radius": 2.0})));
|
||||
// The path holds ten bars, 1 pt high and 2 pt apart.
|
||||
let mut bars = vectorcraft_geom::shapes::rectangle(Rect::new(20.0, -40.0, 90.0, -39.0));
|
||||
for k in 1..10 {
|
||||
let y = -40.0 + 2.0 * k as f64;
|
||||
bars.subpaths.extend(vectorcraft_geom::shapes::rectangle(Rect::new(20.0, y, 90.0, y + 1.0)).subpaths);
|
||||
}
|
||||
let mut striped = Node::path(d.alloc_id(), bars, vectorcraft_doc::Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
|
||||
striped.appearance.effects.push(effect("video.deinterlace", serde_json::json!({})));
|
||||
let d = holder_doc(d, vec![blurred, striped]);
|
||||
edit_draws_as_new(d, |d| {
|
||||
if let Some(def) = d.pattern_mut("Checks") {
|
||||
def.art = tile(Color::rgb(0.0, 0.0, 1.0));
|
||||
}
|
||||
d.raster_effects_ppi = 24.0;
|
||||
});
|
||||
}
|
||||
|
||||
/// Symbol art with a live object (an envelope that distorts a pattern fill) is placed again in
|
||||
/// every frame: when the pattern's tile type changes, the next frame draws what a new renderer
|
||||
/// draws.
|
||||
#[test]
|
||||
fn art_with_live_objects_draws_pattern_layout_edits() {
|
||||
use vectorcraft_doc::live::EnvelopeOptions;
|
||||
use vectorcraft_doc::pattern::{PatternDef, TileType, pattern_paint};
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
// Pattern `Checks` is a red 10 × 10 square in a 20 × 20 tile.
|
||||
let square = vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
let red = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
let mut def = PatternDef::new("Checks", vec![Arc::new(Node::path(NodeId(900), square, red))]);
|
||||
def.tile = Rect::new(0.0, 0.0, 20.0, 20.0);
|
||||
d.patterns.push(def);
|
||||
let band = vectorcraft_geom::shapes::rectangle(Rect::new(-80.0, -40.0, 80.0, 40.0));
|
||||
let filled = Node::path(d.alloc_id(), band, vectorcraft_doc::Appearance::basic(pattern_paint("Checks"), Paint::None, 0.0));
|
||||
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true };
|
||||
let options = EnvelopeOptions { distort_appearance: true, distort_pattern_fills: true, ..Default::default() };
|
||||
let envelope = Node::new(
|
||||
d.alloc_id(),
|
||||
NodeKind::Envelope {
|
||||
content: vec![Arc::new(filled)],
|
||||
kind: Box::new(kind),
|
||||
fidelity: 50.0,
|
||||
editing: false,
|
||||
options,
|
||||
frame: Box::new(Affine::IDENTITY),
|
||||
},
|
||||
);
|
||||
let d = holder_doc(d, vec![envelope]);
|
||||
edit_draws_as_new(d, |d| {
|
||||
if let Some(def) = d.pattern_mut("Checks") {
|
||||
def.tile_type = TileType::BrickByRow { offset: 0.5 };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn effect(id: &str, params: serde_json::Value) -> vectorcraft_doc::Effect {
|
||||
vectorcraft_doc::Effect { id: id.into(), params, visible: true }
|
||||
}
|
||||
|
||||
/// A drop shadow 4 pt right and down, blurred 1 pt, at full opacity.
|
||||
fn drop_shadow() -> vectorcraft_doc::Effect {
|
||||
effect("stylize.dropShadow", serde_json::json!({"x": 4, "y": 4, "blur": 1, "opacity": 100}))
|
||||
}
|
||||
|
||||
/// A path of `d` filled with `fill` (no stroke), with `effects`.
|
||||
fn filled(d: &mut Document, shape: vectorcraft_geom::PathData, fill: Paint, effects: Vec<vectorcraft_doc::Effect>) -> Node {
|
||||
let mut n = Node::path(d.alloc_id(), shape, vectorcraft_doc::Appearance::basic(fill, Paint::None, 0.0));
|
||||
n.appearance.effects = effects;
|
||||
n
|
||||
}
|
||||
|
||||
/// `d` with `art` added to its first layer, bottom first.
|
||||
fn with_art(mut d: Document, art: Vec<Node>) -> Document {
|
||||
let l = d.layers[0].id;
|
||||
for n in art {
|
||||
d.insert(Some(l), usize::MAX, n).unwrap();
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// Pattern `Checks`: a 10 × 10 square of `color` in a 20 × 20 tile.
|
||||
fn checks(color: Color) -> vectorcraft_doc::pattern::PatternDef {
|
||||
let square = vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
let art = Node::path(NodeId(900), square, vectorcraft_doc::Appearance::basic(Paint::solid(color), Paint::None, 0.0));
|
||||
let mut def = vectorcraft_doc::pattern::PatternDef::new("Checks", vec![Arc::new(art)]);
|
||||
def.tile = Rect::new(0.0, 0.0, 20.0, 20.0);
|
||||
def
|
||||
}
|
||||
|
||||
/// Raster effects on a document's own objects are made again when a pattern they paint with
|
||||
/// changes: its art (as Done Editing Pattern writes it) under a blur, and its tile type (as
|
||||
/// Pattern Options sets it) under a blur and a drop shadow.
|
||||
#[test]
|
||||
fn rasters_draw_pattern_edits() {
|
||||
use vectorcraft_doc::pattern::{TileType, pattern_paint};
|
||||
use vectorcraft_geom::shapes::rectangle;
|
||||
let doc = || {
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
d.patterns.push(checks(Color::rgb(1.0, 0.0, 0.0)));
|
||||
let blur = effect("blur.gaussian", serde_json::json!({"radius": 2.0}));
|
||||
let blurred = filled(&mut d, rectangle(Rect::new(10.0, 10.0, 90.0, 90.0)), pattern_paint("Checks"), vec![blur]);
|
||||
let shadowed = filled(&mut d, rectangle(Rect::new(110.0, 10.0, 180.0, 80.0)), pattern_paint("Checks"), vec![drop_shadow()]);
|
||||
with_art(d, vec![blurred, shadowed])
|
||||
};
|
||||
edit_draws_as_new(doc(), |d| {
|
||||
if let Some(def) = d.pattern_mut("Checks") {
|
||||
def.art = checks(Color::rgb(0.0, 0.0, 1.0)).art;
|
||||
}
|
||||
});
|
||||
edit_draws_as_new(doc(), |d| {
|
||||
if let Some(def) = d.pattern_mut("Checks") {
|
||||
def.tile_type = TileType::BrickByRow { offset: 0.5 };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Raster effects are made again when a symbol shown inside another symbol changes (a group's drop
|
||||
/// shadow around an instance of the other symbol), when a brush definition changes (a brushed
|
||||
/// stroke's drop shadow), and when an image's pixels change under the same key, as when a linked
|
||||
/// file is read again (a blur).
|
||||
#[test]
|
||||
fn rasters_draw_symbol_brush_and_image_edits() {
|
||||
use vectorcraft_geom::shapes::{line, rectangle};
|
||||
// A group of an instance of symbol `A`, whose art shows symbol `S`, and a green square.
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
let art = symbol_art(&mut d);
|
||||
d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) });
|
||||
let shown = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::IDENTITY });
|
||||
let a = Node::group(d.alloc_id(), vec![Arc::new(shown)]);
|
||||
d.symbols.push(vectorcraft_doc::Symbol { name: "A".into(), art: Arc::new(a) });
|
||||
let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "A".into(), xf: Affine::translate((50.0, 50.0)) });
|
||||
let green = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(0.0, 0.8, 0.0)), Paint::None, 0.0);
|
||||
let square = Node::path(d.alloc_id(), rectangle(Rect::new(120.0, 30.0, 160.0, 70.0)), green);
|
||||
let mut group = Node::group(d.alloc_id(), vec![Arc::new(instance), Arc::new(square)]);
|
||||
group.appearance.effects.push(drop_shadow());
|
||||
edit_draws_as_new(with_art(d, vec![group]), |d| {
|
||||
let bar = rectangle(Rect::new(-20.0, -4.0, 20.0, 4.0));
|
||||
let red = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
let s = d.symbols.iter().position(|s| s.name == "S").unwrap();
|
||||
d.symbols[s].art = Arc::new(Node::path(d.alloc_id(), bar, red));
|
||||
});
|
||||
// A line stroked with a calligraphic brush, with a drop shadow; the brush gets wider.
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
let mut stroke = vectorcraft_doc::Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
|
||||
stroke.stroke_mut().unwrap().brush = Some("3 pt. Round".into());
|
||||
let mut brushed = Node::path(d.alloc_id(), line((20.0, 50.0).into(), (180.0, 50.0).into()), stroke);
|
||||
brushed.appearance.effects.push(drop_shadow());
|
||||
edit_draws_as_new(with_art(d, vec![brushed]), |d| {
|
||||
let mut lib = vectorcraft_brush::library(d);
|
||||
if let vectorcraft_brush::BrushKind::Calligraphic(c) = &mut lib[0].kind {
|
||||
c.size = 20.0;
|
||||
}
|
||||
vectorcraft_brush::store(d, &lib);
|
||||
});
|
||||
// A 16 × 16 image drawn 80 × 80, blurred.
|
||||
edit_draws_as_new(blurred_image_doc("swapped", 104), |d| {
|
||||
d.images.insert("swapped".into(), ImageBlob::new("image/png", png(16, 16, 105)));
|
||||
});
|
||||
}
|
||||
|
||||
/// A document showing a 16 × 16 image (`key`, pixels from `seed`) drawn 80 × 80 at (20, 10), under
|
||||
/// a 1 pt Gaussian Blur.
|
||||
fn blurred_image_doc(key: &str, seed: u8) -> Document {
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
d.images.insert(key.into(), ImageBlob::new("image/png", png(16, 16, seed)));
|
||||
let xf = Affine::translate((20.0, 10.0)) * Affine::scale(5.0);
|
||||
let im = ImageObject { key: key.into(), width: 16, height: 16, xf, link: None, placement: Default::default() };
|
||||
let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
|
||||
n.appearance.effects.push(effect("blur.gaussian", serde_json::json!({"radius": 1.0})));
|
||||
with_art(d, vec![n])
|
||||
}
|
||||
|
||||
/// Type in a family no font has, 30 pt at (10, 60), with effect `fx`.
|
||||
fn missing_font_type(d: &mut Document, fx: vectorcraft_doc::Effect) -> Node {
|
||||
use vectorcraft_doc::text::{CharStyle, TextObject};
|
||||
let style = CharStyle { size: 30.0, font_family: "No Such Family".into(), ..Default::default() };
|
||||
let mut t = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(vectorcraft_geom::Point::new(10.0, 60.0), "Hamburg", style))));
|
||||
t.appearance.effects.push(fx);
|
||||
t
|
||||
}
|
||||
|
||||
/// Raster effects are made again when a document or layer setting they read changes: the raster
|
||||
/// resolution under De-Interlace, Page Knockout Group around a blurred group of translucent
|
||||
/// objects, Document Setup's highlight of substituted fonts under a blur, and the layer's Dim
|
||||
/// Images under an image's blur.
|
||||
#[test]
|
||||
fn rasters_draw_document_and_layer_setting_edits() {
|
||||
use vectorcraft_geom::shapes::rectangle;
|
||||
// The path holds ten bars, 1 pt high and 2 pt apart.
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
d.raster_effects_ppi = 72.0;
|
||||
let mut bars = rectangle(Rect::new(20.0, 20.0, 180.0, 21.0));
|
||||
for k in 1..10 {
|
||||
let y = 20.0 + 2.0 * k as f64;
|
||||
bars.subpaths.extend(rectangle(Rect::new(20.0, y, 180.0, y + 1.0)).subpaths);
|
||||
}
|
||||
let striped = filled(&mut d, bars, Paint::solid(Color::BLACK), vec![effect("video.deinterlace", serde_json::json!({}))]);
|
||||
edit_draws_as_new(with_art(d, vec![striped]), |d| d.raster_effects_ppi = 24.0);
|
||||
// A blurred group of two overlapping squares at 50% opacity.
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
let mut squares = vec![];
|
||||
for (x, color) in [(40.0, Color::rgb(1.0, 0.0, 0.0)), (80.0, Color::rgb(0.0, 0.0, 1.0))] {
|
||||
let mut n = filled(&mut d, rectangle(Rect::new(x, 20.0, x + 60.0, 80.0)), Paint::solid(color), vec![]);
|
||||
n.opacity = 0.5;
|
||||
squares.push(Arc::new(n));
|
||||
}
|
||||
let mut group = Node::group(d.alloc_id(), squares);
|
||||
group.appearance.effects.push(effect("blur.gaussian", serde_json::json!({"radius": 1.0})));
|
||||
edit_draws_as_new(with_art(d, vec![group]), |d| d.page_knockout = true);
|
||||
// Blurred type in a missing family, with the substitution highlights on screen.
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
d.setup.highlight_substituted_fonts = false;
|
||||
let blurred = missing_font_type(&mut d, effect("blur.gaussian", serde_json::json!({"radius": 1.0})));
|
||||
let mut d = with_art(d, vec![blurred]);
|
||||
let highlights = RenderOptions { background: Some([255, 255, 255, 255]), highlight_substitutions: true, ..Default::default() };
|
||||
// The first lookup of the missing family catalogs the installed fonts, which changes the font
|
||||
// generation: a frame drawn first does that.
|
||||
Renderer::new().render(&d, 200, 100, Affine::IDENTITY, &highlights);
|
||||
draws_as_new(&mut d, &highlights, &highlights, |d| d.setup.highlight_substituted_fonts = true);
|
||||
// The layer of a blurred image dims its images to 50%.
|
||||
edit_draws_as_new(blurred_image_doc("dimmed", 106), |d| {
|
||||
if let NodeKind::Layer { dim_images, .. } = &mut Arc::make_mut(&mut d.layers[0]).kind {
|
||||
*dim_images = Some(50);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Raster effects are made again when a view option their content is drawn with changes: Pixel
|
||||
/// Preview's hard-edged images under a blur, Anti-aliased Artwork off under a blur, Overprint
|
||||
/// Preview for overprinting characters in a blurred group, and isolation mode for a group inside a
|
||||
/// blurred group, entered and left.
|
||||
#[test]
|
||||
fn rasters_follow_the_view_options() {
|
||||
use vectorcraft_doc::text::{CharStyle, TextObject};
|
||||
use vectorcraft_geom::shapes::{ellipse, rectangle};
|
||||
let on_white = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let unchanged = |_: &mut Document| {};
|
||||
draws_as_new(&mut blurred_image_doc("pixels", 107), &on_white, &RenderOptions { smooth_images: false, ..on_white.clone() }, unchanged);
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
let blur = effect("blur.gaussian", serde_json::json!({"radius": 0.5}));
|
||||
let oval = filled(&mut d, ellipse(Rect::new(30.0, 10.0, 170.0, 90.0)), Paint::solid(Color::BLACK), vec![blur]);
|
||||
let hard = RenderOptions { anti_alias: AntiAlias::None, ..on_white.clone() };
|
||||
draws_as_new(&mut with_art(d, vec![oval]), &on_white, &hard, unchanged);
|
||||
// A blurred group of a red rectangle and cyan type whose characters overprint.
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
let red = filled(&mut d, rectangle(Rect::new(0.0, 0.0, 200.0, 100.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), vec![]);
|
||||
let style = CharStyle { size: 50.0, fill: Paint::solid(Color::cmyk(1.0, 0.0, 0.0, 0.0)), overprint_fill: true, ..Default::default() };
|
||||
let text = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(vectorcraft_geom::Point::new(10.0, 70.0), "Hamburg", style))));
|
||||
let mut group = Node::group(d.alloc_id(), vec![Arc::new(red), Arc::new(text)]);
|
||||
group.appearance.effects.push(effect("blur.gaussian", serde_json::json!({"radius": 1.0})));
|
||||
let overprints = RenderOptions { overprint_preview: true, ..on_white.clone() };
|
||||
draws_as_new(&mut with_art(d, vec![group]), &on_white, &overprints, unchanged);
|
||||
// A blurred group of a red square and a group holding a blue square: isolating the inner group
|
||||
// dims the red square.
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
let red = filled(&mut d, rectangle(Rect::new(20.0, 20.0, 80.0, 80.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), vec![]);
|
||||
let blue = filled(&mut d, rectangle(Rect::new(110.0, 20.0, 170.0, 80.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), vec![]);
|
||||
let inner = Node::group(d.alloc_id(), vec![Arc::new(blue)]);
|
||||
let isolated = RenderOptions { isolated: Some(inner.id), ..on_white.clone() };
|
||||
let mut outer = Node::group(d.alloc_id(), vec![Arc::new(red), Arc::new(inner)]);
|
||||
outer.appearance.effects.push(effect("blur.gaussian", serde_json::json!({"radius": 1.0})));
|
||||
let mut d = with_art(d, vec![outer]);
|
||||
draws_as_new(&mut d, &on_white, &isolated, unchanged);
|
||||
draws_as_new(&mut d, &isolated, &on_white, unchanged);
|
||||
}
|
||||
|
||||
/// A thumbnail of an object under a filter that treats color channels apart (Color Halftone) is
|
||||
/// made again when the document's color mode changes: CMYK documents screen ink channels, RGB
|
||||
/// documents light ones.
|
||||
#[test]
|
||||
fn filtered_thumbnails_follow_the_color_mode() {
|
||||
use vectorcraft_geom::shapes::rectangle;
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
d.color_mode = vectorcraft_doc::ColorMode::Cmyk;
|
||||
let halftone = effect("pixelate.colorHalftone", serde_json::json!({"maxRadius": 4}));
|
||||
let orange = filled(&mut d, rectangle(Rect::new(20.0, 20.0, 180.0, 80.0)), Paint::solid(Color::rgb(1.0, 0.5, 0.0)), vec![halftone]);
|
||||
let id = orange.id;
|
||||
let mut d = with_art(d, vec![orange]);
|
||||
let mut r = Renderer::new();
|
||||
let before = r.render_thumbnail(&d, id, 128).unwrap().pixels;
|
||||
d.color_mode = vectorcraft_doc::ColorMode::Rgb;
|
||||
let after = r.render_thumbnail(&d, id, 128).unwrap().pixels;
|
||||
assert!(after != before, "the color mode changes the thumbnail");
|
||||
assert!(after == Renderer::new().render_thumbnail(&d, id, 128).unwrap().pixels, "a new renderer draws the same");
|
||||
}
|
||||
|
||||
/// Symbol instances draw what groups of their art, stained and placed, draw: in every frame, when
|
||||
/// two instances placed alike are stained differently, when the art is a single path, and with a
|
||||
/// cache too small for all of the art. Art no longer drawn leaves the cache after a few frames.
|
||||
#[test]
|
||||
fn symbol_instances_draw_as_groups_of_their_placed_art() {
|
||||
let mut d = symbol_doc();
|
||||
let l = d.layers[0].id;
|
||||
// An instance placed like the first one lies over it, stained green.
|
||||
let mut over = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((30.0, 30.0)) });
|
||||
over.appearance.set_fill(Paint::solid(Color::rgb(0.0, 1.0, 0.0)));
|
||||
d.insert(Some(l), 1, over).unwrap();
|
||||
// Symbol `U` is a single path, a circle with a black stroke.
|
||||
let circle = vectorcraft_geom::shapes::ellipse(Rect::new(-6.0, -6.0, 6.0, 6.0));
|
||||
let ap = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.8, 0.0)), Paint::solid(Color::BLACK), 1.0);
|
||||
let circle = Node::path(d.alloc_id(), circle, ap);
|
||||
d.symbols.push(vectorcraft_doc::Symbol { name: "U".into(), art: Arc::new(circle) });
|
||||
let id = d.alloc_id();
|
||||
d.insert(Some(l), usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: "U".into(), xf: Affine::translate((175.0, 25.0)) })).unwrap();
|
||||
// `groups` holds the same art in place of the instances: groups, and the circle as a path.
|
||||
let mut groups = d.clone();
|
||||
let placed = d.layers[0].children().unwrap().iter().map(|c| match &c.kind {
|
||||
NodeKind::SymbolInstance { symbol, xf } => {
|
||||
let sym = d.symbols.iter().find(|s| s.name == *symbol).unwrap();
|
||||
let mut art = instance_art(&sym.art, c);
|
||||
art.transform(*xf, false);
|
||||
Arc::new(art)
|
||||
}
|
||||
_ => c.clone(),
|
||||
});
|
||||
*groups.children_mut(Some(l)).unwrap() = placed.collect();
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let want = Renderer::new().render(&groups, 200, 100, Affine::IDENTITY, &opts).pixels;
|
||||
let mut r = Renderer::new();
|
||||
r.placed_arts = FrameCache::new(lru::PLACED_ART_BUDGET, 0);
|
||||
for frame in 0..3 {
|
||||
assert!(r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels == want, "frame {frame}");
|
||||
}
|
||||
// A cache that holds half the bytes of that art keeps what fits and places the rest every frame.
|
||||
let all = r.placed_arts.bytes();
|
||||
let mut small = Renderer::new();
|
||||
small.placed_arts = FrameCache::new(all / 2, 0);
|
||||
for frame in 0..3 {
|
||||
assert!(small.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels == want, "frame {frame} over the budget");
|
||||
assert!(!small.placed_arts.is_empty() && small.placed_arts.bytes() <= all / 2, "{} of {all} bytes", small.placed_arts.bytes());
|
||||
}
|
||||
let empty = Document::new(10.0, 10.0);
|
||||
for _ in 0..5 {
|
||||
r.render(&empty, 10, 10, Affine::IDENTITY, &opts);
|
||||
}
|
||||
assert_eq!((r.placed_arts.len(), r.placed_arts.bytes()), (0, 0));
|
||||
}
|
||||
|
||||
/// Inline graphics in type draw the art placed for them in an earlier frame, as instances do.
|
||||
#[test]
|
||||
fn inline_graphics_keep_their_placed_art_between_frames() {
|
||||
use vectorcraft_doc::text::{CharStyle, InlineArt, TextObject, TextRun};
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
let art = symbol_art(&mut d);
|
||||
d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) });
|
||||
let style = CharStyle { size: 24.0, ..Default::default() };
|
||||
let mut t = TextObject::point(vectorcraft_geom::Point::new(10.0, 50.0), "A", style.clone());
|
||||
t.runs.push(TextRun::inline(InlineArt::new("S"), style.clone()));
|
||||
t.runs.push(TextRun::new("B", style));
|
||||
let (l, id) = (d.layers[0].id, d.alloc_id());
|
||||
d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap();
|
||||
let opts = RenderOptions::default();
|
||||
let mut r = Renderer::new();
|
||||
let first = r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels;
|
||||
let placed = placed_copies(&r, &[&d]);
|
||||
assert_eq!(placed.len(), 2, "the square and the line of the inline graphic");
|
||||
assert!(r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels == first);
|
||||
assert_eq!(placed_copies(&r, &[&d]), placed, "the second frame draws the art the first placed");
|
||||
}
|
||||
|
||||
/// Each run draws exactly the outlines it drew when kept as a path of its own: its glyphs and
|
||||
/// decorations in layout order (the whole text's path is kept once, the runs as spans of it).
|
||||
#[test]
|
||||
|
||||
@@ -65,6 +65,38 @@ fn revolve_canvas_matches_baked_art_and_shades_its_surface() {
|
||||
assert_eq!(live.pixels, render_threads(&d, 4).pixels);
|
||||
}
|
||||
|
||||
/// Extrude & Bevel, Inflate and Rotate draw on the canvas exactly as they export, filling more
|
||||
/// than the flat square with faces of differing shades (Rotate: one shade, narrower).
|
||||
#[test]
|
||||
fn solid_effects_canvas_matches_baked_art_and_shades_their_faces() {
|
||||
for (effect, params) in [
|
||||
("threeD.extrude", json!({"depth": 30, "bevel": "rounded"})),
|
||||
("threeD.inflate", json!({"depth": 10})),
|
||||
("threeD.rotate", json!({"rotationX": 0, "rotationY": 55, "rotationZ": 0})),
|
||||
] {
|
||||
let mut n = Node::path(
|
||||
NodeId(0),
|
||||
shapes::rectangle(vectorcraft_geom::Rect::new(30.0, 30.0, 70.0, 70.0)),
|
||||
Appearance::basic(Paint::solid(Color::rgb(0.9, 0.2, 0.1)), Paint::None, 0.0),
|
||||
);
|
||||
n.appearance.effects.push(fx(effect, params));
|
||||
let d = doc_with(Document::new(100.0, 100.0), n);
|
||||
let live = render(&d);
|
||||
let exported = render(&vectorcraft_effects::bake_document(&d).unwrap());
|
||||
assert_eq!(live.pixels, exported.pixels, "{effect}: canvas and baked/exported faces agree");
|
||||
assert_eq!(live.pixels, render_threads(&d, 4).pixels, "{effect}");
|
||||
let reds: Vec<_> =
|
||||
live.pixels.as_chunks::<4>().0.iter().filter(|p| p[0] as u16 > p[1] as u16 * 2 && p[0] as u16 > p[2] as u16 * 2).map(|p| p[0]).collect();
|
||||
let shades = reds.iter().max().unwrap() - reds.iter().min().unwrap();
|
||||
if effect == "threeD.rotate" {
|
||||
assert!(reds.len() < 40 * 40 && reds.len() > 600, "{effect}: the square foreshortened, {} px", reds.len());
|
||||
} else {
|
||||
assert!(reds.len() > 40 * 40, "{effect}: a solid larger than its front, {} px", reds.len());
|
||||
assert!(shades > 60, "{effect}: lighting varies across the faces ({shades})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolve_visible_surface_expansion_preserves_the_view_and_transparent_art() {
|
||||
let mut bp = vectorcraft_geom::BezPath::new();
|
||||
|
||||
@@ -1235,6 +1235,14 @@ impl Writer<'_> {
|
||||
return;
|
||||
}
|
||||
let elements = Node::knockout_elements(children);
|
||||
// Where every element over another covers it fully, drawing them in order knocks out as
|
||||
// much: the masks would only add seams along the shapes' antialiased edges.
|
||||
if elements.iter().skip(1).all(|c| paints_opaque(c)) {
|
||||
for c in elements {
|
||||
self.node(c);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some((first, rest)) = elements.split_first() else { return };
|
||||
for c in rest.iter().rev() {
|
||||
let mid = self.knockout_mask(c);
|
||||
@@ -2157,3 +2165,28 @@ fn line_start(line: &[Segment], anchor: Option<f64>) -> Option<(f64, f64)> {
|
||||
};
|
||||
Some((x, first.y))
|
||||
}
|
||||
|
||||
/// Does `n` paint opaquely wherever it paints (no transparency, blending, masks or effects in
|
||||
/// it)? Anything this can't tell is taken as not.
|
||||
fn paints_opaque(n: &Node) -> bool {
|
||||
use vectorcraft_doc::{AppearanceItem, NodeKind};
|
||||
let paint = |p: &Paint| match p {
|
||||
Paint::None | Paint::Solid { .. } => true,
|
||||
Paint::Gradient(g) => g.gradient.stops.iter().all(|s| s.opacity >= 1.0),
|
||||
_ => false,
|
||||
};
|
||||
let items = n.appearance.items.iter().all(|i| match i {
|
||||
AppearanceItem::Fill(f) => !f.visible || (f.opacity >= 1.0 && f.blend == BlendMode::Normal && f.effects.is_empty() && paint(&f.paint)),
|
||||
// Brush art draws as pieces with opacities of their own (bristles vary theirs).
|
||||
AppearanceItem::Stroke(s) => {
|
||||
!s.visible || (s.opacity >= 1.0 && s.blend == BlendMode::Normal && s.effects.is_empty() && s.brush.is_none() && paint(&s.paint))
|
||||
}
|
||||
});
|
||||
let own = n.opacity >= 1.0 && n.blend == BlendMode::Normal && n.extras().mask.is_none() && n.appearance.effects.is_empty() && items;
|
||||
own && match &n.kind {
|
||||
NodeKind::Path { .. } => true,
|
||||
NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => children.iter().all(|c| paints_opaque(c)),
|
||||
NodeKind::Mesh(m) => m.points.iter().all(|p| p.opacity >= 1.0),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -95,3 +95,48 @@ fn clip_and_invert_masks_match_the_canvas() {
|
||||
assert!(within(canvas.over_white(10, 10), [255, g, g], 2), "{:?}", canvas.over_white(10, 10));
|
||||
}
|
||||
}
|
||||
|
||||
/// A knockout group of opaque art draws its elements plainly: each covers what it knocks out, and
|
||||
/// masks of their shapes would leave seams along the antialiased edges. Translucent art still knocks
|
||||
/// out through masks.
|
||||
#[test]
|
||||
fn a_knockout_group_of_opaque_art_needs_no_masks() {
|
||||
let knockout = |opacity: f32| {
|
||||
let mut d = Document::new(20.0, 20.0);
|
||||
let below = rect(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0), [200, 0, 0]);
|
||||
let mut top = rect(&mut d, Rect::new(5.0, 5.0, 15.0, 15.0), [0, 0, 200]);
|
||||
top.opacity = opacity;
|
||||
let mut group = Node::group(d.alloc_id(), vec![below.into(), top.into()]);
|
||||
group.knockout = vectorcraft_doc::Knockout::On;
|
||||
add(&mut d, group);
|
||||
export(&d, &ExportOptions::default())
|
||||
};
|
||||
let opaque = knockout(1.0);
|
||||
assert!(!opaque.contains("<mask"), "{opaque}");
|
||||
assert!(within(resvg_render(&opaque, 20, 20).over_white(10, 10), [0, 0, 200], 2));
|
||||
let translucent = knockout(0.5);
|
||||
assert!(translucent.contains("<mask"), "{translucent}");
|
||||
// The top half-shows white, not the red it knocks out.
|
||||
assert!(
|
||||
within(resvg_render(&translucent, 20, 20).over_white(10, 10), [128, 128, 228], 3),
|
||||
"{:?}",
|
||||
resvg_render(&translucent, 20, 20).over_white(10, 10)
|
||||
);
|
||||
}
|
||||
|
||||
/// A brushed stroke is no opaque art: its pieces carry their own opacity (a bristle brush's vary),
|
||||
/// so the knockout group keeps its masks.
|
||||
#[test]
|
||||
fn a_knockout_group_with_a_brushed_stroke_keeps_its_masks() {
|
||||
let mut d = Document::new(40.0, 40.0);
|
||||
let below = rect(&mut d, Rect::new(0.0, 0.0, 40.0, 40.0), [200, 0, 0]);
|
||||
let mut stroke = vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::rgb8(0, 0, 200)), 8.0);
|
||||
stroke.brush = Some("Bristle Round".into());
|
||||
let line = shapes::line(vectorcraft_geom::Point::new(5.0, 20.0), vectorcraft_geom::Point::new(35.0, 20.0));
|
||||
let top = Node::path(d.alloc_id(), line, Appearance { items: vec![vectorcraft_doc::AppearanceItem::Stroke(stroke)], ..Default::default() });
|
||||
let mut group = Node::group(d.alloc_id(), vec![below.into(), top.into()]);
|
||||
group.knockout = vectorcraft_doc::Knockout::On;
|
||||
add(&mut d, group);
|
||||
let svg = export(&d, &ExportOptions::default());
|
||||
assert!(svg.contains("<mask"), "{svg}");
|
||||
}
|
||||
@@ -104,7 +104,12 @@ pub fn eps(data: &[u8], page: &str) -> Vec<u8> {
|
||||
/// `private` (the private data streams' contents, such as `%AI12_CompressedData` and zlib data) in
|
||||
/// two streams.
|
||||
pub fn ai(private: &[u8], page: &str) -> Vec<u8> {
|
||||
with_private(private, page, "AIPrivateData")
|
||||
ai_with(private, page, "")
|
||||
}
|
||||
|
||||
/// [`ai`] with more entries in the page dictionary (`/ArtBox [0 0 200 100]`).
|
||||
pub fn ai_with(private: &[u8], page: &str, entries: &str) -> Vec<u8> {
|
||||
with_private(private, page, "AIPrivateData", entries)
|
||||
}
|
||||
|
||||
/// A PDF saved with Illustrator's editing data: [`ai`]'s file with the two streams under
|
||||
@@ -112,11 +117,12 @@ pub fn ai(private: &[u8], page: &str) -> Vec<u8> {
|
||||
/// #868, and nothing else of that file is used; ExifTool's PDF tag table lists them too
|
||||
/// (<https://exiftool.org/TagNames/PDF.html>).
|
||||
pub fn pdf(private: &[u8], page: &str) -> Vec<u8> {
|
||||
with_private(private, page, "AIPDFPrivateData")
|
||||
with_private(private, page, "AIPDFPrivateData", "")
|
||||
}
|
||||
|
||||
/// The file of [`ai`] and [`pdf`], with `private` in the streams `{key}1` and `{key}2`.
|
||||
fn with_private(private: &[u8], page: &str, key: &str) -> Vec<u8> {
|
||||
/// The file of [`ai`] and [`pdf`], with `private` in the streams `{key}1` and `{key}2` and
|
||||
/// `entries` more in the page dictionary.
|
||||
fn with_private(private: &[u8], page: &str, key: &str, entries: &str) -> Vec<u8> {
|
||||
let (first, second) = private.split_at(private.len() / 2);
|
||||
let stream = |part: &[u8]| {
|
||||
let mut s = format!("<< /Length {} >>\nstream\n", part.len()).into_bytes();
|
||||
@@ -128,7 +134,7 @@ fn with_private(private: &[u8], page: &str, key: &str) -> Vec<u8> {
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
format!(
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R \
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] {entries} /Contents 4 0 R \
|
||||
/Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >> \
|
||||
/PieceInfo << /{PIECE} << /Private << /{key}1 5 0 R /{key}2 6 0 R /NumBlock 2 >> >> >> >>"
|
||||
)
|
||||
|
||||
@@ -389,6 +389,8 @@ fn modified(path: &Path) -> Option<std::time::SystemTime> {
|
||||
struct SysFallback {
|
||||
enabled: bool,
|
||||
misses: std::collections::HashSet<char>,
|
||||
/// Counts the scans of the font folders: a search that a scan overtook doesn't record its miss.
|
||||
scans: u64,
|
||||
}
|
||||
|
||||
/// Fonts made for Thai, tried first (when installed) for Thai the font in use lacks: Windows
|
||||
@@ -1734,6 +1736,12 @@ impl FontDb {
|
||||
*self.catalog.write().unwrap_or_else(|e| e.into_inner()) = catalog;
|
||||
*self.aliases.write().unwrap_or_else(|e| e.into_inner()) = aliases;
|
||||
*self.postscript.write().unwrap_or_else(|e| e.into_inner()) = postscript;
|
||||
// A font installed since may cover the characters no font did: they are looked for again.
|
||||
{
|
||||
let mut sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
|
||||
sys.scans = sys.scans.wrapping_add(1);
|
||||
sys.misses.clear();
|
||||
}
|
||||
self.changed();
|
||||
n
|
||||
}
|
||||
@@ -2034,6 +2042,9 @@ impl FontDb {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// The first scan done before its count is read, so the first miss is remembered too.
|
||||
self.ensure_catalog();
|
||||
let scans = self.sys.lock().unwrap_or_else(|e| e.into_inner()).scans;
|
||||
let covered = |db: &FontDb| db.read_faces().iter().any(|f| f.covers(c));
|
||||
for fam in SYSTEM_FALLBACKS {
|
||||
if !self.is_loaded(fam) && self.load_cataloged(fam) && covered(self) {
|
||||
@@ -2055,7 +2066,10 @@ impl FontDb {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.insert(c);
|
||||
let mut sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if sys.scans == scans {
|
||||
sys.misses.insert(c);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
|
||||
+68
-10
@@ -156,6 +156,33 @@ pub fn layout(db: &FontDb, t: &TextObject) -> TextLayout {
|
||||
layout_with(db, t, &opts)
|
||||
}
|
||||
|
||||
/// Area type in a frame too short for its first line draws nothing (type that doesn't fit its frame
|
||||
/// isn't drawn). For importers whose source shows that line: the frame is made taller, from its
|
||||
/// top, by what the first line needs. Whether it was.
|
||||
pub fn fit_first_line(db: &FontDb, t: &mut TextObject) -> bool {
|
||||
let TextKind::Area { frame } = &t.kind else { return false };
|
||||
let Some(b) = frame.bounds().filter(|b| b.height() > 1e-6) else { return false };
|
||||
if !layout(db, t).lines.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// The height of its first line: the line's as point type.
|
||||
let point = TextObject { kind: TextKind::Point, ..t.clone() };
|
||||
let Some(need) = layout(db, &point).lines.first().map(|l| l.ascent + l.descent) else { return false };
|
||||
// (A little more than it, for the rounding of the layout.)
|
||||
let by = (need + 0.05) / b.height();
|
||||
if !(by.is_finite() && by > 1.0) {
|
||||
return false;
|
||||
}
|
||||
let taller = frame.transformed(Affine::translate((0.0, b.y0)) * Affine::scale_non_uniform(1.0, by) * Affine::translate((0.0, -b.y0)));
|
||||
let short = std::mem::replace(&mut t.kind, TextKind::Area { frame: taller });
|
||||
// Something else keeps the line out (a frame narrower than a letter): as the source has it.
|
||||
if layout(db, t).lines.is_empty() {
|
||||
t.kind = short;
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The underline and strikethrough bars of `t` laid out as `layout` (#847): per run and line, a
|
||||
/// bar from the stretch's first glyph to its last one's advance, placed and sized by the font's
|
||||
/// own underline and strikeout metrics through the glyph's transform (so the size, baseline shift
|
||||
@@ -736,8 +763,27 @@ struct Region {
|
||||
gap: f64,
|
||||
}
|
||||
|
||||
/// Flattened closed polygons of a path.
|
||||
fn polygons(path: &BezPath) -> Vec<Vec<Point>> {
|
||||
/// The largest magnitude a coordinate of a frame, a text wrap shape or the path of type on a path
|
||||
/// may have (points), control points included. A frame or path with a larger coordinate, or a
|
||||
/// coordinate that isn't finite, holds no text, and a text wrap shape with one is ignored.
|
||||
const MAX_COORD: f64 = 1e9;
|
||||
|
||||
/// Are the coordinates of `path`, control points included, finite and within [`MAX_COORD`]?
|
||||
fn in_range(path: &BezPath) -> bool {
|
||||
let near = |p: &Point| p.x.abs() <= MAX_COORD && p.y.abs() <= MAX_COORD;
|
||||
path.elements().iter().all(|el| match el {
|
||||
PathEl::MoveTo(p) | PathEl::LineTo(p) => near(p),
|
||||
PathEl::QuadTo(a, p) => near(a) && near(p),
|
||||
PathEl::CurveTo(a, b, p) => near(a) && near(b) && near(p),
|
||||
PathEl::ClosePath => true,
|
||||
})
|
||||
}
|
||||
|
||||
/// Flattened closed polygons of a path; `None` when it isn't [`in_range`].
|
||||
fn polygons(path: &BezPath) -> Option<Vec<Vec<Point>>> {
|
||||
if !in_range(path) {
|
||||
return None;
|
||||
}
|
||||
let mut polys: Vec<Vec<Point>> = Vec::new();
|
||||
kurbo::flatten(path, 0.1, |el| match el {
|
||||
PathEl::MoveTo(p) => polys.push(vec![p]),
|
||||
@@ -749,7 +795,7 @@ fn polygons(path: &BezPath) -> Vec<Vec<Point>> {
|
||||
_ => {}
|
||||
});
|
||||
polys.retain(|p| p.len() >= 3);
|
||||
polys
|
||||
Some(polys)
|
||||
}
|
||||
|
||||
/// Inside intervals (even-odd) of the horizontal line at `y` through `polys`.
|
||||
@@ -817,9 +863,13 @@ fn intersect_intervals(a: &[(f64, f64)], b: &[(f64, f64)]) -> Vec<(f64, f64)> {
|
||||
|
||||
impl Region {
|
||||
fn cells(path: &BezPath, opts: &LayoutOptions, wrap: &[vectorcraft_doc::WrapShape]) -> Vec<Region> {
|
||||
let polys = polygons(path);
|
||||
let wraps: Vec<(Vec<Vec<Point>>, f64, bool)> =
|
||||
wrap.iter().map(|w| (polygons(&w.path.to_bezpath()), w.wrap.offset.max(0.0), w.wrap.invert)).filter(|w| !w.0.is_empty()).collect();
|
||||
// A frame out of range has no cells, and a wrap shape out of range is left out.
|
||||
let Some(polys) = polygons(path) else { return vec![] };
|
||||
let wraps: Vec<(Vec<Vec<Point>>, f64, bool)> = wrap
|
||||
.iter()
|
||||
.filter_map(|w| Some((polygons(&w.path.to_bezpath())?, w.wrap.offset.max(0.0), w.wrap.invert)))
|
||||
.filter(|w| !w.0.is_empty())
|
||||
.collect();
|
||||
let bbox = path.bounding_box();
|
||||
let rect = polys.len() == 1 && {
|
||||
let area: f64 = polys[0].iter().zip(polys[0].iter().cycle().skip(1)).map(|(a, b)| a.x * b.y - b.x * a.y).sum::<f64>().abs() * 0.5;
|
||||
@@ -965,6 +1015,10 @@ struct Pen<'r> {
|
||||
next_top: Option<f64>,
|
||||
}
|
||||
|
||||
/// The largest number of steps layout takes to move a line down a frame cell to find room for it:
|
||||
/// in a cell taller than this many leads, a step is the cell's height divided by this.
|
||||
const MAX_STEPS: usize = 100_000;
|
||||
|
||||
impl Pen<'_> {
|
||||
/// Baseline and horizontal span for a line with estimated metrics `est` and indents; `None` =
|
||||
/// the frame is full (overflow).
|
||||
@@ -985,7 +1039,10 @@ impl Pen<'_> {
|
||||
None => r.top() + r.shift + est.first_baseline(self.fb, self.fb_min),
|
||||
Some(b) => self.next_baseline(b, est) + r.gap,
|
||||
};
|
||||
loop {
|
||||
// The baseline moves down the cell a lead at a time, or a `MAX_STEPS`th of the cell's
|
||||
// height when that is more, in at most `MAX_STEPS` steps.
|
||||
let step = est.lead.max(1.0).max(r.cell.height() / MAX_STEPS as f64);
|
||||
for _ in 0..=MAX_STEPS {
|
||||
if baseline + est.desc > r.bottom() + 0.01 {
|
||||
break;
|
||||
}
|
||||
@@ -993,13 +1050,13 @@ impl Pen<'_> {
|
||||
if r.wraps.is_empty() {
|
||||
match r.span(baseline - est.asc, baseline + est.desc) {
|
||||
Some(s) if fits(&s) => return Some((baseline, s.0, s.1, false)),
|
||||
_ => baseline += est.lead.max(1.0),
|
||||
_ => baseline += step,
|
||||
}
|
||||
} else {
|
||||
// Wrap objects can split a line: fill every span, left to right.
|
||||
let mut spans: Vec<(f64, f64)> = r.spans(baseline - est.asc, baseline + est.desc).into_iter().filter(fits).collect();
|
||||
if spans.is_empty() {
|
||||
baseline += est.lead.max(1.0);
|
||||
baseline += step;
|
||||
continue;
|
||||
}
|
||||
let (a, b) = spans.remove(0);
|
||||
@@ -1725,7 +1782,8 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, path: &Path
|
||||
}
|
||||
}
|
||||
let rtl = rtl.unwrap_or(false);
|
||||
let ap = ArcPath::new(path);
|
||||
// A path out of range holds no text, as a path with no length holds none.
|
||||
let ap = if in_range(&path.to_bezpath()) { ArcPath::new(path) } else { ArcPath::default() };
|
||||
let m = Metrics::max(&sg).map(|m| (m.asc, m.desc)).unwrap_or_else(|| {
|
||||
let s = style_metrics(cx.db, cx.style_at(0));
|
||||
(s.0, s.1)
|
||||
|
||||
@@ -32,7 +32,7 @@ pub use fontdb::{
|
||||
};
|
||||
pub use hyphen::{hyphenation_exceptions, set_hyphenation_exceptions};
|
||||
use kurbo::{Affine, BezPath, Point, Rect, Vec2};
|
||||
pub use layout::{decorations, layout, layout_with};
|
||||
pub use layout::{decorations, fit_first_line, layout, layout_with};
|
||||
pub use vectorcraft_doc::TextObject;
|
||||
|
||||
pub use vectorcraft_doc::{AreaFit, FirstBaseline, VerticalAlign};
|
||||
|
||||
@@ -120,6 +120,33 @@ fn fonts_without_outlines_the_engine_draws_are_left_out() {
|
||||
assert_eq!(db.load_system_fonts(), 1);
|
||||
}
|
||||
|
||||
/// A character no font covered is looked for again once a rescan finds a font that has it (#992).
|
||||
#[test]
|
||||
fn rescanning_retries_characters_no_font_covered() {
|
||||
// A private-use character none of the bundled fonts has.
|
||||
const C: char = '\u{F0000}';
|
||||
let dir = std::env::temp_dir().join(format!("vc-sysfonts-{}-retry", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let db = FontDb::with_font_dirs(vec![dir.clone()]);
|
||||
assert!(db.face_covering(C).is_none());
|
||||
// Source Sans 3 Regular renamed, its character map replaced by one holding only `C`.
|
||||
let base = named(&[(3, EN, 1, "Retry Sans"), (3, EN, 2, "Regular"), (3, EN, 6, "RetrySans-Regular")]);
|
||||
let font = skrifa::FontRef::new(&base).unwrap();
|
||||
let mut builder = write_fonts::FontBuilder::new();
|
||||
builder.add_raw(write_fonts::types::Tag::new(b"cmap"), crate::embed::cmap(&[(C as u32, 1)]).unwrap());
|
||||
for r in font.table_directory.table_records() {
|
||||
let tag = write_fonts::types::Tag::new(&r.tag().to_be_bytes());
|
||||
if !builder.contains(tag) {
|
||||
builder.add_raw(tag, font.table_data(r.tag()).unwrap().as_bytes());
|
||||
}
|
||||
}
|
||||
std::fs::write(dir.join("Retry.ttf"), builder.build()).unwrap();
|
||||
assert!(db.face_covering(C).is_none(), "the miss is remembered until the fonts are scanned again");
|
||||
assert_eq!(db.load_system_fonts(), 1);
|
||||
assert_eq!(db.face_covering(C).unwrap().family, "Retry Sans");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rescanning_finds_fonts_installed_since() {
|
||||
let dir = font_dir("rescan");
|
||||
|
||||
@@ -212,6 +212,124 @@ fn area_flow_inside_circle_keeps_glyphs_inside() {
|
||||
assert!(w(l.lines.len() / 2) > w(0));
|
||||
}
|
||||
|
||||
/// Lays `t` out as [`layout`] does, on a thread of its own, and fails the test when that panics or
|
||||
/// takes more than 20 s.
|
||||
fn layout_soon(t: &TextObject) -> TextLayout {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let (t, text) = (t.clone(), t.plain_text());
|
||||
std::thread::spawn(move || tx.send(layout(db(), &t)).ok());
|
||||
rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|e| panic!("laying out {text:?}: {e}"))
|
||||
}
|
||||
|
||||
/// Each line's baseline, left and right edges, and byte range.
|
||||
fn line_places(l: &TextLayout) -> Vec<(f64, f64, f64, usize, usize)> {
|
||||
l.lines.iter().map(|li| (li.baseline, li.x0, li.x1, li.start, li.end)).collect()
|
||||
}
|
||||
|
||||
/// A frame or text wrap shape with a coordinate beyond a billion points either way, or one that
|
||||
/// isn't finite, is left out: the frame holds no text, and the wrap shape is ignored.
|
||||
#[test]
|
||||
fn frames_and_wrap_shapes_beyond_a_billion_points_are_left_out() {
|
||||
// A box from x0 to x1 and from 0 to `height` whose top edge has a control point at x = `far`,
|
||||
// on the edge's own line.
|
||||
let boxed = |x0: f64, x1: f64, height: f64, far: f64| {
|
||||
let mut p = BezPath::new();
|
||||
p.move_to((x0, 0.0));
|
||||
p.curve_to((x0, 0.0), (far, 0.0), (x1, 0.0));
|
||||
p.line_to((x1, height));
|
||||
p.line_to((x0, height));
|
||||
p.close_path();
|
||||
p
|
||||
};
|
||||
let rectangle = layout(db(), &area("Hi there", style(12.0), Rect::new(0.0, 0.0, 100.0, 40.0), Justify::Left));
|
||||
let within = layout_soon(&area_path("Hi there", style(12.0), &boxed(0.0, 100.0, 40.0, 1e9), Justify::Left));
|
||||
assert_eq!(line_places(&within), line_places(&rectangle));
|
||||
let mut beyond = [8e115, 2e9, f64::INFINITY].map(|far| boxed(0.0, 100.0, 40.0, far)).to_vec();
|
||||
beyond.push(Rect::new(0.0, 0.0, 100.0, f64::NAN).to_path(0.1));
|
||||
for frame in &beyond {
|
||||
let l = layout_soon(&area_path("Hi there", style(12.0), frame, Justify::Left));
|
||||
assert!(l.lines.is_empty() && l.glyphs.is_empty() && l.overflow, "{frame:?}");
|
||||
}
|
||||
// Text wrapped around the right half of a 300 by 200 frame.
|
||||
let mut t = area(COPY, style(12.0), Rect::new(0.0, 0.0, 300.0, 200.0), Justify::Left);
|
||||
let unwrapped = layout(db(), &t);
|
||||
let mut wrapped = |path: BezPath| {
|
||||
t.wrap = vec![vectorcraft_doc::WrapShape { path: PathData::from_bezpath(&path), wrap: Default::default() }];
|
||||
layout_soon(&t)
|
||||
};
|
||||
let half = wrapped(Rect::new(150.0, 0.0, 300.0, 200.0).to_path(0.1));
|
||||
assert_ne!(line_places(&half), line_places(&unwrapped));
|
||||
assert_eq!(line_places(&wrapped(boxed(150.0, 300.0, 200.0, 1e9))), line_places(&half));
|
||||
for far in [8e115, 2e9, f64::INFINITY] {
|
||||
assert_eq!(line_places(&wrapped(boxed(150.0, 300.0, 200.0, far))), line_places(&unwrapped), "{far}");
|
||||
}
|
||||
assert_eq!(line_places(&wrapped(Rect::new(150.0, 0.0, 300.0, f64::NAN).to_path(0.1))), line_places(&unwrapped));
|
||||
}
|
||||
|
||||
/// Layout steps a line down a frame (or a row or column of one) at most 100,000 times to find room
|
||||
/// for it: in a frame taller than 100,000 leads, a step is a 100,000th of the frame's height.
|
||||
#[test]
|
||||
fn tall_frames_are_searched_for_room_in_at_most_100000_steps() {
|
||||
// A triangle from y = -1e9 to 1e9, `base` wide at the bottom.
|
||||
let triangle = |base: f64| {
|
||||
let mut p = BezPath::new();
|
||||
p.move_to((0.0, -1e9));
|
||||
p.line_to((base / 2.0, 1e9));
|
||||
p.line_to((-base / 2.0, 1e9));
|
||||
p.close_path();
|
||||
p
|
||||
};
|
||||
// No line fits in this triangle: it is at most 0.5 pt wide, and a line needs a span wider than
|
||||
// 1 pt or its ascent, whichever is more. A lead of 0.5 pt type counts as 1 pt.
|
||||
let none = layout_soon(&area_path("Hi", style(0.5), &triangle(0.5), Justify::Left));
|
||||
assert!(none.lines.is_empty() && none.overflow);
|
||||
// This one is wide enough for 12 pt type from about y = -7.6e8 down.
|
||||
let some = layout_soon(&area_path("Hi", style(12.0), &triangle(100.0), Justify::Left));
|
||||
assert!(!some.overflow && some.lines.len() == 1);
|
||||
// At the height of the line's top, the line's left and right edges lie inside the triangle.
|
||||
let line = &some.lines[0];
|
||||
let half_width = (line.baseline - line.ascent + 1e9) / 2e9 * 50.0;
|
||||
assert!(line.x0 >= -half_width - 1e-6 && line.x1 <= half_width + 1e-6, "{line:?}");
|
||||
}
|
||||
|
||||
/// A paragraph whose Space Before puts it 1e300 pt above a triangle frame that comes to a point at
|
||||
/// its top overflows. At that height a step of one lead leaves the baseline where it is, and
|
||||
/// layout takes at most 100,000 such steps.
|
||||
#[test]
|
||||
fn a_paragraph_spaced_far_above_its_frame_overflows() {
|
||||
let mut triangle = BezPath::new();
|
||||
triangle.move_to((0.0, 0.0));
|
||||
triangle.line_to((500.0, 1000.0));
|
||||
triangle.line_to((-500.0, 1000.0));
|
||||
triangle.close_path();
|
||||
let mut t = area_path("Hi\nthere", style(12.0), &triangle, Justify::Left);
|
||||
let far = vectorcraft_doc::ParaStyle { space_before: -1e300, ..Default::default() };
|
||||
t.set_paragraph_styles(vec![t.para.clone(), far]);
|
||||
let l = layout_soon(&t);
|
||||
assert!(l.overflow && l.lines.len() == 1, "{:?}", line_places(&l));
|
||||
}
|
||||
|
||||
/// Type on a path whose path has a coordinate beyond a billion points either way, or one that isn't
|
||||
/// finite, holds no text.
|
||||
#[test]
|
||||
fn type_on_a_path_beyond_a_billion_points_holds_no_text() {
|
||||
// An S-shaped curve from (0, 0) to (3s, 0) with control points (s, s) and (2s, -s).
|
||||
let on = |s: f64| {
|
||||
let mut p = BezPath::new();
|
||||
p.move_to((0.0, 0.0));
|
||||
p.curve_to((s, s), (2.0 * s, -s), (3.0 * s, 0.0));
|
||||
let mut t = TextObject::point(Point::ZERO, "On a path", style(8.0));
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&p), start: 0.0, end: None };
|
||||
t
|
||||
};
|
||||
let within = layout_soon(&on(1e9 / 3.0));
|
||||
assert!(!within.overflow && within.glyphs.len() == 9);
|
||||
for s in [1e115, 1e9, f64::INFINITY] {
|
||||
let l = layout_soon(&on(s));
|
||||
assert!(l.glyphs.is_empty() && l.overflow, "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn justify_all_line_widths_equal() {
|
||||
let t = area(COPY, style(12.0), Rect::new(0.0, 0.0, 240.0, 800.0), Justify::JustifyAll);
|
||||
|
||||
@@ -3,10 +3,11 @@ name = "vectorcraft-three-d"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Portable surface-of-revolution geometry and projected vector shading"
|
||||
description = "Portable 3D effect geometry (revolve, extrude and bevel, inflate, rotate) and projected vector shading"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
vectorcraft-geom = { workspace = true }
|
||||
vectorcraft-pathops = { workspace = true }
|
||||
@@ -1,11 +1,14 @@
|
||||
//! A portable surface-of-revolution evaluator. No windowing, GPU or document dependencies.
|
||||
//! Portable 3D effect geometry: Revolve, Extrude & Bevel, Inflate and Rotate. No windowing, GPU or
|
||||
//! document dependencies.
|
||||
//!
|
||||
//! Open profiles generate uncapped surfaces. Faces are projected and sorted back to front,
|
||||
//! then flat shaded. This vector preview uses a painter's algorithm, not a depth buffer:
|
||||
//! intersecting profiles can have ambiguous visibility and are not solid modelling inputs.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod solid;
|
||||
mod visibility;
|
||||
pub use solid::{Bevel, BevelShape, Extrude, Facet, Inflate, View, extrude, inflate, plane};
|
||||
pub use visibility::{VisibleFace, visible_faces};
|
||||
|
||||
use vectorcraft_geom::{BezPath, ParamCurve, PathData, Point, Rect};
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,289 @@
|
||||
use vectorcraft_geom::{BezPath, PathData, Point, Rect};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn square(x: f64, y: f64, s: f64) -> BezPath {
|
||||
let mut b = BezPath::new();
|
||||
b.move_to((x, y));
|
||||
b.line_to((x + s, y));
|
||||
b.line_to((x + s, y + s));
|
||||
b.line_to((x, y + s));
|
||||
b.close_path();
|
||||
b
|
||||
}
|
||||
|
||||
fn shape(b: &BezPath) -> (PathData, Rect) {
|
||||
let p = PathData::from_bezpath(b);
|
||||
let r = p.bounds().unwrap();
|
||||
(p, r)
|
||||
}
|
||||
|
||||
/// Straight on, without perspective.
|
||||
fn front() -> View {
|
||||
View { rotation_x: 0.0, rotation_y: 0.0, rotation_z: 0.0, perspective: 0.0, ..View::default() }
|
||||
}
|
||||
|
||||
fn extent(f: &[Facet]) -> Rect {
|
||||
let pts: Vec<Point> = f.iter().flat_map(|f| f.contours.iter().flatten().copied()).collect();
|
||||
pts.iter().fold(Rect::from_points(pts[0], pts[0]), |r, p| r.union_pt(*p))
|
||||
}
|
||||
|
||||
/// The area the facets cover: each facet's contours counted with their winding (holes
|
||||
/// subtract), whichever way round the facet runs.
|
||||
fn area(f: &[Facet]) -> f64 {
|
||||
f.iter().map(|f| f.contours.iter().map(|c| signed_area(c)).sum::<f64>().abs()).sum()
|
||||
}
|
||||
|
||||
fn close(a: f64, b: f64, tol: f64) -> bool {
|
||||
(a - b).abs() <= tol
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seen_straight_on_an_extrusion_is_its_front_face_exactly_where_the_art_is() {
|
||||
let (p, b) = shape(&square(10.0, 20.0, 100.0));
|
||||
let f = extrude(&p, b, Extrude::default(), front()).unwrap();
|
||||
assert_eq!(f.len(), 1, "the walls and back face away: only the front cap shows");
|
||||
assert_eq!(f[0].contours.len(), 1);
|
||||
assert_eq!(extent(&f), Rect::new(10.0, 20.0, 110.0, 120.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turned_about_y_the_front_foreshortens_and_one_side_wall_shows() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
let o = Extrude { depth: 40.0, ..Extrude::default() };
|
||||
let f = extrude(&p, b, o, View { rotation_y: 30.0, ..front() }).unwrap();
|
||||
// Orthographic: the silhouette is the front's width times cos 30° plus the depth times sin 30°.
|
||||
let w = extent(&f).width();
|
||||
assert!(close(w, 100.0 * 30f64.to_radians().cos() + 40.0 * 30f64.to_radians().sin(), 1e-6), "{w}");
|
||||
assert_eq!(f.len(), 2, "the front cap and one side wall");
|
||||
// Lit from the upper left by default: the cap and the wall are shaded differently.
|
||||
assert!(!close(f[0].brightness, f[1].brightness, 1e-6));
|
||||
// The nearest face is drawn last.
|
||||
assert!(f.windows(2).all(|w| w[0].depth <= w[1].depth));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_depth_is_the_flat_shape_and_no_cap_shows_the_inside() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
let v = View { rotation_x: 20.0, rotation_y: 30.0, ..front() };
|
||||
let flat = extrude(&p, b, Extrude { depth: 0.0, ..Extrude::default() }, v).unwrap();
|
||||
assert_eq!(flat, plane(&p, b, v).unwrap());
|
||||
let solid = extrude(&p, b, Extrude::default(), v).unwrap();
|
||||
let hollow = extrude(&p, b, Extrude { cap: false, ..Extrude::default() }, v).unwrap();
|
||||
// Solid: front, right and top faces. Hollow: all four walls (two seen from inside), no caps.
|
||||
assert_eq!(solid.len(), 3);
|
||||
assert_eq!(hollow.len(), 4);
|
||||
assert!(hollow.iter().all(|f| f.contours.len() == 1 && f.contours[0].len() == 4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn taper_shrinks_the_back_and_twist_turns_it() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
let side = View { rotation_y: 90.0, ..front() };
|
||||
let o = Extrude { depth: 60.0, cap: false, ..Extrude::default() };
|
||||
let straight = extent(&extrude(&p, b, o, side).unwrap());
|
||||
assert!(close(straight.height(), 100.0, 1e-6) && close(straight.width(), 60.0, 1e-6));
|
||||
// Seen from the side, a 50 % taper keeps the front's full height and halves the back's.
|
||||
let tapered = extrude(&p, b, Extrude { taper: 0.5, ..o }, side).unwrap();
|
||||
let back: Vec<Point> = tapered.iter().flat_map(|f| f.contours.iter().flatten().copied()).filter(|q| close(q.x, straight.x0, 1e-6)).collect();
|
||||
let (lo, hi) = back.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), q| (lo.min(q.y), hi.max(q.y)));
|
||||
let at_front: Vec<Point> = tapered.iter().flat_map(|f| f.contours.iter().flatten().copied()).filter(|q| close(q.x, straight.x1, 1e-6)).collect();
|
||||
let (flo, fhi) = at_front.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), q| (lo.min(q.y), hi.max(q.y)));
|
||||
let (back_h, front_h) = (hi - lo, fhi - flo);
|
||||
assert!(close(front_h, 100.0, 1e-6) && close(back_h, 50.0, 1e-6), "front {front_h}, back {back_h}");
|
||||
// Seen from behind, a 45° twist shows the back cap turned into a diamond.
|
||||
let back = View { rotation_y: 180.0, ..front() };
|
||||
let twisted = extrude(&p, b, Extrude { twist: 45.0, cap: true, ..o }, back).unwrap();
|
||||
assert_eq!(twisted.len(), 1, "only the back cap faces the viewer");
|
||||
assert!(close(extent(&twisted).width(), 100.0 * 2f64.sqrt(), 1e-6), "{:?}", extent(&twisted));
|
||||
// Its walls are cut into slices that follow the turn.
|
||||
let open = extrude(&p, b, Extrude { twist: 45.0, ..o }, View { rotation_x: -20.0, rotation_y: 30.0, ..front() }).unwrap();
|
||||
assert!(open.len() > 2 * 8, "{}", open.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bevel_inside_shrinks_the_cap_and_outside_grows_the_walls() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
let bevel = Bevel { width: 10.0, height: 10.0, ..Bevel::default() };
|
||||
let inside = extrude(&p, b, Extrude { bevel: Some(Bevel { inside: true, ..bevel }), ..Extrude::default() }, front()).unwrap();
|
||||
// Straight on: the cap inset by the width, and the chamfer around it filling the outline.
|
||||
let cap = inside.iter().find(|f| close(signed_area(&f.contours[0]).abs(), 80.0 * 80.0, 1e-6));
|
||||
assert!(cap.is_some(), "an 80 × 80 cap");
|
||||
assert_eq!(extent(&inside), Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
assert!(close(area(&inside), 100.0 * 100.0, 1e-6));
|
||||
let outside = extrude(&p, b, Extrude { bevel: Some(bevel), ..Extrude::default() }, front()).unwrap();
|
||||
assert_eq!(extent(&outside), Rect::new(-10.0, -10.0, 110.0, 110.0), "the walls stand 10 pt outside the art");
|
||||
assert!(outside.iter().any(|f| close(signed_area(&f.contours[0]).abs(), 100.0 * 100.0, 1e-6)), "the cap is the art");
|
||||
// The chamfer's faces lean towards the light differently from the cap.
|
||||
assert!(inside.iter().any(|f| !close(f.brightness, inside[inside.len() - 1].brightness, 1e-3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bevel_shapes_repeats_and_both_sides_change_the_surface() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
let v = View { rotation_x: -20.0, rotation_y: 30.0, ..front() };
|
||||
let base = Bevel { width: 12.0, height: 12.0, ..Bevel::default() };
|
||||
let classic = extrude(&p, b, Extrude { bevel: Some(base), ..Extrude::default() }, v).unwrap();
|
||||
for shape in BevelShape::ALL.into_iter().skip(1) {
|
||||
assert_ne!(classic, extrude(&p, b, Extrude { bevel: Some(Bevel { shape, ..base }), ..Extrude::default() }, v).unwrap(), "{shape:?}");
|
||||
}
|
||||
for bevel in [Bevel { repeat: 3, ..base }, Bevel { repeat: 3, space: 0.3, ..base }, Bevel { both_sides: true, ..base }] {
|
||||
assert_ne!(classic, extrude(&p, b, Extrude { bevel: Some(bevel), ..Extrude::default() }, v).unwrap(), "{bevel:?}");
|
||||
}
|
||||
// The step shape keeps its corners: half the width at half the height.
|
||||
assert_eq!(BevelShape::Step.at(0.5), (0.5, 0.5));
|
||||
assert_eq!(BevelShape::Step.at(0.25), (0.0, 0.5));
|
||||
for shape in BevelShape::ALL {
|
||||
assert_eq!(shape.at(0.0), (0.0, 0.0), "{shape:?}");
|
||||
let (x, y) = shape.at(1.0);
|
||||
assert!(close(x, 1.0, 1e-12) && close(y, 1.0, 1e-12), "{shape:?}");
|
||||
assert_eq!(BevelShape::parse(shape.id()), Some(shape));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn holes_stay_holes_in_the_caps_and_get_walls_of_their_own() {
|
||||
let mut b = square(0.0, 0.0, 100.0);
|
||||
// The hole drawn the same way round as the outline: even-odd nesting still makes it a hole.
|
||||
b.extend(square(30.0, 30.0, 40.0));
|
||||
let (p, r) = shape(&b);
|
||||
let f = extrude(&p, r, Extrude::default(), front()).unwrap();
|
||||
assert_eq!(f.len(), 1);
|
||||
assert_eq!(f[0].contours.len(), 2);
|
||||
assert!(close(area(&f), 100.0 * 100.0 - 40.0 * 40.0, 1e-6));
|
||||
let turned = extrude(&p, r, Extrude::default(), View { rotation_y: 40.0, ..front() }).unwrap();
|
||||
// Front cap, the outline's right wall and the hole's left wall (seen through the hole).
|
||||
assert_eq!(turned.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plane_turns_about_the_given_centre() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
let f = plane(&p, b, View { rotation_y: 60.0, ..front() }).unwrap();
|
||||
assert_eq!(f.len(), 1);
|
||||
let e = extent(&f);
|
||||
assert!(close(e.width(), 50.0, 1e-6) && close(e.center().x, 50.0, 1e-9));
|
||||
// Turned about the centre of a larger box, the square moves as well as narrowing.
|
||||
let wide = plane(&p, Rect::new(0.0, 0.0, 300.0, 100.0), View { rotation_y: 60.0, ..front() }).unwrap();
|
||||
assert!(close(extent(&wide).x0, 150.0 - 150.0 * 0.5, 1e-6));
|
||||
// Its back shows when turned past 90°: a plane is two-sided.
|
||||
assert_eq!(plane(&p, b, View { rotation_y: 150.0, ..front() }).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inflate_covers_the_shape_and_puffs_by_the_volume() {
|
||||
let mut c = BezPath::new();
|
||||
c.extend(vectorcraft_geom::kurbo::Shape::path_elements(&vectorcraft_geom::kurbo::Circle::new((100.0, 100.0), 50.0), 0.01));
|
||||
let (p, b) = shape(&c);
|
||||
let f = inflate(&p, b, Inflate::default(), front()).unwrap();
|
||||
// Straight on, the cushion covers the circle (sampled as a polygon) and nothing else.
|
||||
assert!(close(area(&f), std::f64::consts::PI * 50.0 * 50.0, 60.0), "{}", area(&f));
|
||||
assert!(extent(&f).x0 >= 50.0 - 1e-6 && extent(&f).x1 <= 150.0 + 1e-6);
|
||||
// Its slopes catch the light differently.
|
||||
let (lo, hi) = f.iter().fold((1.0f64, 0.0f64), |(lo, hi), f| (lo.min(f.brightness), hi.max(f.brightness)));
|
||||
assert!(hi - lo > 0.3, "{lo}..{hi}");
|
||||
// From the side: 50 % volume rounds a disc into a half sphere on a flat back.
|
||||
let side = View { rotation_y: 90.0, ..front() };
|
||||
let thick = |o: Inflate| extent(&inflate(&p, b, o, side).unwrap()).width();
|
||||
assert!(close(thick(Inflate::default()), 50.0, 2.0), "{}", thick(Inflate::default()));
|
||||
assert!(close(thick(Inflate { both_sides: true, ..Inflate::default() }), 100.0, 4.0));
|
||||
assert!(close(thick(Inflate { depth: 30.0, ..Inflate::default() }), 80.0, 2.0));
|
||||
assert!(close(thick(Inflate { volume: 25.0, ..Inflate::default() }), 25.0, 2.0));
|
||||
assert!(thick(Inflate { volume: 0.0, depth: 10.0, ..Inflate::default() }) <= 10.0 + 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inflate_leaves_holes_open() {
|
||||
let mut b = square(0.0, 0.0, 100.0);
|
||||
b.extend(square(30.0, 30.0, 40.0));
|
||||
let (p, r) = shape(&b);
|
||||
let f = inflate(&p, r, Inflate::default(), front()).unwrap();
|
||||
assert!(close(area(&f), 100.0 * 100.0 - 40.0 * 40.0, 1e-3), "{}", area(&f));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_and_degenerate_input_is_bounded() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
for r in [Rect::new(f64::NAN, 0.0, 1.0, 1.0), Rect::new(0.0, 0.0, 1e9, 1.0)] {
|
||||
assert!(extrude(&p, r, Extrude::default(), front()).is_err());
|
||||
assert!(inflate(&p, r, Inflate::default(), front()).is_err());
|
||||
assert!(plane(&p, r, front()).is_err());
|
||||
}
|
||||
let wild = Extrude {
|
||||
depth: f64::INFINITY,
|
||||
twist: f64::NAN,
|
||||
taper: -3.0,
|
||||
cap: true,
|
||||
bevel: Some(Bevel { width: f64::NAN, height: 1e12, repeat: usize::MAX, space: 7.0, ..Bevel::default() }),
|
||||
};
|
||||
let f = extrude(&p, b, wild, View { rotation_x: f64::NAN, perspective: 9.0, ..front() }).unwrap();
|
||||
assert!(f.iter().flat_map(|f| f.contours.iter().flatten()).all(|q| q.x.is_finite() && q.y.is_finite()));
|
||||
assert!(inflate(&p, b, Inflate { depth: -5.0, volume: f64::NAN, both_sides: true }, front()).is_ok());
|
||||
assert!(extrude(&PathData::default(), b, Extrude::default(), front()).unwrap().is_empty());
|
||||
// A line has no area to extrude.
|
||||
let mut line = BezPath::new();
|
||||
line.move_to((0.0, 0.0));
|
||||
line.line_to((100.0, 0.0));
|
||||
assert!(extrude(&PathData::from_bezpath(&line), b, Extrude::default(), front()).unwrap().is_empty());
|
||||
// Too many anchors: refused, not truncated.
|
||||
let mut many = BezPath::new();
|
||||
many.move_to((0.0, 0.0));
|
||||
for i in 0..3000 {
|
||||
many.line_to((f64::from(i), f64::from(i % 7)));
|
||||
}
|
||||
many.close_path();
|
||||
assert!(extrude(&PathData::from_bezpath(&many), b, Extrude::default(), front()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perspective_makes_the_near_face_larger() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
let o = Extrude { depth: 100.0, ..Extrude::default() };
|
||||
let flat = extent(&extrude(&p, b, o, front()).unwrap());
|
||||
let deep = extent(&extrude(&p, b, o, View { perspective: 0.6, ..front() }).unwrap());
|
||||
assert!(deep.width() > flat.width() + 1.0);
|
||||
assert!(close(deep.center().x, flat.center().x, 1e-6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_inflate_membrane_matches_known_solutions() {
|
||||
// ∇²φ = −1, zero on the outline: a disc of radius r peaks at r²/4, a square of side l at
|
||||
// about 0.07367 l².
|
||||
let mut c = BezPath::new();
|
||||
c.extend(vectorcraft_geom::kurbo::Shape::path_elements(&vectorcraft_geom::kurbo::Circle::new((100.0, 100.0), 50.0), 0.01));
|
||||
let (p, b) = shape(&c);
|
||||
let disc = Membrane::solve(&rings(&p).unwrap(), b.origin(), b.width() / 48.0, 49, 49);
|
||||
assert!(close(disc.peak, 50.0 * 50.0 / 4.0, 625.0 * 0.02), "{}", disc.peak);
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
let sq = Membrane::solve(&rings(&p).unwrap(), b.origin(), 100.0 / 48.0, 49, 49);
|
||||
assert!(close(sq.peak, 0.07367 * 100.0 * 100.0, 736.7 * 0.02), "{}", sq.peak);
|
||||
// Smooth across the middle: no crease where a distance-to-the-edge height would have one
|
||||
// (the diagonals). The second difference along a diagonal stays small and even.
|
||||
let along: Vec<f64> = (10..=90).step_by(5).map(|t| sq.at(Point::new(f64::from(t), f64::from(t)))).collect();
|
||||
// (Near the corners φ grows like r², so the bend changes sign there: that is the solution,
|
||||
// not a crease. A crease would be one bend far larger than its neighbours.)
|
||||
let bends: Vec<f64> = along.windows(3).map(|w| w[0] - 2.0 * w[1] + w[2]).collect();
|
||||
let largest = bends.iter().fold(0.0f64, |m, b| m.max(b.abs()));
|
||||
assert!(bends.windows(2).all(|w| (w[1] - w[0]).abs() < 0.4 * largest), "no kink: {bends:?}");
|
||||
// Zero (or just below, extrapolated) on the outline.
|
||||
assert!(sq.at(Point::new(0.0, 50.0)).abs() < 1.0 && sq.at(Point::new(50.0, 0.0)).abs() < 1.0);
|
||||
}
|
||||
|
||||
/// Grid cells across a hole's edge are cut to the shape itself: a hole's piece cancelling part of
|
||||
/// the outline's only works flat, and lifted onto the surface it left notches along the edge.
|
||||
#[test]
|
||||
fn inflate_cells_across_a_hole_edge_are_cut_to_the_shape() {
|
||||
let mut b = square(80.0, 330.0, 220.0);
|
||||
b.move_to((140.0, 390.0));
|
||||
b.line_to((140.0, 480.0));
|
||||
b.line_to((240.0, 480.0));
|
||||
b.line_to((240.0, 390.0));
|
||||
b.close_path();
|
||||
let (p, r) = shape(&b);
|
||||
let v = View { rotation_x: -21.88, rotation_y: -23.4, rotation_z: 9.06, ..front() };
|
||||
let f = inflate(&p, r, Inflate::default(), v).unwrap();
|
||||
assert!(f.iter().all(|f| f.contours.len() == 1), "every facet is one piece of the surface");
|
||||
// Straight on, the facets still cover exactly the shape.
|
||||
let flat = inflate(&p, r, Inflate::default(), front()).unwrap();
|
||||
assert!(close(area(&flat), 220.0 * 220.0 - 100.0 * 90.0, 1e-3), "{}", area(&flat));
|
||||
}
|
||||
@@ -252,6 +252,11 @@ struct CornerClick {
|
||||
}
|
||||
|
||||
impl CornerDrag {
|
||||
/// The drag has left the click: the corner radius is changing.
|
||||
pub fn adjusting(&self) -> bool {
|
||||
self.began
|
||||
}
|
||||
|
||||
/// Start a drag when the press `ev` is on a widget of the selection (Direct Selection:
|
||||
/// `any_path`).
|
||||
pub fn hit(cx: &ToolContext, ev: &PointerEvent, any_path: bool) -> Option<Self> {
|
||||
@@ -632,7 +637,10 @@ mod tests {
|
||||
let cx2 = cx(&d, &whole, &p);
|
||||
let a = crate::create("selection").pointer(&cx2, &PointerEvent::new(PointerKind::DoubleClick, 190.0, 110.0));
|
||||
assert_eq!(a, vec![Action::Dialog(DIALOG.into(), json!({"id": id.0, "corners": [0, 1, 2, 3]}))]);
|
||||
assert!(crate::create("selection").pointer(&cx2, &PointerEvent::new(PointerKind::DoubleClick, 150.0, 150.0)).is_empty());
|
||||
// Off the widgets, the Selection tool's double-click isolates the shape (Double Click To
|
||||
// Isolate) instead.
|
||||
let a = crate::create("selection").pointer(&cx2, &PointerEvent::new(PointerKind::DoubleClick, 150.0, 150.0));
|
||||
assert_eq!(a, vec![Action::Exec("object.isolate".into(), json!({"id": id.0}))]);
|
||||
}
|
||||
|
||||
/// Hide Corner Widget for angles greater than (#394): a rectangle's right angles hide below
|
||||
|
||||
@@ -27,6 +27,14 @@ pub struct FamilyTool {
|
||||
pub rows: u32,
|
||||
/// Columns / radial dividers.
|
||||
pub columns: u32,
|
||||
/// The grids' divider skews in percent: rows / concentric, then columns / radial.
|
||||
pub skew: [f64; 2],
|
||||
/// Rectangular Grid: the outside is one rectangle (else its four sides).
|
||||
pub frame: bool,
|
||||
/// Polar Grid: the ellipses pair up into compound paths.
|
||||
pub compound: bool,
|
||||
/// The grids take the current fill.
|
||||
pub fill: bool,
|
||||
snap: DrawSnap,
|
||||
}
|
||||
|
||||
@@ -50,6 +58,10 @@ impl FamilyTool {
|
||||
clockwise: true,
|
||||
rows: 5,
|
||||
columns: 5,
|
||||
skew: [0.0; 2],
|
||||
frame: true,
|
||||
compound: false,
|
||||
fill: false,
|
||||
snap: DrawSnap::default(),
|
||||
}
|
||||
}
|
||||
@@ -79,19 +91,14 @@ impl FamilyTool {
|
||||
json!({"cx": start.x, "cy": start.y, "radius": r, "decay": self.decay, "segments": self.segments, "clockwise": self.clockwise}),
|
||||
)
|
||||
}
|
||||
"rectangularGrid" => {
|
||||
// The grids: the dragged box with the tool's options.
|
||||
"rectangularGrid" | "polarGrid" => {
|
||||
let r = drag_rect(start, p, m);
|
||||
(
|
||||
"shape.rectangularGrid".into(),
|
||||
json!({"x": r.x0, "y": r.y0, "width": r.width(), "height": r.height(), "rows": self.rows, "columns": self.columns}),
|
||||
)
|
||||
}
|
||||
"polarGrid" => {
|
||||
let r = drag_rect(start, p, m);
|
||||
(
|
||||
"shape.polarGrid".into(),
|
||||
json!({"x": r.x0, "y": r.y0, "width": r.width(), "height": r.height(), "concentric": self.rows, "radial": self.columns}),
|
||||
)
|
||||
let mut params = self.options();
|
||||
if let Some(o) = params.as_object_mut() {
|
||||
o.extend([("x", r.x0), ("y", r.y0), ("width", r.width()), ("height", r.height())].map(|(k, v)| (k.to_string(), json!(v))));
|
||||
}
|
||||
(format!("shape.{}", self.id), params)
|
||||
}
|
||||
_ => {
|
||||
let d = if m.shift { square(p - start) } else { p - start };
|
||||
@@ -206,8 +213,12 @@ impl Tool for FamilyTool {
|
||||
fn options(&self) -> Value {
|
||||
match self.id {
|
||||
"spiral" => json!({"decay": self.decay, "segments": self.segments, "clockwise": self.clockwise}),
|
||||
"rectangularGrid" => json!({"rows": self.rows, "columns": self.columns}),
|
||||
"polarGrid" => json!({"concentric": self.rows, "radial": self.columns}),
|
||||
"rectangularGrid" => json!({
|
||||
"rows": self.rows, "columns": self.columns, "rowSkew": self.skew[0], "columnSkew": self.skew[1], "frame": self.frame, "fill": self.fill,
|
||||
}),
|
||||
"polarGrid" => json!({
|
||||
"concentric": self.rows, "radial": self.columns, "concentricSkew": self.skew[0], "radialSkew": self.skew[1], "compound": self.compound, "fill": self.fill,
|
||||
}),
|
||||
_ => json!({"closed": self.closed}),
|
||||
}
|
||||
}
|
||||
@@ -219,11 +230,22 @@ impl Tool for FamilyTool {
|
||||
"clockwise" => self.clockwise = v.as_bool().unwrap_or(self.clockwise),
|
||||
"rows" | "concentric" => self.rows = v.as_u64().unwrap_or(5).min(999) as u32,
|
||||
"columns" | "radial" => self.columns = v.as_u64().unwrap_or(5).min(999) as u32,
|
||||
"rowSkew" | "concentricSkew" => self.skew[0] = grid_skew(v, self.skew[0]),
|
||||
"columnSkew" | "radialSkew" => self.skew[1] = grid_skew(v, self.skew[1]),
|
||||
"frame" => self.frame = v.as_bool().unwrap_or(self.frame),
|
||||
"compound" => self.compound = v.as_bool().unwrap_or(self.compound),
|
||||
"fill" => self.fill = v.as_bool().unwrap_or(self.fill),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A grid divider skew from option value `v`: a finite number within the range, else `current`.
|
||||
fn grid_skew(v: &Value, current: f64) -> f64 {
|
||||
let max = vectorcraft_geom::shapes::GRID_SKEW_MAX;
|
||||
v.as_f64().filter(|s| s.is_finite()).map_or(current, |s| s.clamp(-max, max))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -243,7 +265,10 @@ mod tests {
|
||||
assert_eq!(a[0], Action::Begin("Rectangular Grid".into()));
|
||||
assert_eq!(
|
||||
a[1],
|
||||
Action::Preview("shape.rectangularGrid".into(), json!({"x": 10.0, "y": 10.0, "width": 50.0, "height": 30.0, "rows": 5, "columns": 5}))
|
||||
Action::Preview(
|
||||
"shape.rectangularGrid".into(),
|
||||
json!({"x": 10.0, "y": 10.0, "width": 50.0, "height": 30.0, "rows": 5, "columns": 5, "rowSkew": 0.0, "columnSkew": 0.0, "frame": true, "fill": false})
|
||||
)
|
||||
);
|
||||
let a = t.key(&cx, ToolKey::Right, Mods::default());
|
||||
assert!(matches!(&a[0], Action::Preview(_, v) if v["columns"] == 6));
|
||||
@@ -256,6 +281,36 @@ mod tests {
|
||||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 90.0, 60.0)), vec![Action::Commit]);
|
||||
}
|
||||
|
||||
/// #1377: a drag draws with the tool's stored options, which keep to their types and range.
|
||||
#[test]
|
||||
fn a_grid_drag_uses_the_stored_options() {
|
||||
let mut t = FamilyTool::new("polarGrid");
|
||||
for (key, value) in [
|
||||
("concentric", json!(2)),
|
||||
("concentricSkew", json!(-40)),
|
||||
("radialSkew", json!(9000)),
|
||||
("compound", json!(true)),
|
||||
("fill", json!(true)),
|
||||
// Not this tool's, and not values: ignored.
|
||||
("frame", json!("no")),
|
||||
("concentricSkew", json!("far")),
|
||||
("fill", json!(null)),
|
||||
] {
|
||||
t.set_option(key, &value);
|
||||
}
|
||||
let options = json!({"concentric": 2, "radial": 5, "concentricSkew": -40.0, "radialSkew": 500.0, "compound": true, "fill": true});
|
||||
assert_eq!(t.options(), options);
|
||||
let (command, params) = t.command(Point::new(10.0, 20.0), Point::new(40.0, 80.0), Mods::default());
|
||||
assert_eq!(command, "shape.polarGrid");
|
||||
let mut want = options;
|
||||
want.as_object_mut().unwrap().extend(json!({"x": 10.0, "y": 20.0, "width": 30.0, "height": 60.0}).as_object().unwrap().clone());
|
||||
assert_eq!(params, want);
|
||||
let mut t = FamilyTool::new("rectangularGrid");
|
||||
t.set_option("frame", &json!(false));
|
||||
t.set_option("rowSkew", &json!(12.5));
|
||||
assert_eq!(t.options(), json!({"rows": 5, "columns": 5, "rowSkew": 12.5, "columnSkew": 0.0, "frame": false, "fill": false}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn click_opens_dialog_and_arc_shift() {
|
||||
let (d, _) = doc_with_rect();
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//! Guides, or with them off View › Snap to Point, snap it),
|
||||
//! bounding-box scale (Shift proportional, Alt from centre) and rotate (outside corners, Shift 45°),
|
||||
//! drag a live rectangle's or polygon's corner widget to round its corners (Alt-click cycles their kind,
|
||||
//! double-click opens the Corners dialog), double-click to enter isolation mode (Double Click To
|
||||
//! Isolate), Cmd/Ctrl-click to select the object behind (Command Click to Select Objects Behind),
|
||||
//! double-click opens the Corners dialog), double-click a group or an object to enter isolation
|
||||
//! mode (Double Click To Isolate), Cmd/Ctrl-click to select the object behind (Command Click to Select Objects Behind),
|
||||
//! click or drag a ruler guide ([`crate::rulerguide`]), drag the brackets of type on a path
|
||||
//! ([`crate::pathtype`]), double-click the type widget to convert point type to area type and back
|
||||
//! ([`crate::typewidget`]).
|
||||
@@ -234,7 +234,11 @@ impl Tool for SelectionTool {
|
||||
}
|
||||
|
||||
fn transforming(&self) -> bool {
|
||||
matches!(self.state, State::Moving { began: true, .. } | State::Scaling { .. } | State::Rotating { .. })
|
||||
match &self.state {
|
||||
State::Moving { began: true, .. } | State::Scaling { .. } | State::Rotating { .. } => true,
|
||||
State::Corner(c) => c.adjusting(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
@@ -260,12 +264,19 @@ impl Tool for SelectionTool {
|
||||
}
|
||||
if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) {
|
||||
let top = h.top_object(cx.isolation);
|
||||
if cx.double_click_isolate && cx.doc.node(top).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Group { .. })) {
|
||||
return vec![Action::Exec("object.isolate".into(), json!({ "id": top.0 }))];
|
||||
}
|
||||
if cx.doc.node(top).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Text(_))) {
|
||||
return vec![Action::SwitchTool("type".into())];
|
||||
}
|
||||
// A group, a compound path or a single path, image, mesh or placed file, not
|
||||
// the one already isolated. Blends, envelopes, repeats and symbol instances
|
||||
// aren't: they have editing modes of their own.
|
||||
use vectorcraft_doc::NodeKind as K;
|
||||
let isolates = cx.doc.node(top).is_some_and(|n| {
|
||||
matches!(n.kind, K::Group { .. } | K::Compound { .. } | K::Path { .. } | K::Image(_) | K::Mesh(_) | K::PlacedDocument(_))
|
||||
});
|
||||
if cx.double_click_isolate && isolates && cx.isolation != Some(top) {
|
||||
return vec![Action::Exec("object.isolate".into(), json!({ "id": top.0 }))];
|
||||
}
|
||||
} else if cx.isolation.is_some() {
|
||||
return vec![Action::Exec("object.exitIsolation".into(), json!({}))];
|
||||
}
|
||||
@@ -592,6 +603,36 @@ mod tests {
|
||||
assert!((200.0 + x - 350.3).abs() < 1e-9 && (200.0 + y - 250.0).abs() < 1e-9, "the corner lands at ({}, {})", 200.0 + x, 200.0 + y);
|
||||
}
|
||||
|
||||
/// A corner-radius drag is a transform, so the sharp selection box hides while the radius changes.
|
||||
#[test]
|
||||
fn a_corner_drag_counts_as_transforming_once_it_moves() {
|
||||
use vectorcraft_doc::{Appearance, LiveShape, Node, NodeKind};
|
||||
let live = LiveShape::Rectangle {
|
||||
w: 100.0,
|
||||
h: 100.0,
|
||||
radii: [0.0; 4],
|
||||
kinds: Default::default(),
|
||||
xf: vectorcraft_geom::Affine::translate((100.0, 100.0)),
|
||||
};
|
||||
let mut d = vectorcraft_doc::Document::new(500.0, 500.0);
|
||||
let id = d.alloc_id();
|
||||
let mut n = Node::path(id, live.to_path(), Appearance::default_art());
|
||||
if let NodeKind::Path { live: slot, .. } = &mut n.kind {
|
||||
*slot = Some(Box::new(live));
|
||||
}
|
||||
d.insert(Some(d.layers[0].id), 0, n).unwrap();
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = SelectionTool::default();
|
||||
assert!(!t.transforming());
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 190.0, 191.0));
|
||||
assert!(!t.transforming(), "a press is still a click");
|
||||
t.pointer(&cx, &ev(PointerKind::Drag, 175.0, 176.0));
|
||||
assert!(t.transforming());
|
||||
}
|
||||
|
||||
/// #812: with Smart Guides on, hovering near an object's centre or an anchor labels it, and a
|
||||
/// press there grabs the object by that point.
|
||||
#[test]
|
||||
|
||||
@@ -27,8 +27,8 @@ const ROWS: &[Row] = &[
|
||||
(&["puppetWarp"], None, &["showMesh", "expand"]),
|
||||
(&["arc"], None, &["closed"]),
|
||||
(&["spiral"], None, &["decay", "segments", "clockwise"]),
|
||||
(&["rectangularGrid"], None, &["rows", "columns"]),
|
||||
(&["polarGrid"], None, &["concentric", "radial"]),
|
||||
(&["rectangularGrid"], None, &["rows", "columns", "rowSkew", "columnSkew", "frame", "fill"]),
|
||||
(&["polarGrid"], None, &["concentric", "radial", "concentricSkew", "radialSkew", "compound", "fill"]),
|
||||
(&["pencil", "paintbrush"], None, &["fidelity", "fill", "editWithin", "closeWithin"]),
|
||||
(&["smooth"], None, &["fidelity"]),
|
||||
(&["blobBrush", "eraser"], None, &["size"]),
|
||||
|
||||
@@ -87,6 +87,12 @@ fn about(o: Point, a: Affine) -> Affine {
|
||||
Affine::translate(o.to_vec2()) * a * Affine::translate(-o.to_vec2())
|
||||
}
|
||||
|
||||
/// The transform `m` of the selection (of a copy with `copy`): of the anchors selected with Direct
|
||||
/// Selection alone, when there are some.
|
||||
fn preview(m: Affine, copy: bool) -> Action {
|
||||
Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(m), "copy": copy, "anchors": true }))
|
||||
}
|
||||
|
||||
/// Reflection across the line through the origin at angle `theta` (radians, screen space).
|
||||
pub fn reflect_matrix(o: Point, theta: f64) -> Affine {
|
||||
about(o, Affine::rotate(theta) * Affine::scale_non_uniform(1.0, -1.0) * Affine::rotate(-theta))
|
||||
@@ -106,7 +112,8 @@ impl TransformTool {
|
||||
{
|
||||
return Some(*p);
|
||||
}
|
||||
selection_box(cx).map(|b| b.center())
|
||||
// Anchors selected with Direct Selection transform alone, about their own centre.
|
||||
cx.selection.anchor_bounds(cx.doc, cx.preview_bounds).map(|b| b.center()).or_else(|| selection_box(cx).map(|b| b.center()))
|
||||
}
|
||||
|
||||
/// Transform for dragging from `start` to `p` about `o`; returns the matrix and a readout.
|
||||
@@ -209,14 +216,14 @@ impl Tool for TransformTool {
|
||||
d.last = m;
|
||||
self.drag = Some(d);
|
||||
self.measure = Some((p, text));
|
||||
out.push(Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(m), "copy": ev.mods.alt })));
|
||||
out.push(preview(m, ev.mods.alt));
|
||||
out
|
||||
}
|
||||
PointerKind::Up => {
|
||||
let Some(d) = self.end_drag() else { return vec![] };
|
||||
if d.began {
|
||||
// The Alt state at release decides whether the result is a copy.
|
||||
vec![Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(d.last), "copy": ev.mods.alt })), Action::Commit]
|
||||
vec![preview(d.last, ev.mods.alt), Action::Commit]
|
||||
} else {
|
||||
// A click sets the reference point; with Alt, it opens the dialog too.
|
||||
self.origin = Some((d.pick, cx.selection.objects.clone()));
|
||||
|
||||
+47
-80
@@ -1,9 +1,11 @@
|
||||
//! Loop → polygon → Bézier curves.
|
||||
//! Loop → Bézier curves, and centre line → Bézier curves.
|
||||
|
||||
use vectorcraft_geom::{AnchorKind, CubicBez, ParamCurve, PathData, Point, SubPath};
|
||||
use vectorcraft_geom::{Anchor, AnchorKind, CubicBez, ParamCurve, PathData, Point, SubPath};
|
||||
use vectorcraft_pathops::{SimplifyOptions, simplify_with};
|
||||
|
||||
use crate::contour::Loop;
|
||||
use crate::logo::{Cubic, Stats, Style, all_within, fit_outline, resample_closed, sample_cubics};
|
||||
use crate::subpixel::{self, Crack};
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct FitOptions {
|
||||
@@ -46,91 +48,56 @@ fn rdp(pts: &[Point], a: usize, b: usize, tol: f64, keep: &mut [bool]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The polygon approximating a closed loop of pixel-corner vertices within `tol`.
|
||||
pub(crate) fn polygon(loop_pts: &[(i32, i32)], tol: f64) -> Vec<Point> {
|
||||
let raw: Vec<Point> = loop_pts.iter().map(|&(x, y)| Point::new(x as f64, y as f64)).collect();
|
||||
if raw.len() < 4 {
|
||||
return raw;
|
||||
/// Fit one loop of a `w × h` image into a closed Bézier subpath (pixel coordinates): its points
|
||||
/// are placed to a fraction of a pixel ([`subpixel::outline`], which explains `shift`) and fitted as
|
||||
/// Flat Logo mode fits its outlines (corners restored, straight runs as lines, the rest as few
|
||||
/// curves as `style.tol` allows).
|
||||
pub(crate) fn fit_loop(l: &Loop, w: usize, h: usize, shift: &dyn Fn(Crack) -> f64, style: &Style) -> Option<SubPath> {
|
||||
let (pts, hard) = subpixel::outline(l, w, h, shift);
|
||||
let mut style = *style;
|
||||
if hard {
|
||||
// No anti-aliasing: the edge is only known to about half a pixel (as in Flat Logo mode).
|
||||
style.tol *= 2.5;
|
||||
style.line_tol *= 1.8;
|
||||
style.circle_tol *= 1.8;
|
||||
style.sigma = 0.9;
|
||||
}
|
||||
let pts = dejag(loop_pts);
|
||||
let pts = if pts.len() < 3 { raw.clone() } else { pts };
|
||||
let mut stats = Stats::default();
|
||||
let mut segs = fit_outline(&pts, &style, &mut stats)?;
|
||||
// A fit that strayed was replaced by a plain one that nothing has checked: one that strays too
|
||||
// (a wild handle on a tiny or ragged loop) gives way to the outline's own points.
|
||||
if stats.reverted > 0 && !all_within(&sample_cubics(&segs, 0.25), &resample_closed(&pts, 0.5).map_or_else(|| pts.clone(), |(p, _)| p), 1.5) {
|
||||
segs = polygon(&pts, style.tol);
|
||||
}
|
||||
let n = segs.len();
|
||||
// A straight piece has no handles.
|
||||
let straight = |c: &Cubic| seg_dist(c[1], c[0], c[3]) < 1e-6 && seg_dist(c[2], c[0], c[3]) < 1e-6;
|
||||
let mut anchors = Vec::with_capacity(n);
|
||||
for (i, cur) in segs.iter().enumerate() {
|
||||
let before = segs.get((i + n - 1) % n)?;
|
||||
let p = cur[0];
|
||||
let (h_in, h_out) = (if straight(before) { p } else { before[2] }, if straight(cur) { p } else { cur[1] });
|
||||
let (vi, vo) = (p - h_in, h_out - p);
|
||||
let smooth = vi.hypot() > 1e-9 && vo.hypot() > 1e-9 && vi.cross(vo).abs() < 0.07 * vi.hypot() * vo.hypot() && vi.dot(vo) > 0.0;
|
||||
anchors.push(Anchor { p, h_in, h_out, kind: if smooth { AnchorKind::Smooth } else { AnchorKind::Corner } });
|
||||
}
|
||||
(n >= 2).then(|| SubPath::new(anchors, true))
|
||||
}
|
||||
|
||||
/// The closed outline `pts` as straight pieces within `tol` of it (Douglas–Peucker).
|
||||
fn polygon(pts: &[Point], tol: f64) -> Vec<Cubic> {
|
||||
let n = pts.len();
|
||||
if n < 4 {
|
||||
return pts;
|
||||
}
|
||||
// Split the closed loop at vertex 0 and the vertex farthest from it.
|
||||
let far = (1..n).max_by(|&i, &j| pts[i].distance_squared(pts[0]).total_cmp(&pts[j].distance_squared(pts[0]))).unwrap_or(n / 2);
|
||||
let mut ext = pts.clone();
|
||||
ext.push(pts[0]);
|
||||
let Some(&first) = pts.first() else { return vec![] };
|
||||
let far = (1..n).max_by(|&i, &j| pts[i].distance_squared(first).total_cmp(&pts[j].distance_squared(first))).unwrap_or(0);
|
||||
let mut ext = pts.to_vec();
|
||||
ext.push(first);
|
||||
let mut keep = vec![false; n + 1];
|
||||
keep[0] = true;
|
||||
keep[far] = true;
|
||||
rdp(&ext, 0, far, tol, &mut keep);
|
||||
rdp(&ext, far, n, tol, &mut keep);
|
||||
let mut out: Vec<Point> = (0..n).filter(|&i| keep[i]).map(|i| pts[i]).collect();
|
||||
// Vertex 0 was forced; drop it when it's not needed.
|
||||
if out.len() > 3 {
|
||||
let (p, a, b) = (out[0], out[out.len() - 1], out[1]);
|
||||
if seg_dist(p, a, b) <= tol * 0.5 {
|
||||
out.remove(0);
|
||||
}
|
||||
}
|
||||
if out.len() < 3 { raw } else { out }
|
||||
}
|
||||
|
||||
/// Replace pixel-staircase jogs by their midpoints.
|
||||
///
|
||||
/// A unit-length edge whose two ends turn in opposite directions (an S-shaped step) is a jog of
|
||||
/// a staircase approximating a sloped line or curve: its midpoint lies on the ideal boundary, its
|
||||
/// end vertices don't. Vertices not adjacent to any jog (true corners, e.g. of rectangles, or the
|
||||
/// U-turns at the end of one-pixel-wide lines) are kept.
|
||||
fn dejag(v: &[(i32, i32)]) -> Vec<Point> {
|
||||
let n = v.len();
|
||||
let edge = |i: usize| {
|
||||
let (a, b) = (v[i % n], v[(i + 1) % n]);
|
||||
(b.0 - a.0, b.1 - a.1)
|
||||
};
|
||||
// Turn sign at vertex i (between edge i-1 and edge i).
|
||||
let turn = |i: usize| {
|
||||
let (a, b) = (edge((i + n - 1) % n), edge(i));
|
||||
(a.0 as i64 * b.1 as i64 - a.1 as i64 * b.0 as i64).signum()
|
||||
};
|
||||
let jog: Vec<bool> = (0..n)
|
||||
.map(|i| {
|
||||
let (dx, dy) = edge(i);
|
||||
dx.abs() + dy.abs() == 1 && turn(i) * turn(i + 1) < 0
|
||||
})
|
||||
.collect();
|
||||
let mut out = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
if !jog[i] && !jog[(i + n - 1) % n] {
|
||||
out.push(Point::new(v[i].0 as f64, v[i].1 as f64));
|
||||
}
|
||||
if jog[i] {
|
||||
let (a, b) = (v[i], v[(i + 1) % n]);
|
||||
out.push(Point::new((a.0 + b.0) as f64 / 2.0, (a.1 + b.1) as f64 / 2.0));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Fit one loop into a closed Bézier subpath (pixel coordinates).
|
||||
pub(crate) fn fit_loop(l: &Loop, o: &FitOptions) -> Option<SubPath> {
|
||||
let poly = polygon(&l.pts, o.polygon_tol);
|
||||
if poly.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let sp = SubPath::polyline(&poly, true);
|
||||
let fitted =
|
||||
simplify_with(&PathData::single(sp), &SimplifyOptions { tolerance: o.fit_tol, corner_angle_deg: o.corner_angle, straight_lines: false });
|
||||
let mut sp = fitted.subpaths.into_iter().next()?;
|
||||
if sp.anchors.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
if o.snap_lines {
|
||||
snap_to_lines(&mut sp, (o.fit_tol * 2.0).max(1.5));
|
||||
}
|
||||
Some(sp)
|
||||
let kept: Vec<Point> = (0..n).filter(|&i| keep[i]).map(|i| pts[i]).collect();
|
||||
(0..kept.len()).map(|i| [kept[i], kept[i], kept[(i + 1) % kept.len()], kept[(i + 1) % kept.len()]]).collect()
|
||||
}
|
||||
|
||||
/// Fit a centre line through `pts` (pixel-centre points in order) into a Bézier subpath: the
|
||||
|
||||
+146
-7
@@ -15,11 +15,14 @@
|
||||
//! outside are linked into closed loops (inside on the right, right turns at saddles, so
|
||||
//! diagonal pixels stay separate, matching 4-connectivity). Outer loops run clockwise and
|
||||
//! holes counter-clockwise (y down), so the result fills correctly with either fill rule.
|
||||
//! 4. **Polygon**: each loop is reduced to a polygon whose vertices stay within a fidelity
|
||||
//! dependent distance of the pixel boundary (Douglas–Peucker on the crack vertices, which
|
||||
//! removes the staircase).
|
||||
//! 5. **Curves**: the polygon is fitted with cubic Béziers (`vectorcraft_pathops::simplify_with`,
|
||||
//! least squares with corner detection); optionally nearly-straight curves snap to lines.
|
||||
//! 4. **Sub-pixel edges**: each crack of a loop is moved to where the image says the edge really
|
||||
//! is (an anti-aliased or blurred edge pixel is part one tone, part the other), so the outline
|
||||
//! is right to a fraction of a pixel before any fitting; hard-edged images are read as
|
||||
//! staircases instead (see `subpixel`).
|
||||
//! 5. **Curves**: the outline is fitted as Flat Logo mode fits its own (`logo::fit_outline`):
|
||||
//! corners the pixels rounded are put back, straight runs become lines, a round outline a
|
||||
//! true circle, and the rest as few cubic Béziers as the Paths tolerance allows; Snap Curves To
|
||||
//! Lines also straightens slightly curved runs and makes arcs of parts of circles.
|
||||
//!
|
||||
//! With **Create: Strokes**, the parts of a colour layer no wider than the stroke width are traced
|
||||
//! as stroked centre lines instead (see `centerline`); without Fills, wider parts become stroked
|
||||
@@ -35,6 +38,7 @@ mod fit;
|
||||
mod logo;
|
||||
mod mosaic;
|
||||
mod quantize;
|
||||
mod subpixel;
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
@@ -44,6 +48,7 @@ use vectorcraft_geom::PathData;
|
||||
pub use contour::{Component, Loop, trace_mask};
|
||||
pub use mosaic::mosaic;
|
||||
pub use quantize::{FULL_TONE_MAX, Quantized, TRANSPARENT, denoise, quantize};
|
||||
use subpixel::Crack;
|
||||
|
||||
/// Errors decoding a raster.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -307,6 +312,27 @@ impl TraceParams {
|
||||
let f = (self.paths / 100.0).clamp(0.0, 1.0);
|
||||
0.2 + 1.6 * (1.0 - f)
|
||||
}
|
||||
/// How outlines are fitted: Paths sets how closely the curves follow the edge (0.08 px at 100,
|
||||
/// 0.33 px at 50, 0.78 px at 0), Corners the turn that makes a corner (35° at 100, 90° at 0).
|
||||
/// Straight edges are always lines; Snap Curves To Lines also straightens slightly curved ones
|
||||
/// and makes parts of circles true arcs.
|
||||
fn outline_style(&self) -> logo::Style {
|
||||
// Parameters come from users and agents: a NaN or infinity must not reach the geometry.
|
||||
let unit = |v: f64, default: f64| if v.is_finite() { (v / 100.0).clamp(0.0, 1.0) } else { default };
|
||||
let snap = self.snap_curves_to_lines;
|
||||
logo::Style {
|
||||
step: 0.25,
|
||||
tol: 0.08 + 0.7 * (1.0 - unit(self.paths, 0.5)).powf(1.5),
|
||||
corner_deg: 90.0 - 55.0 * unit(self.corners, 0.75),
|
||||
snap: true,
|
||||
arcs: snap,
|
||||
line_tol: if snap { 0.5 } else { 0.2 },
|
||||
circle_tol: if snap { 0.3 } else { 0.15 },
|
||||
restore: Some(logo::RestoreParams::default()),
|
||||
check_step: 0.15,
|
||||
..logo::Style::default()
|
||||
}
|
||||
}
|
||||
/// Turn angle (degrees) above which a polygon vertex stays a corner.
|
||||
fn corner_angle(&self) -> f64 {
|
||||
let c = (self.corners / 100.0).clamp(0.0, 1.0);
|
||||
@@ -450,6 +476,9 @@ pub fn trace_within(img: &Raster, params: &TraceParams, max_anchors: usize) -> R
|
||||
q: &q,
|
||||
rank: &rank,
|
||||
params,
|
||||
img,
|
||||
bw_tones: bw_tones(img, &q),
|
||||
style: params.outline_style(),
|
||||
opts: fit::FitOptions {
|
||||
polygon_tol: params.polygon_tolerance(),
|
||||
fit_tol: params.fit_tolerance(),
|
||||
@@ -472,12 +501,31 @@ pub fn trace_within(img: &Raster, params: &TraceParams, max_anchors: usize) -> R
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The mean luminance of the pixels labelled 0 (white) and 1 (black) of a Black and White
|
||||
/// quantisation: pure white and black where a side has no pixel.
|
||||
fn bw_tones(img: &Raster, q: &Quantized) -> [f64; 2] {
|
||||
let (mut sum, mut n) = ([0.0f64; 2], [0.0f64; 2]);
|
||||
for (p, &l) in img.rgba.as_chunks::<4>().0.iter().zip(&q.labels) {
|
||||
if let (Some(s), Some(n)) = (sum.get_mut(l as usize), n.get_mut(l as usize)) {
|
||||
*s += (f64::from(p[0]) * 299.0 + f64::from(p[1]) * 587.0 + f64::from(p[2]) * 114.0) / 1000.0;
|
||||
*n += 1.0;
|
||||
}
|
||||
}
|
||||
[if n[0] > 0.0 { sum[0] / n[0] } else { 255.0 }, if n[1] > 0.0 { sum[1] / n[1] } else { 0.0 }]
|
||||
}
|
||||
|
||||
/// What tracing one colour layer needs.
|
||||
struct Layers<'a> {
|
||||
q: &'a Quantized,
|
||||
/// Each palette entry's layer, bottom (0) up.
|
||||
rank: &'a [usize],
|
||||
params: &'a TraceParams,
|
||||
img: &'a Raster,
|
||||
/// Black and White: the mean luminance of the white pixels and of the black ones.
|
||||
bw_tones: [f64; 2],
|
||||
/// How outlines are fitted.
|
||||
style: logo::Style,
|
||||
/// How centre lines are fitted.
|
||||
opts: fit::FitOptions,
|
||||
w: usize,
|
||||
h: usize,
|
||||
@@ -487,6 +535,96 @@ struct Layers<'a> {
|
||||
}
|
||||
|
||||
impl Layers<'_> {
|
||||
/// How far the edge lies from a crack between two pixels, towards the outside one (−0.5 to 0.5).
|
||||
///
|
||||
/// Each pixel of the column across the crack is read as a blend of the tones on its two sides
|
||||
/// (the two pixels' palette colours; in Black and White the mean luminance of the black and of
|
||||
/// the white pixels). The edge is where the blends, summed, put it: as far out as there is
|
||||
/// inside tone in the outside pixels, less the outside tone in the inside ones (two pixels each
|
||||
/// way, while they are on the same side and no more blended than the pixel before: more would be
|
||||
/// another edge's doing).
|
||||
///
|
||||
/// Where the inside is at most three pixels deep with the outside's colour behind it (a thin
|
||||
/// line), its two edges share those pixels and cannot be read apart; the inside tone of the
|
||||
/// whole column gives the line's middle (its centroid) and width (its sum) instead. A thin gap
|
||||
/// between two parts of the inside is read the same way, with the tones swapped.
|
||||
///
|
||||
/// Black and White then moves the edge to where the luminance crosses the threshold: by the
|
||||
/// threshold's distance from the middle tone, over how steep the edge is (nothing for a
|
||||
/// threshold in the middle, more the softer the edge).
|
||||
fn shift(&self, c: Crack) -> f64 {
|
||||
let label = |k: i32| c.at(k).and_then(|i| self.q.labels.get(i).copied()).filter(|&l| l != TRANSPARENT);
|
||||
let (Some(la), Some(lb)) = (label(0), label(1)) else { return 0.0 };
|
||||
if la == lb {
|
||||
return 0.0;
|
||||
}
|
||||
let bw = self.params.mode == Mode::BlackAndWhite;
|
||||
let (ca, cb) = if bw {
|
||||
let tone = |l: u16| [self.bw_tones[usize::from(l == 1)]; 3];
|
||||
(tone(la), tone(lb))
|
||||
} else {
|
||||
let (Some(ca), Some(cb)) = (self.q.palette.get(la as usize), self.q.palette.get(lb as usize)) else { return 0.0 };
|
||||
(ca.map(f64::from), cb.map(f64::from))
|
||||
};
|
||||
let d = [0, 1, 2].map(|k| cb[k] - ca[k]);
|
||||
let len2: f64 = d.iter().map(|v| v * v).sum();
|
||||
if len2 <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
// How much of the inside tone (0 to 1) the pixel `k` steps out has; none off the image.
|
||||
let inside = |k: i32| {
|
||||
let Some(p) = c.at(k).and_then(|i| self.img.rgba.get(i * 4..i * 4 + 3)) else { return 0.0 };
|
||||
let p = [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])];
|
||||
let p = if bw { [(p[0] * 299.0 + p[1] * 587.0 + p[2] * 114.0) / 1000.0; 3] } else { p };
|
||||
1.0 - ((0..3).map(|k| (p[k] - ca[k]) * d[k]).sum::<f64>() / len2).clamp(0.0, 1.0)
|
||||
};
|
||||
// The edge from a thin run of `n` pixels starting at pixel `from` and going `dir`, by the
|
||||
// amounts `tone` of the run's own tone in it and in the two pixels at either end of it.
|
||||
// Returns the run's middle and width, measured from the crack towards the outside.
|
||||
let thin = |from: i32, dir: i32, n: i32, tone: &dyn Fn(i32) -> f64| {
|
||||
let (near, far) = (from - dir, from + n * dir);
|
||||
let mut cells: Vec<(i32, f64)> = (0..n).map(|j| from + j * dir).chain([near, far]).map(|k| (k, tone(k))).collect();
|
||||
for (edge, out) in [(near, -dir), (far, dir)] {
|
||||
if label(edge + out) == label(edge) {
|
||||
cells.push((edge + out, tone(edge + out).min(tone(edge))));
|
||||
}
|
||||
}
|
||||
let width: f64 = cells.iter().map(|c| c.1).sum();
|
||||
// Pixel `k`'s centre is `k - 0.5` from the crack.
|
||||
(width > 1e-6).then(|| (cells.iter().map(|c| c.1 * (f64::from(c.0) - 0.5)).sum::<f64>() / width, width))
|
||||
};
|
||||
// How deep each side is here: up to three pixels of its own label, then the other's.
|
||||
let depth = |dir: i32, own: u16, other: u16| {
|
||||
let start = if dir < 0 { 0 } else { 1 };
|
||||
let n = (1..=3).take_while(|j| label(start + j * dir) == Some(own)).count() as i32 + 1;
|
||||
(n <= 3 && label(start + n * dir) == Some(other)).then_some(n)
|
||||
};
|
||||
let mut steep = 1.0;
|
||||
let mut shift = if let Some((middle, width)) = depth(-1, la, lb).and_then(|n| thin(0, -1, n, &inside)) {
|
||||
middle + width / 2.0
|
||||
} else if let Some((middle, width)) = depth(1, lb, la).and_then(|n| thin(1, 1, n, &|k| 1.0 - inside(k))) {
|
||||
middle - width / 2.0
|
||||
} else {
|
||||
let (a1, b1) = (1.0 - inside(0), inside(1));
|
||||
let a2 = (label(-1) == Some(la)).then(|| (1.0 - inside(-1)).min(a1));
|
||||
let b2 = (label(2) == Some(lb)).then(|| inside(2).min(b1));
|
||||
// The edge's steepness per pixel: the square of the change over two pixels (1 for an
|
||||
// anti-aliased edge, less for a blurred one).
|
||||
let change = [a2.map(|v| 1.0 - v - b1), b2.map(|v| 1.0 - a1 - v)].into_iter().flatten().fold(f64::NAN, f64::max);
|
||||
if !change.is_nan() {
|
||||
steep = (change * change).clamp(0.35, 1.0);
|
||||
}
|
||||
b1 + b2.unwrap_or(0.0) - a1 - a2.unwrap_or(0.0)
|
||||
};
|
||||
if bw {
|
||||
// The share of black at which a pixel turns black.
|
||||
let (white, black) = (self.bw_tones[0], self.bw_tones[1]);
|
||||
let turn = ((white - f64::from(self.params.threshold)) / (white - black).max(1.0)).clamp(0.02, 0.98);
|
||||
shift += if la == 1 { 1.0 } else { -1.0 } * (0.5 - turn) / steep;
|
||||
}
|
||||
shift
|
||||
}
|
||||
|
||||
/// The paths of layer `r` (palette entry `ci`), or `None` once the anchors made pass the
|
||||
/// maximum.
|
||||
fn layer(&self, r: usize, ci: usize) -> Option<Vec<TracedPath>> {
|
||||
@@ -524,13 +662,14 @@ impl Layers<'_> {
|
||||
true
|
||||
};
|
||||
for comp in trace_mask(&mask, w, h) {
|
||||
let Some(outer) = fit::fit_loop(&comp.outer, opts) else { continue };
|
||||
let fit = |l: &Loop| fit::fit_loop(l, w, h, &|c| self.shift(c), &self.style);
|
||||
let Some(outer) = fit(&comp.outer) else { continue };
|
||||
let mut subs = vec![outer];
|
||||
for hole in &comp.holes {
|
||||
if hole.area2.abs() / 2 < min_hole && params.noise > 0 {
|
||||
continue;
|
||||
}
|
||||
subs.extend(fit::fit_loop(hole, opts));
|
||||
subs.extend(fit(hole));
|
||||
}
|
||||
if !add(PathData::new(subs), comp.pixels, area_stroke) {
|
||||
return None;
|
||||
|
||||
@@ -24,4 +24,7 @@ mod tests;
|
||||
mod testutil;
|
||||
mod unmix;
|
||||
|
||||
pub(crate) use geom::{Cubic, all_within, resample_closed, sample_cubics};
|
||||
pub(crate) use outline::{Stats, Style, fit_outline};
|
||||
pub(crate) use pipeline::trace_logo;
|
||||
pub(crate) use restore::RestoreParams;
|
||||
@@ -14,11 +14,15 @@ pub(crate) struct Style {
|
||||
pub corner_win: f64,
|
||||
pub tol: f64,
|
||||
pub snap: bool,
|
||||
/// With `snap`: parts of circles become true arcs too.
|
||||
pub arcs: bool,
|
||||
pub line_tol: f64,
|
||||
pub circle_tol: f64,
|
||||
pub restore: Option<RestoreParams>,
|
||||
/// An outline whose fit strays farther than this from the raw contour is fitted plainly.
|
||||
pub max_dev: f64,
|
||||
/// Spacing of the points of the fit that this check looks at.
|
||||
pub check_step: f64,
|
||||
}
|
||||
|
||||
impl Default for Style {
|
||||
@@ -30,10 +34,12 @@ impl Default for Style {
|
||||
corner_win: 0.7,
|
||||
tol: 0.12,
|
||||
snap: true,
|
||||
arcs: true,
|
||||
line_tol: 0.22,
|
||||
circle_tol: 0.25,
|
||||
restore: Some(RestoreParams::default()),
|
||||
max_dev: 1.4,
|
||||
check_step: 0.03,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,6 +167,7 @@ fn styled_fit(p: &[Point], st: &Style, stats: &mut Stats) -> Option<Vec<Cubic>>
|
||||
}
|
||||
}
|
||||
if st.snap
|
||||
&& st.arcs
|
||||
&& sm.len() > 12
|
||||
&& len > 1.0
|
||||
&& let Some(c) = circle_fit(&sm)
|
||||
@@ -198,10 +205,18 @@ pub(crate) fn fit_outline(p: &[Point], st: &Style, stats: &mut Stats) -> Option<
|
||||
}
|
||||
let mut local = Stats::default();
|
||||
// The reference is the raw outline, densified so the check never depends on how sparse it is.
|
||||
let reference = resample_closed(p, 0.1).map_or_else(|| p.to_vec(), |(d, _)| d);
|
||||
let fitted = styled_fit(p, st, &mut local).filter(|s| sane(s)).filter(|s| {
|
||||
let samples = sample_cubics(s, 0.03);
|
||||
all_within(&samples, &reference, st.max_dev) && all_within(&reference, &samples, st.max_dev)
|
||||
let reference = resample_closed(p, st.check_step / 0.3).map_or_else(|| p.to_vec(), |(d, _)| d);
|
||||
let checked = |st: &Style, local: &mut Stats| {
|
||||
styled_fit(p, st, local).filter(|s| sane(s)).filter(|s| {
|
||||
let samples = sample_cubics(s, st.check_step);
|
||||
all_within(&samples, &reference, st.max_dev) && all_within(&reference, &samples, st.max_dev)
|
||||
})
|
||||
};
|
||||
// A fit that strays is tried again without the clever parts before the plain fit, which
|
||||
// nothing checks.
|
||||
let fitted = checked(st, &mut local).or_else(|| {
|
||||
local = Stats::default();
|
||||
checked(&Style { snap: false, restore: None, ..*st }, &mut local)
|
||||
});
|
||||
match fitted {
|
||||
Some(segs) => {
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
//! Outlines placed to a fraction of a pixel.
|
||||
//!
|
||||
//! A boundary loop runs along the cracks between pixels, so on its own it is a staircase that is
|
||||
//! right only to half a pixel. But an anti-aliased or slightly blurred image says where the edge
|
||||
//! really is: the pixels beside it are part one colour and part the other. Each crack between an
|
||||
//! inside and an outside pixel is moved along its normal by what the four pixels across it say (see
|
||||
//! [`Crack`]), which gives one point per crack; a corner of the loop that is a true corner of the
|
||||
//! shape (not a step of a staircase) is kept, moved with the two edges that meet there. The same
|
||||
//! crack gives the same point from either side, so two regions that touch get the same points along
|
||||
//! their shared edge.
|
||||
//!
|
||||
//! An image with hard edges (a GIF, a 1-bit scan) says nothing more than the cracks do: every crack
|
||||
//! moves by the same amount. There the staircase is read the classic way: a one-pixel step between
|
||||
//! two runs is a point of a sloped edge (its middle is on the edge, the corners beside it are not).
|
||||
|
||||
use kurbo::Vec2;
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use crate::contour::Loop;
|
||||
|
||||
/// One straight run of a loop, from one of its vertices to the next.
|
||||
struct Edge {
|
||||
dir: (i32, i32),
|
||||
len: usize,
|
||||
/// Index of its first crack's shift.
|
||||
first: usize,
|
||||
}
|
||||
|
||||
/// A crack between an inside and an outside pixel of a `w × h` image, with the column of pixels
|
||||
/// across it. Read across the edge, the column says where the edge lies: a straight edge that
|
||||
/// crosses it leaves in its pixels, summed, exactly as much of the inside colour as its distance
|
||||
/// along the column (which is how anti-aliasing draws it).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Crack {
|
||||
inside: (i32, i32),
|
||||
/// From the inside pixel to the outside one.
|
||||
step: (i32, i32),
|
||||
w: usize,
|
||||
h: usize,
|
||||
}
|
||||
|
||||
impl Crack {
|
||||
/// The index of the pixel `k` steps out from the inside pixel (0: the inside pixel, 1: the
|
||||
/// outside one, −1: the one behind the inside pixel), if the image has it.
|
||||
pub fn at(&self, k: i32) -> Option<usize> {
|
||||
pixel((self.inside.0 + k * self.step.0, self.inside.1 + k * self.step.1), self.w, self.h)
|
||||
}
|
||||
}
|
||||
|
||||
fn pixel(p: (i32, i32), w: usize, h: usize) -> Option<usize> {
|
||||
let (x, y) = (usize::try_from(p.0).ok()?, usize::try_from(p.1).ok()?);
|
||||
(x < w && y < h).then(|| y * w + x)
|
||||
}
|
||||
|
||||
/// The outline of `l` (a loop of a `w × h` image) as points, and whether its edge is hard.
|
||||
///
|
||||
/// `shift` says how far (in pixels, −0.5 to 0.5, positive towards the outside pixel) the edge lies
|
||||
/// from a crack.
|
||||
pub(crate) fn outline(l: &Loop, w: usize, h: usize, shift: &dyn Fn(Crack) -> f64) -> (Vec<Point>, bool) {
|
||||
let v = &l.pts;
|
||||
let n = v.len();
|
||||
let at = |i: usize| Point::new(f64::from(v[i % n].0), f64::from(v[i % n].1));
|
||||
let raw = || (0..n).map(at).collect::<Vec<_>>();
|
||||
if n < 4 {
|
||||
return (raw(), true);
|
||||
}
|
||||
let mut edges = Vec::with_capacity(n);
|
||||
let mut shifts: Vec<f64> = Vec::new();
|
||||
for i in 0..n {
|
||||
let (a, b) = (v[i], v[(i + 1) % n]);
|
||||
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
|
||||
let dir = (dx.signum(), dy.signum());
|
||||
let len = dx.unsigned_abs().max(dy.unsigned_abs());
|
||||
edges.push(Edge { dir, len: len as usize, first: shifts.len() });
|
||||
for k in 0..len as i32 {
|
||||
// The crack starts at corner `s`; the inside pixel is on its right (y down).
|
||||
let (sx, sy) = (a.0 + dir.0 * k, a.1 + dir.1 * k);
|
||||
let (inside, outside) = match dir {
|
||||
(1, 0) => ((sx, sy), (sx, sy - 1)),
|
||||
(0, 1) => ((sx - 1, sy), (sx, sy)),
|
||||
(-1, 0) => ((sx - 1, sy - 1), (sx - 1, sy)),
|
||||
_ => ((sx, sy - 1), (sx - 1, sy - 1)),
|
||||
};
|
||||
let crack = Crack { inside, step: (outside.0 - inside.0, outside.1 - inside.1), w, h };
|
||||
let s = match crack.at(1) {
|
||||
Some(_) => shift(crack),
|
||||
// The edge of the image is where it is.
|
||||
_ => 0.0,
|
||||
};
|
||||
shifts.push(if s.is_finite() { s.clamp(-0.5, 0.5) } else { 0.0 });
|
||||
}
|
||||
}
|
||||
// A hard edge: nearly every crack moves by the same amount.
|
||||
let mut sorted = shifts.clone();
|
||||
let mid = sorted.len() / 2;
|
||||
let median = if sorted.is_empty() { 0.0 } else { *sorted.select_nth_unstable_by(mid, f64::total_cmp).1 };
|
||||
let hard = shifts.iter().filter(|s| (*s - median).abs() > 0.03).count() * 4 < shifts.len();
|
||||
|
||||
// Which way the loop turns at vertex `i`, and whether edge `i` is a step of a staircase: one
|
||||
// pixel long, turning one way where it starts and the other where it ends.
|
||||
let turn = |i: usize| {
|
||||
let (a, b) = (edges[(i + n - 1) % n].dir, edges[i % n].dir);
|
||||
(a.0 * b.1 - a.1 * b.0).signum()
|
||||
};
|
||||
let step: Vec<bool> = (0..n).map(|i| edges[i].len == 1 && turn(i) * turn(i + 1) < 0).collect();
|
||||
let outward = |d: (i32, i32)| Vec2::new(f64::from(d.1), f64::from(-d.0));
|
||||
let shift_of = |e: &Edge, k: usize| shifts.get(e.first + k).copied().unwrap_or(0.0);
|
||||
|
||||
let mut out = Vec::with_capacity(if hard { n } else { shifts.len() + n });
|
||||
for i in 0..n {
|
||||
let (before, e) = (&edges[(i + n - 1) % n], &edges[i]);
|
||||
if !step[i] && !step[(i + n - 1) % n] {
|
||||
// A true corner: where its two edges, each moved by its own shift, meet.
|
||||
out.push(at(i) + outward(before.dir) * shift_of(before, before.len.saturating_sub(1)) + outward(e.dir) * shift_of(e, 0));
|
||||
}
|
||||
if step[i] || !hard {
|
||||
let along = Vec2::new(f64::from(e.dir.0), f64::from(e.dir.1));
|
||||
out.extend((0..e.len).map(|k| at(i) + along * (k as f64 + 0.5) + outward(e.dir) * shift_of(e, k)));
|
||||
}
|
||||
}
|
||||
if out.len() < 3 { (raw(), hard) } else { (out, hard) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::contour::trace_mask;
|
||||
|
||||
fn loop_of(mask: &[bool], w: usize, h: usize) -> Loop {
|
||||
trace_mask(mask, w, h).remove(0).outer
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hard_rectangle_keeps_its_four_corners() {
|
||||
let (w, h) = (8, 6);
|
||||
let mask: Vec<bool> = (0..w * h).map(|i| (2..6).contains(&(i % w)) && (1..5).contains(&(i / w))).collect();
|
||||
let (pts, hard) = outline(&loop_of(&mask, w, h), w, h, &|_| 0.0);
|
||||
assert!(hard);
|
||||
assert_eq!(pts, [(2.0, 1.0), (6.0, 1.0), (6.0, 5.0), (2.0, 5.0)].map(Point::from));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hard_staircase_becomes_the_sloped_line_through_its_steps() {
|
||||
// Below the line y = x / 4 + 2: treads four pixels long.
|
||||
let (w, h) = (16, 8);
|
||||
let mask: Vec<bool> = (0..w * h).map(|i| (i / w) as f64 + 0.5 > (i % w) as f64 / 4.0 + 2.0).collect();
|
||||
let (pts, hard) = outline(&loop_of(&mask, w, h), w, h, &|_| 0.0);
|
||||
assert!(hard);
|
||||
// Every step's middle lies on one line, a quarter of a pixel's rise per pixel.
|
||||
let steps: Vec<Point> = pts.iter().copied().filter(|p| p.y.fract() == 0.5 && p.x > 0.0 && p.x < w as f64).collect();
|
||||
assert_eq!(steps.len(), 4, "{pts:?}");
|
||||
assert!(steps.windows(2).all(|s| ((s[1].y - s[0].y) / (s[1].x - s[0].x) - 0.25).abs() < 1e-9), "{steps:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_soft_edge_moves_each_crack_by_its_shift() {
|
||||
let (w, h) = (6, 4);
|
||||
// The left half is inside; its right edge really lies 0.3 px further right.
|
||||
let mask: Vec<bool> = (0..w * h).map(|i| i % w < 3).collect();
|
||||
let shift = |c: Crack| if c.at(0).is_some_and(|i| i % w == 2) && c.at(1).is_some_and(|i| i % w == 3) { 0.3 } else { 0.0 };
|
||||
let l = loop_of(&mask, w, h);
|
||||
let (pts, hard) = outline(&l, w, h, &shift);
|
||||
assert!(!hard);
|
||||
// Each crack comes with the column of pixels across it.
|
||||
outline(&l, w, h, &|c| {
|
||||
let (a, b) = (c.at(0).unwrap(), c.at(1).unwrap());
|
||||
assert_eq!((c.at(-1), c.at(2)), (Some(a - 1), Some(b + 1)));
|
||||
assert_eq!((c.at(-3), c.at(4)), (None, None));
|
||||
0.0
|
||||
});
|
||||
let right: Vec<&Point> = pts.iter().filter(|p| p.x > 2.5).collect();
|
||||
// The four cracks' middles and the two corners, all at x = 3.3.
|
||||
assert_eq!(right.len(), 6);
|
||||
assert!(right.iter().all(|p| (p.x - 3.3).abs() < 1e-12), "{right:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shift_that_is_not_a_number_moves_nothing() {
|
||||
let mask = [true, true, false, false, true, true, false, false];
|
||||
let (pts, _) = outline(&loop_of(&mask, 4, 2), 4, 2, &|_| f64::NAN);
|
||||
assert_eq!(pts.len(), 4);
|
||||
assert!(pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()));
|
||||
}
|
||||
}
|
||||
+132
-2
@@ -242,8 +242,11 @@ fn fidelity_changes_anchor_count() {
|
||||
|
||||
#[test]
|
||||
fn snap_curves_to_lines_straightens_near_lines() {
|
||||
// A slightly jagged, almost straight edge.
|
||||
let img = Raster::from_fn(200, 100, |x, y| if y as f64 > 30.0 + (x as f64 * 0.05) + if x % 37 < 2 { 1.0 } else { 0.0 } { BLACK } else { WHITE });
|
||||
// A slightly jagged, almost straight edge. (Hard pixels place it to half a pixel only, and its
|
||||
// ends sit on staircase treads: jags far along it would be more than "slightly" off the line.)
|
||||
let img = Raster::from_fn(200, 100, |x, y| {
|
||||
if y as f64 > 30.0 + (x as f64 * 0.05) + if (5..7).contains(&(x % 37)) && x < 120 { 1.0 } else { 0.0 } { BLACK } else { WHITE }
|
||||
});
|
||||
let res = trace(&img, &TraceParams { snap_curves_to_lines: true, ..bw() });
|
||||
let sp = &res.paths[0].path.subpaths[0];
|
||||
let curves = (0..sp.segment_count()).filter(|&i| !sp.segment_is_line(i)).count();
|
||||
@@ -729,3 +732,130 @@ fn more_noise_removal_never_gives_more_paths() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A shape drawn anti-aliased: each pixel's ink is the share of its 8 × 8 samples that `inside` holds.
|
||||
fn soft(w: u32, h: u32, inside: impl Fn(f64, f64) -> bool) -> Raster {
|
||||
Raster::from_fn(w, h, |x, y| {
|
||||
let n = (0..64).filter(|i| inside(f64::from(x) + (f64::from(i % 8) + 0.5) / 8.0, f64::from(y) + (f64::from(i / 8) + 0.5) / 8.0)).count();
|
||||
let v = (255.0 * (1.0 - n as f64 / 64.0)).round() as u8;
|
||||
[v, v, v, 255]
|
||||
})
|
||||
}
|
||||
|
||||
/// Points along every curve of `p`.
|
||||
fn along(p: &vectorcraft_geom::PathData) -> Vec<Point> {
|
||||
use vectorcraft_geom::ParamCurve;
|
||||
p.subpaths
|
||||
.iter()
|
||||
.flat_map(|sp| (0..sp.segment_count()).flat_map(move |i| (0..16).map(move |k| sp.segment(i).eval(f64::from(k) / 16.0))))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_anti_aliased_disc_is_traced_to_a_fraction_of_a_pixel() {
|
||||
let (c, r) = (Point::new(60.3, 59.6), 40.4);
|
||||
let res = trace(&soft(120, 120, |x, y| Point::new(x, y).distance(c) <= r), &bw());
|
||||
assert_eq!(res.paths.len(), 1);
|
||||
let off = along(&res.paths[0].path).iter().map(|p| (p.distance(c) - r).abs()).fold(0.0, f64::max);
|
||||
assert!(off < 0.12, "{off} px off the circle");
|
||||
// Smooth all the way round, with a handful of anchors.
|
||||
assert!(res.paths[0].path.anchor_count() <= 8, "{} anchors", res.paths[0].path.anchor_count());
|
||||
assert!(res.paths[0].path.subpaths[0].anchors.iter().all(|a| a.kind == vectorcraft_geom::AnchorKind::Smooth));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_anti_aliased_square_has_four_corners_and_straight_sides() {
|
||||
// Edges between pixel rows and columns, turned a little so that none follows the grid.
|
||||
let (c, half, turn) = (Point::new(50.3, 49.7), 30.2, 0.2f64);
|
||||
let inside = |x: f64, y: f64| {
|
||||
let (dx, dy) = (x - c.x, y - c.y);
|
||||
(dx * turn.cos() + dy * turn.sin()).abs() <= half && (dy * turn.cos() - dx * turn.sin()).abs() <= half
|
||||
};
|
||||
let res = trace(&soft(100, 100, inside), &bw());
|
||||
let sp = &res.paths[0].path.subpaths[0];
|
||||
assert_eq!(sp.anchors.len(), 4, "{sp:?}");
|
||||
assert!((0..4).all(|i| sp.segment_is_line(i)));
|
||||
for a in &sp.anchors {
|
||||
let (dx, dy) = (a.p.x - c.x, a.p.y - c.y);
|
||||
let (u, v) = (dx * turn.cos() + dy * turn.sin(), dy * turn.cos() - dx * turn.sin());
|
||||
assert!((u.abs() - half).abs() < 0.15 && (v.abs() - half).abs() < 0.15, "corner {:?} is off", a.p);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_thin_anti_aliased_line_keeps_its_place_and_width() {
|
||||
// 1.2 px wide, lying across two pixel rows.
|
||||
let res = trace(&soft(60, 20, |x, y| (5.0..55.0).contains(&x) && (9.7..10.9).contains(&y)), &bw());
|
||||
assert_eq!(res.paths.len(), 1);
|
||||
let b = res.paths[0].path.bounds().unwrap();
|
||||
assert!((b.y0 - 9.7).abs() < 0.1 && (b.y1 - 10.9).abs() < 0.1, "{b:?}");
|
||||
assert!((b.x0 - 5.0).abs() < 0.1 && (b.x1 - 55.0).abs() < 0.1, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touching_colours_get_the_same_edge() {
|
||||
// Red left of a slanted edge, blue right of it: where the paths meet, each lies on the edge.
|
||||
let edge = |y: f64| 30.3 + 0.2 * y;
|
||||
let img = Raster::from_fn(60, 40, |x, y| {
|
||||
let t = (edge(f64::from(y) + 0.5) - f64::from(x)).clamp(0.0, 1.0);
|
||||
[(200.0 * t + 30.0 * (1.0 - t)) as u8, (40.0 * t + 90.0 * (1.0 - t)) as u8, (50.0 * t + 200.0 * (1.0 - t)) as u8, 255]
|
||||
});
|
||||
let res = trace(&img, &TraceParams { mode: Mode::Color, colors: 2, ..TraceParams::default() });
|
||||
assert_eq!(res.paths.len(), 2);
|
||||
for p in &res.paths {
|
||||
let inner: Vec<Point> = along(&p.path).into_iter().filter(|q| q.x > 1.0 && q.x < 59.0 && q.y > 1.0 && q.y < 39.0).collect();
|
||||
assert!(!inner.is_empty());
|
||||
let off = inner.iter().map(|q| (q.x - edge(q.y)).abs()).fold(0.0, f64::max);
|
||||
assert!(off < 0.12, "{off} px off the shared edge");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hard_edged_disc_is_still_round() {
|
||||
let res = trace(&disc(120, 60.0, 60.0, 40.0), &bw());
|
||||
let c = Point::new(60.0, 60.0);
|
||||
let off = along(&res.paths[0].path).iter().map(|p| (p.distance(c) - 40.0).abs()).fold(0.0, f64::max);
|
||||
assert!(off < 0.75, "{off} px off the circle");
|
||||
assert!(res.paths[0].path.anchor_count() <= 12, "{} anchors", res.paths[0].path.anchor_count());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_higher_threshold_widens_a_blurred_shape_smoothly() {
|
||||
// A soft-edged disc: its luminance ramps over six pixels.
|
||||
let img = Raster::from_fn(100, 100, |x, y| {
|
||||
let d = Point::new(f64::from(x) + 0.5, f64::from(y) + 0.5).distance(Point::new(50.0, 50.0));
|
||||
let v = (255.0 * ((d - 27.0) / 6.0).clamp(0.0, 1.0)) as u8;
|
||||
[v, v, v, 255]
|
||||
});
|
||||
let radius = |threshold: u8| {
|
||||
let res = trace(&img, &TraceParams { threshold, ..bw() });
|
||||
let r: Vec<f64> = along(&res.paths[0].path).iter().map(|p| p.distance(Point::new(50.0, 50.0))).collect();
|
||||
(r.iter().copied().fold(f64::MAX, f64::min), r.iter().copied().fold(0.0, f64::max))
|
||||
};
|
||||
let (low, high) = (radius(64), radius(192));
|
||||
// Luminance 64 is 1.5 px into the ramp and 192 4.5 px.
|
||||
assert!((low.0 - 28.5).abs() < 0.6 && (low.1 - 28.5).abs() < 0.6, "{low:?}");
|
||||
assert!((high.0 - 31.5).abs() < 0.6 && (high.1 - 31.5).abs() < 0.6, "{high:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_path_of_a_noisy_picture_leaves_the_picture() {
|
||||
// Texture and noise: thousands of small ragged regions, where a fit is most likely to stray.
|
||||
let img = Raster::from_fn(160, 120, |x, y| {
|
||||
let mut v = x.wrapping_mul(0x9E37_79B1) ^ y.wrapping_mul(0x85EB_CA77);
|
||||
v ^= v >> 15;
|
||||
v = v.wrapping_mul(0x2C1B_3C6D);
|
||||
v ^= v >> 12;
|
||||
let wave = 60.0 * ((f64::from(x) * 0.31).sin() * (f64::from(y) * 0.23).cos());
|
||||
let c = |k: u32| (128.0 + wave + f64::from((v >> (8 * k)) & 63) + f64::from(x + 2 * y) * 0.2 * f64::from(k)).clamp(0.0, 255.0) as u8;
|
||||
[c(0), c(1), c(2), 255]
|
||||
});
|
||||
for name in PRESET_NAMES {
|
||||
let res = trace(&img, &preset(name).unwrap());
|
||||
for p in &res.paths {
|
||||
// A fit may bulge past the picture's border by its tolerance, never by more.
|
||||
let Some(b) = p.path.bounds() else { continue };
|
||||
assert!(b.x0 > -2.0 && b.y0 > -2.0 && b.x1 < 162.0 && b.y1 < 122.0, "{name}: a path reaches {b:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
//! The harness inputs: ground-truth vector art (drawn in a 256-unit square, rasterised at several
|
||||
//! sizes) and two continuous-tone images computed per pixel.
|
||||
|
||||
use kurbo::{Affine, BezPath, Circle, Ellipse, Point, RoundedRect, Shape};
|
||||
use vectorcraft_trace::Raster;
|
||||
|
||||
use crate::raster::{self, noise};
|
||||
|
||||
/// What an input tells about a trace.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Class {
|
||||
/// Curved one-colour shapes: a circle, ellipses, a rounded badge with a hole, a letter.
|
||||
Shape,
|
||||
/// Straight-edged one-colour shapes: polygons, a star, a letter.
|
||||
Poly,
|
||||
/// Thin lines, 0.5 to 5 units wide.
|
||||
Lines,
|
||||
/// Flat art in several colours, with regions that touch.
|
||||
Flat,
|
||||
/// Continuous tone: a gradient and a photo-like image.
|
||||
Tone,
|
||||
}
|
||||
|
||||
pub const CLASSES: [Class; 5] = [Class::Shape, Class::Poly, Class::Lines, Class::Flat, Class::Tone];
|
||||
|
||||
impl Class {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Class::Shape => "curved shapes",
|
||||
Class::Poly => "polygons",
|
||||
Class::Lines => "thin lines",
|
||||
Class::Flat => "flat colour art",
|
||||
Class::Tone => "gradient, photo",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How an input is degraded after it is drawn.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Variant {
|
||||
Clean,
|
||||
/// One hard sample per pixel: no anti-aliasing (a GIF, a 1-bit scan).
|
||||
Hard,
|
||||
Blur,
|
||||
Noise,
|
||||
Scan,
|
||||
}
|
||||
|
||||
pub const VARIANTS: [Variant; 5] = [Variant::Clean, Variant::Hard, Variant::Blur, Variant::Noise, Variant::Scan];
|
||||
|
||||
impl Variant {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Variant::Clean => "clean",
|
||||
Variant::Hard => "aliased",
|
||||
Variant::Blur => "blurred",
|
||||
Variant::Noise => "noisy",
|
||||
Variant::Scan => "scanned",
|
||||
}
|
||||
}
|
||||
/// The colour a flat `c` of the clean image has in this variant (noise and shading aside).
|
||||
pub fn color(self, c: [u8; 3]) -> [u8; 3] {
|
||||
if self == Variant::Scan { raster::scan_color(c) } else { c }
|
||||
}
|
||||
}
|
||||
|
||||
/// A colour of a continuous-tone input at `(u, v)` in the unit square; `x`, `y` are the pixel.
|
||||
type Tone = fn(f64, f64, u32, u32) -> [f64; 3];
|
||||
|
||||
pub struct Input {
|
||||
pub name: &'static str,
|
||||
pub class: Class,
|
||||
/// The art, bottom first, in 256-unit coordinates. Shapes nest or abut, never partly overlap,
|
||||
/// so every outline is a visible edge.
|
||||
pub shapes: Vec<(BezPath, [u8; 3])>,
|
||||
pub bg: [u8; 3],
|
||||
/// Anchor points the art itself has.
|
||||
pub anchors: usize,
|
||||
/// Its sharp corners.
|
||||
pub corners: Vec<Point>,
|
||||
tone: Option<Tone>,
|
||||
}
|
||||
|
||||
const WHITE: [u8; 3] = [255, 255, 255];
|
||||
const INK: [u8; 3] = [0, 0, 0];
|
||||
|
||||
fn poly(pts: &[(f64, f64)]) -> BezPath {
|
||||
let mut p = BezPath::new();
|
||||
for (i, &pt) in pts.iter().enumerate() {
|
||||
if i == 0 { p.move_to(pt) } else { p.line_to(pt) }
|
||||
}
|
||||
p.close_path();
|
||||
p
|
||||
}
|
||||
|
||||
fn regular(cx: f64, cy: f64, r: f64, n: usize, rot: f64) -> Vec<(f64, f64)> {
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let a = rot + std::f64::consts::TAU * i as f64 / n as f64;
|
||||
(cx + r * a.cos(), cy + r * a.sin())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn points(pts: &[(f64, f64)]) -> Vec<Point> {
|
||||
pts.iter().map(|&p| p.into()).collect()
|
||||
}
|
||||
|
||||
/// `outer` with `holes` cut out (wound the other way: the rasteriser fills non-zero).
|
||||
fn with_holes(outer: BezPath, holes: &[BezPath]) -> BezPath {
|
||||
let mut p = outer;
|
||||
for h in holes {
|
||||
p.extend(h.reverse_subpaths());
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Half of a disc, left or right of the vertical line through its centre (3 anchors).
|
||||
fn half_disc(c: Point, r: f64, left: bool) -> BezPath {
|
||||
let (s, k) = (if left { -r } else { r }, 0.552_284_75 * r);
|
||||
let ks = if left { -k } else { k };
|
||||
let mut p = BezPath::new();
|
||||
p.move_to((c.x, c.y - r));
|
||||
p.curve_to((c.x + ks, c.y - r), (c.x + s, c.y - k), (c.x + s, c.y));
|
||||
p.curve_to((c.x + s, c.y + k), (c.x + ks, c.y + r), (c.x, c.y + r));
|
||||
p.close_path();
|
||||
p
|
||||
}
|
||||
|
||||
fn gradient(u: f64, v: f64, _: u32, _: u32) -> [f64; 3] {
|
||||
let glow = (-((u - 0.7).powi(2) + (v - 0.3).powi(2)) / 0.05).exp();
|
||||
[40.0 + 190.0 * u + 25.0 * glow, 70.0 + 120.0 * v + 60.0 * glow, 200.0 - 150.0 * u * v + 40.0 * glow]
|
||||
}
|
||||
|
||||
/// Photo-like: sky to ground across a soft horizon, soft-edged objects, shading, texture and
|
||||
/// sensor noise.
|
||||
fn photo(u: f64, v: f64, x: u32, y: u32) -> [f64; 3] {
|
||||
let soft = |d: f64, width: f64| 1.0 / (1.0 + (-d / width).exp());
|
||||
let mix = |a: [f64; 3], b: [f64; 3], t: f64| [0, 1, 2].map(|i| a[i] + (b[i] - a[i]) * t);
|
||||
let sky = mix([70.0, 120.0, 200.0], [190.0, 210.0, 235.0], v / 0.55);
|
||||
let ground = mix([95.0, 120.0, 60.0], [50.0, 70.0, 40.0], (v - 0.55) / 0.45);
|
||||
let mut c = mix(sky, ground, soft(v - 0.55 - 0.03 * (u * 9.0).sin(), 0.006));
|
||||
// Objects: (centre, radius, colour, edge softness).
|
||||
let blobs = [
|
||||
(0.28, 0.42, 0.17, [200.0, 80.0, 60.0], 0.004),
|
||||
(0.66, 0.50, 0.22, [230.0, 190.0, 120.0], 0.012),
|
||||
(0.80, 0.22, 0.08, [250.0, 240.0, 200.0], 0.02),
|
||||
(0.45, 0.78, 0.12, [60.0, 50.0, 90.0], 0.003),
|
||||
];
|
||||
for (bx, by, r, col, edge) in blobs {
|
||||
let d = ((u - bx).powi(2) + (v - by).powi(2)).sqrt();
|
||||
// Shaded like a lit ball.
|
||||
let lit = 1.0 - 0.45 * ((u - bx + 0.4 * r) / r).clamp(-1.0, 1.0).abs();
|
||||
c = mix(c, col.map(|k| k * lit), soft(r - d, edge));
|
||||
}
|
||||
let texture = 6.0 * ((u * 61.0).sin() * (v * 47.0).cos()) + 4.0 * ((u + v) * 173.0).sin();
|
||||
[0, 1, 2].map(|i| c[i] + texture + 5.0 * noise(x, y, 31 + i as u32))
|
||||
}
|
||||
|
||||
/// Every input, in report order.
|
||||
pub fn inputs() -> Vec<Input> {
|
||||
let art = |name, class, shapes: Vec<(BezPath, [u8; 3])>, anchors, corners: Vec<Point>| Input {
|
||||
name,
|
||||
class,
|
||||
shapes,
|
||||
bg: WHITE,
|
||||
anchors,
|
||||
corners,
|
||||
tone: None,
|
||||
};
|
||||
let tone = |name, f: Tone| Input { name, class: Class::Tone, shapes: vec![], bg: WHITE, anchors: 0, corners: vec![], tone: Some(f) };
|
||||
let ink = |p: BezPath| vec![(p, INK)];
|
||||
|
||||
let mut ellipses = Ellipse::new((90.3, 99.6), (60.0, 30.0), 0.0).to_path(1e-3);
|
||||
ellipses.extend(Ellipse::new((165.4, 190.2), (70.0, 25.0), 0.6).to_path(1e-3));
|
||||
let badge = with_holes(RoundedRect::new(40.5, 56.5, 215.5, 199.5, 28.0).to_path(1e-3), &[Circle::new((128.2, 127.7), 36.0).to_path(1e-3)]);
|
||||
// A "D": a straight stem joined smoothly to a bowl, with its counter.
|
||||
let mut d = BezPath::new();
|
||||
d.move_to((60.4, 60.3));
|
||||
d.line_to((110.0, 60.3));
|
||||
d.curve_to((170.0, 60.3), (200.0, 86.0), (200.0, 128.0));
|
||||
d.curve_to((200.0, 170.0), (170.0, 195.7), (110.0, 195.7));
|
||||
d.line_to((60.4, 195.7));
|
||||
d.close_path();
|
||||
let mut counter = BezPath::new();
|
||||
counter.move_to((84.4, 84.3));
|
||||
counter.line_to((108.0, 84.3));
|
||||
counter.curve_to((150.0, 84.3), (172.0, 100.0), (172.0, 128.0));
|
||||
counter.curve_to((172.0, 156.0), (150.0, 171.7), (108.0, 171.7));
|
||||
counter.line_to((84.4, 171.7));
|
||||
counter.close_path();
|
||||
let d_corners = points(&[(60.4, 60.3), (60.4, 195.7), (84.4, 84.3), (84.4, 171.7)]);
|
||||
|
||||
let tri = [(40.3, 40.6), (120.7, 60.2), (60.4, 120.8)];
|
||||
let square = regular(180.3, 80.6, 46.0, 4, 0.35);
|
||||
let hexagon = regular(128.4, 185.3, 50.0, 6, 0.17);
|
||||
let mut polygons = poly(&tri);
|
||||
polygons.extend(poly(&square));
|
||||
polygons.extend(poly(&hexagon));
|
||||
let star: Vec<(f64, f64)> = (0..10)
|
||||
.map(|i| {
|
||||
let (a, r) = (-1.4 + std::f64::consts::PI * i as f64 / 5.0, if i % 2 == 0 { 100.0 } else { 40.0 });
|
||||
(128.3 + r * a.cos(), 128.6 + r * a.sin())
|
||||
})
|
||||
.collect();
|
||||
let a_outer = [(128.2, 40.4), (210.3, 216.5), (176.0, 216.5), (160.0, 176.4), (96.0, 176.4), (80.0, 216.5), (46.1, 216.5)];
|
||||
let a_counter = [(128.2, 96.0), (148.3, 150.4), (108.1, 150.4)];
|
||||
|
||||
// Five lines 200 long, 0.5 to 5 wide, at small angles to the pixel grid.
|
||||
let mut lines = BezPath::new();
|
||||
for (i, (deg, width)) in [(0.0f64, 0.5), (6.0, 1.0), (-9.0, 2.0), (12.0, 3.0), (-4.0, 5.0)].into_iter().enumerate() {
|
||||
let (hw, hl) = (width / 2.0, 100.0);
|
||||
let mut strip = poly(&[(-hl, -hw), (hl, -hw), (hl, hw), (-hl, hw)]);
|
||||
strip.apply_affine(Affine::translate((128.3, 36.4 + 46.0 * i as f64)) * Affine::rotate(deg.to_radians()));
|
||||
lines.extend(strip);
|
||||
}
|
||||
|
||||
let centre = Point::new(128.4, 128.3);
|
||||
let touching = vec![(half_disc(centre, 90.0, true), [200, 40, 50]), (half_disc(centre, 90.0, false), [30, 90, 200])];
|
||||
let bar = [(60.3, 180.4), (196.6, 180.4), (196.6, 206.7), (60.3, 206.7)];
|
||||
let multicolor = vec![
|
||||
(RoundedRect::new(30.4, 30.6, 226.3, 226.5, 30.0).to_path(1e-3), [20, 40, 90]),
|
||||
(Circle::new((128.3, 104.6), 50.0).to_path(1e-3), [250, 200, 40]),
|
||||
(Circle::new((128.3, 104.6), 20.0).to_path(1e-3), [240, 110, 30]),
|
||||
(poly(&bar), [40, 160, 90]),
|
||||
];
|
||||
|
||||
vec![
|
||||
art("circle", Class::Shape, ink(Circle::new((128.3, 127.6), 80.4).to_path(1e-3)), 4, vec![]),
|
||||
art("ellipses", Class::Shape, ink(ellipses), 8, vec![]),
|
||||
art("badge with a hole", Class::Shape, ink(badge), 12, vec![]),
|
||||
art("letter D", Class::Shape, ink(with_holes(d, &[counter])), 10, d_corners),
|
||||
art("polygons", Class::Poly, ink(polygons), 13, points(&[&tri[..], &square, &hexagon].concat())),
|
||||
art("star", Class::Poly, ink(poly(&star)), 10, points(&star)),
|
||||
art("letter A", Class::Poly, ink(with_holes(poly(&a_outer), &[poly(&a_counter)])), 10, points(&[&a_outer[..], &a_counter].concat())),
|
||||
art("lines", Class::Lines, ink(lines), 20, vec![]),
|
||||
art("two touching halves", Class::Flat, touching, 6, vec![(centre.x, centre.y - 90.0).into(), (centre.x, centre.y + 90.0).into()]),
|
||||
art("four-colour badge", Class::Flat, multicolor, 20, points(&bar)),
|
||||
tone("gradient", gradient),
|
||||
tone("photo-like", photo),
|
||||
]
|
||||
}
|
||||
|
||||
impl Input {
|
||||
/// The input drawn `size` pixels square as `variant`.
|
||||
pub fn draw(&self, size: u32, variant: Variant) -> Raster {
|
||||
// Art is drawn anti-aliased from 6 × 6 samples per pixel; the aliased variant takes one.
|
||||
let img = self.draw_sampled(size, if variant == Variant::Hard { 1 } else { 6 });
|
||||
match variant {
|
||||
Variant::Clean | Variant::Hard => img,
|
||||
Variant::Blur => raster::blur(&img),
|
||||
Variant::Noise => raster::noisy(&img),
|
||||
Variant::Scan => raster::scanned(&img),
|
||||
}
|
||||
}
|
||||
fn draw_sampled(&self, size: u32, samples: usize) -> Raster {
|
||||
let n = size as usize;
|
||||
match self.tone {
|
||||
Some(f) => Raster::from_fn(size, size, |x, y| {
|
||||
let c = f((f64::from(x) + 0.5) / f64::from(size), (f64::from(y) + 0.5) / f64::from(size), x, y);
|
||||
[c[0].round().clamp(0.0, 255.0) as u8, c[1].round().clamp(0.0, 255.0) as u8, c[2].round().clamp(0.0, 255.0) as u8, 255]
|
||||
}),
|
||||
None => raster::render_truth(&self.scaled(size), self.bg, n, n, samples),
|
||||
}
|
||||
}
|
||||
/// The art in the pixel coordinates of a `size`-pixel drawing.
|
||||
pub fn scaled(&self, size: u32) -> Vec<(BezPath, [u8; 3])> {
|
||||
let k = Affine::scale(f64::from(size) / 256.0);
|
||||
self.shapes.iter().map(|(p, c)| (k * p.clone(), *c)).collect()
|
||||
}
|
||||
pub fn scaled_corners(&self, size: u32) -> Vec<Point> {
|
||||
self.corners.iter().map(|p| (p.to_vec2() * (f64::from(size) / 256.0)).to_point()).collect()
|
||||
}
|
||||
}
|
||||
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user