mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-19 13:44:17 -04:00
Compare commits
167
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
035f1cb2f6 | ||
|
|
e52b07105d | ||
|
|
00f1231334 | ||
|
|
c58c642f07 | ||
|
|
39b59b5730 | ||
|
|
2c6a2ad082 | ||
|
|
b09e23f4a1 | ||
|
|
1e914c8d82 | ||
|
|
45d9a409d4 | ||
|
|
9d4675e7d4 | ||
|
|
d5dfbf4bc3 | ||
|
|
af48dda322 | ||
|
|
b87cb157ed | ||
|
|
d2b927b57b | ||
|
|
04750ece1b | ||
|
|
c08955320c | ||
|
|
742a5c1af2 | ||
|
|
1f80c28662 | ||
|
|
6fed871f88 | ||
|
|
3033fb9985 | ||
|
|
7e1095f9b1 | ||
|
|
f378c5c836 | ||
|
|
55acd88d42 | ||
|
|
319221c087 | ||
|
|
9d43326ad7 | ||
|
|
8baf5b1343 | ||
|
|
a60f8d7b9f | ||
|
|
2c52bed0b5 | ||
|
|
de3dd27fda | ||
|
|
575bd0f924 | ||
|
|
1ef66c48c1 | ||
|
|
35b5895183 | ||
|
|
0bd2a40128 | ||
|
|
605597b09c | ||
|
|
646563b033 | ||
|
|
2861c113d6 | ||
|
|
48a0206adb | ||
|
|
1fc3c23096 | ||
|
|
7dc0c034d8 | ||
|
|
be7fd07562 | ||
|
|
007f536c79 | ||
|
|
515569470c | ||
|
|
c07945b934 | ||
|
|
de4c6b1a6d | ||
|
|
ecaae7ee46 | ||
|
|
7bc7025a62 | ||
|
|
8d123b19ca | ||
|
|
4aa4b0e70b | ||
|
|
4614c08476 | ||
|
|
2ecc488111 | ||
|
|
6ed171c1b4 | ||
|
|
3eed3be8e6 | ||
|
|
85af04a83a | ||
|
|
bdd5470dcf | ||
|
|
157679c1a8 | ||
|
|
320bffb8ad | ||
|
|
3d23fc5175 | ||
|
|
8c6085beca | ||
|
|
6cb075b926 | ||
|
|
0f27503f5b | ||
|
|
92ca32e55d | ||
|
|
37ee8bee3c | ||
|
|
219253f163 | ||
|
|
e6c8ce1759 | ||
|
|
e1761eb489 | ||
|
|
7f01abd687 | ||
|
|
729582dc78 | ||
|
|
7c64ecd998 | ||
|
|
55f98095f3 | ||
|
|
ac1b380b40 | ||
|
|
c3a6aa0a29 | ||
|
|
1d214956f9 | ||
|
|
a466625c89 | ||
|
|
73f921fa0a | ||
|
|
fb18533e12 | ||
|
|
56be31fe9b | ||
|
|
a23d7a827f | ||
|
|
1c80b23745 | ||
|
|
c9a2491ef7 | ||
|
|
2ae992870b | ||
|
|
cec31c5f4a | ||
|
|
3b527ca339 | ||
|
|
4f18f1c860 | ||
|
|
b529f356cc | ||
|
|
048875cc4e | ||
|
|
57ddb71d97 | ||
|
|
49f7f5d7d8 | ||
|
|
aa9c70a7b1 | ||
|
|
278fba490a | ||
|
|
d807236bf2 | ||
|
|
9c1d1d56ed | ||
|
|
53bd154059 | ||
|
|
1f2e90a7b3 | ||
|
|
c7f748c75c | ||
|
|
4de769175e | ||
|
|
9a8e2ac6a0 | ||
|
|
419ed87cff | ||
|
|
eeb9513bbb | ||
|
|
c092ca1a48 | ||
|
|
ba2a11a807 | ||
|
|
f1757c7bda | ||
|
|
e616f1fe5c | ||
|
|
647d51eaf6 | ||
|
|
0d34a58b7b | ||
|
|
3a685eae61 | ||
|
|
e4d94109a2 | ||
|
|
4a90d13fc0 | ||
|
|
8a2d0db225 | ||
|
|
e358bbfdfb | ||
|
|
7d7853d7fd | ||
|
|
0a14001126 | ||
|
|
910e33b796 | ||
|
|
9b64fce9ec | ||
|
|
cd35c27a53 | ||
|
|
ab01a1a927 | ||
|
|
d054b5b4c7 | ||
|
|
ba32ef5b09 | ||
|
|
f0c2d8c890 | ||
|
|
e5c9054324 | ||
|
|
9fe0f03de2 | ||
|
|
429e147164 | ||
|
|
c0ec14b86d | ||
|
|
89a73abc38 | ||
|
|
cc8944ad2a | ||
|
|
05264b2801 | ||
|
|
372887fc6c | ||
|
|
841fc0cd51 | ||
|
|
d7e7066251 | ||
|
|
dc7216f0e4 | ||
|
|
36aecff81d | ||
|
|
9dbab52168 | ||
|
|
09a6fbe470 | ||
|
|
b30d80e056 | ||
|
|
1b8e3d9ae1 | ||
|
|
12d107cc56 | ||
|
|
eba9abc21e | ||
|
|
88494319da | ||
|
|
d25289a3be | ||
|
|
2025c357d9 | ||
|
|
16dcaa425b | ||
|
|
bf19ada5e1 | ||
|
|
c5798f446e | ||
|
|
4675a1633e | ||
|
|
d99b04e594 | ||
|
|
0f6154e7b9 | ||
|
|
fc594b51ee | ||
|
|
cdba0aac03 | ||
|
|
f44c3e9593 | ||
|
|
c965f49fda | ||
|
|
a10604bbc6 | ||
|
|
4f526b4e3f | ||
|
|
f2601137ab | ||
|
|
9938a31c9b | ||
|
|
5f37fccdce | ||
|
|
e8e88c14f2 | ||
|
|
2cdb0d2d53 | ||
|
|
fe16f852db | ||
|
|
43bb588e5e | ||
|
|
a57eaa0782 | ||
|
|
007df2ee24 | ||
|
|
0955e6845c | ||
|
|
4bafe834a6 | ||
|
|
87603e4c35 | ||
|
|
e60882a3a1 | ||
|
|
25a987a94f | ||
|
|
bb14031aa3 | ||
|
|
090659b78b |
No files matched your search
@@ -0,0 +1,33 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Maintain Rust / Cargo dependencies
|
||||
- package-ecosystem: "cargo"
|
||||
directories:
|
||||
- "/"
|
||||
- "/crates/affinity/fuzz"
|
||||
- "/crates/plugins/example"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
# Consolidate updates into a single PR rather than one PR per crate
|
||||
groups:
|
||||
cargo-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
# Maintain GitHub Actions workflows
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "ci(deps)"
|
||||
@@ -35,5 +35,5 @@ jobs:
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
require-craft-fonts: true
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
|
||||
run-package-check: true
|
||||
@@ -26,7 +26,8 @@ jobs:
|
||||
with:
|
||||
app-name: vectorcraft
|
||||
app-title: VectorCraft
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
|
||||
web-craft-fonts: true
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
|
||||
@@ -167,6 +167,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `assets/icons/dc-new-fill.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-new-item.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-new-stroke.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-objects-on-path.svg` | VectorCraft contributors | Original work, drawn for VectorCraft: three small squares along a curve | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-options.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-para-center.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-para-justify-all.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
|
||||
Generated
+1
@@ -5007,6 +5007,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"vectorcraft-engine",
|
||||
"vectorcraft-format",
|
||||
"vectorcraft-text",
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ wat = "1.228"
|
||||
# Without `text` (glyph runs, through glifo and a second skrifa): the renderer draws the outlines
|
||||
# vectorcraft-text gives it. The other default features stay.
|
||||
vello_cpu = { version = "0.2", default-features = false, features = ["std", "png", "u8_pipeline", "f32_pipeline"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp", "tga"] }
|
||||
# Without `text` and the system fonts (fontdb, harfrust and skrifa, at other versions than ours):
|
||||
# SVG import reads `<text>` from the XML itself and never gives usvg a font. A test of the SVG
|
||||
# crate turns `text` on.
|
||||
|
||||
@@ -11,11 +11,11 @@
|
||||
<h1 align="center">VectorCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>Vector illustration; an open-source, clean-room reimplementation of Adobe Illustrator, rebuilt in pure Rust.</b>
|
||||
<b>Vector illustration; an open-source, modern vector art application, built in pure Rust.</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
A fast, open-source, clean-room take on the Adobe Illustrator workflow. It runs natively on
|
||||
A fast, open-source vector illustration workflow. It runs natively on
|
||||
macOS, Windows, Linux and FreeBSD, and in the browser via WebAssembly. Built by the ArtCraft team.
|
||||
</p>
|
||||
|
||||
@@ -147,7 +147,7 @@ exposes to agents, and exported by VectorCraft's own renderer. The source files
|
||||
|
||||
## Why VectorCraft
|
||||
|
||||
- **Familiar.** Illustrator's layout, tools, menus, panels and shortcuts: the Pen, Direct Selection,
|
||||
- **Familiar.** A familiar layout, tools, menus, panels and shortcuts: the Pen, Direct Selection,
|
||||
Pathfinder, Smart Guides, Appearance, Swatches, Layers and more. You already know how to use it.
|
||||
- **Fast.** Multithreaded SIMD rendering off the UI thread. 20,000 shapes render in about 27 ms at
|
||||
full retina resolution while the interface stays at 120 fps.
|
||||
@@ -224,9 +224,8 @@ VectorCraft is under active development. [**ROADMAP.md**](ROADMAP.md) has the st
|
||||
and what's next; [`docs/target-app-parity.md`](docs/target-app-parity.md) the full parity assessment and
|
||||
[`docs/gaps.md`](docs/gaps.md) the ranked list of what's missing.
|
||||
|
||||
**Where we are (2026-10-10): alpha.** About 88% of Illustrator 2026's menu items, tools, effects and formats exist
|
||||
(92.5% of menu items do something), feature depth is ~75%, and we estimate ~65% "ready for real work" for a mainstream illustrator (~62% against
|
||||
all of Illustrator): everyday
|
||||
**Where we are (2026-10-10): alpha.** About 88% of core menu items, tools, effects and formats exist
|
||||
(92.5% of menu items do something), feature depth is ~75%, and we estimate ~65% ready for real work for a mainstream illustrator (~62% across all tracked features): everyday
|
||||
vector illustration is close to usable: drawing and path tools,
|
||||
Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and
|
||||
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package).
|
||||
@@ -240,20 +239,20 @@ and every-board SVG/PDF/PSD export, with fixes for Affinity 3 artboards, source-
|
||||
The interface
|
||||
speaks English, German, Japanese, Korean, Vietnamese, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Polish, Brazilian Portuguese, Russian, Swedish, Ukrainian, Finnish, Bulgarian, Greek, Czech and Georgian;
|
||||
[localization-parity.md](docs/localization-parity.md) has the details.
|
||||
The scores are self-assessed from our code against Illustrator's public documentation;
|
||||
The scores are self-assessed from our code against public documentation;
|
||||
[target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them.
|
||||
|
||||
**What's missing:**
|
||||
- reliable exchange of Illustrator's own `.ai` files (layer structure in real files; Illustrator's editing data isn't written);
|
||||
- reliable exchange of .ai files (layer structure in real files; proprietary editing data isn't written);
|
||||
- an interaction-fidelity pass covering every tool's modifiers and small behaviours;
|
||||
- 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials);
|
||||
- about half of the Photoshop-style raster effects (24 of 57 still missing; Cutout, Film Grain, Neon Glow and Poster Edges are available under Artistic, Halftone Pattern under Sketch) and the Effect Gallery;
|
||||
- about half of the raster effects (24 of 57 still missing; Cutout, Film Grain, Neon Glow and Poster Edges are available under Artistic, Halftone Pattern under Sketch) and the Effect Gallery;
|
||||
- CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface);
|
||||
- scripting, and the rest of Variables (image and graph kinds, dataset import);
|
||||
- generative AI features.
|
||||
|
||||
**Where we're going:** next is the interaction-fidelity pass, `.ai` files from real users and start-up stability,
|
||||
alongside the raster-effects package; then type, the Illustrator 2026 additions and 3D. The ranked list is in
|
||||
alongside the raster-effects package; then type, recent additions and 3D. The ranked list is in
|
||||
[`docs/gaps.md`](docs/gaps.md).
|
||||
|
||||
**Workspace:** `crates/{geom, color, doc, pathops, text, effects, three-d, trace, brush, render, svg, pdf, eps, cad, metafile, format, tools, engine, ui-egui, mcp, testkit}`
|
||||
@@ -372,8 +371,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
|
||||
part of this repository and VectorCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
Forks and modified versions must remove them.
|
||||
|
||||
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. VectorCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
|
||||
@@ -134,6 +134,10 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-18 | Exported and saved files can repeat their bytes: PNG, PDF, EPS, legacy `.ai`, packaged zips and `.vectorcraft` saves recorded the wall-clock time they were made in, so the same commands run twice gave different bytes though the pixels matched. `SOURCE_DATE_EPOCH` now stands in for the clock in everything a file records, as the reproducible-builds specification defines it; a date a command is given still wins, and a malformed value is ignored rather than refusing the export. The recovery heartbeat and the seed PDF encryption draws on keep the real clock (#2880). Headline numbers unchanged. |
|
||||
| 2026-10-18 | Inch rulers divide the inch in half, not in ten. Zooming in stepped an inch ruler through 0.2, 0.1 and 0.05 of an inch, marks an inch ruler does not carry; below an inch the steps are binary fractions now, 1/2 down to 1/16, with the unlabelled ticks between them binary too (down to 1/128, so the 32nds, 64ths and 128ths appear as the report expects), while the metric units, points, pixels and picas keep their tenths. A label carries the decimals its step needs, so a sixteenth reads 0.0625 rather than the rounded 0.06, with trailing zeros dropped (#2827). Headline numbers unchanged. |
|
||||
| 2026-10-18 | Inch rulers divide the inch in half, not in ten. Zooming in stepped an inch ruler through 0.2, 0.1 and 0.05 of an inch, marks an inch ruler does not carry; below an inch the steps are binary fractions now (1/2 to 1/128) and the minor ticks between them are binary too, while the metric units, points, pixels and picas keep their tenths. A label carries the decimals its step needs, so a sixteenth reads 0.0625 rather than the rounded 0.06, with trailing zeros dropped (#2827). Headline numbers unchanged. |
|
||||
| 2026-10-18 | Exported and saved files can repeat their bytes: PNG, PDF, EPS, legacy `.ai`, packaged zips and `.vectorcraft` saves recorded the wall-clock time they were made in, so the same commands run twice gave different bytes though the pixels matched. `SOURCE_DATE_EPOCH` now stands in for the clock in everything a file records, as the reproducible-builds specification defines it; a date a command is given still wins, and a malformed value is ignored rather than refusing the export. The recovery heartbeat and the seed PDF encryption draws on keep the real clock, so a password-protected PDF still differs between runs (#2880). Headline numbers unchanged. |
|
||||
| 2026-10-18 | About 85 community PRs landed in two batches through the full gate, with our fixes on top. New: Word `.docx` files place as area type with their formatting (#2766), CCITT Group 3 TIFF files open (#2770), Paragraph panel menu › Roman Hanging Punctuation (#2775), the Anchor Point tool edits blend spine points and handles (#2643, fixes #2544), shadows and glows each draw from the art with the effects before them, without the old exponential cost (#2395), one to ten coloured lights in 3D effects (#2706), Artboard Options X and Y with a reference point (#2739), more Contextual Task Bar actions (#2471), Optical Margin Alignment for a whole thread (#2773), History panel Step Backward/Forward and Set History Limit (#2764), Edit › Undo and Redo name the step as the History panel does (#2774), bounding-box handles snap to the grid, pixels and guides (#2715), Ctrl/Cmd+A in the Artboards panel (#2761), Tabs panel menu items (#2742), Import Data Sets defines variables from header cells (#2736), a Linux option to use X11 when a pen is attached (#2717). Fidelity and robustness: variable fonts named by their default weight and fallback glyphs keep bold and italic (#2733, #2732), legacy `.ai` layer names in Windows Latin-1 (#2722), WMF window mapping (#2721), Magic Wand inside isolation (#2720), type values out of range in files are clamped (#2728, #2757), raster effects stay responsive in drags (#2745), exports and Save for Web report undecodable images (#2762, #2737, #2684), Windows-safe exported file names (#2738), a marquee skips a Shaper Group's originals (#2772). Second batch: an export progress dialog with Cancel on the shared cancel token (#2564), CMYK gradient meshes drawn several times faster within a 16 MB cap (#2802), Paragraph panel menu › Justification… with file values clamped (#2771), Edit › Clear History (#2843), SVG image links keep their spelling (#2837, fixes main's Windows test failures), the Linux save dialog through the XDG portal (#2783), RGB input stored as CMYK in CMYK documents (#2812), legacy `.ai` export of images and radial highlights (#2801, #2807), `.ai` import notes for damaged editing data, page sizes and variable libraries (#2799, #2800, #2842), PDF and `.ai` keep their creation date (#2838), undecodable images left out of PDF exports with a note (#2790), MCP input waits for queue room (#2776), slice, snapping, selection, type and accessibility fixes (#2794–#2797, #2805, #2806, #2809, #2815, #2844–#2846), dropdowns close after a keyboard pick (#2824), plug-in dialogs scroll (#2840). Languages: the Dutch catalog completed (#2730). Headline numbers unchanged (estimated). |
|
||||
| 2026-10-17 | Background Export shows a progress dialog: the file being exported, the artboard count and a bar, Cancel (Esc) and Continue in Background. It runs on the shared cancel token of #2546, so a cancelled export writes no file and keeps no half-drawn pattern tile or effect raster; translated in every language with complete menus (#2564). Headline numbers unchanged. |
|
||||
| 2026-10-17 | About 145 community PRs landed in six batches, each through the full gate, plus our fixes on top. Crashes and hostile input: an image's RGBA size is checked from its header before decoding, without overflowing, in the renderer, sampling, recoloring and Crop Image (#2442, #2468, #2407), PDF Add Around and huge art are clamped (#2440), graph axis labels past i64, `+` in hex colours and XML ids are handled (#2610, #2470, #2447), Document Raster Effects resolutions open in range (#2579), Batch names stay in their folder (#2504). Regressions: floating panel groups are opaque again while the docked card keeps its rounded corners (#2499), the Wayland pointer returns over panels (#2502). New: MCP cancels long exports, answers while one runs and reports progress (#2546), `doc_inspect` takes ids, kind and paging (#2543), raster export of a page rectangle (#2511), `text.create` returns bounds (#2478), PDF presets and ICC profiles persist (#2547), screen-reader names for widgets (#2611), Expand on Intertwine (#2622, fixes #2540), Duplicate/Copy/Cut of several artboards (#2567), print presets by name (#2607), TSV data sets (#2608), planar TIFF (#2598), Alt-click a swatch onto a gradient stop (#2623), Shift-click ranges in Links, Graphic Styles and Libraries (#2612, #2613, #2618), Space+Shift rotates the view (#2601), Presentation Mode keys (#2583), Small Caps and super/subscript buttons (#2508). Fidelity: editable vector art from Illustrator EPS (#2571), `.ai` Drop Shadow Darkness (#2513), overprint preview with Darken (#2514), Generic CMYK black round-trips (#2503), PNG sRGB chunk (#2519), kerning at a caret (#2518), tab widths in justified area type (#2487), Transform copies keep their own gradients (#2486), smooth Zig Zag (#2475), Live Corners round up to their neighbours (#2484), kerning across style runs (#2554), SVG letter spacing on justified lines (#2574), Dimension tool snapping (#2482), gradient chips (#2495), Transform panel flips and percentages (#2400, #2404). Later the same day: PostScript `.ps` and `.epsf` files open (#2461), EPS images with CCITTFax compression draw (#2445), Illustrator 3 EPS without layers (#2436), `.ai` type opacity and blend modes (#2420), envelope meshes and compound holes in `.ai` (#2410, #2444), CMYK image soft masks in PDF export (#2448), Show Transparency Grid saved with the document (#2488), Save Selection in the Layers panel menu (#2483), Character and Paragraph Reset Panel (#2479, #2438), Cmd/Ctrl+Shift double-click releases a guide (#2467), Shift+Enter keeps a field active (#2431), Shift keeps reshaped handles perpendicular (#2425), emptied type keeps its style (#2464), Smart Blur radius (#2443), freeform gradients recolour (#2397), mesh slivers (#2456), Recolor wheel handles in CMYK (#2454), and six Image Trace fixes (#2387, #2584, #2586–#2589). Fifth batch: Optical Margin Alignment (#2563), Window › Blend panel (#2573), graph column designs (#2406), Live Paint Gap Options (#2424), the SVG Interactivity panel (#2437), RTF placed as area type (#2524), the Touch Type tool (#2469), pattern fills expand into clipped tiles (#2616), Save and Export ask before replacing a file an added extension names (#2619), the Artboard tool's Control bar and Contextual Task Bar (#2559, #2556), Alt-click New Artboard for its options (#2496), Shift-click in Brushes (#2562), the Move dialog remembers the last move (#2542), PDF export uses the document's assigned profiles (#2536), Printer Setup opens the selected printer's preferences on Windows (#2577). Sixth batch: Effect › Artistic › Cutout, Neon Glow and Poster Edges (#2453), View › Rulers › Change to Global / Artboard Rulers (#2416), Symbol Options 9-slice guides (#2512), Create Gradient Mesh on an embedded image (#2538), Rotate View cursors and the Mac trackpad rotation gesture (#2594, #2644), choice parameters as dropdowns in the inline effect editor (#2582), Group Selection picks and moves one path of a compound path (part of #2441), Shift-click ranges in the Artboards panel (#2626), Lock Variables (#2631), a layer dropped under the last row goes to the bottom (#2632), and robustness work: huge selections draw bounded overlays (#2635, #2645, part of #2460), values out of range in presets and files are clamped (#2656), whole-number checks for counts (#2638, #2659), exports warn about overset type (#2628), symbol instances measured by their art (#2614), colour-managed Eyedropper in CMYK documents (#2664). Later: Effect › Rasterize (#2409), variable-font axis sliders in the Character panel (#2710), Image Trace Create Strokes on an exact distance transform with Guo–Hall thinning and a skeleton graph (#2703–#2705), Auto Color keeps rare colours (#2702), large images trace with a warning (#2700), a 1e9 pt input bound on Reshape Segment, Convert Anchor Point and the Pencil (#2709), task-bar Connect, Corner and Smooth for selected anchors (#2697), PDF 0 w strokes import as hairlines (#2696), threading text from theLine truncated
|
||||
|
||||
@@ -79,14 +79,16 @@ USAGE:
|
||||
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON]]... [--export FILE]... [--scale N]
|
||||
Headless batch: open FILE (any readable format) or start a new document, run commands in
|
||||
order, export each FILE in the format its extension picks (see Writable formats; a .ait FILE
|
||||
is a template, written as file.saveAsTemplate writes it). Prints one JSON result per step.
|
||||
is a template, written as file.saveAsTemplate writes it). --scale sizes raster exports
|
||||
(pixels per point, default 1); other formats leave it out. Prints one JSON result per step.
|
||||
|
||||
vectorcraft-cli commands
|
||||
Print the command catalogue as JSON.
|
||||
|
||||
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
|
||||
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
|
||||
formats). --scale sizes a raster OUT (pixels per point, default 1); other formats leave it
|
||||
out. --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.
|
||||
A .ait OUT is a template of every artboard, written as file.saveAsTemplate writes it (not with
|
||||
--artboard or --range).
|
||||
@@ -278,7 +280,8 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
} else {
|
||||
// A conversion keeps hidden layers and objects, written hidden, where the format can (SVG,
|
||||
// PSD).
|
||||
let mut params = json!({"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy});
|
||||
let params = json!({"path": output, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy});
|
||||
let mut params = with_pixel_scale(params, &output, scale);
|
||||
if let Some(decimals) = decimals {
|
||||
params["decimals"] = json!(decimals);
|
||||
}
|
||||
@@ -302,6 +305,16 @@ fn is_template(path: &str) -> bool {
|
||||
vectorcraft_engine::cmd::fileio::format_for_name(path).is_some_and(|f| f.id == "ait")
|
||||
}
|
||||
|
||||
/// The export `params` for `path`, with `--scale` (pixels per point) when its format is raster.
|
||||
/// DXF has a `scale` of its own (drawing units per unit), which would resize the drawing and leave
|
||||
/// its units unset.
|
||||
fn with_pixel_scale(mut params: Value, path: &str, scale: f64) -> Value {
|
||||
if vectorcraft_engine::cmd::fileio::writable_format(None, Some(path)).is_ok_and(|f| f.raster) {
|
||||
params["scale"] = json!(scale);
|
||||
}
|
||||
params
|
||||
}
|
||||
|
||||
fn info(args: &[String]) -> Result<(), String> {
|
||||
let file = args.first().ok_or("info needs a FILE")?;
|
||||
let mut h = Headless::new();
|
||||
@@ -391,7 +404,7 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
let r = if is_template(&path) {
|
||||
h.call("engine.execute", json!({"command": "file.saveAsTemplate", "params": {"path": path, "format": "ait"}}))
|
||||
} else {
|
||||
h.call("app.export", json!({"path": path, "scale": scale}))
|
||||
h.call("app.export", with_pixel_scale(json!({"path": path}), &path, scale))
|
||||
};
|
||||
let r = r.map_err(|e| format!("export {path}: {e}"))?;
|
||||
emit(json!({"step": "export", "result": r}))?;
|
||||
|
||||
@@ -58,6 +58,28 @@ fn run_batch_exports() {
|
||||
assert_eq!(last["result"]["artboards"][0]["width"], 200.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_sizes_raster_exports_only() {
|
||||
// --scale is pixels per point. DXF has a `scale` of its own (drawing units per mm) that would
|
||||
// resize the drawing and leave its units unset.
|
||||
let svg = tmp("scale.svg");
|
||||
std::fs::write(&svg, r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50"><rect x="10" y="10" width="30" height="20"/></svg>"#)
|
||||
.unwrap();
|
||||
let svg = svg.to_str().unwrap();
|
||||
let (plain, scaled, png) = (tmp("plain.dxf"), tmp("scaled.dxf"), tmp("scaled.png"));
|
||||
let ok = |args: &[&str]| {
|
||||
let out = Command::new(BIN).args(args).output().unwrap();
|
||||
assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
};
|
||||
ok(&["convert", svg, plain.to_str().unwrap()]);
|
||||
ok(&["convert", svg, scaled.to_str().unwrap(), "--scale", "2"]);
|
||||
assert!(std::fs::read(&scaled).unwrap() == std::fs::read(&plain).unwrap(), "convert --scale 2 changed the DXF");
|
||||
ok(&["run", "--in", svg, "--export", scaled.to_str().unwrap(), "--export", png.to_str().unwrap(), "--scale", "2"]);
|
||||
assert!(std::fs::read(&scaled).unwrap() == std::fs::read(&plain).unwrap(), "run --scale 2 changed the DXF");
|
||||
// 100 pt at scale 2 → 200 px (IHDR width).
|
||||
assert_eq!(std::fs::read(&png).unwrap()[16..20], 200u32.to_be_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_reports_errors() {
|
||||
let out = Command::new(BIN).args(["run", "--cmd", "nope.nothing"]).output().unwrap();
|
||||
|
||||
@@ -26,6 +26,7 @@ log = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
serde_json = { workspace = true }
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "PointerEvent", "TouchEvent", "AddEventListenerOptions", "DomRect", "DragEvent", "Storage", "Navigator", "RequestInit", "Response", "AbortController", "AbortSignal"] }
|
||||
@@ -0,0 +1,184 @@
|
||||
//! `window.vectorcraft`: the control channel for the page, as promises.
|
||||
//!
|
||||
//! The same JSON methods as the desktop control server (`docs/control-protocol.md`), so an agent
|
||||
//! driving VectorCraft in a browser tab (or an `<iframe>` in another app) uses the same commands as
|
||||
//! the CLI and the MCP server:
|
||||
//!
|
||||
//! ```js
|
||||
//! await vectorcraft.execute("object.rectangle", {x: 0, y: 0, width: 100, height: 50}) // engine command → its result
|
||||
//! await vectorcraft.request("ui.inspect") // control method → its result
|
||||
//! await vectorcraft.commands() // command list
|
||||
//! const {base64} = await vectorcraft.screenshot() // the canvas as PNG (base64)
|
||||
//! await vectorcraft.openFile("logo.svg", bytes) // opened as a dropped file is
|
||||
//! vectorcraft.captureDownloads(true) // saves stay in the page…
|
||||
//! vectorcraft.files(); await vectorcraft.readFile("logo.png") // …to be read back
|
||||
//! ```
|
||||
//!
|
||||
//! Results resolve with the method's `result`; errors reject with an `Error`.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::sync::mpsc::{Receiver, Sender, TryRecvError};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_ui_egui::ControlRequest;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::saved_files::SavedFiles;
|
||||
|
||||
use crate::web::Inbox;
|
||||
|
||||
type Waiting = (Receiver<Value>, js_sys::Function, js_sys::Function);
|
||||
|
||||
thread_local! {
|
||||
static TX: RefCell<Option<Sender<ControlRequest>>> = const { RefCell::new(None) };
|
||||
static WAITING: RefCell<Vec<Waiting>> = const { RefCell::new(Vec::new()) };
|
||||
static CTX: RefCell<Option<egui::Context>> = const { RefCell::new(None) };
|
||||
static INBOX: RefCell<Option<Inbox>> = const { RefCell::new(None) };
|
||||
static CAPTURE: Cell<bool> = const { Cell::new(false) };
|
||||
static SAVED: RefCell<SavedFiles> = RefCell::new(SavedFiles::default());
|
||||
}
|
||||
|
||||
/// Wire the page's requests to the app: control requests go to `tx`, opened files to `inbox`.
|
||||
/// After a graphics restart the app keeps its channel: `tx` is `None` and only the context changes.
|
||||
pub fn connect(tx: Option<Sender<ControlRequest>>, inbox: Inbox, ctx: egui::Context) {
|
||||
if let Some(tx) = tx {
|
||||
TX.with(|t| *t.borrow_mut() = Some(tx));
|
||||
}
|
||||
INBOX.with(|i| *i.borrow_mut() = Some(inbox));
|
||||
CTX.with(|c| *c.borrow_mut() = Some(ctx));
|
||||
}
|
||||
|
||||
fn repaint() {
|
||||
CTX.with(|c| {
|
||||
if let Some(ctx) = c.borrow().as_ref() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Keep a save instead of downloading it, when the page asked for that. True when kept.
|
||||
pub fn capture(path: &str, bytes: &[u8]) -> bool {
|
||||
if !CAPTURE.with(Cell::get) {
|
||||
return false;
|
||||
}
|
||||
SAVED.with(|s| s.borrow_mut().keep(path, bytes));
|
||||
true
|
||||
}
|
||||
|
||||
fn to_js(v: &Value) -> JsValue {
|
||||
js_sys::JSON::parse(&v.to_string()).unwrap_or(JsValue::NULL)
|
||||
}
|
||||
|
||||
fn from_js(v: &JsValue) -> Value {
|
||||
if v.is_undefined() || v.is_null() {
|
||||
return json!({});
|
||||
}
|
||||
js_sys::JSON::stringify(v).ok().and_then(|s| s.as_string()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}))
|
||||
}
|
||||
|
||||
/// Settle the promises of answered requests (called every frame).
|
||||
pub fn poll_replies() {
|
||||
WAITING.with(|w| {
|
||||
w.borrow_mut().retain(|(rx, resolve, reject)| match rx.try_recv() {
|
||||
Ok(v) => {
|
||||
if v.get("ok").and_then(Value::as_bool) == Some(true) {
|
||||
let _ = resolve.call1(&JsValue::NULL, &to_js(v.get("result").unwrap_or(&Value::Null)));
|
||||
} else {
|
||||
let msg = v.get("error").and_then(Value::as_str).unwrap_or("error").to_string();
|
||||
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new(&msg));
|
||||
}
|
||||
false
|
||||
}
|
||||
Err(TryRecvError::Empty) => true,
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("request dropped"));
|
||||
false
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn request(method: &str, params: Value) -> js_sys::Promise {
|
||||
let method = method.to_string();
|
||||
let p = js_sys::Promise::new(&mut |resolve, reject| {
|
||||
let (req, rx) = ControlRequest::new(method.clone(), params.clone());
|
||||
let sent = TX.with(|t| t.borrow().as_ref().is_some_and(|tx| tx.send(req).is_ok()));
|
||||
if sent {
|
||||
WAITING.with(|w| w.borrow_mut().push((rx, resolve, reject)));
|
||||
} else {
|
||||
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("VectorCraft is not running yet"));
|
||||
}
|
||||
});
|
||||
repaint();
|
||||
p
|
||||
}
|
||||
|
||||
fn set_fn(obj: &js_sys::Object, name: &str, f: JsValue) -> Result<(), JsValue> {
|
||||
js_sys::Reflect::set(obj, &name.into(), &f).map(|_| ())
|
||||
}
|
||||
|
||||
/// Install `window.vectorcraft`.
|
||||
pub fn install() -> Result<(), JsValue> {
|
||||
let obj = js_sys::Object::new();
|
||||
set_fn(
|
||||
&obj,
|
||||
"request",
|
||||
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|method: String, params: JsValue| request(&method, from_js(¶ms)))
|
||||
.into_js_value(),
|
||||
)?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"execute",
|
||||
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|command: String, params: JsValue| {
|
||||
request("engine.execute", json!({"command": command, "params": from_js(¶ms)}))
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
set_fn(&obj, "commands", Closure::<dyn Fn() -> js_sys::Promise>::new(|| request("engine.commands", json!({}))).into_js_value())?;
|
||||
set_fn(&obj, "inspect", Closure::<dyn Fn() -> js_sys::Promise>::new(|| request("ui.inspect", json!({}))).into_js_value())?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"screenshot",
|
||||
Closure::<dyn Fn(JsValue) -> js_sys::Promise>::new(|params: JsValue| request("ui.screenshot", from_js(¶ms))).into_js_value(),
|
||||
)?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"openFile",
|
||||
Closure::<dyn Fn(String, js_sys::Uint8Array) -> JsValue>::new(|name: String, bytes: js_sys::Uint8Array| {
|
||||
let queued = INBOX.with(|i| {
|
||||
i.borrow().as_ref().is_some_and(|inbox| {
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes.to_vec()));
|
||||
true
|
||||
})
|
||||
});
|
||||
repaint();
|
||||
JsValue::from_bool(queued)
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
set_fn(&obj, "captureDownloads", Closure::<dyn Fn(bool)>::new(|on: bool| CAPTURE.with(|c| c.set(on))).into_js_value())?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"files",
|
||||
Closure::<dyn Fn() -> JsValue>::new(|| {
|
||||
SAVED.with(|s| to_js(&Value::Array(s.borrow().list().map(|(n, size)| json!({"path": n, "size": size})).collect())))
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"readFile",
|
||||
Closure::<dyn Fn(String) -> Result<JsValue, JsValue>>::new(|name: String| {
|
||||
SAVED.with(|s| {
|
||||
s.borrow()
|
||||
.get(&name)
|
||||
.map(|b| js_sys::Uint8Array::from(b).into())
|
||||
.ok_or_else(|| js_sys::Error::new(&format!("{name}: not found")).into())
|
||||
})
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
let w = web_sys::window().ok_or("no window")?;
|
||||
js_sys::Reflect::set(&w, &"vectorcraft".into(), &obj)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -16,12 +16,16 @@
|
||||
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?lang=es` (any
|
||||
//! language code) stands in for the browser's languages when the interface language is Automatic.
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod api;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod fonts;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod locks;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod pencil;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod saved_files;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
//! What the page keeps of the app's saves (`api.rs`): `readFile` reads them back.
|
||||
|
||||
/// Saved files kept (the oldest go first).
|
||||
pub const MAX_SAVED: usize = 16;
|
||||
|
||||
/// The last component of a `/` or `\` path.
|
||||
fn file_name(path: &str) -> &str {
|
||||
path.rsplit(['/', '\\']).next().filter(|n| !n.is_empty()).unwrap_or(path)
|
||||
}
|
||||
|
||||
/// Files by name, newest last: saving a name again replaces it.
|
||||
#[derive(Default)]
|
||||
pub struct SavedFiles(Vec<(String, Vec<u8>)>);
|
||||
|
||||
impl SavedFiles {
|
||||
/// Keep `bytes` under the last component of `path`.
|
||||
pub fn keep(&mut self, path: &str, bytes: &[u8]) {
|
||||
let name = file_name(path).to_string();
|
||||
self.0.retain(|(n, _)| *n != name);
|
||||
self.0.push((name, bytes.to_vec()));
|
||||
let extra = self.0.len().saturating_sub(MAX_SAVED);
|
||||
self.0.drain(..extra);
|
||||
}
|
||||
|
||||
/// A kept file by its name, or by a path ending in it.
|
||||
pub fn get(&self, name: &str) -> Option<&[u8]> {
|
||||
let wanted = file_name(name);
|
||||
self.0.iter().find(|(n, _)| n == wanted).map(|(_, b)| b.as_slice())
|
||||
}
|
||||
|
||||
/// `(name, size)` of each kept file, oldest first.
|
||||
pub fn list(&self) -> impl Iterator<Item = (&str, usize)> {
|
||||
self.0.iter().map(|(n, b)| (n.as_str(), b.len()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keeps_by_name_replaces_and_reads_back_by_path() {
|
||||
let mut s = SavedFiles::default();
|
||||
s.keep("exports/poster.png", b"one");
|
||||
s.keep(r"C:\out\poster.png", b"two");
|
||||
s.keep("notes.txt", b"three");
|
||||
assert_eq!(s.list().collect::<Vec<_>>(), vec![("poster.png", 3), ("notes.txt", 5)]);
|
||||
assert_eq!(s.get("poster.png"), Some(&b"two"[..]));
|
||||
assert_eq!(s.get("/exports/poster.png"), Some(&b"two"[..]));
|
||||
assert_eq!(s.get("missing.png"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_the_oldest_past_the_cap() {
|
||||
let mut s = SavedFiles::default();
|
||||
for i in 0..MAX_SAVED + 3 {
|
||||
s.keep(&format!("f{i}.png"), &[0]);
|
||||
}
|
||||
assert_eq!(s.list().count(), MAX_SAVED);
|
||||
assert_eq!(s.get("f0.png"), None);
|
||||
assert!(s.get(&format!("f{}.png", MAX_SAVED + 2)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_trailing_separator_keeps_the_whole_name() {
|
||||
let mut s = SavedFiles::default();
|
||||
s.keep("odd/", b"x");
|
||||
assert_eq!(s.get("odd/"), Some(&b"x"[..]));
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ use wasm_bindgen::JsCast as _;
|
||||
use crate::locks::WebLocks;
|
||||
use crate::pencil::SharedPointers;
|
||||
|
||||
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
pub(crate) type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
/// Where the pointer was over the canvas during the last file drag (CSS px from its top-left) and
|
||||
/// whether Shift was held: where dropped files land.
|
||||
@@ -122,9 +122,18 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
|
||||
// The old services asked the old context for frames.
|
||||
app.services = services;
|
||||
app.status("The graphics were lost and have been restarted");
|
||||
crate::api::connect(None, host.inbox.clone(), ctx.clone());
|
||||
app
|
||||
}
|
||||
None => VectorcraftApp::new(Session::new(), services),
|
||||
None => {
|
||||
// `window.vectorcraft`: the page's control channel (crate::api).
|
||||
let (control_tx, control_rx) = std::sync::mpsc::channel();
|
||||
crate::api::connect(Some(control_tx), host.inbox.clone(), ctx.clone());
|
||||
if let Err(e) = crate::api::install() {
|
||||
log::error!("vectorcraft-web: couldn't install window.vectorcraft: {}", js_err(e));
|
||||
}
|
||||
VectorcraftApp::new(Session::new(), services).with_control(control_rx)
|
||||
}
|
||||
};
|
||||
Ok(Box::new(WebShell { app: Some(app), host, canvas: page, loss }))
|
||||
}),
|
||||
@@ -386,6 +395,7 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
}
|
||||
app.logic(ctx);
|
||||
crate::api::poll_replies();
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
@@ -399,6 +409,7 @@ impl eframe::App for WebShell {
|
||||
if let Some(app) = &mut self.app {
|
||||
app.ui(ui);
|
||||
}
|
||||
crate::api::poll_replies();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,6 +555,9 @@ impl RecoveryStore for BrowserStore {
|
||||
|
||||
/// Trigger a browser download of `bytes` named after the last component of `path`.
|
||||
fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
if crate::api::capture(path, bytes) {
|
||||
return Ok(());
|
||||
}
|
||||
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "vectorcraft".into());
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
|
||||
@@ -1090,4 +1090,116 @@ mod tests {
|
||||
assert!(types > 1 && plist.matches("<key>LSHandlerRank</key>").count() == types, "every document type has a rank");
|
||||
assert_eq!(plist.matches("<string>Owner</string>").count(), 2, "only VectorCraft documents and templates are owned");
|
||||
}
|
||||
|
||||
/// The MIME types a Linux desktop gives each extension in `OPEN_EXTS` but `UNASSOCIATED_EXTS`
|
||||
/// (one row per type): shared-mime-info's, or one that `ai.storyteller.vectorcraft.mime.xml`
|
||||
/// declares.
|
||||
const LINUX_TYPES: &[(&str, &str)] = &[
|
||||
("vectorcraft", "application/x-vectorcraft"),
|
||||
("drawcraft", "application/x-vectorcraft"),
|
||||
("svg", "image/svg+xml"),
|
||||
("svgz", "image/svg+xml-compressed"),
|
||||
("pdf", "application/pdf"),
|
||||
("ai", "application/illustrator"), // brand-ok: shared-mime-info's type
|
||||
("ait", "application/x-ai-template"),
|
||||
("png", "image/png"),
|
||||
("jpg", "image/jpeg"),
|
||||
("jpeg", "image/jpeg"),
|
||||
("gif", "image/gif"),
|
||||
("webp", "image/webp"),
|
||||
("tif", "image/tiff"),
|
||||
("tiff", "image/tiff"),
|
||||
("bmp", "image/bmp"),
|
||||
("vctemplate", "application/x-vectorcraft-template"),
|
||||
("dxf", "image/vnd.dxf"),
|
||||
("emf", "image/emf"),
|
||||
("wmf", "image/wmf"),
|
||||
("eps", "image/x-eps"),
|
||||
("af", "application/x-affinity"),
|
||||
("afdesign", "application/x-affinity"),
|
||||
("afpub", "application/x-affinity"),
|
||||
];
|
||||
|
||||
/// The types in `LINUX_TYPES` that shared-mime-info defines. The MIME package declares every
|
||||
/// other one.
|
||||
const LINUX_SHARED_TYPES: &[&str] = &[
|
||||
"image/svg+xml",
|
||||
"image/svg+xml-compressed",
|
||||
"application/pdf",
|
||||
"application/illustrator", // brand-ok: shared-mime-info's type
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/tiff",
|
||||
"image/bmp",
|
||||
"image/vnd.dxf",
|
||||
"image/emf",
|
||||
"image/wmf",
|
||||
"image/x-eps",
|
||||
];
|
||||
|
||||
/// Extensions in `LINUX_TYPES` whose type the MIME package matches by file content and not by a
|
||||
/// glob, with the package's `<magic>` rule for that type (its lines joined, indentation removed).
|
||||
const LINUX_BY_CONTENT: &[(&str, &str)] = &[(
|
||||
"af",
|
||||
// `MAGIC` at offset 0 and `DOCUMENT_KIND` at offset 8 (crates/affinity/src/container.rs).
|
||||
r#"<magic><match type="string" offset="0" value="\x00\xff\x4b\x41"><match type="string" offset="8" value="nsrP"/></match></magic>"#,
|
||||
)];
|
||||
|
||||
/// The desktop entry lists a MIME type for every extension in `OPEN_EXTS` but
|
||||
/// `UNASSOCIATED_EXTS`, and the app's MIME package declares the types shared-mime-info doesn't
|
||||
/// have.
|
||||
#[test]
|
||||
fn the_linux_desktop_entry_opens_every_readable_format() {
|
||||
let desktop = include_str!("../../../packaging/linux/ai.storyteller.vectorcraft.desktop");
|
||||
let mime = include_str!("../../../packaging/linux/ai.storyteller.vectorcraft.mime.xml");
|
||||
let listed: Vec<&str> =
|
||||
desktop.lines().find_map(|l| l.strip_prefix("MimeType=")).unwrap_or_default().split(';').filter(|t| !t.is_empty()).collect();
|
||||
// Each type the MIME package declares, with the extensions of its globs and its `<magic>`
|
||||
// rule (empty when it has none) in the form `LINUX_BY_CONTENT` gives.
|
||||
let declared: Vec<(&str, Vec<&str>, String)> = mime
|
||||
.split("<mime-type type=\"")
|
||||
.skip(1)
|
||||
.map(|block| {
|
||||
let body = block.split("</mime-type>").next().unwrap_or_default();
|
||||
let globs = body.split("<glob pattern=\"*.").skip(1).filter_map(|g| g.split('"').next()).collect();
|
||||
let magic = match (body.find("<magic"), body.find("</magic>")) {
|
||||
(Some(start), Some(end)) => body[start..end + "</magic>".len()].lines().map(str::trim).collect(),
|
||||
_ => String::new(),
|
||||
};
|
||||
(block.split('"').next().unwrap_or_default(), globs, magic)
|
||||
})
|
||||
.collect();
|
||||
let mut associated: Vec<&str> = fileio::OPEN_EXTS.iter().copied().filter(|e| !fileio::UNASSOCIATED_EXTS.contains(e)).collect();
|
||||
let mut typed: Vec<&str> = LINUX_TYPES.iter().map(|(e, _)| *e).collect();
|
||||
associated.sort_unstable();
|
||||
typed.sort_unstable();
|
||||
typed.dedup();
|
||||
assert_eq!(typed, associated, "a MIME type for each extension the packaging associates with the app");
|
||||
for (e, t) in LINUX_TYPES {
|
||||
assert!(listed.contains(t), "the desktop entry doesn't list {t} (.{e})");
|
||||
let package = declared.iter().find(|(name, ..)| name == t);
|
||||
if LINUX_SHARED_TYPES.contains(t) {
|
||||
assert!(package.is_none(), "{t} is shared-mime-info's, and the MIME package declares it again");
|
||||
continue;
|
||||
}
|
||||
let Some((_, globs, magic)) = package else { panic!("the MIME package doesn't declare {t} (.{e})") };
|
||||
if let Some((_, rule)) = LINUX_BY_CONTENT.iter().find(|(content, _)| content == e) {
|
||||
assert!(!globs.contains(e), "{t} matches .{e} files by content only");
|
||||
assert_eq!(magic, rule, "{t}'s magic rule for .{e} files");
|
||||
} else {
|
||||
assert!(globs.contains(e), "{t} has no *.{e} glob");
|
||||
}
|
||||
}
|
||||
for t in &listed {
|
||||
assert!(LINUX_TYPES.iter().any(|(_, known)| known == t), "the desktop entry lists {t}, which the app doesn't open");
|
||||
}
|
||||
for (t, globs, _) in &declared {
|
||||
assert!(listed.contains(t), "the desktop entry doesn't list the declared {t}");
|
||||
for g in globs {
|
||||
assert!(LINUX_TYPES.contains(&(*g, *t)), "{t}'s *.{g} isn't a file the app opens as {t}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18C8 6 16 6 21 14"/><rect x="3.6" y="11.6" width="3.6" height="3.6"/><rect x="10.2" y="6.7" width="3.6" height="3.6"/><rect x="16.8" y="9.2" width="3.6" height="3.6"/></svg>
|
||||
|
After Width: | Height: | Size: 369 B |
@@ -129,3 +129,7 @@ sync_github_name = true
|
||||
|
||||
[people.makesomethingshit]
|
||||
sync_github_name = true
|
||||
|
||||
[people.MiQ71]
|
||||
real_name = "Mirko Kerezovic"
|
||||
sync_github_name = false
|
||||
@@ -56,6 +56,84 @@ struct Sub {
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
/// A subpath close enough to a circle to preserve as a native DXF CIRCLE/ARC. VectorCraft's
|
||||
/// ellipse helper uses the standard four-cubic circle approximation, so recognition needs a small
|
||||
/// relative tolerance rather than exact control-point tests.
|
||||
struct CircularSub {
|
||||
centre: Point,
|
||||
radius: f64,
|
||||
samples: Vec<Point>,
|
||||
}
|
||||
|
||||
fn cubic_point(p0: Point, p1: Point, p2: Point, p3: Point, t: f64) -> Point {
|
||||
let u = 1.0 - t;
|
||||
let a = u * u * u;
|
||||
let b = 3.0 * u * u * t;
|
||||
let c = 3.0 * u * t * t;
|
||||
let d = t * t * t;
|
||||
Point::new(a * p0.x + b * p1.x + c * p2.x + d * p3.x, a * p0.y + b * p1.y + c * p2.y + d * p3.y)
|
||||
}
|
||||
|
||||
/// Conservatively fit a curved subpath to one circle. Straight segments are never promoted. The
|
||||
/// 5e-4 radial tolerance is just above the maximum radial error of the usual kappa cubic circle
|
||||
/// approximation (~2.7e-4 r), while rejecting visibly elliptical or arbitrary cubic curves.
|
||||
fn circular_subpath(sub: &Sub) -> Option<CircularSub> {
|
||||
if sub.segs.is_empty() || sub.segs.iter().any(|s| matches!(s, Seg::Line(_))) {
|
||||
return None;
|
||||
}
|
||||
let mut samples = vec![sub.start];
|
||||
let mut from = sub.start;
|
||||
for seg in &sub.segs {
|
||||
let Seg::Cubic(a, b, to) = *seg else { return None };
|
||||
for i in 1..=8 {
|
||||
samples.push(cubic_point(from, a, b, to, f64::from(i) / 8.0));
|
||||
}
|
||||
from = to;
|
||||
}
|
||||
if samples.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
let mut anchors = vec![sub.start];
|
||||
anchors.extend(sub.segs.iter().map(|s| s.end()));
|
||||
if sub.closed && anchors.last().is_some_and(|p| p.distance(sub.start) <= 1e-9) {
|
||||
anchors.pop();
|
||||
}
|
||||
let (p0, p1, p2) = if anchors.len() >= 3 {
|
||||
(anchors[0], anchors[anchors.len() / 3], anchors[anchors.len() * 2 / 3])
|
||||
} else {
|
||||
(samples[0], samples[samples.len() / 2], *samples.last()?)
|
||||
};
|
||||
let d = 2.0 * (p0.x * (p1.y - p2.y) + p1.x * (p2.y - p0.y) + p2.x * (p0.y - p1.y));
|
||||
let scale2 = p0.distance(p1).max(p1.distance(p2)).max(p2.distance(p0)).powi(2);
|
||||
if !d.is_finite() || d.abs() <= scale2 * 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let q0 = p0.x * p0.x + p0.y * p0.y;
|
||||
let q1 = p1.x * p1.x + p1.y * p1.y;
|
||||
let q2 = p2.x * p2.x + p2.y * p2.y;
|
||||
let centre = Point::new(
|
||||
(q0 * (p1.y - p2.y) + q1 * (p2.y - p0.y) + q2 * (p0.y - p1.y)) / d,
|
||||
(q0 * (p2.x - p1.x) + q1 * (p0.x - p2.x) + q2 * (p1.x - p0.x)) / d,
|
||||
);
|
||||
let radius = centre.distance(p0);
|
||||
if !centre.x.is_finite() || !centre.y.is_finite() || !radius.is_finite() || radius <= 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let tolerance = (radius * 5e-4).max(1e-6);
|
||||
if samples.iter().any(|p| (centre.distance(*p) - radius).abs() > tolerance) {
|
||||
return None;
|
||||
}
|
||||
Some(CircularSub { centre, radius, samples })
|
||||
}
|
||||
|
||||
fn angle_deg(p: Point, centre: Point) -> f64 {
|
||||
(p.y - centre.y).atan2(p.x - centre.x).to_degrees().rem_euclid(360.0)
|
||||
}
|
||||
|
||||
fn on_ccw_arc(start: f64, end: f64, angle: f64) -> bool {
|
||||
(angle - start).rem_euclid(360.0) <= (end - start).rem_euclid(360.0) + 1e-7
|
||||
}
|
||||
|
||||
/// The subpaths of `bp` (quadratics raised to cubics).
|
||||
fn subpaths(bp: &BezPath) -> Vec<Sub> {
|
||||
let mut out = vec![];
|
||||
@@ -576,6 +654,10 @@ impl<'a> Scene<'a> {
|
||||
pts.pop();
|
||||
}
|
||||
self.polyline(&pts, sub.closed, style, width);
|
||||
} else if (v.handles() || width.is_none()) && self.circular(&sub, style) {
|
||||
// Native circles/arcs are preferred by CAD/CAM software. In R12, however, a
|
||||
// stroked curve must stay on the flattened POLYLINE path so groups 40/41 retain
|
||||
// its width. Non-circular curves continue through the existing fallback.
|
||||
} else if v.handles() {
|
||||
self.spline(&sub, style);
|
||||
} else {
|
||||
@@ -636,6 +718,73 @@ impl<'a> Scene<'a> {
|
||||
self.e.entity("SEQEND", end, h, &layer, style, "");
|
||||
}
|
||||
|
||||
/// Write a safely recognized circular subpath as a native DXF CIRCLE or ARC. Returns false
|
||||
/// when the curve is not circular enough, leaving the caller to write it as a spline/polyline.
|
||||
fn circular(&mut self, sub: &Sub, style: &EntityStyle) -> bool {
|
||||
let Some(last) = sub.segs.last().map(|s| s.end()) else { return false };
|
||||
let geometrically_closed = last.distance(sub.start) <= 1e-9;
|
||||
// ClosePath can add an implicit straight closing edge. Do not replace that geometry with
|
||||
// a full CIRCLE; likewise, an open curve that returns to its start is not an ARC. Check
|
||||
// before fitting, updating extents or allocating an entity handle.
|
||||
if sub.closed != geometrically_closed {
|
||||
return false;
|
||||
}
|
||||
let Some(circle) = circular_subpath(sub) else { return false };
|
||||
let centre = self.to_dxf * circle.centre;
|
||||
let radius = circle.radius * self.k.abs();
|
||||
if !centre.x.is_finite() || !centre.y.is_finite() || !radius.is_finite() || radius <= 1e-12 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Count sampled points in the drawing extents. For a full circle include all cardinal
|
||||
// points exactly; for an arc, the dense samples conservatively cover its visible span.
|
||||
if sub.closed {
|
||||
let r = circle.radius;
|
||||
for p in [
|
||||
Point::new(circle.centre.x - r, circle.centre.y),
|
||||
Point::new(circle.centre.x + r, circle.centre.y),
|
||||
Point::new(circle.centre.x, circle.centre.y - r),
|
||||
Point::new(circle.centre.x, circle.centre.y + r),
|
||||
] {
|
||||
self.pt(p);
|
||||
}
|
||||
} else {
|
||||
for p in &circle.samples {
|
||||
self.pt(*p);
|
||||
}
|
||||
}
|
||||
|
||||
let layer = self.layer.clone();
|
||||
let h = self.handle();
|
||||
if sub.closed {
|
||||
self.e.entity("CIRCLE", h, fixed::MODEL_RECORD, &layer, style, "AcDbCircle");
|
||||
self.e.xyz(10, centre.x, centre.y);
|
||||
self.e.num(40, radius);
|
||||
self.e.normal();
|
||||
return true;
|
||||
}
|
||||
|
||||
let start = self.to_dxf * sub.start;
|
||||
let end = self.to_dxf * last;
|
||||
let middle = self.to_dxf * circle.samples[circle.samples.len() / 2];
|
||||
let mut a0 = angle_deg(start, centre);
|
||||
let mut a1 = angle_deg(end, centre);
|
||||
let am = angle_deg(middle, centre);
|
||||
if !on_ccw_arc(a0, a1, am) {
|
||||
std::mem::swap(&mut a0, &mut a1);
|
||||
}
|
||||
self.e.entity("ARC", h, fixed::MODEL_RECORD, &layer, style, "AcDbCircle");
|
||||
self.e.xyz(10, centre.x, centre.y);
|
||||
self.e.num(40, radius);
|
||||
self.e.normal();
|
||||
if self.o.version.handles() {
|
||||
self.e.raw(100, "AcDbArc");
|
||||
}
|
||||
self.e.num(50, a0);
|
||||
self.e.num(51, a1);
|
||||
true
|
||||
}
|
||||
|
||||
/// A curved subpath as one cubic B-spline whose clamped knots repeat at every joint, so it
|
||||
/// passes through every anchor (straight segments become straight cubics).
|
||||
fn spline(&mut self, sub: &Sub, style: &EntityStyle) {
|
||||
|
||||
+73
-14
@@ -143,8 +143,8 @@ fn lines_are_lwpolylines_curves_splines_and_fills_hatches() {
|
||||
let out = export(&d, &opts(&d)).unwrap();
|
||||
assert_eq!(
|
||||
kinds(&out),
|
||||
["LWPOLYLINE", "HATCH", "LWPOLYLINE", "SPLINE"],
|
||||
"the fill's outline and hatch, then the stroke, then the ellipse's stroke"
|
||||
["LWPOLYLINE", "HATCH", "LWPOLYLINE", "CIRCLE"],
|
||||
"the fill's outline and hatch, then the stroke, then the circle's stroke"
|
||||
);
|
||||
let e = entities(&out);
|
||||
// The rectangle, y up from the artboard's bottom-left corner: 10..110 × 40..90.
|
||||
@@ -164,11 +164,9 @@ fn lines_are_lwpolylines_curves_splines_and_fills_hatches() {
|
||||
assert_eq!((get(&e[0], 370), get(&e[2], 370)), (None, Some("35")));
|
||||
let hatch = &e[1];
|
||||
assert_eq!((get(hatch, 2), get(hatch, 70), get(hatch, 91), get(hatch, 93)), (Some("SOLID"), Some("1"), Some("1"), Some("4")));
|
||||
// The ellipse: four cubics through clamped knots.
|
||||
let spline = &e[3];
|
||||
assert_eq!((get(spline, 71), get(spline, 73)), (Some("3"), Some("13")));
|
||||
assert_eq!(get(spline, 70), Some("9"), "closed planar spline (#1299): 1 = closed, 8 = planar");
|
||||
assert_eq!(all(spline, 40), [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 2.0, 2.0, 2.0, 3.0, 3.0, 3.0, 4.0, 4.0, 4.0, 4.0]);
|
||||
// The circular ellipse is preserved as a native circle (#1299).
|
||||
let circle = &e[3];
|
||||
assert_eq!((all(circle, 10), all(circle, 20), all(circle, 40)), (vec![150.0], vec![50.0], vec![30.0]));
|
||||
// R14 has lightweight polylines but no lineweights: the stroke is a polyline width.
|
||||
let r14 = export(&d, &DxfOptions { version: DxfVersion::R14, ..opts(&d) }).unwrap();
|
||||
assert_eq!(get(&entities(&r14)[2], 43), Some("1"));
|
||||
@@ -177,16 +175,77 @@ fn lines_are_lwpolylines_curves_splines_and_fills_hatches() {
|
||||
// R13 has splines and hatches, and polylines of vertices.
|
||||
let r13 = export(&d, &DxfOptions { version: DxfVersion::R13, ..opts(&d) }).unwrap();
|
||||
let poly = ["POLYLINE", "VERTEX", "VERTEX", "VERTEX", "VERTEX", "SEQEND"];
|
||||
assert_eq!(kinds(&r13), [&poly[..], &["HATCH"], &poly, &["SPLINE"]].concat());
|
||||
// R12: polylines only; the fill is its outline, the ellipse flattened.
|
||||
assert_eq!(kinds(&r13), [&poly[..], &["HATCH"], &poly, &["CIRCLE"]].concat());
|
||||
// R12: visible stroke widths live on old POLYLINE entities, including curved strokes.
|
||||
let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
|
||||
let k = kinds(&r12);
|
||||
assert!(k.iter().all(|k| matches!(k.as_str(), "POLYLINE" | "VERTEX" | "SEQEND")), "{k:?}");
|
||||
assert_eq!(k.iter().filter(|k| *k == "POLYLINE").count(), 3);
|
||||
assert!(k.iter().filter(|k| *k == "VERTEX").count() > 20, "the ellipse is flattened");
|
||||
assert_eq!(k.iter().filter(|k| *k == "CIRCLE").count(), 0);
|
||||
assert!(r12.warnings.iter().any(|w| w.contains("R12 has no fills")), "{:?}", r12.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circles_and_circular_arcs_are_native_dxf_entities() {
|
||||
let d = doc_with(|d| {
|
||||
let circle = shapes::ellipse(Rect::new(20.0, 20.0, 80.0, 80.0));
|
||||
let c = Point::new(120.0, 50.0);
|
||||
let r = 30.0;
|
||||
let k = r * shapes::KAPPA;
|
||||
let mut arc = vectorcraft_geom::BezPath::new();
|
||||
arc.move_to(Point::new(c.x + r, c.y));
|
||||
arc.curve_to(Point::new(c.x + r, c.y - k), Point::new(c.x + k, c.y - r), Point::new(c.x, c.y - r));
|
||||
vec![
|
||||
path(d, circle, Paint::None, Paint::solid(Color::BLACK), 1.0),
|
||||
path(d, PathData::from_bezpath(&arc), Paint::None, Paint::solid(Color::BLACK), 1.0),
|
||||
]
|
||||
});
|
||||
for version in DxfVersion::ALL.into_iter().filter(|version| *version != DxfVersion::R12) {
|
||||
let out = export(&d, &DxfOptions { version, ..opts(&d) }).unwrap();
|
||||
let e = entities(&out);
|
||||
assert_eq!(e.iter().map(|x| kind(x)).collect::<Vec<_>>(), ["CIRCLE", "ARC"], "{version:?}");
|
||||
assert_eq!((all(&e[0], 10), all(&e[0], 20), all(&e[0], 40)), (vec![50.0], vec![50.0], vec![30.0]), "{version:?}");
|
||||
assert_eq!((all(&e[1], 10), all(&e[1], 20), all(&e[1], 40)), (vec![120.0], vec![50.0], vec![30.0]), "{version:?}");
|
||||
assert_eq!((all(&e[1], 50), all(&e[1], 51)), (vec![0.0], vec![90.0]), "{version:?}");
|
||||
}
|
||||
|
||||
// An actual ellipse is not close enough to a circle and remains a spline.
|
||||
let ellipse = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(20.0, 20.0, 100.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 1.0)]);
|
||||
assert_eq!(kinds(&export(&ellipse, &opts(&ellipse)).unwrap()), ["SPLINE"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn r12_stroked_circles_keep_polyline_width() {
|
||||
let d = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(20.0, 20.0, 80.0, 80.0)), Paint::None, Paint::solid(Color::BLACK), 3.0)]);
|
||||
let out = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
|
||||
let e = entities(&out);
|
||||
assert_eq!(kind(&e[0]), "POLYLINE");
|
||||
assert_eq!((get(&e[0], 40), get(&e[0], 41)), (Some("3"), Some("3")));
|
||||
assert!(!e.iter().any(|entity| kind(entity) == "CIRCLE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_circular_arc_with_a_closing_chord_is_not_a_circle() {
|
||||
let d = doc_with(|d| {
|
||||
let c = Point::new(50.0, 50.0);
|
||||
let r = 30.0;
|
||||
let k = r * shapes::KAPPA;
|
||||
let mut arc = vectorcraft_geom::BezPath::new();
|
||||
arc.move_to(Point::new(c.x + r, c.y));
|
||||
arc.curve_to(Point::new(c.x + r, c.y - k), Point::new(c.x + k, c.y - r), Point::new(c.x, c.y - r));
|
||||
arc.curve_to(Point::new(c.x - k, c.y - r), Point::new(c.x - r, c.y - k), Point::new(c.x - r, c.y));
|
||||
arc.curve_to(Point::new(c.x - r, c.y + k), Point::new(c.x - k, c.y + r), Point::new(c.x, c.y + r));
|
||||
arc.close_path();
|
||||
vec![path(d, PathData::from_bezpath(&arc), Paint::None, Paint::solid(Color::BLACK), 1.0)]
|
||||
});
|
||||
|
||||
let modern = export(&d, &opts(&d)).unwrap();
|
||||
assert_eq!(kinds(&modern), ["SPLINE"]);
|
||||
let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
|
||||
assert!(kinds(&r12).iter().any(|k| k == "POLYLINE"));
|
||||
assert!(!kinds(&r12).iter().any(|k| k == "CIRCLE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spline_closed_flags_follow_path_intent_across_versions_without_changing_geometry() {
|
||||
let cases = [(true, true), (true, false), (false, false), (false, true)];
|
||||
@@ -353,7 +412,7 @@ fn alter_paths_writes_strokes_as_filled_outlines() {
|
||||
// Each fill and stroke: its outlines, then its hatch.
|
||||
let fill = ["LWPOLYLINE", "HATCH"];
|
||||
let rect_stroke = ["LWPOLYLINE", "LWPOLYLINE", "HATCH"];
|
||||
assert_eq!(kinds(&out), [&fill[..], &rect_stroke, &["SPLINE", "SPLINE", "HATCH"]].concat());
|
||||
assert_eq!(kinds(&out), [&fill[..], &rect_stroke, &["CIRCLE", "CIRCLE", "HATCH"]].concat());
|
||||
// The rectangle's stroke: an outer and an inner boundary.
|
||||
assert_eq!(get(&entities(&out)[4], 91), Some("2"));
|
||||
// Preserve Appearance outlines only the strokes a line can't draw.
|
||||
@@ -362,9 +421,9 @@ fn alter_paths_writes_strokes_as_filled_outlines() {
|
||||
if let Some(st) = d.node_mut(rect).and_then(|n| n.appearance.stroke_mut()) {
|
||||
st.align = vectorcraft_doc::StrokeAlign::Outside;
|
||||
}
|
||||
assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), [&fill[..], &rect_stroke, &["SPLINE"]].concat());
|
||||
assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), [&fill[..], &rect_stroke, &["CIRCLE"]].concat());
|
||||
let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap();
|
||||
assert_eq!(kinds(&editable), ["LWPOLYLINE", "HATCH", "LWPOLYLINE", "SPLINE"]);
|
||||
assert_eq!(kinds(&editable), ["LWPOLYLINE", "HATCH", "LWPOLYLINE", "CIRCLE"]);
|
||||
assert!(editable.warnings.iter().any(|w| w.contains("stroke alignment")));
|
||||
}
|
||||
|
||||
@@ -451,7 +510,7 @@ fn crop_leaves_out_art_outside_the_region() {
|
||||
let d = shapes_doc();
|
||||
// Only the ellipse (120..180) reaches into 115..200.
|
||||
let o = DxfOptions { region: Rect::new(115.0, 0.0, 200.0, 100.0), crop: true, ..opts(&d) };
|
||||
assert_eq!(kinds(&export(&d, &o).unwrap()), ["SPLINE"]);
|
||||
assert_eq!(kinds(&export(&d, &o).unwrap()), ["CIRCLE"]);
|
||||
assert_eq!(kinds(&export(&d, &DxfOptions { crop: false, ..o }).unwrap()).len(), 4);
|
||||
}
|
||||
|
||||
|
||||
@@ -320,9 +320,14 @@ fn labelled(mut p: ColorProfile, name: &str) -> ColorProfile {
|
||||
/// Grid points per input channel of the CMYK → Lab table and of the Lab → CMYK table.
|
||||
const A2B_GRID: usize = 9;
|
||||
const B2A_GRID: usize = 17;
|
||||
/// Grid points per Lab channel of the gamut table.
|
||||
const GAMUT_GRID: usize = 9;
|
||||
|
||||
/// A CMYK output profile sampled from a conversion: `to_lab` (CMYK → media-relative Lab) in a
|
||||
/// 9⁴ table, `from_lab` (Lab → CMYK, gamut-mapped) in a 17³ table, Lab PCS (ICC v4 encoding).
|
||||
/// It carries every tag ICC.1 requires of an output profile: the tables for all three intents
|
||||
/// and the gamut tag (Windows rejects a CMYK profile without them, so Photos couldn't show a CMYK
|
||||
/// TIFF that embedded one, #2707).
|
||||
pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, from_lab: impl Fn(lab::Lab) -> [f32; 4]) -> ColorProfile {
|
||||
let unit = |i: usize, n: usize| i as f32 / (n - 1) as f32;
|
||||
let q = |v: f32| (v.clamp(0.0, 1.0) * 65535.0).round() as u16;
|
||||
@@ -337,6 +342,13 @@ pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, fr
|
||||
let [l, a, b] = [2, 1, 0].map(|d| unit(i / B2A_GRID.pow(d) % B2A_GRID, B2A_GRID));
|
||||
b2a.extend(from_lab(lab::Lab::new(l * 100.0, a * 255.0 - 128.0, b * 255.0 - 128.0)).map(q));
|
||||
}
|
||||
// Gamut: 0 where a colour separates and comes back within a ΔE of 3, else out of gamut.
|
||||
let mut gamut = Vec::with_capacity(GAMUT_GRID.pow(3));
|
||||
for i in 0..GAMUT_GRID.pow(3) {
|
||||
let [l, a, b] = [2, 1, 0].map(|d| unit(i / GAMUT_GRID.pow(d) % GAMUT_GRID, GAMUT_GRID));
|
||||
let want = lab::Lab::new(l * 100.0, a * 255.0 - 128.0, b * 255.0 - 128.0);
|
||||
gamut.push(if lab::delta_e2000(want, to_lab(from_lab(want))) <= 3.0 { 0 } else { u16::MAX });
|
||||
}
|
||||
let identity = |n: usize| vec![ToneReprCurve::Parametric(vec![1.0]); n];
|
||||
let table = |inputs: usize, outputs: usize, grid: usize, clut: Vec<u16>| {
|
||||
let mut grid_points = [0u8; 16];
|
||||
@@ -346,8 +358,9 @@ pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, fr
|
||||
num_output_channels: outputs as u8,
|
||||
grid_points,
|
||||
clut: Some(LutStore::Store16(clut)),
|
||||
// A curves sit on the device side, B curves on the PCS side.
|
||||
a_curves: identity(4),
|
||||
// A curves sit on the device side (the gamut tag's is its one output), B curves on the
|
||||
// PCS side.
|
||||
a_curves: identity(inputs + outputs - 3),
|
||||
b_curves: identity(3),
|
||||
m_curves: vec![],
|
||||
matrix: Matrix3d::IDENTITY,
|
||||
@@ -356,6 +369,7 @@ pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, fr
|
||||
};
|
||||
let a2b = table(4, 3, A2B_GRID, a2b);
|
||||
let b2a = table(3, 4, B2A_GRID, b2a);
|
||||
let gamut = table(3, 1, GAMUT_GRID, gamut);
|
||||
let mut p = ColorProfile::default();
|
||||
p.profile_class = ProfileClass::OutputDevice;
|
||||
p.color_space = DataColorSpace::Cmyk;
|
||||
@@ -364,9 +378,12 @@ pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, fr
|
||||
p.white_point = moxcms::WHITE_POINT_D50.to_xyzd();
|
||||
p.media_white_point = Some(p.white_point);
|
||||
p.lut_a_to_b_perceptual = Some(a2b.clone());
|
||||
p.lut_a_to_b_saturation = Some(a2b.clone());
|
||||
p.lut_a_to_b_colorimetric = Some(a2b);
|
||||
p.lut_b_to_a_perceptual = Some(b2a.clone());
|
||||
p.lut_b_to_a_saturation = Some(b2a.clone());
|
||||
p.lut_b_to_a_colorimetric = Some(b2a);
|
||||
p.gamut = Some(gamut);
|
||||
labelled(p, name)
|
||||
}
|
||||
|
||||
|
||||
@@ -135,3 +135,20 @@ fn written_profiles_align_their_tags() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #2707: the CMYK profiles written here carry every tag ICC.1 requires of an output profile (the
|
||||
/// tables of all three intents and the gamut tag): Windows rejects one without them, and Photos
|
||||
/// then can't show a CMYK TIFF that embeds it.
|
||||
#[test]
|
||||
fn cmyk_profiles_have_the_tags_of_an_output_profile() {
|
||||
for name in BUILTIN_CMYK {
|
||||
let icc = icc_bytes(name).unwrap();
|
||||
let be = |at: usize| u32::from_be_bytes([icc[at], icc[at + 1], icc[at + 2], icc[at + 3]]) as usize;
|
||||
let tags: Vec<&[u8]> = (0..be(128)).map(|i| &icc[132 + 12 * i..136 + 12 * i]).collect();
|
||||
for want in [b"desc", b"cprt", b"wtpt", b"A2B0", b"A2B1", b"A2B2", b"B2A0", b"B2A1", b"B2A2", b"gamt"] {
|
||||
assert!(tags.contains(&&want[..]), "{name}: no {}", String::from_utf8_lossy(want));
|
||||
}
|
||||
let p = parse(&icc);
|
||||
assert!(p.lut_a_to_b_saturation.is_some() && p.lut_b_to_a_saturation.is_some() && p.gamut.is_some(), "{name}");
|
||||
}
|
||||
}
|
||||
@@ -62,11 +62,14 @@ pub struct HitOptions {
|
||||
/// (point type's baseline, area type's frame, type on a path's path), not anywhere in its
|
||||
/// bounds.
|
||||
pub type_path_only: bool,
|
||||
/// Guide objects (paths made into guides) are not hittable: View › Guides › Lock Guides is
|
||||
/// on or the guides are hidden.
|
||||
pub skip_guides: bool,
|
||||
}
|
||||
|
||||
impl Default for HitOptions {
|
||||
fn default() -> Self {
|
||||
Self { tol: 3.0, outline: false, path_only: false, type_path_only: false, scope: None }
|
||||
Self { tol: 3.0, outline: false, path_only: false, type_path_only: false, skip_guides: false, scope: None }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,6 +321,9 @@ fn may_hit(parent: &Node, i: usize, c: &Node, p: Point, opt: HitOptions, symbols
|
||||
if !c.visible || editable_only && (c.locked || c.is_template()) {
|
||||
return false;
|
||||
}
|
||||
if opt.skip_guides && matches!(c.kind, NodeKind::Path { guide: true, .. }) {
|
||||
return false;
|
||||
}
|
||||
// (An envelope's content sits where it was, not where the envelope draws it.)
|
||||
edits_contents(c)
|
||||
|| c.extras().shaper.is_some() && source_of(c, opt.scope)
|
||||
|
||||
+84
-4
@@ -36,6 +36,7 @@ pub mod marks;
|
||||
mod mem;
|
||||
pub mod metadata;
|
||||
pub mod node;
|
||||
pub mod objects_on_path;
|
||||
pub mod orient;
|
||||
pub mod overprint;
|
||||
pub mod pattern;
|
||||
@@ -215,13 +216,33 @@ impl Unit {
|
||||
/// The rulers' label step at `zoom` (screen pixels per point), in this unit: the first of
|
||||
/// 1, 2, 5 × 10ⁿ that puts labels at least 50 pixels apart. Ticks mark every tenth of it.
|
||||
pub fn ruler_step(self, zoom: f64) -> f64 {
|
||||
const STEPS: [f64; 19] =
|
||||
const DECIMAL: [f64; 19] =
|
||||
[0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 2000.0, 5000.0, 10000.0];
|
||||
STEPS.iter().copied().find(|s| s * self.points() * zoom >= 50.0).unwrap_or(10000.0)
|
||||
// An inch is divided in half, not in ten: a tenth of an inch is not a mark an inch ruler
|
||||
// carries, so below an inch the steps are binary fractions (#2827). Labelling stops at a
|
||||
// sixteenth; zooming in past that leaves the labels where they are and only the ticks
|
||||
// between them grow denser, down to a hundred and twenty-eighth
|
||||
// ([`Self::ruler_minor`]). A label is at most six characters that way ("0.0625"), which
|
||||
// the left ruler has room for when it spells one down the ruler a character at a time.
|
||||
// From an inch up the ladder is the shared one.
|
||||
const BINARY: [f64; 15] = [1.0 / 16.0, 0.125, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 10000.0];
|
||||
let steps: &[f64] = if self.ruler_halves() { &BINARY } else { &DECIMAL };
|
||||
steps.iter().copied().find(|s| s * self.points() * zoom >= 50.0).unwrap_or(10000.0)
|
||||
}
|
||||
/// Does this unit's ruler divide a step in half rather than in ten? Inches do; the metric
|
||||
/// units, points, pixels and picas are read in tenths.
|
||||
fn ruler_halves(self) -> bool {
|
||||
matches!(self, Unit::Inches)
|
||||
}
|
||||
/// The unlabelled ticks between two labels, `step` apart ([`Self::ruler_step`]): an eighth of
|
||||
/// the step on an inch ruler, so every tick is a binary fraction of an inch and they stay about
|
||||
/// as dense as the tenths elsewhere; a tenth of it otherwise.
|
||||
pub fn ruler_minor(self, step: f64) -> f64 {
|
||||
if self.ruler_halves() { step / 8.0 } else { step / 10.0 }
|
||||
}
|
||||
/// `v` (points) on the nearest ruler tick at `zoom` ([`Self::ruler_step`]).
|
||||
pub fn snap_to_ruler_tick(self, v: f64, zoom: f64) -> f64 {
|
||||
let tick = self.ruler_step(zoom) / 10.0 * self.points();
|
||||
let tick = self.ruler_minor(self.ruler_step(zoom)) * self.points();
|
||||
(v / tick).round() * tick
|
||||
}
|
||||
pub fn from_pt(self, v: f64) -> f64 {
|
||||
@@ -274,7 +295,12 @@ impl Unit {
|
||||
pub fn named(name: &str) -> Option<Unit> {
|
||||
let letters = |s: &'static str| s.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase());
|
||||
let given = || name.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase());
|
||||
Unit::ALL.into_iter().find(|u| letters(u.label()).eq(given()) || u.suffix().chars().eq(given()))
|
||||
// Feet and Feet & Inches share the suffix `ft`: it names the plain unit, so Feet & Inches
|
||||
// is left to its label and key.
|
||||
Unit::ALL
|
||||
.into_iter()
|
||||
.find(|u| letters(u.label()).eq(given()))
|
||||
.or_else(|| Unit::ALL.into_iter().filter(|u| *u != Unit::FeetInches).find(|u| u.suffix().chars().eq(given())))
|
||||
}
|
||||
/// A point value as the canvas measurement labels show it: two decimals and the suffix
|
||||
/// (`12.50 mm`).
|
||||
@@ -801,6 +827,12 @@ type Places = BTreeMap<Vec<usize>, Vec<(usize, usize)>>;
|
||||
pub const FORMAT_VERSION: u32 = 1;
|
||||
|
||||
impl Document {
|
||||
/// Document Raster Effects Settings › Resolution in ppi, 1 to 2400 (72 when the stored value
|
||||
/// isn't a number).
|
||||
pub fn raster_ppi(&self) -> f64 {
|
||||
Some(self.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0)
|
||||
}
|
||||
|
||||
/// The ruler zero point, or zero on both axes when either stored coordinate is invalid.
|
||||
/// Rulers, coordinate fields and guide snapping use the same supported range.
|
||||
pub fn ruler_origin_or_zero(&self) -> Point {
|
||||
@@ -1549,9 +1581,17 @@ impl Document {
|
||||
{
|
||||
dimension.source = None;
|
||||
}
|
||||
let mut renamed = std::collections::HashMap::new();
|
||||
if let Some(ch) = n.children_mut() {
|
||||
let old: Vec<Arc<Node>> = std::mem::take(ch);
|
||||
*ch = old.iter().map(|c| Arc::new(self.reid(c))).collect();
|
||||
renamed.extend(old.iter().zip(ch.iter()).map(|(o, c)| (o.id, c.id)));
|
||||
}
|
||||
// An Objects on Path group names its path and its objects by id: the copy names the copies.
|
||||
if let Some(e) = n.extras_if_any()
|
||||
&& let Some(on_path) = &mut e.objects_on_path
|
||||
{
|
||||
on_path.rename(&renamed);
|
||||
}
|
||||
n
|
||||
}
|
||||
@@ -1792,6 +1832,46 @@ mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_geom::shapes;
|
||||
|
||||
/// Zooming in on an inch ruler divided the inch into tenths (#2827). Its steps below an inch
|
||||
/// are binary fractions now, and the minor ticks between them stay binary too; every other
|
||||
/// unit is still read in tenths.
|
||||
#[test]
|
||||
fn an_inch_ruler_steps_in_binary_fractions() {
|
||||
let step = |u: Unit, zoom: f64| u.ruler_step(zoom);
|
||||
// Every inch step at or below an inch is 1/2^k, never a tenth or a fifth.
|
||||
for zoom in [1.0, 2.0, 5.0, 7.5, 10.0, 20.0, 40.0, 100.0, 400.0] {
|
||||
let s = step(Unit::Inches, zoom);
|
||||
if s <= 1.0 {
|
||||
let k = s.recip();
|
||||
assert_eq!(k, k.round(), "{s} at zoom {zoom} is 1/k for a whole k");
|
||||
assert!((k.round() as u64).is_power_of_two(), "{s} at zoom {zoom} is 1/2^k");
|
||||
}
|
||||
// A label step always leaves at least 50 px, which is what the ladder is picked for.
|
||||
assert!(s * Unit::Inches.points() * zoom >= 50.0, "zoom {zoom}");
|
||||
}
|
||||
// The steps the report named: tenths became quarters, eighths and sixteenths.
|
||||
assert_eq!(step(Unit::Inches, 5.0), 0.25);
|
||||
assert_eq!(step(Unit::Inches, 10.0), 0.125);
|
||||
assert_eq!(step(Unit::Inches, 20.0), 0.0625);
|
||||
// An inch and wider is unchanged.
|
||||
assert_eq!(step(Unit::Inches, 1.0), 1.0);
|
||||
// Labelling stops at a sixteenth, as the report says Illustrator's does, so zooming in
|
||||
// further only adds ticks. A label therefore never needs more than the six characters of
|
||||
// "0.0625", which the left ruler has room for when it spells one out a character per line.
|
||||
for zoom in [20.0, 50.0, 100.0, 400.0, 1600.0] {
|
||||
assert_eq!(step(Unit::Inches, zoom), 0.0625, "zoom {zoom}");
|
||||
}
|
||||
// The unlabelled ticks go on down to a hundred and twenty-eighth: the 32nds, 64ths and
|
||||
// 128ths the report expects to appear unlabelled when zoomed in close.
|
||||
assert_eq!(Unit::Inches.ruler_minor(0.0625), 1.0 / 128.0);
|
||||
// The minor ticks: an eighth of the step on an inch ruler, a tenth elsewhere.
|
||||
assert_eq!(Unit::Inches.ruler_minor(0.25), 0.03125);
|
||||
assert_eq!(Unit::Millimeters.ruler_minor(25.0), 2.5);
|
||||
assert_eq!(Unit::Points.ruler_minor(100.0), 10.0);
|
||||
// Other units keep the decimal ladder, so a tenth and a fifth still appear.
|
||||
assert!([0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0].contains(&step(Unit::Millimeters, 5.0)), "{}", step(Unit::Millimeters, 5.0));
|
||||
}
|
||||
|
||||
fn doc_with_rects() -> (Document, NodeId, NodeId) {
|
||||
let mut d = Document::new(612.0, 792.0);
|
||||
let layer = d.layers[0].id;
|
||||
|
||||
@@ -253,6 +253,9 @@ fn node_bytes(n: &Node, other: Option<&Node>) -> usize {
|
||||
b += boxed(&e.dimension) + boxed(&e.live_paint_gaps);
|
||||
b += e.trace.as_ref().map_or(0, |t| ALLOC + size_of::<Value>() + json_bytes(t));
|
||||
b += e.intertwine.as_deref().map_or(0, |a| ALLOC + size_of::<PathData>() + path_bytes(a));
|
||||
b += e.objects_on_path.as_deref().map_or(0, |o| {
|
||||
ALLOC + size_of::<crate::objects_on_path::ObjectsOnPath>() + o.members.capacity() * size_of::<crate::objects_on_path::Member>()
|
||||
});
|
||||
}
|
||||
b += match &n.kind {
|
||||
NodeKind::Path { path, live, .. } => path_bytes(path) + boxed(live),
|
||||
|
||||
@@ -82,6 +82,31 @@ impl DocMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
/// The time exported files record: PDF `CreationDate` and `ModDate`, the PNG `Creation Time`
|
||||
/// entry, a zip entry's date, and a saved document's `created` and `modified`. It is the clock,
|
||||
/// unless `SOURCE_DATE_EPOCH` is set, which replaces it so that the same commands produce the same
|
||||
/// bytes twice (#2880).
|
||||
///
|
||||
/// The raw clock stays in [`now_unix`], which is what a heartbeat is judged by and what seeds
|
||||
/// encryption: neither may be pinned by the environment.
|
||||
pub fn output_time() -> Option<i64> {
|
||||
pinned_or(|n| std::env::var(n).ok(), now_unix)
|
||||
}
|
||||
|
||||
/// [`output_time`] with the environment and the clock supplied, so a test can show that the
|
||||
/// variable is preferred and that the clock answers whenever it does not.
|
||||
fn pinned_or(get: impl Fn(&str) -> Option<String>, clock: impl Fn() -> Option<i64>) -> Option<i64> {
|
||||
stamp_from_env(get).or_else(clock)
|
||||
}
|
||||
|
||||
/// `SOURCE_DATE_EPOCH` as Unix seconds: the reproducible-builds variable
|
||||
/// (<https://reproducible-builds.org/specs/source-date-epoch/>), a non-negative decimal integer of
|
||||
/// seconds since the epoch. `None` when it is unset, blank, negative or not an integer, and then
|
||||
/// the clock decides — an export is never refused over the environment.
|
||||
fn stamp_from_env(get: impl Fn(&str) -> Option<String>) -> Option<i64> {
|
||||
get("SOURCE_DATE_EPOCH")?.trim().parse::<i64>().ok().filter(|t| *t >= 0)
|
||||
}
|
||||
|
||||
/// The current time as Unix seconds (UTC); `None` where the platform has no clock (the web build).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn now_unix() -> Option<i64> {
|
||||
@@ -148,4 +173,38 @@ mod tests {
|
||||
);
|
||||
assert!(DocMetadata::default().png_text("").is_empty());
|
||||
}
|
||||
|
||||
/// `SOURCE_DATE_EPOCH` replaces the clock in exported output only when it is a non-negative
|
||||
/// decimal integer; anything else leaves the clock to decide, so an export is never refused.
|
||||
#[test]
|
||||
fn source_date_epoch_is_read_only_as_a_non_negative_integer() {
|
||||
let env = |v: Option<&'static str>| move |n: &str| v.filter(|_| n == "SOURCE_DATE_EPOCH").map(str::to_owned);
|
||||
assert_eq!(stamp_from_env(env(Some("1791208980"))), Some(1_791_208_980));
|
||||
assert_eq!(stamp_from_env(env(Some("0"))), Some(0));
|
||||
assert_eq!(stamp_from_env(env(Some(" 1791208980 "))), Some(1_791_208_980));
|
||||
for bad in ["", " ", "-1", "later", "1791208980.5", "1e9", "99999999999999999999"] {
|
||||
assert_eq!(stamp_from_env(env(Some(bad))), None, "{bad:?}");
|
||||
}
|
||||
assert_eq!(stamp_from_env(env(None)), None, "unset");
|
||||
// Another variable never stands in for it.
|
||||
assert_eq!(stamp_from_env(|_| Some("1791208980".to_owned())), Some(1_791_208_980));
|
||||
assert_eq!(stamp_from_env(|n: &str| (n == "TZ").then(|| "1791208980".to_owned())), None);
|
||||
}
|
||||
|
||||
/// What a file records: the variable when it is usable, the clock whenever it is not. The
|
||||
/// environment and the clock are passed in, so no test changes this process's environment --
|
||||
/// that is `unsafe` under edition 2024 and would reach every other test running beside it.
|
||||
#[test]
|
||||
fn a_pinned_epoch_is_preferred_and_the_clock_answers_otherwise() {
|
||||
let env = |v: Option<&'static str>| move |_: &str| v.map(str::to_owned);
|
||||
let clock = || Some(1_000_000);
|
||||
assert_eq!(pinned_or(env(Some("1791208980")), clock), Some(1_791_208_980), "the variable wins over the clock");
|
||||
assert_eq!(pinned_or(env(Some("0")), clock), Some(0), "including the epoch itself");
|
||||
for ignored in [None, Some(""), Some("-1"), Some("later")] {
|
||||
assert_eq!(pinned_or(env(ignored), clock), Some(1_000_000), "{ignored:?} leaves it to the clock");
|
||||
}
|
||||
// A platform with no clock and no variable has no date to record.
|
||||
assert_eq!(pinned_or(env(None), || None), None);
|
||||
assert_eq!(pinned_or(env(Some("1791208980")), || None), Some(1_791_208_980), "the variable needs no clock");
|
||||
}
|
||||
}
|
||||
@@ -572,6 +572,9 @@ pub struct NodeExtras {
|
||||
pub intertwine: Option<Box<PathData>>,
|
||||
/// Live Paint group: its Gap Options (Object › Live Paint › Gap Options; `None`: the defaults).
|
||||
pub live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
|
||||
/// Object › Objects on Path: the group's path and the objects attached to it (see
|
||||
/// [`crate::objects_on_path`]).
|
||||
pub objects_on_path: Option<Box<crate::objects_on_path::ObjectsOnPath>>,
|
||||
}
|
||||
|
||||
/// No extras: what [`Node::extras`] gives for a node without any.
|
||||
@@ -590,6 +593,7 @@ static NO_EXTRAS: NodeExtras = NodeExtras {
|
||||
dimension: None,
|
||||
intertwine: None,
|
||||
live_paint_gaps: None,
|
||||
objects_on_path: None,
|
||||
};
|
||||
|
||||
/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal
|
||||
@@ -724,6 +728,7 @@ impl Serialize for Node {
|
||||
e.dimension.is_none(),
|
||||
e.intertwine.is_none(),
|
||||
e.live_paint_gaps.is_none(),
|
||||
e.objects_on_path.is_none(),
|
||||
];
|
||||
let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?;
|
||||
let mut skip = skips.into_iter();
|
||||
@@ -762,6 +767,7 @@ impl Serialize for Node {
|
||||
field!("dimension", &e.dimension);
|
||||
field!("intertwine", &e.intertwine);
|
||||
field!("live_paint_gaps", &e.live_paint_gaps);
|
||||
field!("objects_on_path", &e.objects_on_path);
|
||||
st.end()
|
||||
}
|
||||
}
|
||||
@@ -821,6 +827,8 @@ struct NodeWire {
|
||||
intertwine: Option<Box<PathData>>,
|
||||
#[serde(default)]
|
||||
live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
|
||||
#[serde(default)]
|
||||
objects_on_path: Option<Box<crate::objects_on_path::ObjectsOnPath>>,
|
||||
}
|
||||
|
||||
impl From<NodeWire> for Node {
|
||||
@@ -840,6 +848,7 @@ impl From<NodeWire> for Node {
|
||||
dimension: w.dimension,
|
||||
intertwine: w.intertwine,
|
||||
live_paint_gaps: w.live_paint_gaps,
|
||||
objects_on_path: w.objects_on_path,
|
||||
};
|
||||
Node {
|
||||
id: w.id,
|
||||
@@ -1025,6 +1034,7 @@ impl Node {
|
||||
match &self.kind {
|
||||
NodeKind::Layer { .. } => "Layer",
|
||||
NodeKind::Group { clip: true, .. } => "Clip Group",
|
||||
NodeKind::Group { .. } if self.extras().objects_on_path.is_some() => "Objects on Path",
|
||||
NodeKind::Group { .. } => "Group",
|
||||
NodeKind::Path { live: Some(l), .. } => l.label(),
|
||||
NodeKind::Path { guide: true, .. } => "Guide",
|
||||
@@ -1251,6 +1261,12 @@ impl Node {
|
||||
{
|
||||
dimension.transform(a);
|
||||
}
|
||||
// The frames of the objects attached to a path move with them.
|
||||
if let Some(e) = self.extras_if_any()
|
||||
&& let Some(on_path) = &mut e.objects_on_path
|
||||
{
|
||||
on_path.transform(a);
|
||||
}
|
||||
if !self.is_layer() {
|
||||
self.bbox_angle = crate::orient::transformed_angle(self.bbox_angle, a);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
//! Object › Objects on Path: objects attached to a path and spread along it.
|
||||
//!
|
||||
//! An Objects on Path group is an ordinary group ([`NodeKind::Group`]) with an [`ObjectsOnPath`]
|
||||
//! record in its extras ([`crate::node::NodeExtras::objects_on_path`]). One child is the path, and
|
||||
//! the other children are the attached objects, kept where they are drawn: the group draws, hits,
|
||||
//! saves and exports as a group. The record lists the objects in their order along the path, each
|
||||
//! with the frame it is drawn in and the angle that frame is turned by. [`place`] spreads them
|
||||
//! evenly along the path from its first anchor (the first object sits on that anchor, and the last
|
||||
//! one sits on the end of an open path), puts the attachment point of each one
|
||||
//! ([`ObjectsOnPath::pivot`]) on the path, and turns its frame to the path's direction there,
|
||||
//! tilted by [`ObjectsOnPath::rotate`].
|
||||
//!
|
||||
//! Behavior: <https://helpx.adobe.com/illustrator/using/objects-on-path.html>.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_geom::{Affine, PathData, Point};
|
||||
|
||||
use crate::blend::Spine;
|
||||
use crate::symbols::SymbolBoxes;
|
||||
use crate::{Node, NodeId, NodeKind};
|
||||
|
||||
/// The center of the nine reference points.
|
||||
pub const CENTER: u8 = 4;
|
||||
|
||||
fn center() -> u8 {
|
||||
CENTER
|
||||
}
|
||||
|
||||
/// What makes a group an Objects on Path group (see the module docs).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ObjectsOnPath {
|
||||
/// The child of the group that is the path.
|
||||
pub path: NodeId,
|
||||
/// The attached objects, in their order along the path.
|
||||
#[serde(default)]
|
||||
pub members: Vec<Member>,
|
||||
/// Pivot: the reference point of each object that attaches to the path, 0 to 8, row by row
|
||||
/// from the top left of the object's bounds in its frame (4: the center).
|
||||
#[serde(default = "center")]
|
||||
pub pivot: u8,
|
||||
/// Rotate: the counter-clockwise degrees each object is turned from upright on the path. An
|
||||
/// upright object has its frame's x axis along the path's direction at its attachment point.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub rotate: f64,
|
||||
}
|
||||
|
||||
/// An object attached to the path of an Objects on Path group.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(from = "MemberWire")]
|
||||
pub struct Member {
|
||||
pub id: NodeId,
|
||||
/// The object's frame, mapped to the document: the object's art is drawn in this frame.
|
||||
pub frame: Affine,
|
||||
/// The angle the frame is turned by (radians, the document's axes): the path's direction at
|
||||
/// the object's attachment point plus the tilt from Rotate when it was placed, or the angle of
|
||||
/// the frame's x axis before then.
|
||||
pub angle: f64,
|
||||
}
|
||||
|
||||
/// A [`Member`] as read from a file: without `angle`, the angle of the frame's x axis.
|
||||
#[derive(Deserialize)]
|
||||
struct MemberWire {
|
||||
id: NodeId,
|
||||
frame: Affine,
|
||||
#[serde(default)]
|
||||
angle: Option<f64>,
|
||||
}
|
||||
|
||||
impl From<MemberWire> for Member {
|
||||
fn from(w: MemberWire) -> Self {
|
||||
let angle = w.angle.filter(|a| a.is_finite()).unwrap_or_else(|| x_angle(w.frame));
|
||||
Self { id: w.id, frame: w.frame, angle }
|
||||
}
|
||||
}
|
||||
|
||||
/// The angle of `frame`'s x axis (radians, the document's axes).
|
||||
fn x_angle(frame: Affine) -> f64 {
|
||||
let [a0, a1, ..] = frame.as_coeffs();
|
||||
a1.atan2(a0)
|
||||
}
|
||||
|
||||
impl Member {
|
||||
/// `n` in its frame as drawn ([`upright_frame`]).
|
||||
pub fn new(n: &Node) -> Self {
|
||||
let frame = upright_frame(n);
|
||||
Self { id: n.id, frame, angle: x_angle(frame) }
|
||||
}
|
||||
}
|
||||
|
||||
impl ObjectsOnPath {
|
||||
/// A record for the objects `members` (in their order along the path) attached to `path`, each
|
||||
/// in its frame as drawn ([`upright_frame`]).
|
||||
pub fn new(path: NodeId, members: &[&Node], pivot: u8, rotate: f64) -> Self {
|
||||
let members = members.iter().map(|n| Member::new(n)).collect();
|
||||
Self { path, members, pivot: pivot.min(8), rotate: if rotate.is_finite() { rotate } else { 0.0 } }
|
||||
}
|
||||
/// The frames go with the group, and each frame's angle becomes the transformed path's
|
||||
/// direction at the object plus the tilt, so that [`place`] keeps each object's scale, shear
|
||||
/// and reflection when it spreads the objects along the transformed path again. A reflection
|
||||
/// also reverses Rotate: the objects keep the mirrored tilt to the mirrored path.
|
||||
pub fn transform(&mut self, a: Affine) {
|
||||
let before = tilt(self.rotate);
|
||||
if a.determinant() < 0.0 && self.rotate != 0.0 {
|
||||
self.rotate = -self.rotate;
|
||||
}
|
||||
let after = tilt(self.rotate);
|
||||
let [a0, a1, a2, a3, _, _] = a.as_coeffs();
|
||||
for m in &mut self.members {
|
||||
m.frame = a * m.frame;
|
||||
let (sin, cos) = (m.angle - before).sin_cos();
|
||||
let (x, y) = (a0 * cos + a2 * sin, a1 * cos + a3 * sin);
|
||||
if x.is_finite() && y.is_finite() && (x != 0.0 || y != 0.0) {
|
||||
m.angle = y.atan2(x) + after;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The children given new ids (a copy of the group): the path and the objects by their new ids.
|
||||
pub fn rename(&mut self, ids: &HashMap<NodeId, NodeId>) {
|
||||
let to = |id: NodeId| ids.get(&id).copied().unwrap_or(id);
|
||||
self.path = to(self.path);
|
||||
for m in &mut self.members {
|
||||
m.id = to(m.id);
|
||||
}
|
||||
}
|
||||
/// The group's children changed from `before` to `after`: a new child at the index of a member
|
||||
/// that is gone replaces it (Break Link to Symbol, for one), keeping its place on the path.
|
||||
pub fn follow_replacements(&mut self, before: &[Arc<Node>], after: &[Arc<Node>]) {
|
||||
let now: HashSet<NodeId> = after.iter().map(|c| c.id).collect();
|
||||
let then: HashSet<NodeId> = before.iter().map(|c| c.id).collect();
|
||||
let listed: HashSet<NodeId> = self.members.iter().map(|m| m.id).collect();
|
||||
let mut ids = HashMap::new();
|
||||
for (old, new) in before.iter().zip(after) {
|
||||
if listed.contains(&old.id) && !now.contains(&old.id) && !then.contains(&new.id) && new.id != self.path {
|
||||
ids.insert(old.id, new.id);
|
||||
}
|
||||
}
|
||||
if !ids.is_empty() {
|
||||
self.rename(&ids);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `n` an Objects on Path group?
|
||||
pub fn is_objects_on_path(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Group { .. }) && n.extras().objects_on_path.is_some()
|
||||
}
|
||||
|
||||
/// The path of `n` if it is a path object an Objects on Path group can attach objects to: a path
|
||||
/// (not a guide) whose first subpath has some length.
|
||||
pub fn attachable_path(n: &Node) -> Option<&PathData> {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, guide: false, .. } if Spine::new(path).is_some_and(|s| s.length() > 1e-9) => Some(path),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame `n` is drawn in when it is attached: the page's axes turned by its rotation
|
||||
/// ([`Node::bbox_angle`]) less the nearest multiple of 90°, about the center of its bounds. Placed in
|
||||
/// that frame, an object stands square to the path or across it.
|
||||
pub fn upright_frame(n: &Node) -> Affine {
|
||||
let a = n.bbox_angle;
|
||||
let off = if a.is_finite() { a - (a / 90.0).round() * 90.0 } else { 0.0 };
|
||||
match n.geometric_bounds() {
|
||||
Some(b) if off.abs() > 1e-9 => Affine::rotate_about(-off.to_radians(), b.center()),
|
||||
_ => Affine::IDENTITY,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where `n` objects attach along `path` (its first subpath): the point and the direction there
|
||||
/// (radians, as `atan2` gives in the document's axes). They are spread evenly by length from the
|
||||
/// first anchor: on an open path the last one is at its end, on a closed path the gaps go all the way
|
||||
/// around. `None` for a path without length.
|
||||
pub fn stops(path: &PathData, n: usize) -> Option<Vec<(Point, f64)>> {
|
||||
let spine = Spine::new(path)?;
|
||||
let len = spine.length();
|
||||
if !len.is_finite() || len <= 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let closed = path.subpaths.first().is_some_and(|s| s.closed);
|
||||
let gaps = if closed { n } else { n.saturating_sub(1) }.max(1);
|
||||
Some((0..n).map(|i| spine.at_length(len * i as f64 / gaps as f64)).collect())
|
||||
}
|
||||
|
||||
/// The tilt Rotate gives (radians, the document's axes, where y points down): `rotate` degrees
|
||||
/// counter-clockwise on the page.
|
||||
fn tilt(rotate: f64) -> f64 {
|
||||
-rotate.to_radians()
|
||||
}
|
||||
|
||||
/// The frame that puts the attachment point `pivot` of `n` (drawn in `m`'s frame) at `at`, turned
|
||||
/// by `angle` (radians, the document's axes) instead of `m`'s angle: the frame's own scale, shear
|
||||
/// and reflection are kept. Symbol instances are measured by their art (`symbols`).
|
||||
fn placed_frame(n: &Node, symbols: &SymbolBoxes, m: &Member, pivot: u8, at: Point, angle: f64) -> Option<Affine> {
|
||||
let det = m.frame.determinant();
|
||||
if !det.is_finite() || det.abs() < 1e-12 || !at.x.is_finite() || !at.y.is_finite() || !angle.is_finite() || !m.angle.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let mut local = n.clone();
|
||||
local.transform(m.frame.inverse(), false);
|
||||
let q = vectorcraft_geom::reference_point(local.geometric_bounds_with(symbols)?, usize::from(pivot.min(8)));
|
||||
let [a0, a1, a2, a3, _, _] = m.frame.as_coeffs();
|
||||
let shape = Affine::rotate(-m.angle) * Affine::new([a0, a1, a2, a3, 0.0, 0.0]);
|
||||
Some(Affine::translate(at.to_vec2()) * Affine::rotate(angle) * shape * Affine::translate(-q.to_vec2()))
|
||||
}
|
||||
|
||||
/// Is `a` the identity, give or take rounding?
|
||||
fn negligible(a: Affine) -> bool {
|
||||
let [a0, a1, a2, a3, a4, a5] = a.as_coeffs();
|
||||
(a0 - 1.0).abs() < 1e-9 && a1.abs() < 1e-9 && a2.abs() < 1e-9 && (a3 - 1.0).abs() < 1e-9 && a4.abs() < 1e-6 && a5.abs() < 1e-6
|
||||
}
|
||||
|
||||
/// Put the objects of Objects on Path group `g` on its path (see the module docs). Before that, the
|
||||
/// record lets go of the objects that are no longer in the group, and children it does not list
|
||||
/// (other than the path) join it at the end of the path. A group whose path is gone, or is no
|
||||
/// longer a path object, becomes a plain group. Symbol instances are measured by their art
|
||||
/// (`symbols`, from the document the group is in). Returns whether `g` changed.
|
||||
pub fn place(g: &mut Node, symbols: &SymbolBoxes) -> bool {
|
||||
let Some(mut rec) = g.extras().objects_on_path.as_deref().cloned() else { return false };
|
||||
let old = rec.clone();
|
||||
let NodeKind::Group { children, .. } = &mut g.kind else { return false };
|
||||
let path_id = rec.path;
|
||||
let Some(path) = children.iter().find(|c| c.id == path_id).filter(|c| matches!(c.kind, NodeKind::Path { .. })) else {
|
||||
g.extras_mut().objects_on_path = None;
|
||||
return true;
|
||||
};
|
||||
let path = attachable_path(path).cloned();
|
||||
let at: HashMap<NodeId, usize> = children.iter().enumerate().map(|(i, c)| (c.id, i)).collect();
|
||||
rec.members.retain(|m| m.id != path_id && at.contains_key(&m.id));
|
||||
let mut listed: HashSet<NodeId> = rec.members.iter().map(|m| m.id).collect();
|
||||
for c in children.iter() {
|
||||
if c.id != path_id && listed.insert(c.id) {
|
||||
rec.members.push(Member::new(c));
|
||||
}
|
||||
}
|
||||
let mut moved = false;
|
||||
if let Some(stops) = path.and_then(|p| stops(&p, rec.members.len())) {
|
||||
let turn = tilt(rec.rotate);
|
||||
for (m, (p, dir)) in rec.members.iter_mut().zip(stops) {
|
||||
let Some(c) = at.get(&m.id).and_then(|i| children.get_mut(*i)) else { continue };
|
||||
let Some(next) = placed_frame(c, symbols, m, rec.pivot, p, dir + turn) else { continue };
|
||||
let delta = next * m.frame.inverse();
|
||||
if !negligible(delta) {
|
||||
Arc::make_mut(c).transform(delta, false);
|
||||
*m = Member { id: m.id, frame: next, angle: dir + turn };
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if rec != old {
|
||||
g.extras_mut().objects_on_path = Some(Box::new(rec));
|
||||
return true;
|
||||
}
|
||||
moved
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
|
||||
fn rect(id: u64, r: Rect) -> Node {
|
||||
Node::path(NodeId(id), shapes::rectangle(r), Default::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stops_spread_from_the_first_anchor() {
|
||||
let open = shapes::line(Point::new(0.0, 0.0), Point::new(300.0, 0.0));
|
||||
let s = stops(&open, 4).unwrap();
|
||||
let xs: Vec<f64> = s.iter().map(|(p, _)| p.x.round()).collect();
|
||||
assert_eq!(xs, [0.0, 100.0, 200.0, 300.0], "first at the start, last at the end of an open path");
|
||||
let closed = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
let s = stops(&closed, 4).unwrap();
|
||||
let mut pts: Vec<(i64, i64)> = s.iter().map(|(p, _)| (p.x.round() as i64, p.y.round() as i64)).collect();
|
||||
pts.sort();
|
||||
assert_eq!(pts, [(0, 0), (0, 100), (100, 0), (100, 100)], "a closed path spreads them all the way round");
|
||||
assert!(stops(&shapes::line(Point::ZERO, Point::ZERO), 2).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upright_frames_round_the_rotation_to_a_right_angle() {
|
||||
let mut n = rect(1, Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
assert_eq!(upright_frame(&n), Affine::IDENTITY);
|
||||
n.bbox_angle = 90.0;
|
||||
assert_eq!(upright_frame(&n), Affine::IDENTITY);
|
||||
n.bbox_angle = 30.0;
|
||||
let f = upright_frame(&n);
|
||||
let [a0, a1, ..] = f.as_coeffs();
|
||||
assert!((a1.atan2(a0).to_degrees() + 30.0).abs() < 1e-9, "a frame turned 30° counter-clockwise");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_symbol_instance_is_placed_by_its_art() {
|
||||
// The art is a 40 × 10 box to the right of and below the origin, not the 20 × 20 square
|
||||
// around it that instances are measured by without the symbols.
|
||||
let mut d = crate::Document::new(400.0, 400.0);
|
||||
d.symbols.push(crate::Symbol { name: "Bar".into(), art: Arc::new(rect(9, Rect::new(0.0, 0.0, 40.0, 10.0))) });
|
||||
let line = Node::path(NodeId(1), shapes::line(Point::new(100.0, 300.0), Point::new(300.0, 300.0)), Default::default());
|
||||
let instance = Node::new(NodeId(2), NodeKind::SymbolInstance { symbol: "Bar".into(), xf: Affine::IDENTITY });
|
||||
let other = rect(3, Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
let members = [&instance, &other];
|
||||
let mut g = Node::new(
|
||||
NodeId(4),
|
||||
NodeKind::Group { children: vec![Arc::new(line), Arc::new(instance.clone()), Arc::new(other.clone())], clip: false },
|
||||
);
|
||||
g.extras_mut().objects_on_path = Some(Box::new(ObjectsOnPath::new(NodeId(1), &members, 7, 0.0)));
|
||||
let symbols = d.symbol_boxes();
|
||||
assert!(place(&mut g, &symbols));
|
||||
let placed = g.children().and_then(|c| c.iter().find(|c| c.id == NodeId(2)).cloned()).unwrap();
|
||||
let b = placed.geometric_bounds_with(&symbols).unwrap();
|
||||
assert!((b.y1 - 300.0).abs() < 1e-9 && (b.center().x - 100.0).abs() < 1e-9, "the art's bottom middle on the start: {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_member_saved_without_its_angle_reads_the_angle_of_its_frame() {
|
||||
let m = Member { id: NodeId(2), frame: Affine::rotate(0.5), angle: 0.5 };
|
||||
let mut v = serde_json::to_value(m).unwrap();
|
||||
assert_eq!(serde_json::from_value::<Member>(v.clone()).unwrap(), m);
|
||||
v.as_object_mut().unwrap().remove("angle");
|
||||
let back: Member = serde_json::from_value(v).unwrap();
|
||||
assert!((back.angle - 0.5).abs() < 1e-12, "{back:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_without_its_path_is_a_plain_group() {
|
||||
let a = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
let mut g = Node::new(NodeId(3), NodeKind::Group { children: vec![Arc::new(a.clone())], clip: false });
|
||||
g.extras_mut().objects_on_path = Some(Box::new(ObjectsOnPath::new(NodeId(1), &[&a], CENTER, 0.0)));
|
||||
let d = crate::Document::new(100.0, 100.0);
|
||||
assert!(place(&mut g, &d.symbol_boxes()));
|
||||
assert!(!is_objects_on_path(&g));
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,13 @@ fn units_are_named_by_label_key_or_suffix() {
|
||||
assert_eq!(Unit::named(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_suffix_names_the_plain_unit() {
|
||||
assert_eq!(Unit::named(Unit::Feet.suffix()), Some(Unit::Feet));
|
||||
assert_eq!(Unit::named("FT"), Some(Unit::Feet));
|
||||
assert_eq!(Unit::named(Unit::FeetInches.label()), Some(Unit::FeetInches));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readouts_show_two_decimals() {
|
||||
assert_eq!(Unit::Points.readout(15.0), "15.00 pt");
|
||||
|
||||
@@ -594,6 +594,45 @@ pub fn blur_plane(a: &mut Vec<f32>, w: usize, h: usize, sigma: f64) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Grow a `w` × `h` plane by `radius` pixels each way, the box of SVG's feMorphology: each value
|
||||
/// becomes the largest within the radius. A fractional radius blends the two whole radii around
|
||||
/// it, so a silhouette grows smoothly as the raster scales. Does nothing for a radius of zero or
|
||||
/// less, or when the sizes don't match.
|
||||
pub fn dilate_plane(a: &mut Vec<f32>, w: usize, h: usize, radius: f64) {
|
||||
if radius.is_nan() || radius <= 0.0 || w == 0 || h == 0 || w.checked_mul(h) != Some(a.len()) {
|
||||
return;
|
||||
}
|
||||
let radius = radius.min(MAX_BOX as f64);
|
||||
let whole = radius.floor() as usize;
|
||||
let frac = (radius - whole as f64) as f32;
|
||||
let mut tmp = vec![0.0; a.len()];
|
||||
for (len, other) in [(w, h), (h, w)] {
|
||||
max_plane(a, &mut tmp, len, whole, frac);
|
||||
std::mem::swap(a, &mut tmp);
|
||||
transpose(a, &mut tmp, len, other);
|
||||
std::mem::swap(a, &mut tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/// One horizontal pass of [`dilate_plane`] over the rows of `w` values of a plane: the largest
|
||||
/// value within `whole`, blended by `frac` towards the largest within `whole + 1`.
|
||||
fn max_plane(src: &[f32], dst: &mut [f32], w: usize, whole: usize, frac: f32) {
|
||||
for (row, out) in src.chunks_exact(w).zip(dst.chunks_exact_mut(w)) {
|
||||
for (x, o) in out.iter_mut().enumerate() {
|
||||
let lo = x.saturating_sub(whole + 1);
|
||||
let hi = (x + whole + 1).min(w - 1);
|
||||
let (mut inner, mut ring) = (row[x], row[x]);
|
||||
for (j, v) in row[lo..=hi].iter().enumerate() {
|
||||
if (lo + j).abs_diff(x) <= whole {
|
||||
inner = inner.max(*v);
|
||||
}
|
||||
ring = ring.max(*v);
|
||||
}
|
||||
*o = inner + (ring - inner) * frac;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Premultiplied RGBA at 16 bits (×257): blurs and averages that keep their tones.
|
||||
pub(super) type Px16 = [u16; 4];
|
||||
|
||||
|
||||
@@ -41,6 +41,26 @@ fn fx(id: &str, p: serde_json::Value) -> PixelFx {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dilate_plane_grows_by_its_radius_and_blends_a_fraction() {
|
||||
// GUARDS: an Outer Glow grows its silhouette by a quarter raster pixel before the blur; a step in
|
||||
// that growth as the raster scales would show as the glow's width jumping with the zoom.
|
||||
let mut dot = vec![0.0f32; 7 * 7];
|
||||
dot[3 * 7 + 3] = 255.0;
|
||||
let mut grown = dot.clone();
|
||||
dilate_plane(&mut grown, 7, 7, 1.0);
|
||||
assert_eq!(grown[2 * 7 + 2], 255.0);
|
||||
assert_eq!(grown[7 + 3], 0.0);
|
||||
let mut half = dot.clone();
|
||||
dilate_plane(&mut half, 7, 7, 1.5);
|
||||
assert_eq!(half[3 * 7 + 4], 255.0);
|
||||
assert_eq!(half[3 * 7 + 5], 127.5);
|
||||
assert_eq!(half[3 * 7 + 6], 0.0);
|
||||
let mut same = dot.clone();
|
||||
dilate_plane(&mut same, 7, 7, 0.0);
|
||||
assert_eq!(same, dot);
|
||||
}
|
||||
|
||||
fn grey(v: u8) -> [u8; 4] {
|
||||
[v, v, v, 255]
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ pub enum RasterFx {
|
||||
/// instead of `color`; `None` when it is `color`.
|
||||
darkness: Option<f32>,
|
||||
},
|
||||
/// Blurs the art's solid silhouette grown by a quarter of a pixel of the document's raster grid,
|
||||
/// as the glows AI files export measure.
|
||||
OuterGlow {
|
||||
mode: BlendMode,
|
||||
opacity: f32,
|
||||
@@ -93,6 +95,10 @@ impl RasterFx {
|
||||
pub fn spread(&self, bounds: Rect, line: f64) -> Rect {
|
||||
match self {
|
||||
RasterFx::Rasterize(r) => r.image(bounds, line),
|
||||
RasterFx::OuterGlow { .. } => {
|
||||
let o = self.outset(bounds) + 0.25 * line;
|
||||
bounds.inflate(o, o)
|
||||
}
|
||||
fx => {
|
||||
let o = fx.outset(bounds);
|
||||
bounds.inflate(o, o)
|
||||
|
||||
@@ -212,8 +212,12 @@ fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> (PathData,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Rotate turns every fill and stroke, each in its own paint. Extrude & Bevel and Inflate build a
|
||||
/// solid from the path for the top visible fill and one from the outline of each visible stroke,
|
||||
/// each in its own paint. When every one is opaque with normal blending and no colored light makes
|
||||
/// a surface one composite or masks it, their faces are drawn in one back-to-front order (where
|
||||
/// depths tie, as for the caps turned toward the viewer, in the order of the appearance);
|
||||
/// otherwise each solid is a surface of its own, in that order.
|
||||
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);
|
||||
@@ -229,12 +233,72 @@ fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&App
|
||||
}
|
||||
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, rule, b, &p).ok()?;
|
||||
Some(vec![Arc::new(surface(n, f, &paint, opacity, blend, colored))])
|
||||
let top_fill = items.iter().rposition(|i| matches!(i, AppearanceItem::Fill(_)));
|
||||
let parts: Vec<(&AppearanceItem, PathData, FillRule)> = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(k, i)| Some(*k) == top_fill || matches!(i, AppearanceItem::Stroke(_)))
|
||||
.map(|(_, i)| {
|
||||
let (area, rule) = region(path, rule, i);
|
||||
(*i, area, rule)
|
||||
})
|
||||
.collect();
|
||||
// The solids share the bounds of all their shapes: one center to turn, twist and taper about,
|
||||
// and Inflate's grid reaches over the strokes' outlines.
|
||||
let bounds = parts.iter().filter_map(|(_, area, _)| area.bounds()).reduce(|a, c| a.union(c)).unwrap_or(b);
|
||||
let built = if fx.id == INFLATE {
|
||||
// One eye for every puff, as far away as the highest needs.
|
||||
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, area, rule)| (area, *rule)).collect();
|
||||
vectorcraft_three_d::inflate_together(&shapes, bounds, inflate_options(&p), view_options(&p))
|
||||
} else {
|
||||
parts.iter().map(|(_, area, rule)| facets(&fx.id, area, *rule, bounds, &p)).collect()
|
||||
};
|
||||
let mut solids = vec![];
|
||||
for ((item, _, _), f) in parts.iter().zip(built) {
|
||||
let item: &AppearanceItem = item;
|
||||
match f {
|
||||
Ok(f) => solids.push((item, f)),
|
||||
// A stroke's outline past the limits is left out; the fill's solid still draws.
|
||||
Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {}
|
||||
// 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.
|
||||
Err(_) => return None,
|
||||
}
|
||||
}
|
||||
if solids.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let opaque = solids.iter().all(|(item, _)| {
|
||||
let (_, opacity, blend) = paint_of(item);
|
||||
opacity >= 1.0 && blend == BlendMode::Normal
|
||||
});
|
||||
let surfaces: Vec<(Vec<f64>, Node)> = solids
|
||||
.into_iter()
|
||||
.map(|(item, f)| {
|
||||
let depths: Vec<f64> = f.iter().map(|f| f.depth).collect();
|
||||
let (paint, opacity, blend) = paint_of(item);
|
||||
(depths, surface(n, f, &paint, opacity, blend, colored))
|
||||
})
|
||||
.collect();
|
||||
// One order takes a plain group of faces from each solid, one face for each facet. With
|
||||
// colored lights a surface can instead be one clipped composite of all its facets, or carry an
|
||||
// opacity mask, and then it stays whole.
|
||||
let plain = surfaces.iter().all(|(depths, s)| {
|
||||
s.extras().mask.is_none() && matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len())
|
||||
});
|
||||
if !opaque || !plain {
|
||||
return Some(surfaces.into_iter().map(|(_, s)| Arc::new(s)).collect());
|
||||
}
|
||||
let mut faces = vec![];
|
||||
for (depths, s) in surfaces {
|
||||
if let NodeKind::Group { children, .. } = s.kind {
|
||||
faces.extend(depths.into_iter().zip(children));
|
||||
}
|
||||
}
|
||||
// A stable sort: faces at the same depth keep the order of the appearance.
|
||||
faces.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
Some(vec![Arc::new(Node::group(n.id, faces.into_iter().map(|(_, face)| face).collect()))])
|
||||
}
|
||||
|
||||
fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{Document, NodeId};
|
||||
use vectorcraft_geom::{Affine, BezPath};
|
||||
use vectorcraft_geom::{Affine, BezPath, Point, Shape};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -44,7 +44,8 @@ fn every_solid_draws_shaded_faces_and_leaves_the_source_editable() {
|
||||
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.
|
||||
// The faces are the fill's red or the stroke's black, 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}");
|
||||
@@ -76,12 +77,229 @@ fn rotate_turns_every_fill_and_stroke_and_the_solids_use_the_top_fill() {
|
||||
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.
|
||||
// Extrude & Bevel draws the fill's solid and the stroke's, both opaque, as one surface.
|
||||
let mut n = square_node();
|
||||
n.appearance.effects.push(fx(EXTRUDE, json!({})));
|
||||
assert_eq!(solid_art(&n).unwrap().children().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
const BLUE: Color = Color::Rgb { r: 0.1, g: 0.3, b: 0.9 };
|
||||
|
||||
/// `b` with a red fill and a 6 pt blue stroke above it, on the path.
|
||||
fn stroked(b: &BezPath) -> Node {
|
||||
Node::path(NodeId(1), PathData::from_bezpath(b), Appearance::basic(Paint::solid(RED), Paint::solid(BLUE), 6.0))
|
||||
}
|
||||
|
||||
/// A `width` × 100 pt rectangle at (100, 100), stroked.
|
||||
fn stroked_rect(width: f64) -> Node {
|
||||
let mut b = BezPath::new();
|
||||
b.move_to((100.0, 100.0));
|
||||
b.line_to((100.0 + width, 100.0));
|
||||
b.line_to((100.0 + width, 200.0));
|
||||
b.line_to((100.0, 200.0));
|
||||
b.close_path();
|
||||
stroked(&b)
|
||||
}
|
||||
|
||||
/// Is `face` in the stroke's blue (rather than the fill's red)?
|
||||
fn blue(face: &Node) -> bool {
|
||||
let [r, _, b] = fill_rgb(face);
|
||||
b > r
|
||||
}
|
||||
|
||||
/// The bounds of the faces' own paths (without their seam strokes).
|
||||
fn extent(faces: &[&Node]) -> Rect {
|
||||
faces.iter().filter_map(|f| f.path_data()?.bounds()).reduce(|a, b| a.union(b)).unwrap()
|
||||
}
|
||||
|
||||
fn assert_rect(r: Rect, x0: f64, y0: f64, x1: f64, y1: f64) {
|
||||
assert!([r.x0 - x0, r.y0 - y0, r.x1 - x1, r.y1 - y1].iter().all(|d| d.abs() < 1e-6), "{r:?}");
|
||||
}
|
||||
|
||||
/// Extrude & Bevel and Inflate build a solid from each stroke's outline as well as from the path
|
||||
/// for the fill: seen straight on, the stroke's faces reach 3 pt (half its width) outside the
|
||||
/// fill's, and they are drawn in the stroke's paint.
|
||||
#[test]
|
||||
fn a_stroke_builds_a_solid_from_its_outline_beside_the_fill() {
|
||||
for id in [EXTRUDE, INFLATE] {
|
||||
let mut n = stroked_rect(100.0);
|
||||
n.appearance.effects.push(fx(id, json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0})));
|
||||
let art = solid_art(&n).unwrap();
|
||||
let (strokes, fills): (Vec<&Node>, Vec<&Node>) = faces(&art).into_iter().partition(|f| blue(f));
|
||||
assert!(!strokes.is_empty(), "{id}: the stroke draws");
|
||||
assert_rect(extent(&fills), 100.0, 100.0, 200.0, 200.0);
|
||||
assert_rect(extent(&strokes), 97.0, 97.0, 203.0, 203.0);
|
||||
// Its own blue, shaded: never brighter, never another hue.
|
||||
for f in &strokes {
|
||||
let [r, g, b] = fill_rgb(f);
|
||||
assert!(b <= 0.9 + 1e-6 && (r * 9.0 - b).abs() < 1e-4 && (g * 3.0 - b).abs() < 1e-4, "{id}: {r} {g} {b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Turned, the extrusion's walls show in both paints, and the front caps, which nothing of either
|
||||
/// solid can hide, are drawn last: the fill's, then the stroke's above it, equally lit. Without
|
||||
/// depth the two flat shapes are drawn in the same order. A stroke below the fill has its cap
|
||||
/// drawn below the fill's.
|
||||
#[test]
|
||||
fn the_front_caps_are_drawn_last_in_the_order_of_the_appearance() {
|
||||
// A triangle wider than deep, so the near ends of the walls lie nearer than the middle of the
|
||||
// caps; the mean of its outline's points is not the mean of the stroke's.
|
||||
let mut b = BezPath::new();
|
||||
b.move_to((100.0, 100.0));
|
||||
b.line_to((400.0, 100.0));
|
||||
b.line_to((100.0, 200.0));
|
||||
b.close_path();
|
||||
for params in [json!({}), json!({"depth": 120, "bevel": "rounded"}), json!({"depth": 0})] {
|
||||
let mut n = stroked(&b);
|
||||
n.appearance.effects.push(fx(EXTRUDE, params.clone()));
|
||||
n.appearance.items.reverse();
|
||||
let art = solid_art(&n).unwrap();
|
||||
let [.., stroke_cap, fill_cap] = faces(&art)[..] else { panic!("{params}: two caps") };
|
||||
assert!(blue(stroke_cap) && !blue(fill_cap), "{params}: the stroke below");
|
||||
n.appearance.items.reverse();
|
||||
let art = solid_art(&n).unwrap();
|
||||
let faces = faces(&art);
|
||||
let flat = params["depth"] == json!(0);
|
||||
assert!(if flat { faces.len() == 2 } else { faces.len() > 4 }, "{params}: {} faces", faces.len());
|
||||
let [.., fill_cap, stroke_cap] = faces.as_slice() else { panic!("{params}: two caps") };
|
||||
assert!(!blue(fill_cap) && blue(stroke_cap), "{params}");
|
||||
// The stroke's cap is a ring: its outline and the hole the fill shows through.
|
||||
assert_eq!(stroke_cap.path_data().unwrap().subpaths.len(), 2, "{params}");
|
||||
assert_eq!(fill_cap.path_data().unwrap().subpaths.len(), 1, "{params}");
|
||||
let shade = |f: &Node, i: usize, of: f32| fill_rgb(f)[i] / of;
|
||||
assert!((shade(fill_cap, 0, 0.8) - shade(stroke_cap, 2, 0.9)).abs() < 1e-5, "{params}: one plane, one light");
|
||||
if !flat {
|
||||
assert!(faces.iter().filter(|f| blue(f)).count() > 2 && faces.iter().filter(|f| !blue(f)).count() > 1, "{params}: walls of both");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A stroked open path builds a ribbon, the stroke's outline, not the area the path would enclose
|
||||
/// if it were closed: for an L of two 100 pt legs, a 6 pt band with butt ends and a mitered corner.
|
||||
#[test]
|
||||
fn a_stroked_open_path_builds_a_ribbon() {
|
||||
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));
|
||||
for id in [EXTRUDE, INFLATE] {
|
||||
let mut n = Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(Paint::None, Paint::solid(BLUE), 6.0));
|
||||
n.appearance.effects.push(fx(id, json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "volume": 0})));
|
||||
let art = solid_art(&n).unwrap();
|
||||
let faces = faces(&art);
|
||||
assert!(faces.iter().all(|f| blue(f)), "{id}");
|
||||
assert_rect(extent(&faces), 100.0, 97.0, 203.0, 200.0);
|
||||
let area: f64 = faces.iter().map(|f| f.path_data().unwrap().to_bezpath().area().abs()).sum();
|
||||
// 6 × 200 less the 3 × 3 the legs share, plus the 3 × 3 miter.
|
||||
assert!((area - 1200.0).abs() < 1e-6, "{id}: {area}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A stroke whose outline takes more points than a solid takes is left out, and the fill's solid
|
||||
/// still draws.
|
||||
#[test]
|
||||
fn a_stroke_past_the_limits_is_left_out() {
|
||||
// 1,500 corners: within the 2,048 points of the fill's solid, but not the stroke's outline.
|
||||
let points: Vec<Point> = (0..1500)
|
||||
.map(|k| {
|
||||
let (s, c) = (k as f64 * std::f64::consts::TAU / 1500.0).sin_cos();
|
||||
let r = if k % 2 == 0 { 100.0 } else { 96.0 };
|
||||
Point::new(200.0 + r * c, 200.0 + r * s)
|
||||
})
|
||||
.collect();
|
||||
let path = PathData::single(vectorcraft_geom::SubPath::polyline(&points, true));
|
||||
for id in [EXTRUDE, INFLATE] {
|
||||
let mut n = Node::path(NodeId(1), path.clone(), Appearance::basic(Paint::solid(RED), Paint::solid(BLUE), 2.0));
|
||||
n.appearance.effects.push(fx(id, json!({})));
|
||||
let art = solid_art(&n).unwrap_or_else(|| panic!("{id}: the fill's solid draws"));
|
||||
assert!(!faces(&art).is_empty() && faces(&art).iter().all(|f| !blue(f)), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// At no volume Inflate's front is flat: turned, the fill's cells are all drawn before the stroke's,
|
||||
/// whatever their depths, as the extrusion's caps are. When the fill puffs, a stroke too narrow to
|
||||
/// puff on the grid stays flat and keeps its depths: the fill's puff is drawn over its far side.
|
||||
#[test]
|
||||
fn a_flat_inflate_draws_the_strokes_front_above_the_fills() {
|
||||
// On the 100 pt square's grid (cells 101 / 48 pt apart from 99.5) no grid point falls inside a
|
||||
// 1 pt stroke, so the stroke stays flat.
|
||||
for (volume, width, length) in [(0.0, 6.0, 300.0), (50.0, 1.0, 100.0)] {
|
||||
let mut n = stroked_rect(length);
|
||||
if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
|
||||
s.width = width;
|
||||
}
|
||||
n.appearance.effects.push(fx(INFLATE, json!({ "volume": volume })));
|
||||
let art = solid_art(&n).unwrap();
|
||||
let faces = faces(&art);
|
||||
let first_blue = faces.iter().position(|f| blue(f)).unwrap();
|
||||
let last_red = faces.iter().rposition(|f| !blue(f)).unwrap();
|
||||
assert_eq!(last_red < first_blue, volume == 0.0, "{volume}: red at {last_red}, blue from {first_blue} of {}", faces.len());
|
||||
}
|
||||
}
|
||||
|
||||
/// With Perspective one eye sees the fill's puff and the stroke's, however much higher one rises:
|
||||
/// seen from behind, the back of a stroke aligned outside meets the fill's back exactly.
|
||||
#[test]
|
||||
fn inflate_sees_the_fill_and_the_stroke_from_one_eye() {
|
||||
let mut n = stroked_rect(100.0);
|
||||
if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
|
||||
s.align = vectorcraft_doc::StrokeAlign::Outside;
|
||||
}
|
||||
let params = json!({"volume": 100, "depth": 100, "perspectiveAngle": 120, "rotationX": 15, "rotationY": 160, "rotationZ": 0});
|
||||
n.appearance.effects.push(fx(INFLATE, params));
|
||||
let art = solid_art(&n).unwrap();
|
||||
let [.., fill_back, stroke_back] = faces(&art)[..] else { panic!("two backs") };
|
||||
assert!(!blue(fill_back) && blue(stroke_back));
|
||||
let fill = fill_back.path_data().unwrap().bounds().unwrap();
|
||||
// The ring's inner contour: the smaller of its two.
|
||||
let rims = stroke_back.path_data().unwrap().subpaths.iter().filter_map(|s| PathData::new(vec![s.clone()]).bounds());
|
||||
let hole = rims.reduce(|a, b| a.intersect(b)).unwrap();
|
||||
assert_rect(hole, fill.x0, fill.y0, fill.x1, fill.y1);
|
||||
}
|
||||
|
||||
/// A stroke with its own transparency or blending is a surface of its own above the fill's, so its
|
||||
/// opacity applies to the stroke as a whole.
|
||||
#[test]
|
||||
fn a_translucent_stroke_is_a_surface_of_its_own() {
|
||||
for (opacity, blend) in [(0.5, BlendMode::Normal), (1.0, BlendMode::Multiply)] {
|
||||
let mut n = stroked_rect(100.0);
|
||||
if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
|
||||
s.opacity = opacity;
|
||||
s.blend = blend;
|
||||
}
|
||||
n.appearance.effects.push(fx(EXTRUDE, json!({})));
|
||||
let art = solid_art(&n).unwrap();
|
||||
let surfaces = art.children().unwrap();
|
||||
assert_eq!(surfaces.len(), 2);
|
||||
assert_eq!((surfaces[0].opacity, surfaces[0].blend), (1.0, BlendMode::Normal));
|
||||
assert_eq!((surfaces[1].opacity, surfaces[1].blend), (opacity, blend));
|
||||
assert!(surfaces[0].children().unwrap().iter().all(|f| !blue(f)) && surfaces[1].children().unwrap().iter().all(|f| blue(f)));
|
||||
}
|
||||
}
|
||||
|
||||
/// With a light that is not white, a fill in a gradient with transparent stops is drawn with an
|
||||
/// opacity mask over its whole surface: the fill's solid and the stroke's stay surfaces of their
|
||||
/// own, and the mask stays on the fill's. Under a white light the same object is one surface.
|
||||
#[test]
|
||||
fn a_masked_surface_under_a_colored_light_stays_whole() {
|
||||
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
|
||||
let mut half = GradientStop::new(1.0, RED);
|
||||
half.opacity = 0.5;
|
||||
let gradient = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops: vec![GradientStop::new(0.0, RED), half] });
|
||||
for (lights, surfaces) in [(json!({"lights": [{"color": "#ff8080"}]}), 2), (json!({}), 1)] {
|
||||
let mut n = stroked_rect(100.0);
|
||||
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) {
|
||||
f.paint = Paint::Gradient(Box::new(gradient.clone()));
|
||||
}
|
||||
n.appearance.effects.push(fx(EXTRUDE, lights.clone()));
|
||||
let art = solid_art(&n).unwrap();
|
||||
let drawn = art.children().unwrap();
|
||||
assert_eq!(drawn.len(), surfaces, "{lights}");
|
||||
assert_eq!(drawn[0].extras().mask.is_some(), surfaces == 2, "{lights}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparency_stays_on_the_whole_surface_and_items_take_their_own_effect() {
|
||||
let mut n = square_node();
|
||||
@@ -230,9 +448,9 @@ 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, "perspectiveAngle": 0})));
|
||||
// Straight on, the extrusion shows its front: the offset square.
|
||||
// Straight on, the extrusion shows its front: the offset square and its 4 pt stroke.
|
||||
let b = solid_art(&n).unwrap().visual_bounds().unwrap();
|
||||
assert!((b.width() - 140.0 - 0.75).abs() < 1e-6, "{b:?}");
|
||||
assert!((b.width() - 144.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");
|
||||
@@ -316,6 +534,9 @@ fn colored_solid_inflate_batches_channels_and_clips_once_per_surface() {
|
||||
}
|
||||
let (groups, clips, paths) = count(&art);
|
||||
assert!(paths > 1000, "exercise a tessellated surface");
|
||||
assert_eq!(clips, 1);
|
||||
assert_eq!(groups, 7, "group count must not grow with triangle count");
|
||||
// The fill's solid and the stroke's, each one composite with its own clip.
|
||||
let surfaces = art.children().unwrap().len();
|
||||
assert_eq!(surfaces, 2);
|
||||
assert_eq!(clips, surfaces);
|
||||
assert_eq!(groups, 1 + 6 * surfaces, "group count must not grow with triangle count");
|
||||
}
|
||||
+124
-12
@@ -26,7 +26,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Pencil",
|
||||
[],
|
||||
None,
|
||||
"{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic brushes with Pressure variation; a point without one presses fully), fidelity?: pt (1.5), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: it is not, and the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}} fit a freehand stroke → {id}",
|
||||
"{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic brushes with Pressure variation; a point without one presses fully), fidelity?: pt (1.5), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: it is not, and the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end; redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it",
|
||||
has_doc,
|
||||
freehand
|
||||
),
|
||||
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Delete Anchor Point",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath, anchor} remove one anchor, re-fitting the curve",
|
||||
"{id, subpath?, anchor} remove one anchor, re-fitting the curve",
|
||||
has_doc,
|
||||
remove_anchor
|
||||
),
|
||||
@@ -71,7 +71,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Convert Anchor Point",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath, anchor, to: \"corner\"|\"smooth\", x?, y?} (smooth with x/y: out handle there, in handle mirrored; without: as the Curvature tool curves a point, along the circle through it and its neighbours, or at an open end one handle into the curve)",
|
||||
"{id, subpath?, anchor, to: \"corner\"|\"smooth\", x?, y?} (smooth with x/y: out handle there, in handle mirrored; without: as the Curvature tool curves a point, along the circle through it and its neighbours, or at an open end one handle into the curve)",
|
||||
has_doc,
|
||||
convert_anchor
|
||||
),
|
||||
@@ -80,7 +80,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reshape Segment",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath, segment, t, dx, dy, perpendicular?: bool} bend a segment so the point at t moves by (dx, dy); with perpendicular (Shift), both handles stand at right angles to the line between its anchors at one length, and the point at t lies as far from that line as the moved point does",
|
||||
"{id, subpath?, segment, t, dx, dy, perpendicular?: bool} bend a segment so the point at t moves by (dx, dy); with perpendicular (Shift), both handles stand at right angles to the line between its anchors at one length, and the point at t lies as far from that line as the moved point does",
|
||||
has_doc,
|
||||
reshape_segment
|
||||
),
|
||||
@@ -89,7 +89,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Split Path",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath, segment, t} or {id, subpath, anchor}: closed paths open there, open paths become two → {ids}",
|
||||
"{id, subpath?, segment, t} or {id, subpath?, anchor}: closed paths open there, open paths become two → {ids}",
|
||||
has_doc,
|
||||
split
|
||||
),
|
||||
@@ -215,6 +215,15 @@ fn usize_req(p: &Value, key: &str, cmd: &str) -> Result<usize> {
|
||||
p.get(key).and_then(Value::as_u64).map(|v| v as usize).ok_or_else(|| bad(cmd, format!("missing integer `{key}`")))
|
||||
}
|
||||
|
||||
/// The whole number of 0 or more that `key` holds, or `None` when `key` is absent or null. Any other
|
||||
/// value is [`usize_req`]'s error.
|
||||
pub(super) fn usize_opt(p: &Value, key: &str, cmd: &str) -> Result<Option<usize>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(_) => usize_req(p, key, cmd).map(Some),
|
||||
}
|
||||
}
|
||||
|
||||
fn path_mut(d: &mut Document, id: NodeId) -> Result<&mut PathData> {
|
||||
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
|
||||
match &mut n.kind {
|
||||
@@ -548,6 +557,87 @@ fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath {
|
||||
fitted.subpaths.into_iter().next().filter(|s| s.anchors.len() >= 2).unwrap_or(sp)
|
||||
}
|
||||
|
||||
/// Pencil's Edit Selected Paths in the middle of a path: a stroke from one point of a subpath of
|
||||
/// `pd` to another, each within `within` of it, replaces the section between them (the shorter
|
||||
/// way round a closed subpath) with the stroke fitted at `tol`. The subpath's index and its new
|
||||
/// shape; None when the stroke doesn't start and end on the same subpath, or meets it once.
|
||||
fn redraw_section(pd: &PathData, pts: &[Point], tol: f64, within: f64) -> Option<(usize, SubPath)> {
|
||||
use kurbo::ParamCurveArclen;
|
||||
let (first, last) = (*pts.first()?, *pts.last()?);
|
||||
let (si, _, _, _, d_a) = pd.nearest(first)?;
|
||||
let (sj, _, _, _, d_b) = pd.nearest(last)?;
|
||||
if si != sj || d_a > within || d_b > within || pts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let mut sp = pd.subpaths.get(si)?.clone();
|
||||
if sp.anchors.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
// The anchor nearest to `q`: the one there, else a new one, and whether it was added. Each
|
||||
// point is looked up on the subpath as it is by then: splitting a straight segment makes two
|
||||
// new ones, which a `t` along the old one doesn't measure.
|
||||
let place = |sp: &mut SubPath, q: Point| {
|
||||
let (_, seg, t, _, _) = PathData::single(sp.clone()).nearest(q)?;
|
||||
let n = sp.anchors.len();
|
||||
Some(if t <= 1e-6 {
|
||||
(seg % n, false)
|
||||
} else if t >= 1.0 - 1e-6 {
|
||||
((seg + 1) % n, false)
|
||||
} else {
|
||||
(sp.insert_anchor(seg, t), true)
|
||||
})
|
||||
};
|
||||
let (mut from, _) = place(&mut sp, first)?;
|
||||
let (to, added) = place(&mut sp, last)?;
|
||||
if added && to <= from {
|
||||
from += 1;
|
||||
}
|
||||
if from == to {
|
||||
return None;
|
||||
}
|
||||
let n = sp.anchors.len();
|
||||
// The length of the run of segments from anchor i forward to anchor j.
|
||||
let run = |i: usize, j: usize| (0..(j + n - i) % n).map(|k| sp.segment((i + k) % n).arclen(0.01)).sum::<f64>();
|
||||
// The stroke goes from `start` to `end` and replaces the run between them; `kept` runs from
|
||||
// `end` round to `start`, then the stroke's inner anchors follow.
|
||||
let (start, end, reversed) = if sp.closed {
|
||||
if run(from, to) <= run(to, from) { (from, to, false) } else { (to, from, true) }
|
||||
} else if from < to {
|
||||
(from, to, false)
|
||||
} else {
|
||||
(to, from, true)
|
||||
};
|
||||
let mut stroke = pts.to_vec();
|
||||
if reversed {
|
||||
stroke.reverse();
|
||||
}
|
||||
let (Some(s0), Some(s1)) = (stroke.first_mut(), sp.anchors.get(start)) else { return None };
|
||||
*s0 = s1.p;
|
||||
let (Some(e0), Some(e1)) = (stroke.last_mut(), sp.anchors.get(end)) else { return None };
|
||||
*e0 = e1.p;
|
||||
let fit = fit_freehand(&stroke, tol, false).anchors;
|
||||
let (Some(f0), Some(f1)) = (fit.first().copied(), fit.last().copied()) else { return None };
|
||||
let inner = fit.get(1..fit.len().saturating_sub(1)).unwrap_or_default();
|
||||
let (before, after): (Vec<Anchor>, Vec<Anchor>) = if sp.closed {
|
||||
((0..=(start + n - end) % n).filter_map(|k| sp.anchors.get((end + k) % n).copied()).collect(), vec![])
|
||||
} else {
|
||||
(sp.anchors.get(..=start)?.to_vec(), sp.anchors.get(end..)?.to_vec())
|
||||
};
|
||||
let mut anchors = before;
|
||||
if let Some(a) = anchors.last_mut() {
|
||||
*a = Anchor::with_handles(a.p, a.h_in, f0.h_out);
|
||||
}
|
||||
anchors.extend_from_slice(inner);
|
||||
let at_end = anchors.len();
|
||||
anchors.extend(after);
|
||||
// The anchor the stroke ends on: the first after it, or round to the first of a closed one.
|
||||
let end_at = if sp.closed { 0 } else { at_end };
|
||||
if let Some(a) = anchors.get_mut(end_at) {
|
||||
*a = Anchor::with_handles(a.p, f1.h_in, a.h_out);
|
||||
}
|
||||
Some((si, SubPath::new(anchors, sp.closed)))
|
||||
}
|
||||
|
||||
fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.freehand";
|
||||
let samples = samples_param(p, "points", C)?;
|
||||
@@ -630,6 +720,28 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})?;
|
||||
return Ok(json!({ "id": id.0 }));
|
||||
}
|
||||
if let Some(rd) = p.get("redraw").filter(|v| !v.is_null()) {
|
||||
let id = id_param(rd, "id").ok_or_else(|| bad(C, "redraw needs id"))?;
|
||||
let within = f64_or(rd, "within", 12.0);
|
||||
if !within.is_finite() || within < 0.0 {
|
||||
return Err(bad(C, "redraw within must be a finite distance of 0 or more"));
|
||||
}
|
||||
let (pd, _) = path_of(&s.doc()?.doc, id)?;
|
||||
// shortcut: a stroke pressure profile is left as it was, measured along the old path; fit
|
||||
// it to the new length if Calligraphic pressure strokes get redrawn.
|
||||
if let Some((si, sp)) = redraw_section(&pd, &pts, tol, within) {
|
||||
s.edit(if brush { "Paintbrush" } else { "Pencil" }, |d, sel| {
|
||||
*path_mut(d, id)?.subpaths.get_mut(si).ok_or_else(|| bad(C, "no such subpath"))? = sp;
|
||||
if keep_selected {
|
||||
sel.set([id]);
|
||||
} else {
|
||||
sel.remove(id);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
return Ok(json!({ "id": id.0 }));
|
||||
}
|
||||
}
|
||||
if pts.len() < 2 {
|
||||
return Err(bad(C, "need at least two points"));
|
||||
}
|
||||
@@ -692,7 +804,7 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use vectorcraft_tools::draw2::{EndCurve, curvature_close, curvature_extend, curvature_insert, curvature_move};
|
||||
const C: &str = "path.curvatureEdit";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let at = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
|
||||
(Some(x), Some(y)) if x.is_finite() && y.is_finite() => Some(Point::new(x, y)),
|
||||
(None, None) => None,
|
||||
@@ -756,7 +868,7 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn remove_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.removeAnchor";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let ai = usize_req(p, "anchor", C)?;
|
||||
let removed = s.edit("Delete Anchor Point", |d, sel| {
|
||||
let sp = path_mut(d, id)?.subpaths.get(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
|
||||
@@ -863,7 +975,7 @@ fn remove_anchors_of(
|
||||
fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.convertAnchor";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let ai = usize_req(p, "anchor", C)?;
|
||||
let smooth = str_param(p, "to") == Some("smooth");
|
||||
let target = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
|
||||
@@ -898,7 +1010,7 @@ fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.reshapeSegment";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let seg = usize_req(p, "segment", C)?;
|
||||
let t = f64_or(p, "t", 0.5).clamp(0.05, 0.95);
|
||||
let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
|
||||
@@ -923,9 +1035,9 @@ fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn split(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.split";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let anchor = p.get("anchor").and_then(Value::as_u64).map(|v| v as usize);
|
||||
let seg = p.get("segment").and_then(Value::as_u64).map(|v| v as usize);
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let anchor = usize_opt(p, "anchor", C)?;
|
||||
let seg = usize_opt(p, "segment", C)?;
|
||||
if anchor.is_none() && seg.is_none() {
|
||||
return Err(bad(C, "need `anchor` or `segment` + `t`"));
|
||||
}
|
||||
|
||||
@@ -278,7 +278,7 @@ pub(super) fn encode(doc: &Document, p: &Value) -> Result<Encoded> {
|
||||
if transparent && flat.is_some() {
|
||||
enc.warnings.push("transparency is flattened into opaque art and images (EPS has none): see the flattener preset".into());
|
||||
}
|
||||
let created = vectorcraft_doc::metadata::now_unix();
|
||||
let created = vectorcraft_doc::metadata::output_time();
|
||||
let mut renderer = vectorcraft_render::Renderer::new();
|
||||
for (board, region, origin) in pages {
|
||||
let rect = vectorcraft_eps::preview_rect(region, origin);
|
||||
|
||||
@@ -110,7 +110,7 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result
|
||||
if boards.len() > 1 {
|
||||
enc.warnings.push(SEPARATE.to_string());
|
||||
}
|
||||
let created = vectorcraft_doc::metadata::now_unix();
|
||||
let created = vectorcraft_doc::metadata::output_time();
|
||||
let failed = vectorcraft_render::decode_failures();
|
||||
for b in boards {
|
||||
let one = super::save::artboard_doc(doc, b);
|
||||
|
||||
@@ -194,6 +194,12 @@ fn from_editing_data_alone(bytes: &[u8], opts: &LoadOptions) -> Option<Result<(D
|
||||
)
|
||||
}
|
||||
|
||||
/// Do `opts` read every page of the PDF `bytes`: no `pages`, or a range that names each one?
|
||||
fn every_page(bytes: &[u8], opts: &LoadOptions) -> bool {
|
||||
let Some(range) = &opts.pages else { return true };
|
||||
vectorcraft_pdf::info(bytes, opts.password.as_deref()).is_ok_and(|i| parse_range(range, i.pages.len()).is_ok_and(|p| p.len() == i.pages.len()))
|
||||
}
|
||||
|
||||
/// [`load`] with `document.open` options (the PDF pages, box and password; the DXF options).
|
||||
pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded> {
|
||||
let format = detect(name, bytes).ok_or_else(|| match super::unsupported(&super::extension(name)) {
|
||||
@@ -239,9 +245,11 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
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, r.user_unit)))
|
||||
}
|
||||
// Saved without its PDF part: the editing copy is all the file has.
|
||||
// Saved without its PDF part: the editing copy is all the file has (`pages` that
|
||||
// name every page read it as the whole file).
|
||||
Err(e) if e.to_string() == vectorcraft_pdf::PdfError::PlaceholderOnly.to_string() => {
|
||||
from_editing_data_alone(bytes, opts).unwrap_or(Err(e))
|
||||
let whole = LoadOptions { pages: None, ..opts.clone() };
|
||||
from_editing_data_alone(bytes, if every_page(bytes, opts) { &whole } else { opts }).unwrap_or(Err(e))
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
})?
|
||||
@@ -481,6 +489,10 @@ pub(crate) fn too_large_note(bytes: &[u8]) -> Option<String> {
|
||||
/// too ([`vectorcraft_doc::cmyk::group3_tiff_rgba`]).
|
||||
/// Grayscale TIFFs with an alpha channel or other extra channels, which the decoder can't read
|
||||
/// either, become RGBA too, whether they store their channels together or one plane per channel.
|
||||
/// A Targa file, which starts with no signature, is recognized by its header
|
||||
/// ([`super::tgaread::is_targa`]) and becomes RGBA with the transparency it stores
|
||||
/// ([`super::tgaread::fix_alpha`]). An image stored as PNG whose decoded samples, or whose pixels
|
||||
/// as RGBA, need more than [`MAX_RASTER_ALLOC`] is refused as too large to draw.
|
||||
/// A TIFF with more strips or tiles than the decoder can count
|
||||
/// ([`vectorcraft_doc::cmyk::oversized_tiff`]) is refused as too large to draw.
|
||||
pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
|
||||
@@ -493,6 +505,9 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
|
||||
return as_png(psdread::decode(bytes, MAX_RASTER_ALLOC, |c| cms.cmyk_to_srgb(c, false)).map_err(err)?, ppi);
|
||||
}
|
||||
let mut reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(err)?;
|
||||
if reader.format().is_none() && super::tgaread::is_targa(bytes) {
|
||||
reader.set_format(image::ImageFormat::Tga);
|
||||
}
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(MAX_RASTER_ALLOC);
|
||||
reader.limits(limits);
|
||||
@@ -511,13 +526,25 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
|
||||
let cms = vectorcraft_color::cms::active();
|
||||
vectorcraft_doc::cmyk::extra_samples_tiff_rgba(bytes, |c| cms.cmyk_to_srgb(c, false))
|
||||
};
|
||||
let img = match (f.id == "tiff").then(extra_samples).flatten() {
|
||||
let mut img = match (f.id == "tiff").then(extra_samples).flatten() {
|
||||
Some(img) => img,
|
||||
None => match (f.id == "tiff").then(|| vectorcraft_doc::cmyk::group3_tiff_rgba(bytes)).flatten() {
|
||||
Some(read) => read.map_err(err)?,
|
||||
None => reader.decode().map_err(err)?.to_rgba8(),
|
||||
None => {
|
||||
use image::ImageDecoder as _;
|
||||
let decoder = reader.into_decoder().map_err(err)?;
|
||||
// The allowance counts the decoded samples and the RGBA pixels they become (#1717).
|
||||
let (w, h) = decoder.dimensions();
|
||||
if decoder.total_bytes() > MAX_RASTER_ALLOC || u64::from(w) * u64::from(h) * 4 > MAX_RASTER_ALLOC {
|
||||
return Err(err(format!("an image of {w} × {h} pixels is too large to draw")));
|
||||
}
|
||||
image::DynamicImage::from_decoder(decoder).map_err(err)?.to_rgba8()
|
||||
}
|
||||
},
|
||||
};
|
||||
if f.id == "tga" {
|
||||
super::tgaread::fix_alpha(bytes, &mut img);
|
||||
}
|
||||
as_png(img, ppi)
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ mod save;
|
||||
mod screens;
|
||||
mod svg;
|
||||
mod text;
|
||||
mod tgaread;
|
||||
pub mod zip;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -206,7 +207,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"PDF Info",
|
||||
[],
|
||||
None,
|
||||
"{path | name? | dataBase64 (a file's path, or its bytes), password?, thumbnail?: page (1-based), thumbnailSize?: 160 (px, longest side), cropTo?: crop (the thumbnail's box)} → {pages, needsPassword, wrongPassword?, pageInfo: [{width, height (pt, as shown), rotation, boxes: {media, crop, bleed, trim, art: [x0, y0, x1, y1] (PDF space, pt)}}], thumbnail?: PNG dataBase64}; a page whose UserUnit (PDF 1.6) is above 1 gives its size and boxes in pt, its numbers times the UserUnit (one above 75,000 counts as 75,000); an encrypted PDF without its password → {pages: 0, needsPassword: true}",
|
||||
"{path | name? | dataBase64 (a file's path, or its bytes), password?, thumbnail?: page (1-based), thumbnailSize?: 160 (px, longest side), cropTo?: crop (the thumbnail's box)} → {pages, needsPassword, wrongPassword?, pageInfo: [{width, height (pt, as shown), rotation, boxes: {media, crop, bleed, trim, art: [x0, y0, x1, y1] (PDF space, pt)}}], thumbnail?: PNG dataBase64}; a page whose UserUnit (PDF 1.6) is above 1 gives its size and boxes in pt, its numbers times the UserUnit (one above 75,000 counts as 75,000); an encrypted PDF without its password → {pages: 0, needsPassword: true}; a PostScript file (an EPS, a .ps file, a .ai in PostScript form) → {pages: 1, needsPassword: false, pageInfo: [{width, height (its bounding box, a US Letter page without one), rotation: 0, boxes: {}}]} (password and cropTo don't apply; thumbnail: 1 shows what the page prints)",
|
||||
always,
|
||||
pdfimport::pdf_info
|
||||
),
|
||||
@@ -646,11 +647,20 @@ pub const FORMATS: &[Format] = &[
|
||||
},
|
||||
Format { id: "emf", label: "EMF", extensions: &["emf"], mime: "image/emf", read: true, write: true, raster: false, options: metafile::OPTIONS },
|
||||
Format { id: "wmf", label: "WMF", extensions: &["wmf"], mime: "image/wmf", read: true, write: true, raster: false, options: metafile::OPTIONS },
|
||||
Format { id: "tga", label: "Targa", extensions: &["tga"], mime: "image/x-tga", read: false, write: true, raster: true, options: TGA_OPTIONS },
|
||||
Format {
|
||||
id: "tga",
|
||||
label: "Targa",
|
||||
extensions: &["tga", "vda", "icb", "vst"],
|
||||
mime: "image/x-tga",
|
||||
read: true,
|
||||
write: true,
|
||||
raster: true,
|
||||
options: TGA_OPTIONS,
|
||||
},
|
||||
Format { id: "psd", label: "PSD", extensions: &["psd"], mime: "image/x-psd", read: true, write: true, raster: true, options: PSD_OPTIONS },
|
||||
// Affinity Photo's `.afphoto` opens by its content but isn't listed: Finder shouldn't offer a
|
||||
// vector app for a raster editor's documents.
|
||||
reader("affinity", "Affinity", &["af", "afdesign", "afpub"], "application/vnd.affinity", false),
|
||||
reader("affinity", "Affinity", &["af", "afdesign", "afpub"], "application/x-affinity", false),
|
||||
// Photoshop's large document format: read like a PSD (its merged image), never written.
|
||||
reader("psb", "PSB", &["psb"], "image/x-psb", true),
|
||||
// PostScript programs, read as EPS files are; Print to PostScript writes them, exports don't.
|
||||
@@ -674,6 +684,10 @@ pub const OPEN_EXTS: &[&str] = &[
|
||||
"tif",
|
||||
"tiff",
|
||||
"bmp",
|
||||
"tga",
|
||||
"vda",
|
||||
"icb",
|
||||
"vst",
|
||||
"psd",
|
||||
"psb",
|
||||
"vctemplate",
|
||||
@@ -688,11 +702,12 @@ pub const OPEN_EXTS: &[&str] = &[
|
||||
"afpub",
|
||||
];
|
||||
|
||||
/// Extensions in [`OPEN_EXTS`] the desktop packaging doesn't associate with VectorCraft: Photoshop
|
||||
/// documents belong to raster editors, so Finder and file managers don't offer a vector app for
|
||||
/// them, and the packaging declares no `.epsf` or `.ps` type (File › Open and Place still read
|
||||
/// them all).
|
||||
pub const UNASSOCIATED_EXTS: &[&str] = &["psd", "psb", "epsf", "ps"];
|
||||
/// Extensions in [`OPEN_EXTS`] the packaging declares no type for: Photoshop documents belong to
|
||||
/// raster editors, so Finder and file managers don't offer a vector app for them, and there is no
|
||||
/// `.epsf`, `.ps` or Targa type (File › Open and Place still read them all). On Linux the desktop
|
||||
/// entry's EPS type (shared-mime-info's) also covers `.epsf`, so file managers offer VectorCraft
|
||||
/// for it.
|
||||
pub const UNASSOCIATED_EXTS: &[&str] = &["psd", "psb", "epsf", "ps", "tga", "vda", "icb", "vst"];
|
||||
|
||||
/// The extension that picks each writable format when exporting (the format's first; PNG-8 shares
|
||||
/// `.png` with PNG, so `.png` comes once), in [`FORMATS`] order.
|
||||
@@ -733,6 +748,10 @@ pub const PLACE_EXTS: &[&str] = &[
|
||||
"tif",
|
||||
"tiff",
|
||||
"bmp",
|
||||
"tga",
|
||||
"vda",
|
||||
"icb",
|
||||
"vst",
|
||||
"psd",
|
||||
"psb",
|
||||
"vctemplate",
|
||||
@@ -1085,14 +1104,74 @@ pub(crate) fn absolute_path(path: &str) -> String {
|
||||
/// stored in, when it has one: a relative path is taken from its folder. Only files on this
|
||||
/// computer, or on the network share the document is on, are followed (see `craft_docpath`); a
|
||||
/// path that is still relative (no document) is refused, as the working directory isn't the
|
||||
/// document's choice. Every stored path reaches the file system through this.
|
||||
/// document's choice. On Windows, a document on a mapped drive is on the share behind the drive
|
||||
/// ([`share_path`]): a path refused as on another computer is checked again against the
|
||||
/// document's path on that share. Every stored path reaches the file system through this.
|
||||
pub(crate) fn document_path(stored: &str, document: Option<&str>) -> std::result::Result<String, craft_docpath::Refused> {
|
||||
document_path_with(stored, document, share_path)
|
||||
}
|
||||
|
||||
/// [`document_path`], with `share` giving a document's path on the network share behind its drive.
|
||||
pub(crate) fn document_path_with(
|
||||
stored: &str,
|
||||
document: Option<&str>,
|
||||
share: impl FnOnce(&str) -> Option<String>,
|
||||
) -> std::result::Result<String, craft_docpath::Refused> {
|
||||
use craft_docpath::Refused;
|
||||
let document = document.map(absolute_path);
|
||||
let path = craft_docpath::resolve(stored, document.as_deref().map(std::path::Path::new))?;
|
||||
let resolve = |document: Option<&str>| craft_docpath::resolve(stored, document.map(std::path::Path::new));
|
||||
let path = match resolve(document.as_deref()) {
|
||||
Err(Refused::Network) => {
|
||||
let share = document.as_deref().and_then(share).ok_or(Refused::Network)?;
|
||||
resolve(Some(share.as_str()))?
|
||||
}
|
||||
path => path?,
|
||||
};
|
||||
if !path.is_absolute() {
|
||||
return Err(craft_docpath::Refused::Invalid);
|
||||
return Err(Refused::Invalid);
|
||||
}
|
||||
path.into_os_string().into_string().map_err(|_| craft_docpath::Refused::Invalid)
|
||||
path.into_os_string().into_string().map_err(|_| Refused::Invalid)
|
||||
}
|
||||
|
||||
/// The path of `document` (on a drive, `Z:\jobs\doc.vectorcraft`) on the network share the drive is
|
||||
/// mapped to (`\\server\share\jobs\doc.vectorcraft`), when its folder is on one and automation may
|
||||
/// read the folder. The folder's final path ([`std::fs::canonicalize`]) names the share as
|
||||
/// `\\?\UNC\server\share\…`.
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn share_path(document: &str) -> Option<String> {
|
||||
share_of(document, |folder| {
|
||||
may_read(folder).ok()?;
|
||||
std::fs::canonicalize(folder).ok()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub(crate) fn share_path(_: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The path of `document` on the network share its drive is mapped to, given `final_path`, which
|
||||
/// returns the final path of a folder: `None` when `document` is not on a drive (`C:\` or `C:/`)
|
||||
/// or `final_path` gives no share (`\\?\UNC\server\share\…`), such as a folder on a local volume.
|
||||
#[cfg(any(windows, test))]
|
||||
pub(crate) fn share_of(document: &str, final_path: impl FnOnce(&str) -> Option<std::path::PathBuf>) -> Option<String> {
|
||||
let mut chars = document.chars();
|
||||
if !matches!((chars.next(), chars.next(), chars.next()), (Some(l), Some(':'), Some('\\' | '/')) if l.is_ascii_alphabetic()) {
|
||||
return None;
|
||||
}
|
||||
// The drive's root folder keeps its separator (`Z:\`).
|
||||
let split = document.rfind(['\\', '/'])?;
|
||||
let (folder, name) = (document.get(..split.max(3))?, document.get(split + 1..)?);
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let target = final_path(folder)?;
|
||||
let target = target.to_str()?;
|
||||
let unc = target.get(..8).filter(|p| p.eq_ignore_ascii_case(r"\\?\UNC\")).and(target.get(8..))?.trim_end_matches('\\');
|
||||
if unc.split('\\').filter(|c| !c.is_empty()).count() < 2 {
|
||||
return None;
|
||||
}
|
||||
Some(format!(r"\\{unc}\{name}"))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -242,7 +242,7 @@ pub fn expand_preset<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result<Cow<'a,
|
||||
pub fn options(cmd: &str, doc: &Document, p: &Value) -> Result<PdfOptions> {
|
||||
let pick = if p.is_object() { ArtboardPick::deserialize(p).map_err(|e| bad(cmd, format!("PDF options: {e}")))? } else { ArtboardPick::default() };
|
||||
let artboards = pick.resolve(doc.artboards.len()).map_err(|e| bad(cmd, e))?;
|
||||
let now = vectorcraft_doc::metadata::now_unix();
|
||||
let now = vectorcraft_doc::metadata::output_time();
|
||||
Ok(PdfOptions { settings: settings(cmd, p)?, artboards, created: doc.metadata.created.or(now), modified: now, ..Default::default() })
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Opening and placing PDF (and PDF-compatible `.ai`/`.ait`) files: the options `document.open`
|
||||
//! and `file.place` read ([`LoadOptions`]), the pages they import and `document.pdfInfo` (pages,
|
||||
//! boxes, a thumbnail) for the Import PDF dialog.
|
||||
//! boxes, a thumbnail; one page for a PostScript file) for the Import PDF dialog.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{ColorMode, Document};
|
||||
@@ -143,13 +143,13 @@ pub(super) fn import(bytes: &[u8], o: &LoadOptions, editor_document: bool) -> Re
|
||||
}
|
||||
|
||||
/// Page `page` (0-based) of a PDF as a document of one artboard (its `o.crop` box) and one
|
||||
/// layer of what it shows (text as outlines: it looks as printed), and the import notes.
|
||||
/// layer of what it shows, with the same text choice as Open, and the import notes.
|
||||
pub fn page_document(bytes: &[u8], page: usize, o: &LoadOptions) -> Result<(Document, Vec<String>)> {
|
||||
let opts = ImportOptions {
|
||||
pages: Some(vec![page]),
|
||||
crop: o.crop,
|
||||
password: o.password.clone(),
|
||||
text_as: TextAs::Outlines,
|
||||
text_as: o.text_as,
|
||||
layers: false,
|
||||
..Default::default()
|
||||
};
|
||||
@@ -162,6 +162,10 @@ const INFO: &str = "document.pdfInfo";
|
||||
pub(super) fn pdf_info(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let bytes = source(p, INFO)?.bytes;
|
||||
let o = LoadOptions::from_params(INFO, p)?;
|
||||
// Programs whose header is only `%!` are PostScript too, as `clipboard.importEps` reads them.
|
||||
if bytes.starts_with(b"%!") || vectorcraft_pdf::is_postscript(&bytes) {
|
||||
return postscript_info(&bytes, p);
|
||||
}
|
||||
let info = match vectorcraft_pdf::info(&bytes, o.password.as_deref()) {
|
||||
Ok(i) => i,
|
||||
Err(e @ (PdfError::NeedsPassword | PdfError::WrongPassword)) => {
|
||||
@@ -179,14 +183,42 @@ pub(super) fn pdf_info(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
.collect();
|
||||
let mut out = json!({"pages": pages.len(), "needsPassword": false, "pageInfo": pages});
|
||||
if let Some(n) = p.get("thumbnail").and_then(Value::as_u64) {
|
||||
let page = usize::try_from(n).ok().and_then(|n| n.checked_sub(1)).filter(|i| *i < info.pages.len());
|
||||
let page = page.ok_or_else(|| bad(INFO, format!("thumbnail: no page {n} (the PDF has {})", info.pages.len())))?;
|
||||
if let Some(page) = thumbnail_page(p, info.pages.len(), "PDF")? {
|
||||
let (doc, _) = page_document(&bytes, page, &o)?;
|
||||
let size = f64_or(p, "thumbnailSize", 160.0).clamp(16.0, 1024.0);
|
||||
let r = doc.artboards.first().map(|a| a.rect).ok_or_else(|| err("the page has no area"))?;
|
||||
let png = super::encode(&doc, "png", &json!({"scale": size / r.width().max(r.height()).max(1.0)}))?;
|
||||
out["thumbnail"] = json!(vectorcraft_format::base64_encode(&png));
|
||||
out["thumbnail"] = json!(thumbnail(&doc, p)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The page (0-based) `document.pdfInfo`'s `thumbnail` names, from 1 to `count`; `None` when it is
|
||||
/// absent or null. Any other value (0, a negative or fractional number, a string) is an error that
|
||||
/// names the pages the `what` has.
|
||||
fn thumbnail_page(p: &Value, count: usize, what: &str) -> Result<Option<usize>> {
|
||||
let Some(v) = p.get("thumbnail").filter(|v| !v.is_null()) else { return Ok(None) };
|
||||
let page = v.as_u64().and_then(|n| usize::try_from(n).ok()).and_then(|n| n.checked_sub(1)).filter(|i| *i < count);
|
||||
page.map(Some).ok_or_else(|| bad(INFO, format!("thumbnail: no page {v} (the {what} has {count})")))
|
||||
}
|
||||
|
||||
/// `document.pdfInfo` of a PostScript file (an EPS, a `.ps` file, a `.ai` in PostScript form): one
|
||||
/// page, the page [`vectorcraft_eps::page`] reads, with no PDF boxes. Its thumbnail is the file as
|
||||
/// the EPS reader reads it without any editing data it carries: what the page prints, or the
|
||||
/// file's preview when its PostScript can't be read.
|
||||
fn postscript_info(bytes: &[u8], p: &Value) -> Result<Value> {
|
||||
let page = vectorcraft_eps::page(bytes).map_err(err)?;
|
||||
let info = json!({"width": page.width(), "height": page.height(), "rotation": 0, "boxes": {}});
|
||||
let mut out = json!({"pages": 1, "needsPassword": false, "pageInfo": [info]});
|
||||
if thumbnail_page(p, 1, "file")?.is_some() {
|
||||
let doc = vectorcraft_eps::import_with(bytes, false).map_err(err)?.document;
|
||||
out["thumbnail"] = json!(thumbnail(&doc, p)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A PNG of `doc`'s first artboard, `thumbnailSize` px (default 160, from 16 to 1024) on its longer
|
||||
/// side, base64.
|
||||
fn thumbnail(doc: &Document, p: &Value) -> Result<String> {
|
||||
let size = f64_or(p, "thumbnailSize", 160.0).clamp(16.0, 1024.0);
|
||||
let r = doc.artboards.first().map(|a| a.rect).ok_or_else(|| err("the page has no area"))?;
|
||||
let png = super::encode(doc, "png", &json!({"scale": size / r.width().max(r.height()).max(1.0)}))?;
|
||||
Ok(vectorcraft_format::base64_encode(&png))
|
||||
}
|
||||
@@ -387,7 +387,7 @@ impl<'a> Naming<'a> {
|
||||
let prefix = file_part(o.prefix.as_deref().unwrap_or(""));
|
||||
let when = match o.date.as_deref() {
|
||||
Some(d) => naming::parse_date(d).ok_or_else(|| bad(C, format!("date `{d}`: YYYY-MM-DD")))?,
|
||||
None => vectorcraft_doc::metadata::now_unix().map_or([1970, 1, 1, 0, 0, 0], vectorcraft_doc::metadata::civil),
|
||||
None => vectorcraft_doc::metadata::output_time().map_or([1970, 1, 1, 0, 0, 0], vectorcraft_doc::metadata::civil),
|
||||
};
|
||||
let per_file = match o.counter_per.as_deref() {
|
||||
None | Some("item") => false,
|
||||
|
||||
@@ -189,6 +189,22 @@ fn planar_tiffs_with_more_strips_than_can_be_counted_are_too_large() {
|
||||
assert_eq!(vectorcraft_doc::cmyk::oversized_tiff(&file), Some((3, 1 << 31 | 1)));
|
||||
}
|
||||
|
||||
/// A 16-bit RGB TIFF whose samples need more than the allowance (6 bytes a pixel), though its RGBA
|
||||
/// pixels would fit, is refused as too large to draw before it is decoded.
|
||||
#[test]
|
||||
fn tiffs_whose_samples_pass_the_allowance_are_too_large() {
|
||||
let plane = |v: u16| vec![v; 6];
|
||||
let mut file = hand_tiff(2, 16, &[], false, &[plane(9000), plane(30_000), plane(60_000)]);
|
||||
// The loop sets ImageWidth, ImageLength and RowsPerStrip (the first, second and eighth entries) to `side`.
|
||||
let side = 20_000u32;
|
||||
assert!(u64::from(side * side) * 6 > MAX_RASTER_ALLOC && u64::from(side * side) * 4 <= MAX_RASTER_ALLOC);
|
||||
for at in [18, 30, 102] {
|
||||
file[at..at + 4].copy_from_slice(&side.to_le_bytes());
|
||||
}
|
||||
let e = raster_image(&file).err().expect("refused");
|
||||
assert_eq!(e.to_string(), "an image of 20000 × 20000 pixels is too large to draw");
|
||||
}
|
||||
|
||||
/// A planar CMYK TIFF opens and places with its ink amounts, stored as a TIFF the image decoder
|
||||
/// reads (#1466).
|
||||
#[test]
|
||||
|
||||
@@ -229,6 +229,19 @@ fn an_ai_file_saved_without_its_pdf_part_opens_from_its_editing_data() {
|
||||
assert!(e.contains("placeholder"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ai_file_saved_without_its_pdf_part_opens_with_pages_naming_every_page() {
|
||||
let file = ai::ai(&compressed(&ai::sample_data()), PLACEHOLDER);
|
||||
for pages in [json!("1"), json!(1), json!("1-")] {
|
||||
let mut s = Session::new();
|
||||
open(&mut s, "art.ai", &file, json!({"pages": pages})).unwrap();
|
||||
check_sample(&s);
|
||||
}
|
||||
// Without editing data, the placeholder still isn't opened as the art.
|
||||
let e = open(&mut Session::new(), "plain.ai", &ai::ai(b"", PLACEHOLDER), json!({"pages": "1"})).unwrap_err().to_string();
|
||||
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";
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! EPS and PostScript files opened and placed: our own EPS files restore the document they carry,
|
||||
//! others are read by the PostScript interpreter or come in as their preview.
|
||||
|
||||
use std::io::Write as _;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::tests_svgedit::{comparable, rich};
|
||||
@@ -93,6 +95,33 @@ fn cmyk_images_keep_their_inks_when_an_eps_opens_or_is_placed() {
|
||||
assert_eq!(images(&s), [want.clone(), want]);
|
||||
}
|
||||
|
||||
/// A 4-bit gray image compressed with Flate and TIFF predictor 2 draws the samples it had before
|
||||
/// differencing, in an EPS and in a PostScript `.ai`.
|
||||
#[test]
|
||||
fn tiff_predicted_4_bit_images_open_from_eps_and_postscript_ai_files() {
|
||||
// Two rows of four samples, 0 5 15 3 and 9 9 2 0, differenced to 0 5 10 4 and 9 0 9 14.
|
||||
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
z.write_all(&[0x05, 0xa4, 0x90, 0x9e]).unwrap();
|
||||
let hex: String = z.finish().unwrap().iter().map(|b| format!("{b:02x}")).collect();
|
||||
let ps = format!(
|
||||
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 40 20\n%%EndComments\n40 20 scale 4 2 4 [4 0 0 -2 0 2] currentfile /ASCIIHexDecode filter \
|
||||
<< /Predictor 2 /Colors 1 /BitsPerComponent 4 /Columns 4 >> /FlateDecode filter image\n{hex}>\nshowpage\n%%EOF\n"
|
||||
);
|
||||
for name in ["predicted.eps", "predicted.ai"] {
|
||||
let mut s = Session::new();
|
||||
let r = open(&mut s, name, ps.as_bytes()).unwrap();
|
||||
assert_eq!(r["warnings"], json!([]), "{name}: {r}");
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let mut grays = vec![];
|
||||
d.walk(|n| {
|
||||
if let vectorcraft_doc::NodeKind::Image(im) = &n.kind {
|
||||
grays = image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_luma8().into_raw();
|
||||
}
|
||||
});
|
||||
assert_eq!(grays, [0, 5, 15, 3, 9, 9, 2, 0].map(|v| v * 17), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_eps_file_comes_in_as_its_preview() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -69,6 +69,33 @@ fn pages_changed_elsewhere_open_as_artwork_with_a_warning() {
|
||||
assert_eq!(r["warnings"][0], load::EDITING_DAMAGED, "{r}");
|
||||
}
|
||||
|
||||
/// `pdf` with its editing file's description rewritten by another app in UTF-16BE with a byte
|
||||
/// order mark: "hash " and the hash it held, padded with spaces to its length.
|
||||
fn utf16_described(pdf: &[u8]) -> Vec<u8> {
|
||||
let from = b"(VectorCraft editing data; page hash ";
|
||||
let at = pdf.windows(from.len()).position(|w| w == from).unwrap_or_else(|| panic!("no description"));
|
||||
let (hash, len) = (&pdf[at + from.len()..at + from.len() + 16], from.len() + 17);
|
||||
let mut desc = b"(\xFE\xFF".to_vec();
|
||||
desc.extend(b"hash ".iter().chain(hash).flat_map(|&c| [0, c]));
|
||||
desc.push(b')');
|
||||
desc.resize(len, b' ');
|
||||
patched(pdf, &pdf[at..at + len], &desc)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages_changed_elsewhere_are_noticed_with_a_utf16_description() {
|
||||
let mut s = rich();
|
||||
let pdf = b64(&s.execute("document.exportPdf", &json!({"compression": {"compressText": false}})).unwrap());
|
||||
let ai = b64(&s.execute("document.export", &json!({"format": "ai", "compress": false})).unwrap());
|
||||
for (name, file) in [("edited.pdf", pdf), ("edited.ai", ai)] {
|
||||
let r = open(&mut s, name, &utf16_described(&file));
|
||||
assert_eq!((&r["warnings"], &r["restored"]), (&json!([]), &json!(true)), "{name}: the same pages: {r}");
|
||||
let edited = patched(&file, b"/MediaBox[0 0 300 200]", b"/MediaBox[0 0 300 201]");
|
||||
let r = open(&mut s, name, &utf16_described(&edited));
|
||||
assert_eq!((&r["warnings"][0], &r["restored"]), (&json!(load::EDITING_STALE), &json!(false)), "{name}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_legacy_payload_name_is_accepted() {
|
||||
let mut s = rich();
|
||||
|
||||
@@ -38,6 +38,64 @@ fn text_opens_as_type_or_outlines() {
|
||||
assert!(e.contains("textAs must be one of text, outlines"), "{e}");
|
||||
}
|
||||
|
||||
fn live_text_pdf() -> Vec<u8> {
|
||||
let page = |text: &str| PdfPage {
|
||||
resources: "/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >>".into(),
|
||||
..PdfPage::new(200.0, 100.0, &format!("BT /F1 12 Tf 10 50 Td ({text}) Tj ET"))
|
||||
};
|
||||
pdf(&[page("First page"), page("Second page")], None)
|
||||
}
|
||||
|
||||
fn live_texts(s: &Session) -> Vec<String> {
|
||||
let mut texts = vec![];
|
||||
s.doc().unwrap().doc.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
texts.push(t.plain_text());
|
||||
}
|
||||
});
|
||||
texts
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_place_keeps_live_text_from_bytes_and_unlinked_paths() {
|
||||
let bytes = live_text_pdf();
|
||||
let dir = vectorcraft_testkit::temp_dir("pdf-place-live-text");
|
||||
let path = dir.join("text.pdf");
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
for source in [json!({"name": "text.pdf", "dataBase64": vectorcraft_format::base64_encode(&bytes)}), json!({"path": path, "link": false})] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let mut p = source;
|
||||
p["page"] = json!(2);
|
||||
let r = s.execute("file.place", &p).unwrap();
|
||||
assert_eq!(live_texts(&s), ["Second page"], "{r}");
|
||||
assert!(!r["warnings"].to_string().contains("outlines"), "{r}");
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_clipboard_paste_keeps_live_text_on_the_selected_page() {
|
||||
let bytes = live_text_pdf();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let r = s.execute("clipboard.importPdf", &json!({"dataBase64": vectorcraft_format::base64_encode(&bytes), "page": 2})).unwrap();
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
assert_eq!(live_texts(&s), ["Second page"], "{r}");
|
||||
assert!(!r["warnings"].to_string().contains("outlines"), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_place_honors_explicit_text_outlines() {
|
||||
let bytes = live_text_pdf();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let r =
|
||||
s.execute("file.place", &json!({"name": "text.pdf", "dataBase64": vectorcraft_format::base64_encode(&bytes), "textAs": "outlines"})).unwrap();
|
||||
assert!(live_texts(&s).is_empty());
|
||||
assert!(r["warnings"].to_string().contains("outlines"), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_option_keeps_or_flattens_optional_content() {
|
||||
let (a, b) = (first_extra(1), first_extra(1) + 1);
|
||||
|
||||
@@ -157,27 +157,31 @@ fn a_postscript_ai_file_says_it_cannot_be_opened_yet() {
|
||||
let e = s.execute("document.open", &json!({"name": name, "dataBase64": b64(ps)})).unwrap_err().to_string();
|
||||
assert!(e.contains(name) && e.contains("PostScript") && e.contains("save it as PDF"), "{name}: {e}");
|
||||
}
|
||||
// An EPS with a binary header too, and the PDF info of either.
|
||||
// An EPS with a binary header too, and the PDF info of each. The plain program's PDF info gives
|
||||
// its page, and its thumbnail gives the reader's error; the binary file's header points at no
|
||||
// PostScript, so its PDF info is an error.
|
||||
let mut binary = vec![0xC5, 0xD0, 0xD3, 0xC6];
|
||||
binary.extend_from_slice(&[0; 28]);
|
||||
binary.extend_from_slice(ps);
|
||||
assert!(s.execute("document.open", &json!({"name": "x.eps", "dataBase64": b64(&binary)})).unwrap_err().to_string().contains("PostScript"));
|
||||
assert!(s.execute("document.pdfInfo", &json!({"dataBase64": b64(ps)})).unwrap_err().to_string().contains("PostScript"));
|
||||
assert!(s.execute("document.pdfInfo", &json!({"dataBase64": b64(&binary)})).unwrap_err().to_string().contains("PostScript"));
|
||||
let v = s.execute("document.pdfInfo", &json!({"dataBase64": b64(ps)})).unwrap();
|
||||
assert_eq!((v["pages"].as_u64(), v["pageInfo"][0]["width"].as_f64()), (Some(1), Some(100.0)), "{v}");
|
||||
let e = s.execute("document.pdfInfo", &json!({"dataBase64": b64(ps), "thumbnail": 1})).unwrap_err().to_string();
|
||||
assert!(e.contains("PostScript") && e.contains("frobnicate"), "{e}");
|
||||
assert!(s.active().is_none());
|
||||
}
|
||||
|
||||
/// PostScript given where a PDF is read (`document.pdfInfo`, `clipboard.importPdf`) is refused
|
||||
/// with an error that names File › Open and File › Place, and both of those read the file.
|
||||
/// PostScript given where a PDF is read (`clipboard.importPdf`) is refused with an error that
|
||||
/// names File › Open and File › Place, and both of those read the file.
|
||||
#[test]
|
||||
fn postscript_read_as_a_pdf_names_open_and_place() {
|
||||
let ps = b"%!PS-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n1 0 0 setrgbcolor 10 10 50 50 rectfill\nshowpage\n%%EOF\n";
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
for cmd in ["document.pdfInfo", "clipboard.importPdf"] {
|
||||
let e = s.execute(cmd, &json!({"dataBase64": b64(ps)})).unwrap_err().to_string();
|
||||
assert!(e.contains("PostScript") && e.contains("not a PDF: File › Open and File › Place read it as EPS"), "{cmd}: {e}");
|
||||
assert!(!e.contains("can't be opened"), "{cmd}: {e}");
|
||||
}
|
||||
let e = s.execute("clipboard.importPdf", &json!({"dataBase64": b64(ps)})).unwrap_err().to_string();
|
||||
assert!(e.contains("PostScript") && e.contains("not a PDF: File › Open and File › Place read it as EPS"), "{e}");
|
||||
assert!(!e.contains("can't be opened"), "{e}");
|
||||
for name in ["legacy.ai", "art.eps"] {
|
||||
let r = s.execute("document.open", &json!({"name": name, "dataBase64": b64(ps)})).unwrap();
|
||||
assert_eq!(r["warnings"], json!([]), "{name}: {r}");
|
||||
@@ -186,6 +190,65 @@ fn postscript_read_as_a_pdf_names_open_and_place() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `document.pdfInfo` reads PostScript (an EPS, a `.ai` in PostScript form, a `.ps` file) as one
|
||||
/// page, its bounding box or a letter page without one, and its thumbnail shows that page. Each
|
||||
/// file here opens on that page with `editingData: false`.
|
||||
#[test]
|
||||
fn pdf_info_reads_postscript_as_one_page() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
|
||||
let data = |r: Value| vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let eps = data(s.execute("document.exportEps", &json!({"useArtboards": true})).unwrap());
|
||||
let ai = data(s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap());
|
||||
assert!(eps.starts_with(&[0xC5, 0xD0, 0xD3, 0xC6]) && ai.starts_with(b"%!PS"));
|
||||
let hires = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 101 41\n%%HiResBoundingBox: 0 0 100.5 40.25\n%%EndComments\n0 0 1 setrgbcolor 0 0 50 20 rectfill\n";
|
||||
let letter = b"%!PS\n1 0 0 setrgbcolor 72 72 144 72 rectfill\nshowpage\n";
|
||||
let bare = b"%!\n0 0 1 setrgbcolor 100 100 50 50 rectfill\nshowpage\n";
|
||||
for (name, bytes, size) in [
|
||||
("art.eps", &eps[..], (300.0, 200.0)),
|
||||
("legacy.ai", &ai, (101.0, 51.0)),
|
||||
("hires.eps", hires, (100.5, 40.25)),
|
||||
("page.ps", letter, (612.0, 792.0)),
|
||||
("bare.ps", bare, (612.0, 792.0)),
|
||||
] {
|
||||
let v = s.execute("document.pdfInfo", &json!({"dataBase64": b64(bytes), "thumbnail": 1, "thumbnailSize": 100})).unwrap();
|
||||
let png = vectorcraft_format::base64_decode(v["thumbnail"].as_str().unwrap()).unwrap();
|
||||
let img = image::load_from_memory(&png).unwrap();
|
||||
assert_eq!(img.width().max(img.height()), 100, "{name}");
|
||||
let mut v = v;
|
||||
v["thumbnail"] = json!("png");
|
||||
let page = json!({"width": size.0, "height": size.1, "rotation": 0, "boxes": {}});
|
||||
assert_eq!(v, json!({"pages": 1, "needsPassword": false, "pageInfo": [page], "thumbnail": "png"}), "{name}");
|
||||
s.execute("document.open", &json!({"name": name, "dataBase64": b64(bytes), "editingData": false})).unwrap();
|
||||
let board = s.doc().unwrap().doc.artboards[0].rect;
|
||||
assert_eq!((board.width(), board.height()), size, "{name}: the page document.open reads");
|
||||
}
|
||||
let e = s.execute("document.pdfInfo", &json!({"dataBase64": b64(&eps), "thumbnail": 2})).unwrap_err().to_string();
|
||||
assert!(e.contains("no page 2"), "{e}");
|
||||
}
|
||||
|
||||
/// `document.pdfInfo`'s `thumbnail` is a page number from 1, for a PDF and for PostScript alike:
|
||||
/// any other value is an error that names the pages, and an absent or null one gives no thumbnail.
|
||||
#[test]
|
||||
fn pdf_info_refuses_a_thumbnail_that_is_not_a_page_number() {
|
||||
let ps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 50\n%%EndComments\n0 0 1 setrgbcolor 0 0 50 20 rectfill\n";
|
||||
let mut s = Session::new();
|
||||
for (bytes, pages) in [(&three_pages()[..], "(the PDF has 3)"), (ps, "(the file has 1)")] {
|
||||
for n in [json!(-1), json!(0), json!(1.5), json!("1"), json!(true)] {
|
||||
let e = s.execute("document.pdfInfo", &json!({"dataBase64": b64(bytes), "thumbnail": n})).unwrap_err().to_string();
|
||||
assert!(e.contains(&format!("thumbnail: no page {n} {pages}")), "{n}: {e}");
|
||||
}
|
||||
for none in [json!({"dataBase64": b64(bytes)}), json!({"dataBase64": b64(bytes), "thumbnail": null})] {
|
||||
assert!(s.execute("document.pdfInfo", &none).unwrap().get("thumbnail").is_none(), "{pages}");
|
||||
}
|
||||
let v = s.execute("document.pdfInfo", &json!({"dataBase64": b64(bytes), "thumbnail": 1, "thumbnailSize": 64})).unwrap();
|
||||
let png = vectorcraft_format::base64_decode(v["thumbnail"].as_str().unwrap()).unwrap();
|
||||
let img = image::load_from_memory(&png).unwrap();
|
||||
assert_eq!(img.width().max(img.height()), 64, "{pages}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A page showing only text, with an editor's private data when `private`.
|
||||
fn text_page(private: bool, also: &str) -> PdfPage {
|
||||
PdfPage {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! TIFF, BMP and Targa export through `document.export`: the options reach the files, impossible
|
||||
//! ones are refused, and the files open again.
|
||||
//! ones are refused, and the files open again; Targa files open and place.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -135,6 +135,152 @@ fn targa_depth_reaches_the_file() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The RGBA pixels of the image `document.open` or `file.place` stored for the first object of the
|
||||
/// active document's first layer.
|
||||
fn stored_pixels(s: &Session) -> image::RgbaImage {
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let im = match &doc.layers[0].children().unwrap()[0].kind {
|
||||
vectorcraft_doc::NodeKind::Image(im) => im.clone(),
|
||||
other => panic!("expected an image, got {other:?}"),
|
||||
};
|
||||
image::load_from_memory(&doc.images[&im.key].bytes).unwrap().to_rgba8()
|
||||
}
|
||||
|
||||
/// A Targa header: no image id or color map, image type `kind`, `w` × `h` pixels of `depth` bits,
|
||||
/// image descriptor `descriptor`.
|
||||
fn targa_header(kind: u8, w: u16, h: u16, depth: u8, descriptor: u8) -> Vec<u8> {
|
||||
let mut f = vec![0, 0, kind, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
f.extend(w.to_le_bytes());
|
||||
f.extend(h.to_le_bytes());
|
||||
f.extend([depth, descriptor]);
|
||||
f
|
||||
}
|
||||
|
||||
/// Targa files open and place at their pixel size with the transparency they store, by any of the
|
||||
/// four Targa extensions; copied in a file manager they paste as a bitmap; the dialogs list them.
|
||||
#[test]
|
||||
fn targa_files_open_and_place() {
|
||||
use crate::cmd::clipboard::{PASTE_ORDER, file_flavour};
|
||||
let mut s = session();
|
||||
let b64 = vectorcraft_format::base64_encode;
|
||||
// VectorCraft's own exports: the transparent corner stays transparent at 16 and 32 bits (24
|
||||
// bits are flattened on white) and the black rectangle stays black.
|
||||
for depth in [16, 24, 32] {
|
||||
let tga = export(&mut s, json!({"format": "tga", "depth": depth}));
|
||||
let r = s.execute("document.open", &json!({"name": "art.tga", "dataBase64": b64(&tga)})).unwrap();
|
||||
assert_eq!((&r["format"], &r["warnings"]), (&json!("tga"), &json!([])), "{depth} bits: {r}");
|
||||
assert_eq!(s.doc().unwrap().doc.artboards[0].rect.size(), vectorcraft_geom::Size::new(60.0, 40.0));
|
||||
let px = stored_pixels(&s);
|
||||
assert_eq!(px.dimensions(), (60, 40));
|
||||
let corner = if depth == 24 { [255; 4] } else { [0, 0, 0, 0] };
|
||||
assert_eq!(px.get_pixel(5, 5).0[3], corner[3], "{depth} bits: the corner's alpha");
|
||||
if depth == 24 {
|
||||
assert_eq!(px.get_pixel(5, 5).0, corner, "flattened on white");
|
||||
}
|
||||
let black = px.get_pixel(40, 20).0;
|
||||
assert!(black[3] == 255 && black[..3].iter().all(|c| *c < 80), "{depth} bits: {black:?}");
|
||||
// The next export starts from the session's document again.
|
||||
s = session();
|
||||
}
|
||||
let tga = export(&mut s, json!({"format": "tga", "depth": 32}));
|
||||
for name in ["art.vda", "ART.ICB", "art.vst"] {
|
||||
let r = s.execute("document.open", &json!({"name": name, "dataBase64": b64(&tga)})).unwrap();
|
||||
assert_eq!(r["format"], "tga", "{name}: {r}");
|
||||
}
|
||||
s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap();
|
||||
let r = s.execute("file.place", &json!({"name": "art.tga", "dataBase64": b64(&tga)})).unwrap();
|
||||
assert_eq!((&r["format"], r["ids"].as_array().map(Vec::len)), (&json!("tga"), Some(1)), "{r}");
|
||||
assert_eq!(stored_pixels(&s).get_pixel(5, 5).0[3], 0, "placed with its transparency");
|
||||
// Copied in a file manager, a Targa file pastes as a bitmap.
|
||||
assert_eq!(file_flavour("art.tga", &tga, &PASTE_ORDER), Some("image/x-tga"));
|
||||
let r = s.execute("clipboard.importImage", &json!({"mime": "image/x-tga", "dataBase64": b64(&tga)})).unwrap();
|
||||
assert_eq!((&r["count"], &r["pixelWidth"], &r["pixelHeight"]), (&json!(1), &json!(60), &json!(40)), "{r}");
|
||||
// The dialogs list the four extensions; the desktop packaging doesn't associate them.
|
||||
for e in ["tga", "vda", "icb", "vst"] {
|
||||
assert!(OPEN_EXTS.contains(&e) && PLACE_EXTS.contains(&e) && UNASSOCIATED_EXTS.contains(&e), "{e}");
|
||||
}
|
||||
let targa = ("Targa", &["tga", "vda", "icb", "vst"][..]);
|
||||
assert!(open_filters().any(|f| f == targa) && place_filters().any(|f| f == targa));
|
||||
}
|
||||
|
||||
/// Targa pixels in every layout: run-length encoded rows stored top down (as the `image` crate
|
||||
/// writes them), a 16-bit file whose one attribute bit per pixel is its alpha, with runs, stored
|
||||
/// right to left and top down, 32-bit files with no attribute bits (opaque), with eight, and with
|
||||
/// each Attributes Type in an extension area, and color-mapped files whose 16-bit entries carry the
|
||||
/// attribute bit.
|
||||
#[test]
|
||||
fn targa_layouts_and_alpha_read() {
|
||||
let src = image::RgbaImage::from_fn(3, 2, |x, y| image::Rgba([x as u8 * 100, y as u8 * 200, 50, if x == 1 { 128 } else { 255 }]));
|
||||
let mut rle = Vec::new();
|
||||
src.write_to(&mut std::io::Cursor::new(&mut rle), image::ImageFormat::Tga).unwrap();
|
||||
assert_eq!((rle[2], rle[17] & 0x20), (10, 0x20), "run-length encoded true color, stored top down");
|
||||
assert_eq!(raster_image(&rle).map(|r| image::load_from_memory(&r.blob.bytes).unwrap().to_rgba8()).unwrap(), src);
|
||||
|
||||
// 3 × 2, 5-5-5 color and an attribute bit, right to left (bit 4), top down (bit 5): a raw
|
||||
// packet of red, blue and a clear pixel for the first row, then a run of green.
|
||||
let mut f = targa_header(10, 3, 2, 16, 0x31);
|
||||
f.push(0x02);
|
||||
f.extend([0xfc00u16, 0x801f, 0x0000].iter().flat_map(|v| v.to_le_bytes()));
|
||||
f.push(0x82);
|
||||
f.extend(0x83e0u16.to_le_bytes());
|
||||
let img = image::load_from_memory(&raster_image(&f).unwrap().blob.bytes).unwrap().to_rgba8();
|
||||
let px = |x, y| img.get_pixel(x, y).0;
|
||||
assert_eq!((px(2, 0), px(1, 0), px(0, 0)[3]), ([255, 0, 0, 255], [0, 0, 255, 255], 0), "the first row, right to left");
|
||||
assert!((0..3).all(|x| px(x, 1) == [0, 255, 0, 255]), "the run");
|
||||
|
||||
// 2 × 1 at 32 bits with no attribute bits: the fourth byte isn't alpha, whatever it holds.
|
||||
for fourth in [[0, 0], [0, 255]] {
|
||||
let mut f = targa_header(2, 2, 1, 32, 0x20);
|
||||
f.extend([10, 20, 30, fourth[0], 40, 50, 60, fourth[1]]);
|
||||
let img = image::load_from_memory(&raster_image(&f).unwrap().blob.bytes).unwrap().to_rgba8();
|
||||
assert_eq!((img.get_pixel(0, 0).0, img.get_pixel(1, 0).0), ([30, 20, 10, 255], [60, 50, 40, 255]), "{fourth:?}");
|
||||
}
|
||||
// The same with eight attribute bits: the fourth byte is alpha, also when it is 0 everywhere.
|
||||
let mut f = targa_header(2, 2, 1, 32, 0x28);
|
||||
f.extend([10, 20, 30, 0, 40, 50, 60, 0]);
|
||||
let img = image::load_from_memory(&raster_image(&f).unwrap().blob.bytes).unwrap().to_rgba8();
|
||||
assert!(img.pixels().all(|p| p.0[3] == 0));
|
||||
// The extension area's Attributes Type: undefined data (1, 2) isn't alpha, premultiplied colors
|
||||
// (4) are divided by their alpha, and alpha (3) or 0, the field left unset, keeps it.
|
||||
for (kind, want) in [(0, [50, 50, 50, 128]), (1, [50, 50, 50, 255]), (2, [50, 50, 50, 255]), (3, [50, 50, 50, 128]), (4, [100, 100, 100, 128])] {
|
||||
let mut f = targa_header(2, 1, 1, 32, 0x28);
|
||||
f.extend([50, 50, 50, 128]);
|
||||
let mut area = vec![0; 495];
|
||||
area[..2].copy_from_slice(&495u16.to_le_bytes());
|
||||
area[494] = kind;
|
||||
let at = f.len() as u32;
|
||||
f.extend(area);
|
||||
f.extend(at.to_le_bytes());
|
||||
f.extend([0; 4]);
|
||||
f.extend(b"TRUEVISION-XFILE.\0");
|
||||
let img = image::load_from_memory(&raster_image(&f).unwrap().blob.bytes).unwrap().to_rgba8();
|
||||
assert_eq!(img.get_pixel(0, 0).0, want, "Attributes Type {kind}");
|
||||
}
|
||||
|
||||
// Color-mapped, one attribute bit, top down: 16-bit entries 5 (clear black) and 6 (red), as a
|
||||
// raw row of 8-bit indices and as runs.
|
||||
for (kind, data) in [(1, vec![6, 5, 6]), (9, vec![0x80, 6, 0x00, 5, 0x80, 6])] {
|
||||
let mut f = vec![0, 1, kind, 5, 0, 2, 0, 16, 0, 0, 0, 0, 3, 0, 1, 0, 8, 0x21];
|
||||
f.extend([0x0000u16, 0xfc00].iter().flat_map(|v| v.to_le_bytes()));
|
||||
f.extend(data);
|
||||
let img = image::load_from_memory(&raster_image(&f).unwrap().blob.bytes).unwrap().to_rgba8();
|
||||
let row: Vec<[u8; 4]> = (0..3).map(|x| img.get_pixel(x, 0).0).collect();
|
||||
assert_eq!(row, [[255, 0, 0, 255], [0, 0, 0, 0], [255, 0, 0, 255]], "image type {kind}");
|
||||
}
|
||||
|
||||
// A grayscale image the decoder could hold whose RGBA pixels pass the allowance is refused from
|
||||
// its header.
|
||||
let huge = targa_header(11, 32768, 32768, 8, 0);
|
||||
let e = raster_image(&huge).err().expect("refused");
|
||||
assert_eq!(e.to_string(), "an image of 32768 × 32768 pixels is too large to draw");
|
||||
|
||||
// Headers with values the specification doesn't define aren't Targa files.
|
||||
for bad in [targa_header(2, 0, 1, 24, 0), targa_header(1, 1, 1, 8, 0), targa_header(2, 1, 1, 12, 0), targa_header(2, 1, 1, 24, 0xc0)] {
|
||||
assert!(!super::tgaread::is_targa(&bad), "{bad:?}");
|
||||
}
|
||||
assert!(raster_image(b"nothing an image reader knows").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_formats_are_listed_and_write_one_file_per_artboard() {
|
||||
let mut s = Session::new();
|
||||
@@ -143,7 +289,7 @@ fn the_formats_are_listed_and_write_one_file_per_artboard() {
|
||||
let find = |id: &str| r["formats"].as_array().unwrap().iter().find(|f| f["id"] == id).unwrap().clone();
|
||||
let (tiff, bmp, tga) = (find("tiff"), find("bmp"), find("tga"));
|
||||
assert_eq!((tiff["write"].as_bool(), tiff["read"].as_bool()), (Some(true), Some(true)));
|
||||
assert_eq!((bmp["write"].as_bool(), tga["write"].as_bool(), tga["read"].as_bool()), (Some(true), Some(true), Some(false)));
|
||||
assert_eq!((bmp["write"].as_bool(), tga["write"].as_bool(), tga["read"].as_bool()), (Some(true), Some(true), Some(true)));
|
||||
for (f, keys) in [
|
||||
(&tiff, &["colorModel", "lzw", "byteOrder", "embedIcc", "ppi", "antiAlias"][..]),
|
||||
(&bmp, &["colorModel", "depth", "fileFormat", "rle", "flipRows", "reduction", "dither"]),
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
//! Reading Targa files (`.tga`, `.vda`, `.icb`, `.vst`) for File › Open and File › Place, from the
|
||||
//! Truevision TGA File Format Specification, version 2.0. The `image` crate decodes the pixels.
|
||||
//! A Targa file starts with no signature, so [`is_targa`] checks its header's fields instead, and
|
||||
//! [`fix_alpha`] gives the decoded image the transparency the file stores. The file is untrusted:
|
||||
//! every read is checked.
|
||||
|
||||
/// Does `bytes` start with a Targa header? The fields must hold values the specification defines:
|
||||
/// a color map type of 0 or 1; an image type of 1, 2, 3, 9, 10 or 11 (color-mapped, true-color or
|
||||
/// black-and-white, raw or run-length encoded), with a color map for the color-mapped types; a
|
||||
/// color map entry size of 15, 16, 24 or 32 bits when there is a map; a width and a height; a pixel
|
||||
/// depth of 8, 15, 16, 24 or 32 bits; and bits 6 and 7 of the image descriptor clear.
|
||||
pub(super) fn is_targa(bytes: &[u8]) -> bool {
|
||||
let [_, map_type, image_type, _, _, _, _, entry_bits, _, _, _, _, w0, w1, h0, h1, depth, descriptor, ..] = *bytes else {
|
||||
return false;
|
||||
};
|
||||
matches!(map_type, 0 | 1)
|
||||
&& matches!(image_type, 1 | 2 | 3 | 9 | 10 | 11)
|
||||
&& (map_type == 1 || !matches!(image_type, 1 | 9))
|
||||
&& (map_type == 0 || matches!(entry_bits, 15 | 16 | 24 | 32))
|
||||
&& u16::from_le_bytes([w0, w1]) > 0
|
||||
&& u16::from_le_bytes([h0, h1]) > 0
|
||||
&& matches!(depth, 8 | 15 | 16 | 24 | 32)
|
||||
&& descriptor & 0xc0 == 0
|
||||
}
|
||||
|
||||
/// Give `img`, decoded from Targa file `bytes`, the transparency the file stores. Bits 0 to 3 of
|
||||
/// the image descriptor give the number of attribute bits per pixel, and the extension area's
|
||||
/// Attributes Type, when the file has one, says what they hold:
|
||||
/// - with no attribute bits, or attribute data the Attributes Type calls undefined (1 or 2), the
|
||||
/// image is opaque, whatever a 32-bit pixel's fourth byte holds (the decoder reads it as alpha);
|
||||
/// - with one bit, a 16-bit true-color pixel takes that bit as its alpha (clear: transparent), and
|
||||
/// a color-mapped pixel takes the bit of the 16-bit color map entry it indexes; the decoder drops
|
||||
/// the bit;
|
||||
/// - with more, the decoder's alpha stays, and colors the Attributes Type calls premultiplied (4)
|
||||
/// are divided by it.
|
||||
///
|
||||
/// An Attributes Type of 0 ("no alpha") with attribute bits in the descriptor counts as alpha.
|
||||
pub(super) fn fix_alpha(bytes: &[u8], img: &mut image::RgbaImage) {
|
||||
let Some(bits) = bytes.get(17).map(|d| d & 0x0f) else { return };
|
||||
let kind = attributes_type(bytes);
|
||||
if bits == 0 || matches!(kind, Some(1 | 2)) {
|
||||
img.pixels_mut().for_each(|p| p.0[3] = 255);
|
||||
return;
|
||||
}
|
||||
if bits == 1 {
|
||||
one_bit_alpha(bytes, img);
|
||||
}
|
||||
if kind == Some(4) {
|
||||
for p in img.pixels_mut() {
|
||||
let a = u16::from(p.0[3]);
|
||||
for c in &mut p.0[..3] {
|
||||
// A fully transparent pixel keeps its colors.
|
||||
if let Some(v) = (u16::from(*c) * 255 + a / 2).checked_div(a) {
|
||||
*c = v.min(255) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Attributes Type of a Targa 2.0 file's extension area: `None` without the footer's
|
||||
/// `TRUEVISION-XFILE.` signature, an extension area, or one of the 495 bytes the specification
|
||||
/// gives it.
|
||||
fn attributes_type(bytes: &[u8]) -> Option<u8> {
|
||||
let footer = bytes.len().checked_sub(26)?;
|
||||
if bytes.get(footer + 8..)? != b"TRUEVISION-XFILE.\0" {
|
||||
return None;
|
||||
}
|
||||
let at = usize::try_from(u32::from_le_bytes(bytes.get(footer..footer + 4)?.try_into().ok()?)).ok()?;
|
||||
// The area follows the header and ends before the footer; its first field is its size.
|
||||
if at < 18 || at.checked_add(495)? > footer || u16::from_le_bytes([*bytes.get(at)?, *bytes.get(at + 1)?]) != 495 {
|
||||
return None;
|
||||
}
|
||||
bytes.get(at + 494).copied()
|
||||
}
|
||||
|
||||
/// The attribute bit of each pixel of a Targa image with one attribute bit per pixel, as `img`'s
|
||||
/// alpha: the top bit of a 16-bit true-color pixel, or of the 16-bit color map entry an 8- or
|
||||
/// 16-bit index names, raw or run-length encoded. Rows and columns are stored in the order the
|
||||
/// image descriptor's bits 5 (top to bottom) and 4 (right to left) give. Other images, and pixels
|
||||
/// the data doesn't reach, keep the alpha they have.
|
||||
fn one_bit_alpha(bytes: &[u8], img: &mut image::RgbaImage) {
|
||||
let [id_len, map_type, image_type, o0, o1, m0, m1, entry_bits, _, _, _, _, w0, w1, h0, h1, depth, descriptor, ..] = *bytes else {
|
||||
return;
|
||||
};
|
||||
let (w, h) = (u32::from(u16::from_le_bytes([w0, w1])), u32::from(u16::from_le_bytes([h0, h1])));
|
||||
let mapped = matches!(image_type, 1 | 9);
|
||||
// The number of bytes each pixel takes.
|
||||
let size = match image_type {
|
||||
2 | 10 if depth == 16 => 2,
|
||||
1 | 9 if map_type == 1 && entry_bits == 16 && matches!(depth, 8 | 16) => usize::from(depth / 8),
|
||||
_ => return,
|
||||
};
|
||||
if img.dimensions() != (w, h) {
|
||||
return;
|
||||
}
|
||||
let (first, entries) = (u16::from_le_bytes([o0, o1]), u16::from_le_bytes([m0, m1]));
|
||||
let map_at = 18 + usize::from(id_len);
|
||||
let map_len = if map_type == 1 { usize::from(entries) * usize::from(entry_bits).div_ceil(8) } else { 0 };
|
||||
// The attribute bit is the top bit of the little-endian pixel or color map entry.
|
||||
let attribute = |at: usize| -> Option<bool> {
|
||||
let px = bytes.get(at..at.checked_add(size)?)?;
|
||||
let high = if mapped {
|
||||
let index = match *px {
|
||||
[i] => u16::from(i),
|
||||
[lo, hi] => u16::from_le_bytes([lo, hi]),
|
||||
_ => return None,
|
||||
};
|
||||
let entry = index.checked_sub(first).filter(|e| *e < entries)?;
|
||||
*bytes.get(map_at + usize::from(entry) * 2 + 1)?
|
||||
} else {
|
||||
*px.get(1)?
|
||||
};
|
||||
Some(high & 0x80 != 0)
|
||||
};
|
||||
let (top_down, right_to_left) = (descriptor & 0x20 != 0, descriptor & 0x10 != 0);
|
||||
let total = u64::from(w) * u64::from(h);
|
||||
let mut set = |i: u64, opaque: bool| {
|
||||
let (row, col) = ((i / u64::from(w)) as u32, (i % u64::from(w)) as u32);
|
||||
let y = if top_down { row } else { h - 1 - row };
|
||||
let x = if right_to_left { w - 1 - col } else { col };
|
||||
img.get_pixel_mut(x, y).0[3] = if opaque { 255 } else { 0 };
|
||||
};
|
||||
let mut at = map_at + map_len;
|
||||
let mut i = 0;
|
||||
while i < total {
|
||||
if image_type < 9 {
|
||||
let Some(opaque) = attribute(at) else { return };
|
||||
set(i, opaque);
|
||||
(i, at) = (i + 1, at + size);
|
||||
continue;
|
||||
}
|
||||
let Some(&packet) = bytes.get(at) else { return };
|
||||
let count = u64::from(packet & 0x7f) + 1;
|
||||
at += 1;
|
||||
if packet & 0x80 != 0 {
|
||||
// A run packet repeats one pixel.
|
||||
let Some(opaque) = attribute(at) else { return };
|
||||
at += size;
|
||||
for _ in 0..count.min(total - i) {
|
||||
set(i, opaque);
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
for _ in 0..count.min(total - i) {
|
||||
let Some(opaque) = attribute(at) else { return };
|
||||
set(i, opaque);
|
||||
(i, at) = (i + 1, at + size);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -341,6 +341,7 @@ pub(crate) fn transform_each_again(s: &mut Session, p: &Value) -> Result<Value>
|
||||
/// repeats; a dialog's preview records it as it commits (`Session::commit_interaction`).
|
||||
fn transform_each_as(s: &mut Session, p: &Value, label: &str) -> Result<Value> {
|
||||
let roots = selected_roots(s)?;
|
||||
check_numbers(p, &["scaleH", "scaleV", "moveH", "moveV", "rotate"], "object.transformEach")?;
|
||||
// Bounded so coordinates stay finite (and the document stays serializable).
|
||||
let sh = f64_or(p, "scaleH", 100.0).clamp(-100_000.0, 100_000.0);
|
||||
let sv = f64_or(p, "scaleV", 100.0).clamp(-100_000.0, 100_000.0);
|
||||
@@ -480,6 +481,7 @@ impl Rasterized {
|
||||
/// `cmd`, each defaulting to `doc`'s raster effects settings: over the bounds of what the art draws
|
||||
/// ([`vectorcraft_render::drawn_bounds`]) and the room around them, in whole pixels.
|
||||
pub(crate) fn rasterize_art(doc: &vectorcraft_doc::Document, nodes: Vec<Node>, p: &Value, cmd: &str) -> Result<Rasterized> {
|
||||
check_numbers(p, &["ppi"], cmd)?;
|
||||
let ppi = f64_or(p, "ppi", doc.raster_effects_ppi);
|
||||
if !(1.0..=2400.0).contains(&ppi) {
|
||||
return Err(bad(cmd, "ppi must be between 1 and 2400"));
|
||||
@@ -600,6 +602,7 @@ fn object_mosaic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let colors = vectorcraft_trace::mosaic(&raster, cols, rows);
|
||||
// Tiles in image space, placed by the image transform (scaled to a new size if asked).
|
||||
let b = node.geometric_bounds().ok_or_else(|| bad(C, "image has no bounds"))?;
|
||||
check_numbers(p, &["width", "height", "spacingX", "spacingY"], C)?;
|
||||
let (nw, nh) = (f64_or(p, "width", b.width()).max(0.1), f64_or(p, "height", b.height()).max(0.1));
|
||||
let resize = Affine::translate(b.origin().to_vec2())
|
||||
* Affine::scale_non_uniform(nw / b.width().max(1e-9), nh / b.height().max(1e-9))
|
||||
@@ -671,8 +674,8 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return trim_image(s, id, (**im).clone());
|
||||
}
|
||||
let ib = node.geometric_bounds().ok_or_else(|| bad(C, "image has no bounds"))?;
|
||||
let rect = match p.get("rect").and_then(Value::as_array) {
|
||||
Some(a) if a.len() == 4 => {
|
||||
let rect = match p.get("rect").filter(|v| !v.is_null()) {
|
||||
Some(Value::Array(a)) if a.len() == 4 => {
|
||||
let v: Vec<f64> = a.iter().filter_map(Value::as_f64).collect();
|
||||
if v.len() != 4 {
|
||||
return Err(bad(C, "rect must be [x, y, width, height]"));
|
||||
@@ -832,6 +835,7 @@ impl Session {
|
||||
|
||||
fn trim_marks(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.createTrimMarks";
|
||||
check_numbers(p, &["offset", "length", "bleed", "weight"], C)?;
|
||||
let style = match str_param(p, "style") {
|
||||
Some(v) => MarkStyle::parse(v).ok_or_else(|| bad(C, "`style` must be roman or japanese"))?,
|
||||
None => s.crop_mark_style(),
|
||||
@@ -1001,6 +1005,7 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
L::Row => (1, n_ab),
|
||||
L::Column => (n_ab, 1),
|
||||
};
|
||||
check_numbers(p, &["spacing"], CMD)?;
|
||||
let spacing = f64_or(p, "spacing", 20.0).clamp(-1.0e5, 1.0e5);
|
||||
let move_art = bool_or(p, "moveArtwork", true);
|
||||
let locked_and_hidden = s.prefs.move_locked_with_artboard;
|
||||
|
||||
@@ -47,6 +47,7 @@ pub(crate) mod menucmds;
|
||||
pub(crate) mod newart;
|
||||
pub mod newdoc;
|
||||
mod object;
|
||||
pub(crate) mod objects_on_path;
|
||||
mod opacitymask;
|
||||
mod overprint;
|
||||
mod package;
|
||||
@@ -257,6 +258,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(brushsym::specs());
|
||||
v.extend(patterncmds::specs());
|
||||
v.extend(intertwine::specs());
|
||||
v.extend(objects_on_path::specs());
|
||||
v.extend(prefscmds::specs());
|
||||
v.extend(distortcmds::specs());
|
||||
v.extend(paint::proxy_specs());
|
||||
@@ -500,7 +502,7 @@ pub(crate) fn date_param(p: &Value, key: &str, cmd: &str) -> Result<Option<Optio
|
||||
/// no clock). It joins the running command's journal entry as `key`, so a replay of the journal
|
||||
/// gives the same date whenever it runs (an action leaves it out: [`crate::Session::journal_for_action`]).
|
||||
pub(crate) fn clock_date(s: &mut Session, key: &str, given: Option<Option<i64>>) -> Option<i64> {
|
||||
let t = given.unwrap_or_else(vectorcraft_doc::metadata::now_unix);
|
||||
let t = given.unwrap_or_else(vectorcraft_doc::metadata::output_time);
|
||||
s.note_journal(key, serde_json::json!(t));
|
||||
t
|
||||
}
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
//! Object › Objects on Path (#2545): objects attached to a path and spread along it.
|
||||
//!
|
||||
//! `object.objectsOnPath.attach` puts the selected objects and a path in an Objects on Path group:
|
||||
//! a group with a [`ObjectsOnPath`] record ([`vectorcraft_doc::objects_on_path`]), the path at its
|
||||
//! bottom. Called without a path (Object › Objects on Path › Attach) it chooses the Objects on Path
|
||||
//! tool, whose click on a path attaches the selected objects to that path. The objects go along the
|
||||
//! path column by column from left to right, top to bottom in each column, spread evenly from its
|
||||
//! first anchor and turned to follow it. `object.objectsOnPath.options` sets the group's Pivot and
|
||||
//! Rotate, and `object.objectsOnPath.expand` makes it a plain group.
|
||||
//!
|
||||
//! After every edit, [`refresh`] puts the objects of each Objects on Path group that changed back
|
||||
//! on its path: editing the path moves them, an object moved on its own goes back to its place,
|
||||
//! objects added to or taken out of the group join the end of the path or leave the record, and
|
||||
//! art that replaces an object at its index (Break Link to Symbol) keeps its place.
|
||||
//!
|
||||
//! Behavior: <https://helpx.adobe.com/illustrator/using/objects-on-path.html> (the tool and
|
||||
//! Object › Objects on Path › Attach; the order on the path, the attachment point, the distribution,
|
||||
//! the objects squared to the path; Pivot and Rotate; path edits realign the objects; Expand).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::objects_on_path::{CENTER, ObjectsOnPath, attachable_path, is_objects_on_path, place};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use super::edit::{roots_of, selected_roots};
|
||||
use super::*;
|
||||
|
||||
/// The id of the Objects on Path tool.
|
||||
pub const TOOL: &str = "objectsOnPath";
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"object.objectsOnPath.attach",
|
||||
"Attach",
|
||||
["Object", "Objects on Path"],
|
||||
None,
|
||||
"{path?: id of the path to attach to (without it: choose the Objects on Path tool, whose click on a path attaches the selection to it), ids?: the objects to attach (default: the selection; the path is left out of it), pivot?: 0-8 (the reference point of each object that attaches to the path, row by row from the top left; default 4, the center), rotate?: degrees counter-clockwise from upright on the path (default 0)} put the objects and the path in an Objects on Path group: the path at the bottom, the objects spread evenly along it from its first anchor (the last one at the end of an open path) in their order on the canvas, column by column from left to right and top to bottom in each column, each turned to follow the path; editing the path moves them → {id}",
|
||||
has_selection,
|
||||
attach
|
||||
),
|
||||
cmd!(
|
||||
"object.objectsOnPath.options",
|
||||
"Objects on Path Options",
|
||||
[],
|
||||
None,
|
||||
"{ids?: Objects on Path groups or objects in them (default: the selection), pivot?: 0-8 (the reference point of each object that attaches to the path, row by row from the top left; 4: the center), rotate?: degrees counter-clockwise from upright on the path} set how the objects of the groups attach → {ids}",
|
||||
has_objects_on_path,
|
||||
options
|
||||
),
|
||||
cmd!(
|
||||
"object.objectsOnPath.expand",
|
||||
"Expand",
|
||||
["Object", "Objects on Path"],
|
||||
None,
|
||||
"{ids?: Objects on Path groups or objects in them (default: the selection)} make the groups plain groups of the path and the objects where they are → {ids}",
|
||||
has_objects_on_path,
|
||||
expand
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
// ---------- predicates ----------
|
||||
|
||||
/// The Objects on Path groups of `ids`: the ones among them, and the innermost one around each
|
||||
/// object in one.
|
||||
fn groups_of(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
|
||||
let mut out = vec![];
|
||||
for id in ids {
|
||||
let group = d.ancestry(*id).unwrap_or_default().into_iter().rev().find(|a| d.node(*a).is_some_and(is_objects_on_path));
|
||||
if let Some(g) = group
|
||||
&& !out.contains(&g)
|
||||
{
|
||||
out.push(g);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The Objects on Path groups of the selection ([`groups_of`]).
|
||||
fn selected_groups(s: &Session) -> Vec<NodeId> {
|
||||
s.active().map(|st| groups_of(&st.doc, &st.selection.objects)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The groups a command acts on: those of `ids` when given, otherwise those of the selection.
|
||||
fn target_groups(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
|
||||
if p.get("ids").is_none_or(Value::is_null) {
|
||||
return Ok(selected_groups(s));
|
||||
}
|
||||
let ids = checked_ids_param(s, p, "ids", cmd)?;
|
||||
let groups = groups_of(&s.doc()?.doc, &ids);
|
||||
if groups.is_empty() {
|
||||
return Err(bad(cmd, "`ids` must name Objects on Path groups or objects in them"));
|
||||
}
|
||||
Ok(groups)
|
||||
}
|
||||
|
||||
fn has_objects_on_path(s: &Session) -> std::result::Result<(), String> {
|
||||
has_selection(s)?;
|
||||
if selected_groups(s).is_empty() { Err("select an Objects on Path group".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// The Objects on Path group around `id` (not `id` itself), if any.
|
||||
fn group_around(d: &Document, id: NodeId) -> Option<NodeId> {
|
||||
d.ancestry(id).unwrap_or_default().into_iter().filter(|a| *a != id).find(|a| d.node(*a).is_some_and(is_objects_on_path))
|
||||
}
|
||||
|
||||
// ---------- Attach ----------
|
||||
|
||||
/// `pivot` and `rotate` from `p`, each checked.
|
||||
fn pivot_rotate(p: &Value, cmd: &str, pivot: u8, rotate: f64) -> Result<(u8, f64)> {
|
||||
check_numbers(p, &["pivot", "rotate"], cmd)?;
|
||||
let pivot = match p.get("pivot").filter(|v| !v.is_null()) {
|
||||
None => pivot,
|
||||
Some(v) => v.as_u64().filter(|n| *n <= 8).map(|n| n as u8).ok_or_else(|| bad(cmd, "`pivot` must be a whole number from 0 to 8"))?,
|
||||
};
|
||||
let rotate = match p.get("rotate").and_then(Value::as_f64) {
|
||||
None => rotate,
|
||||
Some(r) if r.is_finite() => r,
|
||||
Some(_) => return Err(bad(cmd, "`rotate` must be a finite number")),
|
||||
};
|
||||
Ok((pivot, rotate))
|
||||
}
|
||||
|
||||
/// `ids` in their order along the path: column by column from left to right, top to bottom in each
|
||||
/// column. A column is a run of objects, taken by their left edges, that each start left of the
|
||||
/// right edge of the column so far. Objects without bounds go last. Symbol instances are measured
|
||||
/// by their art.
|
||||
fn path_order(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
|
||||
let symbols = d.symbol_boxes();
|
||||
let mut placed: Vec<(NodeId, Rect)> = vec![];
|
||||
let mut rest = vec![];
|
||||
for id in ids {
|
||||
match d.node(*id).and_then(|n| n.geometric_bounds_with(&symbols)) {
|
||||
Some(b) => placed.push((*id, b)),
|
||||
None => rest.push(*id),
|
||||
}
|
||||
}
|
||||
placed.sort_by(|a, b| a.1.x0.total_cmp(&b.1.x0));
|
||||
let mut columns: Vec<(f64, Vec<(NodeId, Rect)>)> = vec![];
|
||||
for (id, b) in placed {
|
||||
match columns.last_mut() {
|
||||
Some((right, column)) if b.x0 < *right => {
|
||||
*right = right.max(b.x1);
|
||||
column.push((id, b));
|
||||
}
|
||||
_ => columns.push((b.x1, vec![(id, b)])),
|
||||
}
|
||||
}
|
||||
let mut out = vec![];
|
||||
for (_, mut column) in columns {
|
||||
column.sort_by(|a, b| a.1.y0.total_cmp(&b.1.y0));
|
||||
out.extend(column.into_iter().map(|(id, _)| id));
|
||||
}
|
||||
out.extend(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// Why the path `path` can't take objects, if it can't.
|
||||
fn path_refused(d: &Document, path: NodeId) -> Option<&'static str> {
|
||||
let n = d.node(path)?;
|
||||
if attachable_path(n).is_none() {
|
||||
return Some("pick a path to attach the objects to (not a guide, a compound path or type)");
|
||||
}
|
||||
if group_around(d, path).is_some() {
|
||||
return Some("the path is already in an Objects on Path group");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Why `n` can't be attached to a path, if it can't (the help page names graphs).
|
||||
fn object_refused(d: &Document, n: &Node) -> Option<&'static str> {
|
||||
if n.is_layer() {
|
||||
return Some("layers can't be attached to a path: select the objects in them");
|
||||
}
|
||||
if group_around(d, n.id).is_some() {
|
||||
return Some("an object already on a path can't be attached again");
|
||||
}
|
||||
let mut graph = false;
|
||||
n.walk(&mut |c| graph |= c.extras().graph.is_some());
|
||||
graph.then_some("graphs can't be attached to a path")
|
||||
}
|
||||
|
||||
fn attach(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.objectsOnPath.attach";
|
||||
let Some(path) = p.get("path").filter(|v| !v.is_null()) else {
|
||||
let view = s.last_view;
|
||||
s.select_tool(TOOL, view)?;
|
||||
return Ok(json!({ "tool": TOOL }));
|
||||
};
|
||||
let path = checked_id(s, path, C)?;
|
||||
let (pivot, rotate) = pivot_rotate(p, C, CENTER, 0.0)?;
|
||||
let roots = match p.get("ids") {
|
||||
Some(_) => roots_of(&s.doc()?.doc, checked_ids_param(s, p, "ids", C)?),
|
||||
None => selected_roots(s)?,
|
||||
};
|
||||
let ids: Vec<NodeId> = roots.into_iter().filter(|id| *id != path).collect();
|
||||
let order = {
|
||||
let d = &s.doc()?.doc;
|
||||
if let Some(why) = path_refused(d, path) {
|
||||
return Err(bad(C, why));
|
||||
}
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "select the objects to attach to the path"));
|
||||
}
|
||||
for id in &ids {
|
||||
let n = d.node(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
if d.ancestry(path).unwrap_or_default().contains(id) {
|
||||
return Err(bad(C, "the path can't be inside one of the objects to attach"));
|
||||
}
|
||||
if let Some(why) = object_refused(d, n) {
|
||||
return Err(bad(C, why));
|
||||
}
|
||||
}
|
||||
path_order(d, &ids)
|
||||
};
|
||||
let id = s.edit("Attach Objects on Path", |d, sel| {
|
||||
let members: Vec<Node> = order.iter().filter_map(|id| d.node(*id).cloned()).collect();
|
||||
let rec = ObjectsOnPath::new(path, &members.iter().collect::<Vec<_>>(), pivot, rotate);
|
||||
let mut wrapped = ids.clone();
|
||||
wrapped.push(path);
|
||||
let wrapped = d.paint_order(wrapped);
|
||||
let id = d.alloc_id();
|
||||
d.wrap(&wrapped, |mut children| {
|
||||
// The path at the bottom: the objects are drawn over it.
|
||||
if let Some(i) = children.iter().position(|c| c.id == path) {
|
||||
let p = children.remove(i);
|
||||
children.insert(0, p);
|
||||
}
|
||||
let mut g = Node::new(id, NodeKind::Group { children, clip: false });
|
||||
g.extras_mut().objects_on_path = Some(Box::new(rec));
|
||||
g
|
||||
})?;
|
||||
place_in(d, id);
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
// ---------- Options ----------
|
||||
|
||||
fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.objectsOnPath.options";
|
||||
let groups = target_groups(s, p, C)?;
|
||||
pivot_rotate(p, C, CENTER, 0.0)?;
|
||||
s.edit("Objects on Path Options", |d, _| {
|
||||
for g in &groups {
|
||||
let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?;
|
||||
let Some(rec) = n.extras().objects_on_path.as_deref().cloned() else { continue };
|
||||
let (pivot, rotate) = pivot_rotate(p, C, rec.pivot, rec.rotate)?;
|
||||
n.extras_mut().objects_on_path = Some(Box::new(ObjectsOnPath { pivot, rotate, ..rec }));
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "ids": groups.iter().map(|g| g.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
// ---------- Expand ----------
|
||||
|
||||
fn expand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let groups = target_groups(s, p, "object.objectsOnPath.expand")?;
|
||||
s.edit("Expand Objects on Path", |d, sel| {
|
||||
for g in &groups {
|
||||
d.node_mut(*g).ok_or(EngineError::NoNode(*g))?.extras_mut().objects_on_path = None;
|
||||
}
|
||||
sel.set(groups.iter().copied());
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "ids": groups.iter().map(|g| g.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
// ---------- refresh ----------
|
||||
|
||||
/// After an edit: put the objects of every Objects on Path group that changed back on its path
|
||||
/// ([`place`]), inner groups first. Only the parts of the tree that the edit changed are searched.
|
||||
pub(crate) fn refresh(before: &Document, doc: &mut Document) {
|
||||
let mut groups = vec![];
|
||||
changed_groups(&before.layers, &doc.layers, &mut groups);
|
||||
for id in groups.iter().rev() {
|
||||
follow_replacements(before, doc, *id);
|
||||
place_in(doc, *id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Members of the group `id` that the edit replaced with new art at the same index keep their
|
||||
/// place on the path ([`ObjectsOnPath::follow_replacements`]).
|
||||
fn follow_replacements(before: &Document, doc: &mut Document, id: NodeId) {
|
||||
let (Some(old), Some(new)) = (before.node(id), doc.node(id)) else { return };
|
||||
let (Some(old), Some(new)) = (old.children(), new.children()) else { return };
|
||||
let Some(mut rec) = doc.node(id).and_then(|n| n.extras().objects_on_path.as_deref().cloned()) else { return };
|
||||
let unchanged = rec.clone();
|
||||
rec.follow_replacements(old, new);
|
||||
if rec != unchanged
|
||||
&& let Some(n) = doc.node_mut(id)
|
||||
{
|
||||
n.extras_mut().objects_on_path = Some(Box::new(rec));
|
||||
}
|
||||
}
|
||||
|
||||
/// [`place`] the Objects on Path group `id` of `doc`, measuring symbol instances by their art.
|
||||
fn place_in(doc: &mut Document, id: NodeId) {
|
||||
let Some(n) = doc.node(id) else { return };
|
||||
let mut g = n.clone();
|
||||
if place(&mut g, &doc.symbol_boxes())
|
||||
&& let Some(slot) = doc.node_mut(id)
|
||||
{
|
||||
*slot = g;
|
||||
}
|
||||
}
|
||||
|
||||
/// The Objects on Path groups among `after` and inside them that are not the same nodes as before
|
||||
/// (`before`: the same list before the edit), outer ones first.
|
||||
fn changed_groups(before: &[Arc<Node>], after: &[Arc<Node>], out: &mut Vec<NodeId>) {
|
||||
let mut by_id: Option<HashMap<NodeId, &Arc<Node>>> = None;
|
||||
for (i, a) in after.iter().enumerate() {
|
||||
let b = match before.get(i) {
|
||||
Some(b) if b.id == a.id => Some(b),
|
||||
_ => by_id.get_or_insert_with(|| before.iter().map(|b| (b.id, b)).collect()).get(&a.id).copied(),
|
||||
};
|
||||
if b.is_some_and(|b| Arc::ptr_eq(b, a)) {
|
||||
continue;
|
||||
}
|
||||
if is_objects_on_path(a) {
|
||||
out.push(a.id);
|
||||
}
|
||||
if let Some(children) = a.children() {
|
||||
changed_groups(b.and_then(|b| b.children()).map_or(&[], Vec::as_slice), children, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -400,7 +400,7 @@ fn check_no_overwrite(root: &Path, entries: &[Entry], sources: &[&str]) -> Resul
|
||||
|
||||
/// The MS-DOS time and date a zip entry records: now (UTC), else 1980-01-01.
|
||||
fn dos_time() -> (u16, u16) {
|
||||
let Some(t) = vectorcraft_doc::metadata::now_unix() else { return (0, 0x21) };
|
||||
let Some(t) = vectorcraft_doc::metadata::output_time() else { return (0, 0x21) };
|
||||
let [y, mo, d, h, mi, s] = vectorcraft_doc::metadata::civil(t);
|
||||
let date = (((y - 1980).clamp(0, 127) << 9) | (mo << 5) | d) as u16;
|
||||
(((h << 11) | (mi << 5) | (s / 2)) as u16, date)
|
||||
|
||||
@@ -7,6 +7,7 @@ use vectorcraft_doc::{NodeId, NodeKind, Scaling};
|
||||
use vectorcraft_geom::{Anchor, AnchorKind, PathData, Point, SubPath, Vec2};
|
||||
|
||||
use super::create::anchor_from_json;
|
||||
use super::draw2::usize_opt;
|
||||
use super::pathops::Beside;
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
@@ -37,10 +38,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Move Direction Handle",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath, anchor, which: \"in\"|\"out\", x, y, independent?: bool}",
|
||||
"{id, subpath?, anchor, which: \"in\"|\"out\", x, y, independent?: bool}",
|
||||
has_doc,
|
||||
set_handle
|
||||
),
|
||||
cmd!(
|
||||
"path.moveAnchor",
|
||||
"Move Anchor Point",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath?, anchor, x, y} move one anchor to (x, y), its handles with it",
|
||||
has_doc,
|
||||
move_anchor
|
||||
),
|
||||
cmd!(
|
||||
"path.setAnchors",
|
||||
"Set Anchors",
|
||||
@@ -100,11 +110,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reshape",
|
||||
[],
|
||||
None,
|
||||
"{id, x, y, dx, dy, tol=3, radius?} Reshape tool: grab the path at (x, y) (adding an anchor there unless one is within tol) and drag it by (dx, dy); nearby anchors follow with a smooth falloff over radius (default: half the subpath's size)",
|
||||
"{id, x, y, dx, dy, tol=3, radius?} Reshape tool: grab the path at (x, y) (adding an anchor there unless one is within tol) and drag it by (dx, dy); nearby anchors follow with a smooth falloff over radius, measured along the path (default: half the subpath's size)",
|
||||
has_doc,
|
||||
reshape
|
||||
),
|
||||
cmd!("path.insertAnchor", "Add Anchor Point", [], None, "{id, subpath, segment, t: 0..1}", has_doc, insert_anchor),
|
||||
cmd!("path.insertAnchor", "Add Anchor Point", [], None, "{id, subpath?, segment, t: 0..1}", has_doc, insert_anchor),
|
||||
cmd!(
|
||||
"path.cutAtAnchors",
|
||||
"Cut Path at Selected Anchor Points",
|
||||
@@ -215,7 +225,7 @@ pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_ge
|
||||
|
||||
fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("path.setHandle", "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let si = usize_opt(p, "subpath", "path.setHandle")?.unwrap_or(0);
|
||||
let ai = p.get("anchor").and_then(Value::as_u64).ok_or_else(|| bad("path.setHandle", "missing anchor"))? as usize;
|
||||
let out = str_param(p, "which") != Some("in");
|
||||
let pos = Point::new(f64_req(p, "x", "path.setHandle")?, f64_req(p, "y", "path.setHandle")?);
|
||||
@@ -229,6 +239,22 @@ fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// `path.moveAnchor`: one anchor to (x, y), its handles keeping their offsets (the Anchor Point
|
||||
/// tool's Shift-drag).
|
||||
fn move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("path.moveAnchor", "missing id"))?;
|
||||
let si = usize_opt(p, "subpath", "path.moveAnchor")?.unwrap_or(0);
|
||||
let ai = p.get("anchor").and_then(Value::as_u64).ok_or_else(|| bad("path.moveAnchor", "missing anchor"))? as usize;
|
||||
let pos = Point::new(f64_req(p, "x", "path.moveAnchor")?, f64_req(p, "y", "path.moveAnchor")?);
|
||||
s.edit("Move", |d, _| {
|
||||
let path = path_mut(d, id)?;
|
||||
let a = path.anchor_mut(si, ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?;
|
||||
*a = a.transform(vectorcraft_geom::Affine::translate(pos - a.p));
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
fn set_anchors(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("path.setAnchors", "missing id"))?;
|
||||
let subs = p.get("subpaths").and_then(Value::as_array).ok_or_else(|| bad("path.setAnchors", "missing subpaths"))?;
|
||||
@@ -601,7 +627,6 @@ fn without_anchors(sp: SubPath, removed: &BTreeSet<usize>) -> Vec<SubPath> {
|
||||
if gone.is_empty() {
|
||||
return vec![sp];
|
||||
}
|
||||
let keep = |anchors: Vec<Anchor>| (anchors.len() >= 2).then(|| SubPath::new(anchors, false));
|
||||
let mut runs: Vec<Vec<Anchor>> = vec![];
|
||||
let mut run = vec![];
|
||||
for (i, anchor) in sp.anchors.into_iter().enumerate() {
|
||||
@@ -629,7 +654,7 @@ fn without_anchors(sp: SubPath, removed: &BTreeSet<usize>) -> Vec<SubPath> {
|
||||
} else if !run.is_empty() {
|
||||
runs.push(run);
|
||||
}
|
||||
runs.into_iter().filter_map(keep).collect()
|
||||
runs.into_iter().filter_map(open_run).collect()
|
||||
}
|
||||
|
||||
/// Remove connections, splitting open/closed contours into their surviving open runs.
|
||||
@@ -680,18 +705,27 @@ fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some((i, dist)) if dist <= tol => i,
|
||||
_ => sp.insert_anchor(seg, t),
|
||||
};
|
||||
let origin = sp.anchors[grabbed].p;
|
||||
// Each anchor's distance along the path from the grabbed one, the shorter way round a
|
||||
// closed path: the lengths are measured once, as a path can have many anchors.
|
||||
let pos = sp.anchor_arc_positions();
|
||||
let total = pos.last().copied().unwrap_or(0.0);
|
||||
// The default reach is the subpath's size, as before, now measured along it.
|
||||
let r = radius.unwrap_or_else(|| {
|
||||
let mut bp = vectorcraft_geom::BezPath::new();
|
||||
sp.to_bezpath_into(&mut bp);
|
||||
let b = vectorcraft_geom::Shape::bounding_box(&bp);
|
||||
(b.width().hypot(b.height()) / 2.0).max(1.0)
|
||||
});
|
||||
let from = pos.get(grabbed).copied().unwrap_or(0.0);
|
||||
let closed = sp.closed;
|
||||
for (i, a) in sp.anchors.iter_mut().enumerate() {
|
||||
let w = if i == grabbed {
|
||||
1.0
|
||||
} else {
|
||||
let q = (a.p.distance(origin) / r).min(1.0);
|
||||
let d = (pos.get(i).copied().unwrap_or(f64::INFINITY) - from).abs();
|
||||
let d = if closed { d.min(total - d) } else { d };
|
||||
// A distance or radius that isn't finite gives NaN here, which `min` reads as 1: no move.
|
||||
let q = (d / r).min(1.0);
|
||||
(1.0 - q * q).powi(2)
|
||||
};
|
||||
let v = delta * w;
|
||||
@@ -706,7 +740,7 @@ fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn insert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("path.insertAnchor", "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let si = usize_opt(p, "subpath", "path.insertAnchor")?.unwrap_or(0);
|
||||
let seg = p.get("segment").and_then(Value::as_u64).ok_or_else(|| bad("path.insertAnchor", "missing segment"))? as usize;
|
||||
let t = f64_or(p, "t", 0.5).clamp(0.0, 1.0);
|
||||
let idx = s.edit("Add Anchor Point", |d, _| {
|
||||
|
||||
@@ -89,6 +89,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
anchors_many
|
||||
),
|
||||
cmd!(
|
||||
"select.color",
|
||||
"Select by Color",
|
||||
[],
|
||||
None,
|
||||
"{fill?: colour, stroke?: colour (as paint.setFill's color; at least one)} select the objects whose fill (and stroke) reads as that colour in #rrggbb, as document.inspect reports colours, whatever its swatch or tint; with both, the objects that match both. No object needs to be selected first; Select > Reselect repeats it → {count}",
|
||||
has_doc,
|
||||
select_color
|
||||
),
|
||||
cmd!(
|
||||
"select.same.fillColor",
|
||||
"Fill Color",
|
||||
@@ -528,6 +537,29 @@ fn stroke_weight(n: &Node) -> f64 {
|
||||
if paint.is_none() { 0.0 } else { width }
|
||||
}
|
||||
|
||||
/// Select by Color: the objects whose fill and stroke read as the colours given, compared as the
|
||||
/// `#rrggbb` `document.inspect` reports, so a script can pick art by colour without an object id.
|
||||
fn select_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "select.color";
|
||||
let hex = |key: &str| match p.get(key).filter(|v| !v.is_null()) {
|
||||
None => Ok(None),
|
||||
Some(v) => color_value(v).map(|c| Some(c.to_hex())).ok_or_else(|| bad(C, format!("`{key}`: not a colour: {v}"))),
|
||||
};
|
||||
let (fill, stroke) = (hex("fill")?, hex("stroke")?);
|
||||
if fill.is_none() && stroke.is_none() {
|
||||
return Err(bad(C, "give `fill`, `stroke` or both"));
|
||||
}
|
||||
let reads_as = |paint: Paint, hex: Option<&str>| hex.is_none_or(|h| paint.color().is_some_and(|c| c.to_hex() == h));
|
||||
select_where(s, C, p, |d, n| paint_reported(d, n) && reads_as(fill_of(n), fill.as_deref()) && reads_as(stroke_of(n).0, stroke.as_deref()))
|
||||
}
|
||||
|
||||
/// Does `document.inspect` report a fill and stroke for `n`? A compound path's are its own, though
|
||||
/// it holds its members, and its members report none.
|
||||
fn paint_reported(d: &Document, n: &Node) -> bool {
|
||||
let compound = |n: &Node| matches!(n.kind, NodeKind::Compound { .. });
|
||||
compound(n) || (!n.is_container() && !d.parent_of(n.id).and_then(|p| d.node(p)).is_some_and(compound))
|
||||
}
|
||||
|
||||
fn same(s: &mut Session, cmd: &str, eq: impl Fn(&Node, &Node) -> bool) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let refn = st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned().ok_or_else(|| bad(cmd, "nothing selected"))?;
|
||||
|
||||
@@ -459,7 +459,7 @@ fn dataset_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
|
||||
fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
|
||||
const C: &str = "dataset.select";
|
||||
let (applied, skipped, mut typed) = s.edit("Apply Data Set", |d, sel| {
|
||||
let (applied, skipped, mut typed, bound) = s.edit("Apply Data Set", |d, sel| {
|
||||
let ds = d.variables.dataset(name).cloned().ok_or_else(|| bad(C, format!("no dataset named `{name}`")))?;
|
||||
// One object may be driven by several variables; walk them in definition order, so two
|
||||
// text variables on the same object land the later one's value and both are counted.
|
||||
@@ -467,6 +467,8 @@ fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
|
||||
for v in &d.variables.variables {
|
||||
pairs.extend(d.variables.objects_of(&v.name).into_iter().map(|id| (id, v.name.clone())));
|
||||
}
|
||||
// Whether an object is bound to a variable that this row has a value for (the reply's `warning`).
|
||||
let bound = pairs.iter().any(|(_, var)| ds.values.contains_key(var));
|
||||
let mut applied = 0usize;
|
||||
let mut skipped = 0usize;
|
||||
// Collects the type objects whose text the row rewrote, for the reply's `overflow`.
|
||||
@@ -514,12 +516,18 @@ fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
|
||||
d.variables.active_dataset = Some(name.to_string());
|
||||
// Every selected object left hidden or locked, such as one the row hid, is deselected.
|
||||
sel.deselect_uneditable(d);
|
||||
Ok((applied, skipped, typed))
|
||||
Ok((applied, skipped, typed, bound))
|
||||
})?;
|
||||
// Two text variables on one object rewrite it twice; it is named once.
|
||||
typed.sort_unstable();
|
||||
typed.dedup();
|
||||
Ok(super::typecmd::with_overflow(s, &typed, json!({"name": name, "applied": applied, "skipped": skipped})))
|
||||
let mut out = super::typecmd::with_overflow(s, &typed, json!({"name": name, "applied": applied, "skipped": skipped}));
|
||||
// A row with no value for any bound variable applies nothing. The reply then carries a
|
||||
// `warning`, which the app shows in its status bar.
|
||||
if !bound {
|
||||
out["warning"] = json!(format!("nothing in data set {name} was applied: no object is bound to a variable it has a value for"));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn dataset_select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1037,7 +1045,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Select Data Set",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{name} apply a dataset in one undo step, deselecting every selected object left hidden or locked (the key object too) → {applied, skipped, overflow?: [id…] (area type and type on a path whose text it rewrote and that no longer fits; left out when all fits)}",
|
||||
"{name} apply a dataset in one undo step, deselecting every selected object left hidden or locked (the key object too) → {applied, skipped, overflow?: [id…] (area type and type on a path whose text it rewrote and that no longer fits; left out when all fits), warning? (when no object is bound to a variable that the dataset has a value for, so nothing was applied)}",
|
||||
has_doc,
|
||||
dataset_select
|
||||
),
|
||||
|
||||
@@ -87,10 +87,11 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!(s.menu.wand))
|
||||
}
|
||||
|
||||
/// The fill an object shows: type's comes from its characters (the first run speaks for all).
|
||||
/// The fill an object shows: type's comes from its characters (the first run speaks for all; type
|
||||
/// without any, the default character fill, as `document.inspect` reports it).
|
||||
pub(crate) fn fill_of(n: &Node) -> Paint {
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) => t.runs.first().map(|r| r.style.fill.clone()).unwrap_or_default(),
|
||||
NodeKind::Text(t) => t.runs.first().map(|r| r.style.fill.clone()).unwrap_or_else(|| vectorcraft_doc::CharStyle::default().fill),
|
||||
_ => n.appearance.fill_paint(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1702,14 +1702,17 @@ impl Session {
|
||||
let before = st.doc.clone();
|
||||
let before_sel = st.selection.clone();
|
||||
let doc = Arc::make_mut(&mut st.doc);
|
||||
// Dimensions follow the art they measure.
|
||||
// Objects attached to a path follow it, and dimensions follow the art they measure.
|
||||
let result = match f(doc, &mut st.selection).and_then(|v| {
|
||||
cmd::objects_on_path::refresh(&before, doc);
|
||||
cmd::dimension::refresh(&before, doc)?;
|
||||
Ok(v)
|
||||
}) {
|
||||
Ok(v) => match cmd::shaper::refresh(&before, Arc::make_mut(&mut st.doc)) {
|
||||
Err(e) => Err(e),
|
||||
Ok(()) => {
|
||||
// Attached Shaper groups whose art was rebuilt go back on their path.
|
||||
cmd::objects_on_path::refresh(&before, Arc::make_mut(&mut st.doc));
|
||||
// Inline graphics in text follow their symbols.
|
||||
cmd::inline::refresh(&before, Arc::make_mut(&mut st.doc));
|
||||
// Text Wrap: area type follows its wrap objects; then threads re-flow.
|
||||
@@ -2185,6 +2188,8 @@ mod tests_newart;
|
||||
#[cfg(test)]
|
||||
mod tests_newdoc;
|
||||
#[cfg(test)]
|
||||
mod tests_objects_on_path;
|
||||
#[cfg(test)]
|
||||
mod tests_objexpand;
|
||||
#[cfg(test)]
|
||||
mod tests_opacitymask;
|
||||
|
||||
@@ -2343,6 +2343,29 @@ fn reshape_moves_the_grabbed_point_and_its_neighbourhood() {
|
||||
assert!(pts.iter().filter(|p| (p.y - 100.0).abs() < 1e-9).count() == 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_falloff_follows_arc_length_on_open_paths() {
|
||||
let mut s = session();
|
||||
let id = NodeId(
|
||||
s.execute(
|
||||
"path.create",
|
||||
&json!({
|
||||
"anchors": [{"x": 0, "y": 0}, {"x": 100, "y": 0}, {"x": 200, "y": 0}, {"x": 300, "y": 0}],
|
||||
"closed": false
|
||||
}),
|
||||
)
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap(),
|
||||
);
|
||||
s.execute("path.reshape", &json!({"id": id.0, "x": 300.0, "y": 0.0, "dx": 0.0, "dy": 100.0})).unwrap();
|
||||
let pts: Vec<_> = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].anchors.iter().map(|a| a.p).collect();
|
||||
assert!((pts[3].y - 100.0).abs() < 1e-6, "grabbed end moves full delta: {pts:?}");
|
||||
assert!(pts[0].y.abs() < 1e-6, "far end stays put: {pts:?}");
|
||||
assert!(pts[2].y > 1.0 && pts[2].y < 100.0, "mid-anchor moves partially: {pts:?}");
|
||||
assert!(pts[1].y < pts[2].y, "nearer anchors move more: {pts:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_tool_drag_creates_a_graph_and_asks_for_data() {
|
||||
let mut s = session();
|
||||
@@ -2429,6 +2452,29 @@ fn object_mosaic_refuses_columns_and_rows_that_are_not_whole_numbers() {
|
||||
assert_eq!(r["tiles"], json!(2));
|
||||
}
|
||||
|
||||
/// A new width or height, or a spacing, given as text or as another value that is not a number is an error naming the
|
||||
/// key, and no mosaic is made.
|
||||
#[test]
|
||||
fn object_mosaic_refuses_a_size_or_spacing_given_as_text() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 0.0, 0.0, 100.0, 50.0);
|
||||
s.execute("object.rasterize", &json!({"ppi": 72})).unwrap();
|
||||
let undo = |s: &Session| s.doc().unwrap().history.undo.len();
|
||||
let before = undo(&s);
|
||||
for (p, msg) in [
|
||||
(json!({"width": "200"}), "`width` must be a number, not \"200\""),
|
||||
(json!({"columns": 2, "height": "50 pt"}), "`height` must be a number, not \"50 pt\""),
|
||||
(json!({"spacingX": "4"}), "`spacingX` must be a number, not \"4\""),
|
||||
(json!({"spacingY": [4]}), "`spacingY` must be a number, not [4]"),
|
||||
] {
|
||||
let r = s.execute("object.createObjectMosaic", &p);
|
||||
assert!(matches!(&r, Err(EngineError::BadParams { msg: m, .. }) if m == msg), "{p}: {r:?}");
|
||||
assert_eq!(undo(&s), before, "{p}");
|
||||
}
|
||||
let r = s.execute("object.createObjectMosaic", &json!({"columns": 2, "rows": 1, "width": 200, "spacingX": null})).unwrap();
|
||||
assert_eq!(r["tiles"], json!(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snap_to_pixel_rounds_drawing_and_moves() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -130,6 +130,62 @@ fn pencil_continues_selected_open_path() {
|
||||
assert_eq!(p.subpaths[0].anchors[0].p, Point::new(100.0, 100.0));
|
||||
}
|
||||
|
||||
/// Edit Selected Paths in the middle of a path (#2850): a Pencil stroke that starts and ends on a
|
||||
/// selected path replaces the section between, drawn either way; the path stays one path with
|
||||
/// its ends, and is one undo step.
|
||||
#[test]
|
||||
fn pencil_redraws_the_section_of_a_selected_path_between_its_ends() {
|
||||
let bump = [(100.0, 101.0), (125.0, 70.0), (150.0, 60.0), (175.0, 70.0), (200.0, 101.0)];
|
||||
for reversed in [false, true] {
|
||||
let mut s = session();
|
||||
let id = line(&mut s, 0.0, 100.0, 300.0, 100.0);
|
||||
let mut pts = bump.to_vec();
|
||||
if reversed {
|
||||
pts.reverse();
|
||||
}
|
||||
let undo = undo_steps(&s);
|
||||
drag(&mut s, "pencil", &pts, Mods::default());
|
||||
assert_eq!(paths(&s).len(), 1, "no new object");
|
||||
assert_eq!(undo_steps(&s), undo + 1);
|
||||
let sp = &path(&s, id).subpaths[0];
|
||||
assert!(!sp.closed);
|
||||
let ps: Vec<Point> = sp.anchors.iter().map(|a| a.p).collect();
|
||||
assert!(near(ps[0], Point::new(0.0, 100.0)) && near(*ps.last().unwrap(), Point::new(300.0, 100.0)), "{ps:?}");
|
||||
// The section starts and ends on the line, where the stroke met it, and rises between.
|
||||
let on = |x: f64| ps.iter().any(|p| (p.x - x).abs() < 1.5 && (p.y - 100.0).abs() < 1e-6);
|
||||
assert!(on(100.0) && on(200.0), "{reversed}: {ps:?}");
|
||||
let top = kurbo::Shape::bounding_box(&path(&s, id).to_bezpath()).y0;
|
||||
assert!(top < 75.0, "the bump: top {top}");
|
||||
assert!(ps.windows(2).all(|w| w[0].x <= w[1].x + 1.0), "in order along the line: {ps:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// On a closed path the stroke replaces the shorter way between where it starts and ends, and the
|
||||
/// path stays closed; a stroke that doesn't end on the path draws a new one.
|
||||
#[test]
|
||||
fn pencil_redraws_the_shorter_way_round_a_closed_path() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 0.0, 0.0, 200.0, 100.0);
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("path.freehand", &json!({"points": [[50, 1], [80, -25], [100, -30], [120, -25], [150, 1]], "redraw": {"id": id.0, "within": 4}}))
|
||||
.unwrap();
|
||||
assert_eq!(paths(&s).len(), 1);
|
||||
let sp = &path(&s, id).subpaths[0];
|
||||
assert!(sp.closed);
|
||||
let ps: Vec<Point> = sp.anchors.iter().map(|a| a.p).collect();
|
||||
for corner in [(0.0, 0.0), (200.0, 0.0), (200.0, 100.0), (0.0, 100.0)] {
|
||||
assert!(ps.iter().any(|p| near(*p, Point::new(corner.0, corner.1))), "{corner:?} kept: {ps:?}");
|
||||
}
|
||||
let top = kurbo::Shape::bounding_box(&path(&s, id).to_bezpath()).y0;
|
||||
assert!(top < -20.0, "the dip: top {top}");
|
||||
// Not ending on the path: a new path, the rectangle as it was.
|
||||
let before = path(&s, id);
|
||||
s.execute("path.freehand", &json!({"points": [[50, 100], [80, 150], [120, 160]], "redraw": {"id": id.0, "within": 4}})).unwrap();
|
||||
assert_eq!(paths(&s).len(), 2);
|
||||
assert_eq!(path(&s, id), before);
|
||||
assert!(s.execute("path.freehand", &json!({"points": [[0, 0], [1, 1]], "redraw": {"id": id.0, "within": -1}})).is_err());
|
||||
}
|
||||
|
||||
/// With Keep selected off, the path a stroke draws or continues is not selected afterwards, the
|
||||
/// rest of the selection stays, and the stroke is one undo step.
|
||||
#[test]
|
||||
@@ -811,6 +867,149 @@ fn draw2_commands_reject_bad_params() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A path of two open subpaths, each of three anchors from x 100 to x 300 with a middle anchor that
|
||||
/// has two handles: subpath 0 at y 100, subpath 1 at y 300.
|
||||
fn two_subpaths(s: &mut Session) -> NodeId {
|
||||
let id = curve(s);
|
||||
let sub = |y: f64| {
|
||||
let anchors = json!([
|
||||
{"x": 100, "y": y, "out": [130, y - 30.0]},
|
||||
{"x": 200, "y": y, "in": [170, y - 30.0], "out": [230, y - 30.0]},
|
||||
{"x": 300, "y": y, "in": [270, y - 30.0]},
|
||||
]);
|
||||
json!({"anchors": anchors, "closed": false})
|
||||
};
|
||||
s.execute("path.setAnchors", &json!({"id": id.0, "subpaths": [sub(100.0), sub(300.0)]})).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
/// Runs `cmd` with `p` and `key` set to each of 1.5, -1 and "1": each call fails with an error that
|
||||
/// names `key`, and the document and the undo history stay as they were.
|
||||
fn refuses_an_index_that_is_not_whole(s: &mut Session, cmd: &str, p: &serde_json::Value, key: &str) {
|
||||
for v in [json!(1.5), json!(-1), json!("1")] {
|
||||
let mut p = p.clone();
|
||||
p[key] = v;
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let undo = undo_steps(s);
|
||||
let Err(e) = s.execute(cmd, &p) else { panic!("{cmd} {p} succeeded") };
|
||||
assert!(e.to_string().contains(&format!("`{key}`")), "{cmd} {p}: {e}");
|
||||
assert!(s.doc().unwrap().doc == before, "{cmd} {p} changed the document");
|
||||
assert_eq!(undo_steps(s), undo, "{cmd} {p} left an undo step");
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `cmd` with `p` on path `id` (made by [`two_subpaths`]) with `subpath: 1`, without `subpath`
|
||||
/// and with `subpath: null`, undoing each: the first changes subpath 1 alone, and the others change
|
||||
/// subpath 0 alone.
|
||||
fn edits_the_subpath_given(s: &mut Session, cmd: &str, p: &serde_json::Value, id: NodeId) {
|
||||
let before = path(s, id).subpaths;
|
||||
for (si, p) in [(1, with(p, json!({"subpath": 1}))), (0, p.clone()), (0, with(p, json!({"subpath": null})))] {
|
||||
s.execute(cmd, &p).unwrap();
|
||||
let after = path(s, id).subpaths;
|
||||
assert_eq!(after.len(), 2, "{cmd} {p}");
|
||||
assert!(after[si] != before[si] && after[1 - si] == before[1 - si], "{cmd} {p}: {after:?}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(path(s, id).subpaths, before, "{cmd} {p}: undo");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curvature_edit_refuses_a_subpath_that_is_not_a_whole_number() {
|
||||
let mut s = session();
|
||||
let id = two_subpaths(&mut s);
|
||||
let p = json!({"id": id.0, "op": "move", "anchor": 1, "x": 200, "y": 150});
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.curvatureEdit", &p, "subpath");
|
||||
edits_the_subpath_given(&mut s, "path.curvatureEdit", &p, id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_anchor_refuses_a_subpath_that_is_not_a_whole_number() {
|
||||
let mut s = session();
|
||||
let id = two_subpaths(&mut s);
|
||||
let p = json!({"id": id.0, "anchor": 1});
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.removeAnchor", &p, "subpath");
|
||||
edits_the_subpath_given(&mut s, "path.removeAnchor", &p, id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_anchor_refuses_a_subpath_that_is_not_a_whole_number() {
|
||||
let mut s = session();
|
||||
let id = two_subpaths(&mut s);
|
||||
let p = json!({"id": id.0, "anchor": 1, "to": "corner"});
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.convertAnchor", &p, "subpath");
|
||||
edits_the_subpath_given(&mut s, "path.convertAnchor", &p, id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_segment_refuses_a_subpath_that_is_not_a_whole_number() {
|
||||
let mut s = session();
|
||||
let id = two_subpaths(&mut s);
|
||||
let p = json!({"id": id.0, "segment": 0, "t": 0.5, "dx": 0, "dy": 20});
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.reshapeSegment", &p, "subpath");
|
||||
edits_the_subpath_given(&mut s, "path.reshapeSegment", &p, id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_refuses_a_subpath_anchor_or_segment_that_is_not_a_whole_number() {
|
||||
// A bad `anchor` beside a good `segment` is refused, and so is a bad `segment` beside a good
|
||||
// `anchor`.
|
||||
let mut s = session();
|
||||
let id = two_subpaths(&mut s);
|
||||
let at_anchor = json!({"id": id.0, "anchor": 1});
|
||||
let at_segment = json!({"id": id.0, "segment": 0, "t": 0.5});
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.split", &at_anchor, "subpath");
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.split", &at_segment, "subpath");
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.split", &at_segment, "anchor");
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.split", &at_anchor, "segment");
|
||||
let before = path(&s, id).subpaths;
|
||||
for (si, p) in [(1, with(&at_anchor, json!({"subpath": 1}))), (0, at_anchor.clone()), (0, with(&at_anchor, json!({"subpath": null})))] {
|
||||
s.execute("path.split", &p).unwrap();
|
||||
let after = path(&s, id).subpaths;
|
||||
// The subpath split becomes two of two anchors each, in its place.
|
||||
let kept = if si == 1 { &after[0] } else { &after[2] };
|
||||
assert!(after.len() == 3 && after[si].anchors.len() == 2 && after[si + 1].anchors.len() == 2, "{p}: {after:?}");
|
||||
assert_eq!(kept, &before[1 - si], "{p}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(path(&s, id).subpaths, before, "{p}: undo");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_handle_refuses_a_subpath_that_is_not_a_whole_number() {
|
||||
let mut s = session();
|
||||
let id = two_subpaths(&mut s);
|
||||
let p = json!({"id": id.0, "anchor": 0, "which": "out", "x": 140, "y": 250});
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.setHandle", &p, "subpath");
|
||||
edits_the_subpath_given(&mut s, "path.setHandle", &p, id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_anchor_takes_its_handles_along_and_refuses_a_subpath_that_is_not_a_whole_number() {
|
||||
let mut s = session();
|
||||
let id = two_subpaths(&mut s);
|
||||
let p = json!({"id": id.0, "anchor": 0, "x": 140, "y": 250});
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.moveAnchor", &p, "subpath");
|
||||
edits_the_subpath_given(&mut s, "path.moveAnchor", &p, id);
|
||||
// The anchor lands on (x, y) and its handles keep their offsets from it.
|
||||
s.execute("path.setHandle", &json!({"id": id.0, "anchor": 0, "which": "out", "x": 30, "y": 40})).unwrap();
|
||||
let a = path(&s, id).subpaths[0].anchors[0];
|
||||
s.execute("path.moveAnchor", &json!({"id": id.0, "anchor": 0, "x": a.p.x + 15.0, "y": a.p.y - 5.0})).unwrap();
|
||||
let b = path(&s, id).subpaths[0].anchors[0];
|
||||
assert_eq!(b.p, a.p + vectorcraft_geom::Vec2::new(15.0, -5.0));
|
||||
assert_eq!(b.h_out - b.p, a.h_out - a.p);
|
||||
assert_eq!(b.h_in - b.p, a.h_in - a.p);
|
||||
assert!(s.execute("path.moveAnchor", &json!({"id": id.0, "anchor": 99, "x": 0, "y": 0})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_anchor_refuses_a_subpath_that_is_not_a_whole_number() {
|
||||
let mut s = session();
|
||||
let id = two_subpaths(&mut s);
|
||||
let p = json!({"id": id.0, "segment": 0, "t": 0.5});
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.insertAnchor", &p, "subpath");
|
||||
edits_the_subpath_given(&mut s, "path.insertAnchor", &p, id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_commands_refuse_a_delta_or_point_of_1e9_pt_or_more() {
|
||||
// `path.reshapeSegment` refuses a delta of 1e9 pt or longer, and `path.convertAnchor` and
|
||||
@@ -1787,6 +1986,26 @@ fn open_anchor_delete_preserves_curves_appearance_and_history() {
|
||||
assert_eq!(s.doc().unwrap().doc, after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_anchor_delete_then_join_closes_with_a_line() {
|
||||
let mut s = session();
|
||||
let data = "M100 100C120 60 180 60 200 100C220 140 280 140 300 100C320 60 380 60 400 100C420 140 480 140 500 100C500 200 100 200 100 100Z";
|
||||
let id = NodeId(s.execute("path.create", &json!({"d": data})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 2]]})).unwrap();
|
||||
s.execute("path.deleteAnchors", &json!({})).unwrap();
|
||||
let result = path(&s, id);
|
||||
assert_eq!(result.subpaths.len(), 1);
|
||||
let sp = &result.subpaths[0];
|
||||
assert!(!sp.closed);
|
||||
assert!(!sp.anchors.first().unwrap().has_in() && !sp.anchors.last().unwrap().has_out(), "dangling handles are cleared");
|
||||
s.execute("path.join", &json!({"ids": [id.0]})).unwrap();
|
||||
let joined = path(&s, id);
|
||||
let sp = &joined.subpaths[0];
|
||||
assert!(sp.closed);
|
||||
let closing = sp.segment(sp.segment_count() - 1);
|
||||
assert!(closing.p1 == closing.p0 && closing.p2 == closing.p3, "closing segment is a line: {closing:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_anchor_delete_does_not_change_explicit_remove_anchors() {
|
||||
for command in ["path.removeAnchors", "path.removeAnchor"] {
|
||||
|
||||
@@ -62,10 +62,14 @@ fn clicking_without_typing_leaves_no_text_and_no_history() {
|
||||
click(&mut s, 150.0, 100.0);
|
||||
s.select_tool("rectangle", v).unwrap();
|
||||
assert!(texts(&s).is_empty());
|
||||
// Click in two places: the first is gone when the second is placed.
|
||||
// Click in two places: the second click discards the first type and places none (#2871); a
|
||||
// third places new type.
|
||||
s.select_tool("type", v).unwrap();
|
||||
click(&mut s, 100.0, 300.0);
|
||||
click(&mut s, 400.0, 300.0);
|
||||
assert!(texts(&s).is_empty());
|
||||
assert!(!s.tool_wants_text());
|
||||
click(&mut s, 400.0, 300.0);
|
||||
assert_eq!(texts(&s).len(), 1);
|
||||
s.select_tool("selection", v).unwrap();
|
||||
assert!(texts(&s).is_empty());
|
||||
@@ -95,6 +99,25 @@ fn typed_text_stays_and_typing_it_away_leaves_nothing() {
|
||||
assert_eq!(undo_labels(&s), kept);
|
||||
}
|
||||
|
||||
/// A click on blank canvas while type is being edited ends the editing and makes no new type; the
|
||||
/// Type tool stays, and the next click places point type (#2871, #2854).
|
||||
#[test]
|
||||
fn a_click_away_from_typed_text_ends_editing_without_new_type() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
click(&mut s, 100.0, 100.0);
|
||||
s.tool_text("Hi", v).unwrap();
|
||||
click(&mut s, 400.0, 300.0);
|
||||
assert_eq!(texts(&s).iter().map(|t| t.2.as_str()).collect::<Vec<_>>(), ["Hi"]);
|
||||
assert_eq!(s.tool_id(), "type");
|
||||
assert!(!s.tool_wants_text());
|
||||
assert_eq!(undo_labels(&s), ["Type", "Typing"]);
|
||||
click(&mut s, 400.0, 300.0);
|
||||
assert_eq!(texts(&s).len(), 2);
|
||||
assert!(s.tool_wants_text());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_stay_when_left_empty() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -175,9 +175,9 @@ fn assert_dated(bytes: &[u8], created: std::ops::RangeInclusive<i64>, modified:
|
||||
|
||||
/// Run `f` → (its result, the time of writing: the Unix seconds before and after it).
|
||||
fn written<T>(f: impl FnOnce() -> T) -> (T, std::ops::RangeInclusive<i64>) {
|
||||
let before = vectorcraft_doc::metadata::now_unix().unwrap();
|
||||
let before = vectorcraft_doc::metadata::output_time().unwrap();
|
||||
let out = f();
|
||||
(out, before..=vectorcraft_doc::metadata::now_unix().unwrap())
|
||||
(out, before..=vectorcraft_doc::metadata::output_time().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -371,7 +371,7 @@ fn transparent_images_can_be_left_out_and_opacity_makes_one_transparent() {
|
||||
assert_eq!(v["options"]["transparentImages"]["value"], "flatten", "{v}");
|
||||
}
|
||||
|
||||
/// #2692's files: an image clipped to a circle inside a circle's stroke, and an image with
|
||||
/// #2692's file, an image clipped to a circle inside a circle's stroke, and #2830's, an image with
|
||||
/// see-through pixels (which the save flattens into an image inside a clipping group), each with
|
||||
/// an orange square on the next layer.
|
||||
const CLIPPED_IMAGE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="400" height="200" viewBox="0 0 400 200">
|
||||
@@ -389,14 +389,16 @@ const SEE_THROUGH_IMAGE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" xmln
|
||||
<g id="above"><rect x="220" y="20" width="160" height="160" fill="#f7941d"/></g>
|
||||
</svg>"##;
|
||||
|
||||
/// #2692: the image inside a clipping group, clipped in the file or flattened by the save, is
|
||||
/// written as the version 7 specification shows one (§8.1, §8.4): the matrix with a space inside
|
||||
/// each bracket and the samples on comment lines of 60 hex digits, between `W n` and `Q`. The next
|
||||
/// #2692 and #2830: the image inside a clipping group, clipped in the file or flattened by the
|
||||
/// save, is written as the version 7 specification shows one (§8.1, §8.4): the matrix with a space
|
||||
/// inside each bracket and the samples on comment lines of 60 hex digits, between `W n` and `Q`.
|
||||
/// A line `F`, which the specification's examples do not show, follows `%AI5_EndRaster`. The next
|
||||
/// layer follows with the square's exact lines, and the file opens again with both layers, the
|
||||
/// clipping group, the image and the square.
|
||||
/// clipping group, the image, the square and, besides the clipping path, only the paths the SVG
|
||||
/// holds.
|
||||
#[test]
|
||||
fn a_clipped_or_flattened_image_is_written_as_the_specification_shows_it() {
|
||||
for (svg, layer) in [(CLIPPED_IMAGE, "clipped-image"), (SEE_THROUGH_IMAGE, "alpha-image")] {
|
||||
for (svg, layer, paths) in [(CLIPPED_IMAGE, "clipped-image", 2), (SEE_THROUGH_IMAGE, "alpha-image", 1)] {
|
||||
let mut s = Session::new();
|
||||
s.execute("document.open", &json!({"name": "t.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())})).unwrap();
|
||||
let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap();
|
||||
@@ -411,7 +413,8 @@ fn a_clipped_or_flattened_image_is_written_as_the_specification_shows_it() {
|
||||
let end = at + lines[at..].iter().position(|l| *l == "%AI5_EndRaster").unwrap();
|
||||
let (last, full) = lines[at + 1..end].split_last().unwrap();
|
||||
assert!(full.iter().all(|l| l.len() == 61) && (2..=61).contains(&last.len()), "{layer}");
|
||||
assert!(text.contains("\nW\nn\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nQ\nLB\n%AI5_EndLayer--\n"), "{text}");
|
||||
assert_eq!(lines[end + 1], "F", "{layer}");
|
||||
assert!(text.contains("\nW\nn\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nF\nQ\nLB\n%AI5_EndLayer--\n"), "{text}");
|
||||
let square = "%AI5_BeginLayer\n1 1 1 1 0 0 1 255 79 79 Lb\n(above) Ln\n0 A\n0.9686 0.5804 0.1137 Xa\n0 O\n0 XR\n\
|
||||
220 180 m\n380 180 L\n380 20 L\n220 20 L\n220 180 L\nf\nLB\n%AI5_EndLayer--\n%%PageTrailer\n";
|
||||
assert!(text.contains(square), "{text}");
|
||||
@@ -419,6 +422,9 @@ fn a_clipped_or_flattened_image_is_written_as_the_specification_shows_it() {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(layer_names(d), [layer, "above"]);
|
||||
assert_eq!(images(d), (1, 1), "{layer}");
|
||||
let mut drawn = 0;
|
||||
d.walk(|n| drawn += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Path { clipping: false, .. })));
|
||||
assert_eq!(drawn, paths, "{layer}");
|
||||
assert_eq!(colour_at(d, 300.0, 100.0), [247, 148, 29], "{layer}");
|
||||
assert_ne!(colour_at(d, 100.0, 100.0), [255, 255, 255], "{layer}");
|
||||
}
|
||||
|
||||
@@ -88,6 +88,86 @@ fn linked_files_on_other_computers_are_never_read() {
|
||||
assert!(s.link_stamps.keys().all(|(_, p)| !far.contains(p)), "{:?}", s.link_stamps.keys());
|
||||
}
|
||||
|
||||
/// The final path Windows gives a folder of the drive `Z:` mapped to `\\server\share`. The
|
||||
/// share's root folder has no trailing separator (`\\?\UNC\server\share`).
|
||||
fn on_share(folder: &str) -> Option<std::path::PathBuf> {
|
||||
let rest = folder.get(2..)?.trim_start_matches(['\\', '/']).replace('/', "\\");
|
||||
let root = r"\\?\UNC\server\share";
|
||||
Some(if rest.is_empty() { root.into() } else { format!(r"{root}\{rest}").into() })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_on_a_mapped_drive_is_on_the_share_behind_it() {
|
||||
use crate::cmd::fileio::share_of;
|
||||
// craft-docpath refuses a link on the share when the document is given by its drive letter.
|
||||
let link = r"\\server\share\img\logo.png";
|
||||
assert_eq!(craft_docpath::resolve(link, Some(std::path::Path::new(r"Z:\jobs\doc.vectorcraft"))), Err(craft_docpath::Refused::Network));
|
||||
assert!(craft_docpath::resolve(link, Some(std::path::Path::new(r"\\server\share\jobs\doc.vectorcraft"))).is_ok());
|
||||
|
||||
// The document's path on the share, from its folder's final path.
|
||||
let mut asked = vec![];
|
||||
let mut ask = |folder: &str| {
|
||||
asked.push(folder.to_string());
|
||||
on_share(folder)
|
||||
};
|
||||
assert_eq!(share_of(r"Z:\jobs\doc.vectorcraft", &mut ask).as_deref(), Some(r"\\server\share\jobs\doc.vectorcraft"));
|
||||
assert_eq!(share_of(r"Z:\doc.vectorcraft", &mut ask).as_deref(), Some(r"\\server\share\doc.vectorcraft"));
|
||||
assert_eq!(share_of("Z:/jobs/doc.vectorcraft", &mut ask).as_deref(), Some(r"\\server\share\jobs\doc.vectorcraft"));
|
||||
assert_eq!(asked, [r"Z:\jobs", r"Z:\", "Z:/jobs"]);
|
||||
let unc = |p: &'static str| move |_: &str| Some(std::path::PathBuf::from(p));
|
||||
assert_eq!(share_of(r"Z:\doc.vectorcraft", unc(r"\\?\unc\server\share\")).as_deref(), Some(r"\\server\share\doc.vectorcraft"));
|
||||
|
||||
// A folder on a local volume (also behind a `subst` drive), a folder that can't be opened,
|
||||
// and a final path without a share name give no path on a share.
|
||||
assert_eq!(share_of(r"C:\art\doc.vectorcraft", unc(r"\\?\C:\art")), None);
|
||||
assert_eq!(share_of(r"S:\doc.vectorcraft", unc(r"\\?\D:\real")), None);
|
||||
assert_eq!(share_of(r"Z:\gone\doc.vectorcraft", |_| None), None);
|
||||
assert_eq!(share_of(r"Z:\doc.vectorcraft", unc(r"\\?\UNC\server")), None);
|
||||
// A document given by its share, or not by a drive, is not looked up.
|
||||
for doc in [
|
||||
r"\\server\share\doc.vectorcraft",
|
||||
r"\\?\Z:\doc.vectorcraft",
|
||||
"/Users/a/doc.vectorcraft",
|
||||
"doc.vectorcraft",
|
||||
"Z:doc.vectorcraft",
|
||||
r"Z:\jobs\",
|
||||
] {
|
||||
assert_eq!(share_of(doc, |f: &str| panic!("{doc}: looked up {f}")), None, "{doc}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn links_on_a_mapped_drives_share_are_followed() {
|
||||
use crate::cmd::fileio::{document_path_with, share_of};
|
||||
let doc = Some(r"Z:\jobs\doc.vectorcraft");
|
||||
let share = |d: &str| share_of(d, on_share);
|
||||
// A link on the share behind the drive is followed; one on another share still isn't.
|
||||
assert_eq!(document_path_with(r"\\server\share\img\logo.png", doc, share).as_deref(), Ok(r"\\server\share\img\logo.png"));
|
||||
assert_eq!(document_path_with(r"\\SERVER\Share\logo.png", doc, share).as_deref(), Ok(r"\\SERVER\Share\logo.png"));
|
||||
assert_eq!(document_path_with(r"\\other\share\logo.png", doc, share), Err(craft_docpath::Refused::Network));
|
||||
assert_eq!(document_path_with("file://server/share/logo.png", doc, share), Err(craft_docpath::Refused::Network));
|
||||
// On a local volume, the link stays refused.
|
||||
assert_eq!(document_path_with(r"\\server\share\logo.png", doc, |_| None), Err(craft_docpath::Refused::Network));
|
||||
// Links that were followed resolve as before, without looking the share up.
|
||||
let unasked = |d: &str| -> Option<String> { panic!("looked up {d}") };
|
||||
assert_eq!(document_path_with(r"img\logo.png", doc, unasked).as_deref(), Ok(r"Z:\jobs\img\logo.png"));
|
||||
assert_eq!(document_path_with(r"D:\art\logo.png", doc, unasked).as_deref(), Ok(r"D:\art\logo.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_off_a_mapped_drive_keeps_its_path() {
|
||||
use crate::cmd::fileio::share_path;
|
||||
let dir = Folder::new("docpath-share");
|
||||
// A folder on this computer's own disk, and one that doesn't exist.
|
||||
assert_eq!(share_path(&dir.file("doc.vectorcraft")), None);
|
||||
assert_eq!(share_path(&dir.file("gone/doc.vectorcraft")), None);
|
||||
// Elsewhere than Windows, no document is looked up.
|
||||
if cfg!(not(windows)) {
|
||||
assert_eq!(share_path(r"Z:\jobs\doc.vectorcraft"), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_images_on_other_computers_are_never_read() {
|
||||
let dir = Folder::new("docpath-svg");
|
||||
|
||||
@@ -157,6 +157,42 @@ fn a_linked_photoshop_document_places_and_updates() {
|
||||
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE));
|
||||
}
|
||||
|
||||
/// An uncompressed 24-bit `w`×`h` Targa file of one color.
|
||||
fn targa(w: u16, h: u16, rgb: [u8; 3]) -> Vec<u8> {
|
||||
let mut f = vec![0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
f.extend(w.to_le_bytes());
|
||||
f.extend(h.to_le_bytes());
|
||||
f.extend([24, 0]);
|
||||
let [r, g, b] = rgb;
|
||||
f.extend(std::iter::repeat_n([b, g, r], usize::from(w) * usize::from(h)).flatten());
|
||||
f
|
||||
}
|
||||
|
||||
/// A Targa file placed linked shows its pixels and updates when it changes, and a link can be
|
||||
/// relinked to one.
|
||||
#[test]
|
||||
fn a_linked_targa_file_places_updates_and_relinks() {
|
||||
let dir = Folder::new("tga");
|
||||
let pic = dir.file("art/photo.tga");
|
||||
write(&pic, &targa(600, 300, RED));
|
||||
let mut s = session();
|
||||
let id = place(&mut s, &pic);
|
||||
assert_eq!(image(&s, id).link.unwrap().path, pic);
|
||||
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
|
||||
write(&pic, &targa(400, 400, BLUE));
|
||||
assert_eq!(s.execute("links.update", &json!({})).unwrap(), json!({"updated": [id.0], "missing": []}));
|
||||
let im = image(&s, id);
|
||||
assert_eq!((im.width, im.height), (400, 400));
|
||||
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE));
|
||||
let other = dir.file("other/red.vst");
|
||||
write(&other, &targa(300, 100, RED));
|
||||
let r = s.execute("links.relink", &json!({"ids": [id.0], "path": other})).unwrap();
|
||||
assert_eq!(r, json!({"relinked": [id.0], "notFound": []}));
|
||||
let im = image(&s, id);
|
||||
assert_eq!((im.width, im.height, im.link.unwrap().path), (300, 100, other));
|
||||
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_relative_path_finds_the_file_after_the_folder_moves() {
|
||||
let a = Folder::new("move-a");
|
||||
|
||||
@@ -83,6 +83,13 @@ fn close(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-6
|
||||
}
|
||||
|
||||
/// `cmd` with `p` fails with the error for `key`, which `p` gives as something other than a number.
|
||||
fn refuses_non_number(s: &mut Session, cmd: &str, p: &Value, key: &str) {
|
||||
let r = s.execute(cmd, p);
|
||||
let want = format!("`{key}` must be a number, not {}", p[key]);
|
||||
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}");
|
||||
}
|
||||
|
||||
// ---------- Object → Lock / Hide ----------
|
||||
|
||||
#[test]
|
||||
@@ -399,6 +406,29 @@ fn transform_each_refuses_a_reference_or_seed_that_is_not_a_whole_number() {
|
||||
assert_eq!(bounds(&s, a), Rect::new(0.0, 0.0, 50.0, 50.0));
|
||||
}
|
||||
|
||||
/// Transform Each refuses a scale, move or rotation given as text or as another value that is not a
|
||||
/// number, and the object stays as it was; numbers for the scales and the horizontal move transform it.
|
||||
#[test]
|
||||
fn transform_each_refuses_a_number_given_as_text() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
sel(&mut s, &[a]);
|
||||
let before = (undo_len(&s), bounds(&s, a));
|
||||
for (key, p) in [
|
||||
("scaleH", json!({"scaleH": "50"})),
|
||||
("scaleV", json!({"scaleH": 50, "scaleV": "50%"})),
|
||||
("moveH", json!({"moveH": "10"})),
|
||||
("moveV", json!({"moveH": 10, "moveV": [5]})),
|
||||
("rotate", json!({"rotate": "45"})),
|
||||
("rotate", json!({"moveH": 10, "rotate": true})),
|
||||
] {
|
||||
refuses_non_number(&mut s, "object.transformEach", &p, key);
|
||||
assert_eq!((undo_len(&s), bounds(&s, a)), before, "{p}");
|
||||
}
|
||||
s.execute("object.transformEach", &json!({"scaleH": 50, "scaleV": 50, "moveH": 10, "moveV": null})).unwrap();
|
||||
assert_eq!(bounds(&s, a), Rect::new(35.0, 25.0, 85.0, 75.0));
|
||||
}
|
||||
|
||||
/// #1910: Transform Each's Reflect X flips left-right; Reflect Y flips top-bottom (same as
|
||||
/// Effect › Distort › Transform and Illustrator's Transform Each checkboxes).
|
||||
#[test]
|
||||
@@ -571,6 +601,22 @@ fn rasterize_replaces_selection_with_image() {
|
||||
assert_eq!(selected(&s), vec![img]);
|
||||
}
|
||||
|
||||
/// Rasterize refuses a resolution given as text or as another value that is not a number, and the
|
||||
/// selected art stays as it was.
|
||||
#[test]
|
||||
fn rasterize_refuses_a_ppi_given_as_text() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0, 20.0, 40.0, 30.0);
|
||||
sel(&mut s, &[a]);
|
||||
let before = (undo_len(&s), node(&s, a));
|
||||
for p in [json!({"ppi": "144"}), json!({"ppi": "300 ppi", "padding": 0}), json!({"ppi": [72]})] {
|
||||
refuses_non_number(&mut s, "object.rasterize", &p, "ppi");
|
||||
assert_eq!((undo_len(&s), node(&s, a)), before, "{p}");
|
||||
}
|
||||
let img = id_of(&s.execute("object.rasterize", &json!({"ppi": 144, "padding": 0})).unwrap());
|
||||
assert!(matches!(node(&s, img).kind, NodeKind::Image(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rasterize_says_when_an_image_could_not_be_decoded() {
|
||||
let mut s = session();
|
||||
@@ -608,6 +654,25 @@ fn crop_image_to_rect() {
|
||||
assert!(s.execute("object.cropImage", &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// Crop Image refuses a `rect` that is not an array, such as text, and the image stays as it was; a
|
||||
/// null `rect` crops to the artboard as a missing one does.
|
||||
#[test]
|
||||
fn crop_image_refuses_a_rect_given_as_text() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, -20.0, -20.0, 100.0, 100.0);
|
||||
sel(&mut s, &[a]);
|
||||
let img = id_of(&s.execute("object.rasterize", &json!({"ppi": 72})).unwrap());
|
||||
let before = (undo_len(&s), node(&s, img));
|
||||
for p in [json!({"rect": "0, 0, 20, 20"}), json!({"rect": {"x": 0, "y": 0, "width": 20, "height": 20}}), json!({"rect": 20})] {
|
||||
let r = s.execute("object.cropImage", &p);
|
||||
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == "rect must be [x, y, width, height]"), "{p}: {r:?}");
|
||||
assert_eq!((undo_len(&s), node(&s, img)), before, "{p}");
|
||||
}
|
||||
s.execute("object.cropImage", &json!({"rect": null})).unwrap();
|
||||
let b = bounds(&s, img);
|
||||
assert!(close(b.x0, 0.0) && close(b.y0, 0.0), "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_image_disabled_without_image() {
|
||||
let mut s = session();
|
||||
@@ -656,6 +721,30 @@ fn trim_marks_around_selection() {
|
||||
assert!(close(b.x0, vb.x0 - 30.0) && close(b.y1, vb.y1 + 30.0), "{b:?} {vb:?}");
|
||||
}
|
||||
|
||||
/// Create Trim Marks refuses an offset, length, bleed or weight given as text or as another value
|
||||
/// that is not a number, around the selection and around the artboards, and makes no marks.
|
||||
#[test]
|
||||
fn trim_marks_refuse_a_number_given_as_text() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
|
||||
let calls = [
|
||||
("offset", json!({"offset": "10"})),
|
||||
("length", json!({"length": "20 pt"})),
|
||||
("bleed", json!({"style": "japanese", "bleed": "3"})),
|
||||
("weight", json!({"weight": true})),
|
||||
("offset", json!({"allArtboards": true, "offset": {"value": 10}})),
|
||||
];
|
||||
for ids in [vec![a], vec![]] {
|
||||
sel(&mut s, &ids);
|
||||
let before = (undo_len(&s), s.doc().unwrap().doc.layers[0].children().unwrap().len());
|
||||
for (key, p) in &calls {
|
||||
refuses_non_number(&mut s, "object.createTrimMarks", p, key);
|
||||
assert_eq!((undo_len(&s), s.doc().unwrap().doc.layers[0].children().unwrap().len()), before, "{p}");
|
||||
}
|
||||
}
|
||||
s.execute("object.createTrimMarks", &json!({"offset": 10, "length": 20, "bleed": null})).unwrap();
|
||||
}
|
||||
|
||||
// ---------- Convert to Shape ----------
|
||||
|
||||
#[test]
|
||||
@@ -791,6 +880,22 @@ fn rearrange_refuses_columns_that_are_not_a_whole_number() {
|
||||
assert_eq!((r["rows"].clone(), r["columns"].clone()), (json!(2), json!(1)));
|
||||
}
|
||||
|
||||
/// Rearrange All Artboards refuses a spacing given as text or as another value that is not a number,
|
||||
/// and the artboards stay where they were.
|
||||
#[test]
|
||||
fn rearrange_refuses_a_spacing_given_as_text() {
|
||||
let mut s = session();
|
||||
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 400, "height": 300})).unwrap();
|
||||
let boards = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect::<Vec<_>>();
|
||||
let before = (boards(&s), undo_len(&s));
|
||||
for p in [json!({"spacing": "50"}), json!({"columns": 1, "spacing": "50 pt"}), json!({"layout": "row", "spacing": false})] {
|
||||
refuses_non_number(&mut s, "artboard.rearrange", &p, "spacing");
|
||||
assert_eq!((boards(&s), undo_len(&s)), before, "{p}");
|
||||
}
|
||||
s.execute("artboard.rearrange", &json!({"columns": 1, "spacing": 50})).unwrap();
|
||||
assert_eq!(boards(&s)[1], Rect::new(0.0, 650.0, 400.0, 950.0));
|
||||
}
|
||||
|
||||
/// Fit to Artwork Bounds fits each artboard it is given to the art on it, in one undo step: an
|
||||
/// object partly on an artboard counts for each artboard it overlaps, art flush with an edge from
|
||||
/// outside or on no artboard doesn't count, and an artboard with no art stays as it is.
|
||||
@@ -1026,6 +1131,55 @@ fn select_same_paint_reads_type_characters() {
|
||||
assert_eq!(same(&mut s, t1, "select.same.fillAndStroke"), vec![t1, r]);
|
||||
}
|
||||
|
||||
/// Select by Color needs no object to start from: it matches fills and strokes as
|
||||
/// `document.inspect` reports them, `#rrggbb`.
|
||||
#[test]
|
||||
fn select_color_matches_fill_and_stroke_without_a_selection() {
|
||||
let mut s = session();
|
||||
let [a, b, c] = [0.0, 20.0, 40.0].map(|x| rect(&mut s, x, 0.0, 10.0, 10.0));
|
||||
let t = text(&mut s, "a");
|
||||
fill(&mut s, a, "#000000");
|
||||
fill(&mut s, t, "#000000");
|
||||
// A grey as a PDF stores it reads as #fefefe.
|
||||
s.execute("paint.setFill", &json!({"ids": [b.0], "color": [0.996, 0.996, 0.996]})).unwrap();
|
||||
fill(&mut s, c, "#ff0000");
|
||||
s.execute("paint.setStroke", &json!({"ids": [a.0, c.0], "color": "#0000ff"})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let pick = |s: &mut Session, p: Value| {
|
||||
s.execute("select.color", &p).unwrap();
|
||||
let mut v = selected(s);
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
assert_eq!(pick(&mut s, json!({"fill": "#000000"})), vec![a, t]);
|
||||
assert_eq!(pick(&mut s, json!({"fill": "#fefefe"})), vec![b]);
|
||||
assert_eq!(pick(&mut s, json!({"stroke": "#0000ff"})), vec![a, c]);
|
||||
assert_eq!(pick(&mut s, json!({"fill": "#000000", "stroke": "#0000ff"})), vec![a]);
|
||||
// Select > Reselect repeats it.
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.execute("select.reselect", &json!({})).unwrap();
|
||||
assert_eq!(selected(&s), vec![a]);
|
||||
// A compound path reads as its own paint, never its members'.
|
||||
let outer = rect(&mut s, 100.0, 0.0, 50.0, 50.0);
|
||||
let inner = rect(&mut s, 110.0, 10.0, 20.0, 20.0);
|
||||
fill(&mut s, outer, "#000000");
|
||||
fill(&mut s, inner, "#000000");
|
||||
sel(&mut s, &[outer, inner]);
|
||||
let compound = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap());
|
||||
assert_eq!(pick(&mut s, json!({"fill": "#000000"})), vec![a, t, compound]);
|
||||
fill(&mut s, compound, "#ff0000");
|
||||
assert_eq!(pick(&mut s, json!({"fill": "#000000"})), vec![a, t]);
|
||||
assert_eq!(pick(&mut s, json!({"fill": "#ff0000"})), vec![c, compound]);
|
||||
// Type without characters reads as the default character fill, black.
|
||||
let empty = text(&mut s, "x");
|
||||
if let Some(NodeKind::Text(t)) = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(empty).map(|n| &mut n.kind) {
|
||||
t.runs.clear();
|
||||
}
|
||||
assert_eq!(pick(&mut s, json!({"fill": "#000000"})), vec![a, t, empty]);
|
||||
assert!(s.execute("select.color", &json!({})).is_err());
|
||||
assert!(s.execute("select.color", &json!({"fill": "not a colour"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_direction_handles_selects_all_anchors() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -160,3 +160,20 @@ fn newer_keys_and_legacy_profiles_survive_open_and_save() {
|
||||
let v2 = json_of(&saved(&mut s, json!({"version": 2})));
|
||||
assert_eq!(v2["document"]["unknown"]["colorProfiles"]["rgb"], "sRGB IEC61966-2.1");
|
||||
}
|
||||
|
||||
/// A save records the date the document was made in, so the same commands twice gave different
|
||||
/// bytes (#2880). A pinned date makes them identical — `file.new {created}` here, and
|
||||
/// `SOURCE_DATE_EPOCH` for an export that takes no date param.
|
||||
#[test]
|
||||
fn a_pinned_date_makes_saves_byte_identical() {
|
||||
let once = |t: i64| {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 200, "created": t})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
|
||||
saved(&mut s, json!({}))
|
||||
};
|
||||
let at = 1_791_208_980;
|
||||
assert_eq!(once(at), once(at), "the same document and date give the same bytes");
|
||||
// The date really is in the bytes, so the comparison above is not vacuous.
|
||||
assert_ne!(once(at), once(at + 1), "another date shows in the bytes");
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
//! Object › Objects on Path (#2545): objects attached to a path, spread along it and turned to
|
||||
//! follow it, kept there while the path is edited. Driven through `Session::execute`.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
|
||||
use super::tests_clip::{id_of, select};
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
|
||||
id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap())
|
||||
}
|
||||
|
||||
fn line(s: &mut Session, a: (f64, f64), b: (f64, f64)) -> NodeId {
|
||||
id_of(&s.execute("shape.line", &json!({"x1": a.0, "y1": a.1, "x2": b.0, "y2": b.1})).unwrap())
|
||||
}
|
||||
|
||||
fn bounds(s: &Session, id: NodeId) -> Rect {
|
||||
s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap()
|
||||
}
|
||||
|
||||
fn center(s: &Session, id: NodeId) -> Point {
|
||||
let c = bounds(s, id).center();
|
||||
Point::new((c.x * 1000.0).round() / 1000.0, (c.y * 1000.0).round() / 1000.0)
|
||||
}
|
||||
|
||||
fn attach(s: &mut Session, path: NodeId, objects: &[NodeId], extra: Value) -> NodeId {
|
||||
select(s, objects);
|
||||
let mut p = json!({ "path": path.0 });
|
||||
if let (Some(p), Some(e)) = (p.as_object_mut(), extra.as_object()) {
|
||||
p.extend(e.clone());
|
||||
}
|
||||
id_of(&s.execute("object.objectsOnPath.attach", &p).unwrap())
|
||||
}
|
||||
|
||||
fn set_line(s: &mut Session, id: NodeId, a: (f64, f64), b: (f64, f64)) {
|
||||
let anchors = json!([{"x": a.0, "y": a.1}, {"x": b.0, "y": b.1}]);
|
||||
s.execute("path.setAnchors", &json!({"id": id.0, "subpaths": [{"anchors": anchors, "closed": false}]})).unwrap();
|
||||
}
|
||||
|
||||
/// Three 20 pt squares, out of order on the canvas, and a 300 pt line from (50, 300) to the right.
|
||||
fn squares_and_line(s: &mut Session) -> ([NodeId; 3], NodeId) {
|
||||
let right = rect(s, 250.0, 20.0, 20.0, 20.0);
|
||||
let left = rect(s, 10.0, 100.0, 20.0, 20.0);
|
||||
let middle = rect(s, 120.0, 0.0, 20.0, 20.0);
|
||||
let path = line(s, (50.0, 300.0), (350.0, 300.0));
|
||||
([left, middle, right], path)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_spreads_the_objects_evenly_along_an_open_path_from_left_to_right() {
|
||||
let mut s = session();
|
||||
let ([left, middle, right], path) = squares_and_line(&mut s);
|
||||
let g = attach(&mut s, path, &[right, left, middle], json!({}));
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let n = d.node(g).unwrap();
|
||||
assert_eq!(n.kind_label(), "Objects on Path");
|
||||
assert_eq!(n.children().unwrap().first().map(|c| c.id), Some(path), "the path is at the bottom of the group");
|
||||
let rec = n.extras().objects_on_path.as_deref().unwrap();
|
||||
assert_eq!(rec.members.iter().map(|m| m.id).collect::<Vec<_>>(), [left, middle, right], "left to right on the canvas");
|
||||
assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [g]);
|
||||
// First on the start, last on the end, evenly between: the centers on the line.
|
||||
assert_eq!(center(&s, left), Point::new(50.0, 300.0));
|
||||
assert_eq!(center(&s, middle), Point::new(200.0, 300.0));
|
||||
assert_eq!(center(&s, right), Point::new(350.0, 300.0));
|
||||
let b = bounds(&s, middle);
|
||||
assert!((b.width() - 20.0).abs() < 1e-9 && (b.height() - 20.0).abs() < 1e-9, "upright on a level path: {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objects_in_a_column_go_top_to_bottom() {
|
||||
let mut s = session();
|
||||
// Its left edge is the leftmost, but it is below the other one in their column.
|
||||
let lower = rect(&mut s, 8.0, 80.0, 20.0, 20.0);
|
||||
let upper = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
|
||||
let beside = rect(&mut s, 100.0, 0.0, 20.0, 20.0);
|
||||
let path = line(&mut s, (0.0, 300.0), (300.0, 300.0));
|
||||
let g = attach(&mut s, path, &[beside, lower, upper], json!({}));
|
||||
let ids: Vec<NodeId> = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().unwrap().members.iter().map(|m| m.id).collect();
|
||||
assert_eq!(ids, [upper, lower, beside]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_objects_turn_with_the_path_and_rotate_tilts_them() {
|
||||
let mut s = session();
|
||||
// A 40 × 10 bar on a path that runs straight down: it turns a quarter.
|
||||
let bar = rect(&mut s, 0.0, 0.0, 40.0, 10.0);
|
||||
let other = rect(&mut s, 100.0, 0.0, 40.0, 10.0);
|
||||
let path = line(&mut s, (200.0, 50.0), (200.0, 350.0));
|
||||
let g = attach(&mut s, path, &[bar, other], json!({}));
|
||||
let b = bounds(&s, bar);
|
||||
assert!((b.width() - 10.0).abs() < 1e-6 && (b.height() - 40.0).abs() < 1e-6, "across the path: {b:?}");
|
||||
assert_eq!(center(&s, bar), Point::new(200.0, 50.0));
|
||||
// Rotate 90: turned back level, still centered on the path.
|
||||
s.execute("object.objectsOnPath.options", &json!({"rotate": 90})).unwrap();
|
||||
let b = bounds(&s, bar);
|
||||
assert!((b.width() - 40.0).abs() < 1e-6 && (b.height() - 10.0).abs() < 1e-6, "tilted by Rotate: {b:?}");
|
||||
assert_eq!(center(&s, other), Point::new(200.0, 350.0));
|
||||
let rec = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().cloned().unwrap();
|
||||
assert_eq!((rec.pivot, rec.rotate), (4, 90.0));
|
||||
// The same angle given to Attach.
|
||||
let mut t = session();
|
||||
let bar = rect(&mut t, 0.0, 0.0, 40.0, 10.0);
|
||||
let path = line(&mut t, (200.0, 50.0), (200.0, 350.0));
|
||||
attach(&mut t, path, &[bar], json!({"rotate": 90}));
|
||||
let b = bounds(&t, bar);
|
||||
assert!((b.width() - 40.0).abs() < 1e-6 && (b.height() - 10.0).abs() < 1e-6, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_turned_object_attaches_square_to_the_path_or_across_it() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 40.0, 10.0);
|
||||
let b = rect(&mut s, 100.0, 0.0, 40.0, 10.0);
|
||||
s.execute("object.rotate", &json!({"ids": [a.0], "angle": 30})).unwrap();
|
||||
s.execute("object.rotate", &json!({"ids": [b.0], "angle": 60})).unwrap();
|
||||
let path = line(&mut s, (50.0, 300.0), (350.0, 300.0));
|
||||
attach(&mut s, path, &[a, b], json!({}));
|
||||
let (ba, bb) = (bounds(&s, a), bounds(&s, b));
|
||||
assert!((ba.width() - 40.0).abs() < 1e-6 && (ba.height() - 10.0).abs() < 1e-6, "30° rounds to level: {ba:?}");
|
||||
assert!((bb.width() - 10.0).abs() < 1e-6 && (bb.height() - 40.0).abs() < 1e-6, "60° rounds to upright: {bb:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reflected_group_keeps_the_mirrored_tilt() {
|
||||
let mut s = session();
|
||||
let bar = rect(&mut s, 0.0, 0.0, 40.0, 10.0);
|
||||
let other = rect(&mut s, 100.0, 0.0, 40.0, 10.0);
|
||||
let path = line(&mut s, (50.0, 300.0), (350.0, 300.0));
|
||||
let g = attach(&mut s, path, &[bar, other], json!({"rotate": 30}));
|
||||
// The x of the bar's topmost corner, from its center: tilted counter-clockwise, it is on the right.
|
||||
let top_x = |s: &Session| {
|
||||
let n = s.doc().unwrap().doc.node(bar).unwrap();
|
||||
let anchors = &n.path_data().unwrap().subpaths[0].anchors;
|
||||
let top = anchors.iter().min_by(|a, b| a.p.y.total_cmp(&b.p.y)).unwrap().p;
|
||||
top.x - bounds(s, bar).center().x
|
||||
};
|
||||
assert!(top_x(&s) > 1.0);
|
||||
select(&mut s, &[g]);
|
||||
s.execute("object.reflect", &json!({"axis": "horizontal"})).unwrap();
|
||||
assert!(top_x(&s) < -1.0, "mirrored: tilted clockwise");
|
||||
let rec = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().cloned().unwrap();
|
||||
assert_eq!(rec.rotate, -30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaling_the_group_out_of_proportion_keeps_the_scaled_tilt_and_scaling_back_restores_it() {
|
||||
let mut s = session();
|
||||
let bar = rect(&mut s, 0.0, 0.0, 40.0, 10.0);
|
||||
let other = rect(&mut s, 100.0, 0.0, 40.0, 10.0);
|
||||
let path = line(&mut s, (50.0, 300.0), (350.0, 300.0));
|
||||
let g = attach(&mut s, path, &[bar, other], json!({"rotate": 45}));
|
||||
let anchors = |s: &Session| -> Vec<(f64, f64)> {
|
||||
let n = s.doc().unwrap().doc.node(bar).unwrap().clone();
|
||||
n.path_data().unwrap().subpaths[0].anchors.iter().map(|a| (a.p.x, a.p.y)).collect()
|
||||
};
|
||||
let before = anchors(&s);
|
||||
select(&mut s, &[g]);
|
||||
s.execute("object.scale", &json!({"sx": 200, "sy": 100, "origin": [200, 300]})).unwrap();
|
||||
// The bar's long edge was at 45°: twice as wide, it slopes at atan(1/2).
|
||||
let a = anchors(&s);
|
||||
let (dx, dy) = (a[1].0 - a[0].0, a[1].1 - a[0].1);
|
||||
assert!((dy.abs().atan2(dx.abs()).to_degrees() - 0.5f64.atan().to_degrees()).abs() < 1e-6, "{a:?}");
|
||||
s.execute("object.scale", &json!({"sx": 50, "sy": 100, "origin": [200, 300]})).unwrap();
|
||||
for (p, q) in anchors(&s).iter().zip(&before) {
|
||||
assert!((p.0 - q.0).abs() < 1e-6 && (p.1 - q.1).abs() < 1e-6, "{:?} against {before:?}", anchors(&s));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pivot_picks_the_point_that_sits_on_the_path() {
|
||||
let mut s = session();
|
||||
let ([left, middle, right], path) = squares_and_line(&mut s);
|
||||
attach(&mut s, path, &[left, middle, right], json!({"pivot": 7}));
|
||||
// The middle of each square's bottom edge is on the line: the squares stand on it.
|
||||
let b = bounds(&s, middle);
|
||||
assert!((b.y1 - 300.0).abs() < 1e-6 && (b.center().x - 200.0).abs() < 1e-6, "{b:?}");
|
||||
s.execute("object.objectsOnPath.options", &json!({"pivot": 1})).unwrap();
|
||||
let b = bounds(&s, middle);
|
||||
assert!((b.y0 - 300.0).abs() < 1e-6, "hanging from the line by their top edge: {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closed_path_spreads_the_objects_all_the_way_round_from_its_first_anchor() {
|
||||
let mut s = session();
|
||||
let ids: Vec<NodeId> = (0..4).map(|i| rect(&mut s, 10.0 + 40.0 * f64::from(i), 0.0, 10.0, 10.0)).collect();
|
||||
let ring = rect(&mut s, 100.0, 100.0, 200.0, 200.0);
|
||||
let first = s.doc().unwrap().doc.node(ring).unwrap().path_data().unwrap().subpaths[0].anchors[0].p;
|
||||
attach(&mut s, ring, &ids, json!({}));
|
||||
assert_eq!(center(&s, ids[0]), first, "the first object on the first anchor");
|
||||
let mut at: Vec<(i64, i64)> = ids.iter().map(|id| center(&s, *id)).map(|p| (p.x.round() as i64, p.y.round() as i64)).collect();
|
||||
at.sort();
|
||||
assert_eq!(at, [(100, 100), (100, 300), (300, 100), (300, 300)], "a quarter of the way round apart");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_the_path_moves_the_objects_and_undo_takes_it_back() {
|
||||
let mut s = session();
|
||||
let ([left, middle, right], path) = squares_and_line(&mut s);
|
||||
attach(&mut s, path, &[left, middle, right], json!({}));
|
||||
set_line(&mut s, path, (50.0, 250.0), (250.0, 250.0));
|
||||
assert_eq!(center(&s, left), Point::new(50.0, 250.0));
|
||||
assert_eq!(center(&s, middle), Point::new(150.0, 250.0));
|
||||
assert_eq!(center(&s, right), Point::new(250.0, 250.0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(center(&s, middle), Point::new(200.0, 300.0), "one undo step puts the path and the objects back");
|
||||
// Attach itself is one step.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(center(&s, middle), Point::new(130.0, 10.0));
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert!(d.layers.iter().all(|l| l.children().unwrap().iter().all(|c| !vectorcraft_doc::objects_on_path::is_objects_on_path(c))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_object_moved_on_its_own_goes_back_on_the_path() {
|
||||
let mut s = session();
|
||||
let ([left, middle, right], path) = squares_and_line(&mut s);
|
||||
let g = attach(&mut s, path, &[left, middle, right], json!({}));
|
||||
s.execute("object.move", &json!({"ids": [middle.0], "dx": 30, "dy": -40})).unwrap();
|
||||
assert_eq!(center(&s, middle), Point::new(200.0, 300.0));
|
||||
// The whole group moves as one.
|
||||
select(&mut s, &[g]);
|
||||
s.execute("object.move", &json!({"dx": 10, "dy": 20})).unwrap();
|
||||
assert_eq!(center(&s, middle), Point::new(210.0, 320.0));
|
||||
assert_eq!(center(&s, right), Point::new(360.0, 320.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objects_moved_into_the_group_join_the_end_and_ones_moved_out_leave() {
|
||||
let mut s = session();
|
||||
let ([left, middle, right], path) = squares_and_line(&mut s);
|
||||
let g = attach(&mut s, path, &[left, middle], json!({}));
|
||||
s.execute("layer.move", &json!({"ids": [right.0], "target": g.0, "place": "inside"})).unwrap();
|
||||
assert_eq!(center(&s, left), Point::new(50.0, 300.0));
|
||||
assert_eq!(center(&s, middle), Point::new(200.0, 300.0));
|
||||
assert_eq!(center(&s, right), Point::new(350.0, 300.0), "the new object goes at the end");
|
||||
s.execute("layer.move", &json!({"ids": [middle.0], "target": g.0, "place": "above"})).unwrap();
|
||||
let rec = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().cloned().unwrap();
|
||||
assert_eq!(rec.members.iter().map(|m| m.id).collect::<Vec<_>>(), [left, right]);
|
||||
assert_eq!(center(&s, right), Point::new(350.0, 300.0), "two objects stay on the ends");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_that_replaces_an_object_keeps_its_place_on_the_path() {
|
||||
let mut s = session();
|
||||
let mark = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
|
||||
let first = id_of(&s.execute("symbol.new", &json!({"name": "Mark", "ids": [mark.0]})).unwrap());
|
||||
let second = id_of(&s.execute("symbol.place", &json!({"name": "Mark", "x": 100, "y": 10})).unwrap());
|
||||
let third = id_of(&s.execute("symbol.place", &json!({"name": "Mark", "x": 200, "y": 10})).unwrap());
|
||||
let path = line(&mut s, (50.0, 300.0), (350.0, 300.0));
|
||||
let g = attach(&mut s, path, &[first, second, third], json!({}));
|
||||
let v = s.execute("symbol.breakLink", &json!({"ids": [first.0]})).unwrap();
|
||||
let art = v["ids"].as_array().and_then(|a| a.first()).and_then(Value::as_u64).map(NodeId).unwrap();
|
||||
let rec = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().cloned().unwrap();
|
||||
assert_eq!(rec.members.iter().map(|m| m.id).collect::<Vec<_>>(), [art, second, third], "the art takes the instance's place");
|
||||
assert_eq!(center(&s, art), Point::new(50.0, 300.0));
|
||||
assert_eq!(center(&s, third), Point::new(350.0, 300.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shaper_group_stays_on_the_path_when_a_source_changes() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
rect(&mut s, 50.0, 0.0, 100.0, 100.0);
|
||||
let pts = [[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0]];
|
||||
let shaper = NodeId(s.execute("shaper.scribble", &json!({"points": pts})).unwrap()["id"].as_u64().unwrap());
|
||||
let other = rect(&mut s, 300.0, 0.0, 20.0, 20.0);
|
||||
let path = line(&mut s, (50.0, 300.0), (350.0, 300.0));
|
||||
attach(&mut s, path, &[shaper, other], json!({}));
|
||||
assert_eq!(center(&s, shaper), Point::new(50.0, 300.0));
|
||||
select(&mut s, &[a]);
|
||||
s.execute("object.setBounds", &json!({"width": 200.0, "height": 100.0, "reference": 0})).unwrap();
|
||||
// The rebuilt art is wider, and its center is still on the start of the path.
|
||||
assert!(bounds(&s, shaper).width() > 180.0, "{:?}", bounds(&s, shaper));
|
||||
assert_eq!(center(&s, shaper), Point::new(50.0, 300.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_group_saves_and_reopens_live_and_exports_as_plain_art() {
|
||||
let mut s = session();
|
||||
let ([left, middle, right], path) = squares_and_line(&mut s);
|
||||
let g = attach(&mut s, path, &[left, middle, right], json!({"rotate": 30, "pivot": 7}));
|
||||
let data = s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap()["dataBase64"].clone();
|
||||
let mut o = Session::new();
|
||||
o.execute("document.open", &json!({"name": "objects.vectorcraft", "dataBase64": data})).unwrap();
|
||||
let before = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.clone();
|
||||
assert_eq!(o.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path, before);
|
||||
set_line(&mut o, path, (50.0, 200.0), (350.0, 200.0));
|
||||
assert!((bounds(&o, middle).center().x - bounds(&s, middle).center().x).abs() < 1e-6);
|
||||
assert!(bounds(&o, middle).center().y < 210.0, "editing the reopened path moves the objects");
|
||||
for format in ["svg", "pdf"] {
|
||||
let data = s.execute("document.export", &json!({"format": format})).unwrap()["dataBase64"].clone();
|
||||
let mut o = Session::new();
|
||||
o.execute("document.open", &json!({"name": format!("objects.{format}"), "dataBase64": data})).unwrap();
|
||||
let r = vectorcraft_render::Renderer::new().render(&o.doc().unwrap().doc, 400, 400, vectorcraft_geom::Affine::IDENTITY, &Default::default());
|
||||
let c = bounds(&s, middle).center();
|
||||
assert!(r.pixel(c.x as u32, c.y as u32 - 4)[3] > 128, "{format}: the square is drawn where it is attached");
|
||||
assert!(r.pixel(130, 10)[3] < 10, "{format}: not where it was drawn");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pasted_copy_stays_live_on_its_own_path() {
|
||||
let mut s = session();
|
||||
let ([left, middle, right], path) = squares_and_line(&mut s);
|
||||
let g = attach(&mut s, path, &[left, middle, right], json!({}));
|
||||
select(&mut s, &[g]);
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
let copy = s.doc().unwrap().selection.objects.to_vec();
|
||||
let [copy] = copy[..] else { panic!("{copy:?}") };
|
||||
assert_ne!(copy, g);
|
||||
let n = s.doc().unwrap().doc.node(copy).unwrap().clone();
|
||||
let rec = n.extras().objects_on_path.as_deref().cloned().unwrap();
|
||||
let kids: Vec<NodeId> = n.children().unwrap().iter().map(|c| c.id).collect();
|
||||
assert_eq!(kids.first(), Some(&rec.path), "the copy names its own path");
|
||||
assert!(rec.members.iter().all(|m| kids.contains(&m.id) && ![left, middle, right].contains(&m.id)));
|
||||
let (a, b) = (s.doc().unwrap().doc.node(rec.path).unwrap().geometric_bounds().unwrap(), bounds(&s, path));
|
||||
set_line(&mut s, rec.path, (a.x0, a.y0 - 50.0), (a.x1, a.y0 - 50.0));
|
||||
assert!((bounds(&s, rec.members[1].id).center().y - (a.y0 - 50.0)).abs() < 1e-6, "the copy's objects follow the copy's path");
|
||||
assert_eq!(bounds(&s, path), b);
|
||||
assert_eq!(center(&s, middle), Point::new(200.0, 300.0), "the original stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_leaves_a_plain_group() {
|
||||
let mut s = session();
|
||||
let ([left, middle, right], path) = squares_and_line(&mut s);
|
||||
let g = attach(&mut s, path, &[left, middle, right], json!({}));
|
||||
let v = s.execute("object.objectsOnPath.expand", &json!({})).unwrap();
|
||||
assert_eq!(v["ids"], json!([g.0]));
|
||||
let n = s.doc().unwrap().doc.node(g).unwrap().clone();
|
||||
assert_eq!(n.kind_label(), "Group");
|
||||
assert_eq!(n.children().unwrap().len(), 4);
|
||||
set_line(&mut s, path, (50.0, 250.0), (250.0, 250.0));
|
||||
assert_eq!(center(&s, middle), Point::new(200.0, 300.0), "the objects stay where they were");
|
||||
assert!(s.execute("object.objectsOnPath.expand", &json!({})).is_err(), "nothing left to expand");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_without_a_path_chooses_the_tool() {
|
||||
let mut s = session();
|
||||
let ([left, ..], _) = squares_and_line(&mut s);
|
||||
select(&mut s, &[left]);
|
||||
assert_eq!(s.execute("object.objectsOnPath.attach", &json!({})).unwrap()["tool"], "objectsOnPath");
|
||||
assert_eq!(s.tool_id(), "objectsOnPath");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_cannot_be_attached_is_refused_with_a_reason() {
|
||||
let mut s = session();
|
||||
let ([left, _, right], path) = squares_and_line(&mut s);
|
||||
let err = |s: &mut Session, p: Value| s.execute("object.objectsOnPath.attach", &p).unwrap_err().to_string();
|
||||
select(&mut s, &[path]);
|
||||
assert!(err(&mut s, json!({"path": path.0})).contains("select the objects"), "the path alone");
|
||||
let dot = line(&mut s, (10.0, 10.0), (10.0, 10.0));
|
||||
assert!(err(&mut s, json!({"path": dot.0, "ids": [left.0]})).contains("pick a path"), "a path without length");
|
||||
let guide = line(&mut s, (0.0, 380.0), (300.0, 380.0));
|
||||
select(&mut s, &[guide]);
|
||||
s.execute("view.guides.make", &json!({})).unwrap();
|
||||
assert!(err(&mut s, json!({"path": guide.0, "ids": [right.0]})).contains("pick a path"), "a guide");
|
||||
let g = attach(&mut s, path, &[right], json!({}));
|
||||
let other = line(&mut s, (0.0, 350.0), (300.0, 350.0));
|
||||
assert!(err(&mut s, json!({"path": other.0, "ids": [right.0]})).contains("already on a path"));
|
||||
assert!(err(&mut s, json!({"path": path.0, "ids": [left.0]})).contains("already in an Objects on Path group"));
|
||||
assert!(err(&mut s, json!({"path": other.0, "ids": [g.0], "pivot": 9})).contains("pivot"));
|
||||
assert!(s.execute("object.objectsOnPath.options", &json!({"ids": [other.0]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_and_expand_take_ids_without_a_selection() {
|
||||
let mut s = session();
|
||||
let ([left, middle, right], path) = squares_and_line(&mut s);
|
||||
let g = attach(&mut s, path, &[left, middle, right], json!({}));
|
||||
s.execute("select.set", &json!({"ids": []})).unwrap();
|
||||
// An object in the group names the group.
|
||||
let v = s.execute("object.objectsOnPath.options", &json!({"ids": [middle.0], "pivot": 7})).unwrap();
|
||||
assert_eq!(v["ids"], json!([g.0]));
|
||||
assert!((bounds(&s, middle).y1 - 300.0).abs() < 1e-6, "standing on the line");
|
||||
let v = s.execute("object.objectsOnPath.expand", &json!({"ids": [g.0]})).unwrap();
|
||||
assert_eq!(v["ids"], json!([g.0]));
|
||||
assert_eq!(s.doc().unwrap().doc.node(g).unwrap().kind_label(), "Group");
|
||||
assert!(s.execute("object.objectsOnPath.expand", &json!({"ids": [g.0]})).is_err(), "no longer an Objects on Path group");
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
//! report mixed values, and saves and exports never carry the editing layer.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_geom::Affine;
|
||||
use vectorcraft_render::{RenderOptions, Renderer};
|
||||
|
||||
use super::*;
|
||||
use crate::cmd::maskedit::MASK_EDIT_LAYER;
|
||||
@@ -46,6 +48,21 @@ fn data(v: &Value) -> Vec<u8> {
|
||||
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// #2727: the canvas passes `isolation` while mask art is edited; the editing layer is not drawn on
|
||||
/// the page, so isolation dimming must not run or the artwork washes out.
|
||||
#[test]
|
||||
fn mask_editing_does_not_dim_the_canvas() {
|
||||
let mut s = session();
|
||||
editing_mask(&mut s);
|
||||
let st = s.doc().unwrap();
|
||||
let layer = st.isolation.expect("mask editing isolates the temporary layer");
|
||||
let render = |opts: &RenderOptions| Renderer::new().render(&st.doc, 300, 300, Affine::IDENTITY, opts);
|
||||
let plain = render(&RenderOptions::default());
|
||||
let canvas = render(&RenderOptions { isolated: Some(layer), ..Default::default() });
|
||||
assert_eq!(plain.pixel(50, 100), canvas.pixel(50, 100), "inside the mask");
|
||||
assert_eq!(plain.pixel(250, 100), canvas.pixel(250, 100), "outside the mask");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_options_act_on_the_masked_object_while_editing() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -1181,7 +1181,10 @@ fn new_type_starts_with_placeholder_text() {
|
||||
gesture(&mut s, v, &[(Down, 100.0, 150.0), (Drag, 300.0, 300.0), (Up, 300.0, 300.0)]);
|
||||
assert!(text(&s).len() > placeholder.len());
|
||||
set_pref(&mut s, "placeholderText", json!(false));
|
||||
gesture(&mut s, v, &[(Down, 50.0, 350.0), (Up, 50.0, 350.0)]);
|
||||
// The first click ends editing the area type (#2871); the second places point type.
|
||||
for _ in 0..2 {
|
||||
gesture(&mut s, v, &[(Down, 50.0, 350.0), (Up, 50.0, 350.0)]);
|
||||
}
|
||||
assert_eq!(text(&s), "");
|
||||
}
|
||||
|
||||
|
||||
@@ -131,6 +131,44 @@ fn mismatched_and_missing_bindings_are_skipped_and_counted() {
|
||||
assert_eq!(plain(&s, t), "y");
|
||||
}
|
||||
|
||||
/// A row with no value for any bound variable, such as one from Import Data Sets… before anything
|
||||
/// is bound, applies nothing, and the reply's `warning` names it. A bound variable with a value in
|
||||
/// the row gives no warning, even when its object is locked and skipped.
|
||||
#[test]
|
||||
fn a_row_with_no_value_for_a_bound_variable_warns_that_nothing_was_applied() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "x");
|
||||
s.execute("dataset.import", &json!({"csv": "name,Name,Title\nA,Ann,Boss\nB,Bob,Chief\n"})).unwrap();
|
||||
let warning = |row: &str| format!("nothing in data set {row} was applied: no object is bound to a variable it has a value for");
|
||||
let v = s.execute("dataset.select", &json!({"name": "A"})).unwrap();
|
||||
assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64(), &v["warning"]), (Some(0), Some(0), &json!(warning("A"))), "{v}");
|
||||
let v = s.execute("dataset.next", &json!({})).unwrap();
|
||||
assert_eq!(v["warning"], json!(warning("B")), "{v}");
|
||||
let v = s.execute("dataset.prev", &json!({})).unwrap();
|
||||
assert_eq!(v["warning"], json!(warning("A")), "{v}");
|
||||
assert_eq!(plain(&s, t), "x");
|
||||
|
||||
// A bound variable that the row has no value for still applies nothing.
|
||||
s.execute("variable.define", &json!({"name": "Other", "kind": "text"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Other", "ids": [t.0]})).unwrap();
|
||||
let v = s.execute("dataset.select", &json!({"name": "B"})).unwrap();
|
||||
assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64(), &v["warning"]), (Some(0), Some(1), &json!(warning("B"))), "{v}");
|
||||
|
||||
// Bound to a variable with a value, the row applies it and gives no warning.
|
||||
s.execute("variable.bind", &json!({"variable": "Title", "ids": [t.0]})).unwrap();
|
||||
let v = s.execute("dataset.select", &json!({"name": "A"})).unwrap();
|
||||
assert_eq!(v["applied"].as_u64(), Some(1), "{v}");
|
||||
assert!(v.get("warning").is_none(), "{v}");
|
||||
assert_eq!(plain(&s, t), "Boss");
|
||||
|
||||
// Locked art is skipped, and its binding still counts as bound.
|
||||
s.execute("select.set", &json!({"ids": [t.0]})).unwrap();
|
||||
s.execute("object.lock", &json!({})).unwrap();
|
||||
let v = s.execute("dataset.select", &json!({"name": "B"})).unwrap();
|
||||
assert_eq!(v["applied"].as_u64(), Some(0), "{v}");
|
||||
assert!(v.get("warning").is_none(), "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_variable_cleans_bindings_values_and_leaves_datasets_alone() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -227,6 +227,43 @@ fn live_objects_draw_through_new_color_settings() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The art drawn for 3D effects and color adjustments follows the Color Settings: in the next
|
||||
/// frame, a CMYK cyan rectangle with Revolve, with Extrude & Bevel, with Inflate, with Rotate, with
|
||||
/// Brightness/Contrast, and a group holding it with Brightness/Contrast, draw through the new
|
||||
/// working CMYK profile.
|
||||
#[test]
|
||||
fn effect_art_draws_through_new_color_settings() {
|
||||
let _g = settings();
|
||||
let old = cms::active_settings();
|
||||
let cyan = json!({"c": 100, "m": 0, "y": 0, "k": 0});
|
||||
let brighter = json!({"brightness": 30});
|
||||
let cases = [
|
||||
("the revolved rectangle", "threeD.revolve", json!({}), false),
|
||||
("the extruded rectangle", "threeD.extrude", json!({}), false),
|
||||
("the inflated rectangle", "threeD.inflate", json!({}), false),
|
||||
("the rotated rectangle", "threeD.rotate", json!({}), false),
|
||||
("the adjusted rectangle", "adjust.brightnessContrast", brighter.clone(), false),
|
||||
("the adjusted group", "adjust.brightnessContrast", brighter, true),
|
||||
];
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let frame = |r: &mut Renderer, s: &Session| r.render(&s.doc().unwrap().doc, 400, 300, Affine::IDENTITY, &opts).pixels;
|
||||
for (what, effect, params, grouped) in cases {
|
||||
let mut s = session();
|
||||
let shape = rect(&mut s, 150.0, 60.0, 60.0, 120.0);
|
||||
exec(&mut s, "paint.setFill", json!({"ids": [shape.0], "color": cyan}));
|
||||
let target = if grouped { id_of(&exec(&mut s, "object.group", json!({"ids": [shape.0]}))) } else { shape };
|
||||
exec(&mut s, "effect.apply", json!({"ids": [target.0], "effect": effect, "params": params}));
|
||||
let mut r = Renderer::new();
|
||||
let before = frame(&mut r, &s);
|
||||
s.execute("edit.colorSettings", &json!({"cmyk": cms::DEVICE_CMYK})).unwrap();
|
||||
let fresh = frame(&mut Renderer::new(), &s);
|
||||
let warm = frame(&mut r, &s);
|
||||
cms::set_active(&old).unwrap();
|
||||
assert!(fresh != before, "{what}: the new profile changes the frame");
|
||||
assert!(warm == fresh, "{what}: drawn as a new renderer draws it");
|
||||
}
|
||||
}
|
||||
|
||||
/// #2276: with a working RGB space other than sRGB, the Eyedropper takes an image's sRGB pixel
|
||||
/// into the working space, so the sampled fill shows as the pixel it was picked from.
|
||||
#[test]
|
||||
|
||||
@@ -219,6 +219,8 @@ fn structured_junk() {
|
||||
("path.insertAnchor", json!({"id": 2, "subpath": 0, "segment": 99, "t": 5})),
|
||||
("path.insertAnchor", json!({"id": 2, "subpath": 7, "segment": 0, "t": -1})),
|
||||
("path.setHandle", json!({"id": 2, "subpath": 0, "anchor": 100, "which": "in", "x": 0, "y": 0})),
|
||||
("path.moveAnchor", json!({"id": 2, "subpath": 0, "anchor": 100, "x": 0, "y": 0})),
|
||||
("path.moveAnchor", json!({"id": 2, "subpath": 0, "anchor": 0, "x": 1e308, "y": -1e308})),
|
||||
("path.removeAnchor", json!({"id": 2, "subpath": 0, "anchor": 0})),
|
||||
("path.split", json!({"id": 2, "subpath": 0, "segment": 1000, "t": 0.5})),
|
||||
("path.reshapeSegment", json!({"id": 2, "subpath": 0, "segment": 0, "t": 2.0, "dx": 1e308, "dy": 0})),
|
||||
|
||||
@@ -1428,6 +1428,57 @@ proptest! {
|
||||
}
|
||||
}
|
||||
|
||||
/// Targa files, which start with no signature: run-length encoded 24 bits (as the `image` crate
|
||||
/// writes them), a 16-bit export with a one-bit alpha, and by hand a 16-bit run-length encoded file
|
||||
/// stored right to left and top down, a run-length encoded color-mapped one with 16-bit entries,
|
||||
/// and a 32-bit one with an extension area.
|
||||
fn targa_samples() -> Vec<(&'static str, Vec<u8>)> {
|
||||
let mut rle = vec![];
|
||||
image::RgbImage::from_pixel(5, 3, image::Rgb([200, 40, 10])).write_to(&mut std::io::Cursor::new(&mut rle), image::ImageFormat::Tga).unwrap();
|
||||
let mut s = rich_session();
|
||||
let r = s.execute("document.export", &json!({"format": "tga", "depth": 16, "ppi": 2})).unwrap();
|
||||
let export = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let mut alpha = vec![0, 0, 10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 0, 2, 0, 16, 0x31, 0x02, 0x00, 0xfc, 0x1f, 0x80, 0x00, 0x00];
|
||||
alpha.extend([0x82, 0xe0, 0x83]);
|
||||
let mut mapped = vec![0, 1, 9, 5, 0, 2, 0, 16, 0, 0, 0, 0, 3, 0, 2, 0, 8, 0x21, 0x00, 0x00, 0x00, 0xfc];
|
||||
mapped.extend([0x80, 6, 0x00, 5, 0x83, 6]);
|
||||
let mut extended = vec![0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 32, 0x28, 50, 50, 50, 128];
|
||||
let mut area = vec![0; 495];
|
||||
area[..2].copy_from_slice(&495u16.to_le_bytes());
|
||||
area[494] = 4;
|
||||
extended.extend(area);
|
||||
extended.extend(22u32.to_le_bytes());
|
||||
extended.extend([0; 4]);
|
||||
extended.extend(b"TRUEVISION-XFILE.\0");
|
||||
vec![("a.tga", rle), ("b.tga", export), ("c.tga", alpha), ("d.vda", mapped), ("e.vst", extended)]
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(config())]
|
||||
|
||||
/// Mutated Targa files (headers, run-length packets, color maps, the extension area and the
|
||||
/// footer) never crash placing them.
|
||||
#[test]
|
||||
fn mutated_targa_files_place_without_panics(which in 0usize..5, cut in 0usize..600, edits in prop::collection::vec((0usize..560, any::<u8>()), 0..12)) {
|
||||
let (name, mut bytes) = targa_samples().swap_remove(which);
|
||||
for (at, b) in edits {
|
||||
if let Some(x) = bytes.get_mut(at) {
|
||||
*x = b;
|
||||
}
|
||||
}
|
||||
bytes.truncate(cut.max(8));
|
||||
let r = catch_quiet(|| {
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
let p = json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes), "thumbnail": 8});
|
||||
let _ = no_panic(s.execute("file.place.info", &p));
|
||||
let _ = no_panic(s.execute("file.place", &p));
|
||||
let _ = no_panic(s.execute("clipboard.importImage", &json!({"dataBase64": vectorcraft_format::base64_encode(&bytes)})));
|
||||
});
|
||||
prop_assert!(r.is_ok(), "{name}: panicked: {:?}", r.err());
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- SVG saved with hidden layers and editing data ----------
|
||||
|
||||
/// The rich document with a hidden layer, saved as SVG with its editing data.
|
||||
@@ -1741,6 +1792,27 @@ proptest! {
|
||||
let text = mutate_text(&ccitt_images_eps(), cut, &edits);
|
||||
survive("mutated EPS CCITT images", || vectorcraft_eps::import(text.as_bytes()).ok().map(|r| r.document))?;
|
||||
}
|
||||
|
||||
/// TIFF-predicted Flate data (Predictor 2) at every depth the filter accepts, with any bytes, 1 to 5
|
||||
/// colors and 1 to 39 columns, read by images of up to 23 × 7 pixels at 1, 2, 4, 8, 12 or 16 bits:
|
||||
/// read as an image, or as an error.
|
||||
#[test]
|
||||
fn eps_tiff_predicted_images_never_panic(
|
||||
data in prop::collection::vec(any::<u8>(), 0..300),
|
||||
bpc in prop::sample::select(vec![1u8, 2, 4, 8, 16]),
|
||||
colors in 1u8..6,
|
||||
columns in 1u16..40,
|
||||
image_bpc in prop::sample::select(vec![1u8, 2, 4, 8, 12, 16]),
|
||||
(width, height) in (1u8..24, 1u8..8),
|
||||
) {
|
||||
let hex: String = deflated(&data).iter().map(|b| format!("{b:02x}")).collect();
|
||||
let text = format!(
|
||||
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n40 30 scale {width} {height} {image_bpc} [{width} 0 0 -{height} 0 {height}] \
|
||||
currentfile /ASCIIHexDecode filter << /Predictor 2 /Colors {colors} /BitsPerComponent {bpc} /Columns {columns} >> /FlateDecode filter image\n\
|
||||
{hex}>\nshowpage\n%%EOF\n"
|
||||
);
|
||||
survive("TIFF-predicted EPS images", || vectorcraft_eps::import(text.as_bytes()).ok().map(|r| r.document))?;
|
||||
}
|
||||
}
|
||||
|
||||
/// An EPS with two 8 × 3 CCITT fax images that the PostScript interpreter reads, coded by hand from
|
||||
@@ -1851,7 +1923,8 @@ fn arb_ps_token() -> impl Strategy<Value = String> {
|
||||
"status", "token", "bytesavailable", "resetfile", "pdfmark", "execform", "nulldevice", "strokepath", "pathforall",
|
||||
"currenthsbcolor", "currentcolorrendering", "gcheck", "rootfont", "setcachedevice2", "writestring", "(%stdout) (w) file",
|
||||
"//x", "{ //dup }", "/ReusableStreamDecode", "currentfile /ASCII85Decode filter /ReusableStreamDecode filter",
|
||||
"<< /Predictor 12 /Columns 2 /Colors 1 >> /FlateDecode", "<< /FormType 1 /BBox [0 0 9 9] /PaintProc { pop 0 0 5 5 rectfill } >>",
|
||||
"<< /Predictor 12 /Columns 2 /Colors 1 >> /FlateDecode", "<< /Predictor 2 /Colors 3 /BitsPerComponent 4 /Columns 3 >> /LZWDecode",
|
||||
"<< /Predictor 2 /BitsPerComponent 16 /Columns 5 >> /FlateDecode", "<< /FormType 1 /BBox [0 0 9 9] /PaintProc { pop 0 0 5 5 rectfill } >>",
|
||||
// Tiling patterns, uncoloured ones and patterns drawing patterns.
|
||||
"<< /PatternType 1 /PaintType 1 /XStep 5 /YStep 5 /BBox [0 0 5 5] /PaintProc { pop 0 0 3 3 rectfill } >> matrix makepattern",
|
||||
"<< /PatternType 1 /PaintType 2 /XStep 5 /YStep 5 /BBox [0 0 5 5] /PaintProc { pop dup setpattern 0 0 3 3 rectfill } >>",
|
||||
|
||||
@@ -1324,6 +1324,10 @@ impl<'a> Reader<'a> {
|
||||
// Far-Eastern type) and final-form positions (§15.3).
|
||||
"Tk" | "TK" | "TC" | "TW" | "Tq" | "Tc" | "Tw" | "Tm" | "Td" | "T*" | "TR" | "T+" | "T-" | "TV" | "Tv" | "Ty" | "TY" | "TG" | "Tg"
|
||||
| "Tu" | "Xu" => {}
|
||||
// Hyphenation and tab-ruler attributes the reader doesn't keep either (§22): `Th`
|
||||
// hyphen control, `XL` hyphenation language, `Xb`/`XB` a tab definition (and the `Xe`
|
||||
// tab descriptions between them).
|
||||
"Th" | "XL" | "Xb" | "XB" | "Xe" => {}
|
||||
"TE" => self.te(&vals),
|
||||
// An image whose samples follow in comment lines (read at `%AI5_EndRaster`).
|
||||
"XI" if self.raster.is_some() => {
|
||||
@@ -2012,9 +2016,15 @@ impl<'a> Reader<'a> {
|
||||
/// An image of operands `vals` and samples `data`.
|
||||
fn image_from(&mut self, vals: Vec<V>, data: &[u8]) -> Result<(), String> {
|
||||
let space = self.raster.as_ref().and_then(|r| r.space.clone()).unwrap_or_default();
|
||||
let Some(m) = vals.iter().find_map(|v| if let V::Arr(a) = v { Some(Self::nums(a)) } else { None }) else { return Ok(()) };
|
||||
let Some(m) = vals.iter().find_map(|v| if let V::Arr(a) = v { Some(Self::nums(a)) } else { None }) else {
|
||||
self.warn("an image VectorCraft can't read was left out");
|
||||
return Ok(());
|
||||
};
|
||||
let n = Self::nums(&vals);
|
||||
let [_, _, _, _, w, h, bits, kind, alpha, _, binary, ..] = n.as_slice() else { return Ok(()) };
|
||||
let [_, _, _, _, w, h, bits, kind, alpha, _, binary, ..] = n.as_slice() else {
|
||||
self.warn("an image VectorCraft can't read was left out");
|
||||
return Ok(());
|
||||
};
|
||||
let (w, h) = (*w as u64, *h as u64);
|
||||
if w == 0 || h == 0 || w.saturating_mul(h) > MAX_PIXELS || ![1.0, 8.0].contains(bits) {
|
||||
self.warn("an image VectorCraft can't read was left out");
|
||||
@@ -2080,7 +2090,10 @@ impl<'a> Reader<'a> {
|
||||
rgba.extend_from_slice(&[px[0], px[1], px[2], a]);
|
||||
}
|
||||
}
|
||||
let Some(img) = image::RgbaImage::from_raw(w as u32, h as u32, rgba) else { return Ok(()) };
|
||||
let Some(img) = image::RgbaImage::from_raw(w as u32, h as u32, rgba) else {
|
||||
self.warn("an image VectorCraft can't read was left out");
|
||||
return Ok(());
|
||||
};
|
||||
let mut png = vec![];
|
||||
if img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_err() {
|
||||
self.warn("an image VectorCraft can't read was left out");
|
||||
@@ -2096,10 +2109,14 @@ impl<'a> Reader<'a> {
|
||||
// The matrix is an art-space matrix (y up) applied to the pixels with their rows running
|
||||
// downwards, (u, -v): pixels → (u, -v) → art space. Flipping the matrix's result instead
|
||||
// gives the same place only when the image isn't rotated (b = c = 0).
|
||||
let [a, b, c, d, tx, ty] = m else { return Ok(()) };
|
||||
let [a, b, c, d, tx, ty] = m else {
|
||||
self.warn("an image VectorCraft can't read was left out");
|
||||
return Ok(());
|
||||
};
|
||||
let to_doc = self.space();
|
||||
let xf = to_doc * Affine::new([*a, *b, *c, *d, *tx, *ty]) * FLIP;
|
||||
if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 {
|
||||
self.warn("an image VectorCraft can't read was left out");
|
||||
return Ok(());
|
||||
}
|
||||
let mask = alpha.and_then(|a| self.alpha_mask(a, w, h, xf));
|
||||
|
||||
@@ -677,6 +677,40 @@ fn images_with_alpha_channels() {
|
||||
assert!(art(&r.doc).is_empty() && r.warnings.iter().any(|w| w.contains("cut short")));
|
||||
}
|
||||
|
||||
/// Editing data `data` opens without its one image and with the warning "an image VectorCraft can't read was left out" once.
|
||||
fn image_left_out_with_a_warning(data: &[u8]) {
|
||||
let r = read(data).unwrap();
|
||||
assert!(art(&r.doc).is_empty(), "{:#?}", art(&r.doc));
|
||||
let left_out = r.warnings.iter().filter(|w| *w == "an image VectorCraft can't read was left out").count();
|
||||
assert_eq!(left_out, 1, "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_without_a_matrix_is_left_out_with_a_warning() {
|
||||
image_left_out_with_a_warning(&image("", (1, 1), ("DeviceRGB", 3, 0), &[255, 0, 0], 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_with_too_few_operands_is_left_out_with_a_warning() {
|
||||
// The operands hold 10 numbers after the matrix, one short of the binary flag.
|
||||
let text = String::from_utf8(image("[ 1 0 0 1 0 100 ]", (1, 1), ("DeviceRGB", 3, 0), b"abc", 3)).unwrap();
|
||||
let data = text.replacen("] 0 0 1 1 1 1 8 3 0 0 1 0 4 4 0 0\n", "] 0 0 1 1 1 1 8 3 0 0\n", 1);
|
||||
assert_ne!(data, text);
|
||||
image_left_out_with_a_warning(data.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_whose_matrix_isnt_six_numbers_is_left_out_with_a_warning() {
|
||||
image_left_out_with_a_warning(&image("[ 1 0 0 1 ]", (1, 1), ("DeviceRGB", 3, 0), &[255, 0, 0], 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_whose_matrix_has_no_area_is_left_out_with_a_warning() {
|
||||
image_left_out_with_a_warning(&image("[ 0 0 0 0 0 0 ]", (1, 1), ("DeviceRGB", 3, 0), &[255, 0, 0], 3));
|
||||
// This matrix maps the image onto a line.
|
||||
image_left_out_with_a_warning(&image("[ 1 2 2 4 0 100 ]", (1, 1), ("DeviceRGB", 3, 0), &[255, 0, 0], 3));
|
||||
}
|
||||
|
||||
/// The raster matrix `[a b c d tx ty]` is an art-space matrix (y up) applied to the image's pixels
|
||||
/// with their rows running downwards, `(u, -v)`: art x = a·u − c·v + tx, art y = b·u − d·v + ty.
|
||||
/// Without rotation (b = c = 0) that is the same as flipping the matrix's result; a rotated image
|
||||
|
||||
@@ -324,16 +324,46 @@ impl Predictor {
|
||||
// Bytes per pixel (at least one) is how far back the left neighbour is.
|
||||
let bpp = (self.colors * self.bpc).div_ceil(8).max(1);
|
||||
if self.kind == 2 {
|
||||
// Horizontal differencing (ISO 32000-1, §7.4.4.4): each component is stored as its
|
||||
// difference from the same component of the pixel to its left, wrapping within its bit
|
||||
// width. Each row starts again, and the padding bits at a row's end stay as they are.
|
||||
let mut out = data.to_vec();
|
||||
// Horizontal differencing, for 8-bit components (the common case).
|
||||
if self.bpc == 8 {
|
||||
for line in out.chunks_mut(row.max(1)) {
|
||||
for i in bpp..line.len() {
|
||||
let left = line.get(i - bpp).copied().unwrap_or(0);
|
||||
if let Some(b) = line.get_mut(i) {
|
||||
*b = b.wrapping_add(left);
|
||||
for line in out.chunks_mut(row.max(1)) {
|
||||
match self.bpc {
|
||||
8 => {
|
||||
for i in bpp..line.len() {
|
||||
let left = line.get(i - bpp).copied().unwrap_or(0);
|
||||
if let Some(b) = line.get_mut(i) {
|
||||
*b = b.wrapping_add(left);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 16-bit components are big-endian and add as 16-bit numbers.
|
||||
16 => {
|
||||
let byte = |line: &[u8], i: usize| line.get(i).copied().unwrap_or(0);
|
||||
for i in (bpp..line.len().saturating_sub(1)).step_by(2) {
|
||||
let left = u16::from_be_bytes([byte(line, i - bpp), byte(line, i - bpp + 1)]);
|
||||
let sum = u16::from_be_bytes([byte(line, i), byte(line, i + 1)]).wrapping_add(left);
|
||||
if let Some(w) = line.get_mut(i..i + 2) {
|
||||
w.copy_from_slice(&sum.to_be_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
// 1, 2 or 4 bits: components are packed from each byte's high bit.
|
||||
bits @ (1 | 2 | 4) => {
|
||||
let mask = (1u8 << bits) - 1;
|
||||
let shift = |k: usize| 8 - bits - k * bits % 8;
|
||||
let sample = |line: &[u8], k: usize| line.get(k * bits / 8).map_or(0, |b| (b >> shift(k)) & mask);
|
||||
let samples = (line.len() * 8 / bits).min(self.colors * self.columns);
|
||||
for k in self.colors..samples {
|
||||
let sum = sample(line, k).wrapping_add(sample(line, k - self.colors)) & mask;
|
||||
if let Some(b) = line.get_mut(k * bits / 8) {
|
||||
*b = (*b & !(mask << shift(k))) | (sum << shift(k));
|
||||
}
|
||||
}
|
||||
}
|
||||
// `Predictor::of` accepts no other depth.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -186,13 +186,27 @@ pub fn import(bytes: &[u8]) -> Result<Imported, String> {
|
||||
import_with(bytes, true)
|
||||
}
|
||||
|
||||
/// [`import`], an Illustrator EPS through the editing copy of its art only when `editing_data`
|
||||
/// (else what its page prints, as any EPS).
|
||||
pub fn import_with(bytes: &[u8], editing_data: bool) -> Result<Imported, String> {
|
||||
/// The PostScript of an EPS or PostScript file (behind its binary header, if it has one).
|
||||
fn program(bytes: &[u8]) -> Result<&[u8], String> {
|
||||
let (ps, _) = crate::sections(bytes).ok_or("the file's preview header points outside the file")?;
|
||||
if !ps.starts_with(b"%!") {
|
||||
return Err("this is not a PostScript file".into());
|
||||
}
|
||||
Ok(ps)
|
||||
}
|
||||
|
||||
/// An EPS or PostScript file's page at the origin, read from its comments without running its
|
||||
/// program: its bounding box (`%%HiResBoundingBox` when it has one), a US Letter page without
|
||||
/// one. [`import_with`] runs the program on the same page when it reads what the page prints.
|
||||
pub fn page(bytes: &[u8]) -> Result<Rect, String> {
|
||||
let [llx, lly, urx, ury] = Dsc::read(program(bytes)?).bbox.unwrap_or(LETTER);
|
||||
Ok(Rect::new(0.0, 0.0, urx - llx, ury - lly))
|
||||
}
|
||||
|
||||
/// [`import`], an Illustrator EPS through the editing copy of its art only when `editing_data`
|
||||
/// (else what its page prints, as any EPS).
|
||||
pub fn import_with(bytes: &[u8], editing_data: bool) -> Result<Imported, String> {
|
||||
let ps = program(bytes)?;
|
||||
let dsc = Dsc::read(ps);
|
||||
// A legacy `.ai` VectorCraft wrote: its prolog prints the art plainly (gradients flat, no
|
||||
// type), and its program has all of it, which is read as the format (as before the file
|
||||
|
||||
@@ -121,6 +121,23 @@ fn a_postscript_ai_whose_layers_cant_be_read_says_why() {
|
||||
assert!(e.contains("`Adobe_packedarray`") && e.contains("its layers couldn't be read either") && e.contains("`frobnicate`"), "{e}");
|
||||
}
|
||||
|
||||
/// A `.ai` in PostScript form (read through its layers) whose text carries hyphenation and
|
||||
/// tab-ruler attributes: `XL` (hyphenation language), `Xb`/`XB` (a tab definition) and `Th`
|
||||
/// (hyphen control). These keep no geometry, so the reader drops them rather than failing the
|
||||
/// file the way it did before — the symptom seen on Illustrator 6/8 EPS files whose
|
||||
/// `Adobe_level2_AI5` program can't be run.
|
||||
#[test]
|
||||
fn text_with_hyphenation_and_tab_attributes_opens_instead_of_failing() {
|
||||
let text = "0 To\n1 0 0 1 10 50 0 Tp\nTP\n0 Tr\n0 0 0 1 k\n/_Helvetica 9 Tf\n\
|
||||
%_ 0 XL\n36 0 Xb\nXB\n0 1 2 2 99 Th\n0 Ta\n(hi) Tx\nTO\n";
|
||||
// The operators sit on a visible layer (so they'd be reported as unknown, not quietly skipped
|
||||
// on a hidden one), beside the square so the layer is never empty.
|
||||
let src = NAMED_PROLOG.replace("S\nLB\n%AI5_EndLayer--\n%AI5_BeginLayer", &format!("S\n{text}LB\n%AI5_EndLayer--\n%AI5_BeginLayer"));
|
||||
let r = import(src.as_bytes()).unwrap();
|
||||
assert!(!r.preview, "read through the layers, not a fallback preview");
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("doesn't know") || w.contains("couldn't be read")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// An Illustrator 3 file of paths alone, as image editors export their paths (storytold/photocraft#6036):
|
||||
/// no prolog and no layers, the paths unpainted (`n` closes, `N` doesn't), a compound path of two.
|
||||
const PATHS_ONLY: &str = "%!PS-Adobe-2.0 EPSF-1.2\n%%Creator: an image editor\n%%BoundingBox: 0 0 200 100\n\
|
||||
|
||||
@@ -408,3 +408,57 @@ fn predicted_rows_of_an_odd_width() {
|
||||
let want: Vec<[u8; 4]> = raw.iter().flat_map(|row| (0..3).map(move |x| [row[3 * x], row[3 * x + 1], row[3 * x + 2], 255])).collect();
|
||||
assert_eq!(pixels(&r, 3, 5), want);
|
||||
}
|
||||
|
||||
/// Rows of `bpc`-bit samples packed from the high bit (16-bit ones big-endian), each row padded to
|
||||
/// a whole byte.
|
||||
fn packed(rows: &[Vec<u32>], bpc: usize) -> Vec<u8> {
|
||||
let mut out = vec![];
|
||||
for row in rows {
|
||||
let mut bytes = vec![0u8; (row.len() * bpc).div_ceil(8)];
|
||||
for (k, v) in row.iter().enumerate() {
|
||||
if bpc == 16 {
|
||||
bytes[2 * k..2 * k + 2].copy_from_slice(&(*v as u16).to_be_bytes());
|
||||
} else {
|
||||
let bit = k * bpc;
|
||||
bytes[bit / 8] |= (*v as u8) << (8 - bpc - bit % 8);
|
||||
}
|
||||
}
|
||||
out.extend(bytes);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A TIFF-predicted Flate stream (Predictor 2, horizontal differencing: ISO 32000-1 §7.4.4.4) at
|
||||
/// 1, 2, 4, 8 and 16 bits a component, gray and RGB, five pixels a row: each component is the
|
||||
/// difference from the same component of the pixel to its left, within its bit width, and each
|
||||
/// row starts again. The filter gives back the samples before differencing, read here as 8-bit
|
||||
/// gray pixels one byte each.
|
||||
#[test]
|
||||
fn tiff_predicted_rows_at_every_depth() {
|
||||
let columns = 5;
|
||||
let mut wrong = vec![];
|
||||
for bpc in [1, 2, 4, 8, 16] {
|
||||
for colors in [1, 3] {
|
||||
let max = (1u32 << bpc) - 1;
|
||||
let rows: Vec<Vec<u32>> =
|
||||
(0..3u32).map(|y| (0..(columns * colors) as u32).map(|i| (i * i * 40_503 + i * 7 + y * 12_345 + 1) & max).collect()).collect();
|
||||
let differenced: Vec<Vec<u32>> = rows
|
||||
.iter()
|
||||
.map(|row| (0..row.len()).map(|i| if i < colors { row[i] } else { row[i].wrapping_sub(row[i - colors]) & max }).collect())
|
||||
.collect();
|
||||
let (want, data) = (packed(&rows, bpc), packed(&differenced, bpc));
|
||||
let width = want.len() / 3;
|
||||
let r = read(&format!(
|
||||
"30 30 scale << /ImageType 1 /Width {width} /Height 3 /BitsPerComponent 8 /Decode [0 1] /ImageMatrix [{width} 0 0 -3 0 3] \
|
||||
/DataSource currentfile /ASCII85Decode filter << /Predictor 2 /Colors {colors} /BitsPerComponent {bpc} /Columns {columns} >> \
|
||||
/FlateDecode filter >> image\n{}",
|
||||
crate::ps::ascii85(&crate::ps::deflate(&data))
|
||||
));
|
||||
let got: Vec<u8> = pixels(&r, width as u32, 3).iter().map(|p| p[0]).collect();
|
||||
if got != want {
|
||||
wrong.push(format!("{bpc} bits, {colors} colors: {got:?}, not {want:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(wrong.is_empty(), "{wrong:#?}");
|
||||
}
|
||||
+86
-32
@@ -29,7 +29,7 @@
|
||||
//! `f` (or `F`), and `0 BB`, or `2 BB` (`1 BB` open) to stroke it too.
|
||||
//! - Images (§8.1, §8.4): `%AI5_File:`, `%AI5_BeginRaster`,
|
||||
//! `[ a b c d tx ty ] 0 0 w h w h 8 3 0 0 0 0 XI`, the RGB samples in hex on comment lines of 60
|
||||
//! digits, `%AI5_EndRaster`.
|
||||
//! digits, `%AI5_EndRaster`, then a line `F` that §8.4's examples do not show.
|
||||
//! - Type (§15): point type, area type and type on a path as text objects, `type To`, `a b c d tx
|
||||
//! ty startPt Tp` (the text matrix; the frame or path in art space between it and `TP`), each
|
||||
//! run's paint style, `Tr` (fill, stroke, both or neither), `/_font size ascent descent Tf`,
|
||||
@@ -50,7 +50,9 @@
|
||||
//! their own above their layer, are left out or show, as [`HiddenObjects`] says (in a hidden
|
||||
//! layer they stay where they are). Type that 8-bit text can't hold (characters outside
|
||||
//! Windows-1252, vertical type, inline graphics, gradient or pattern fills) is written as its
|
||||
//! outlines, and all type with [`LegacyAiOptions::outline_text`].
|
||||
//! outlines, and all type with [`LegacyAiOptions::outline_text`]. Layer, spot color and gradient
|
||||
//! names and the title are written in Windows-1252 too, and a character outside it is written as
|
||||
//! `?`. Two spot colors or two gradients whose names become the same that way are numbered (`? 2`).
|
||||
//!
|
||||
//! The prolog is a procedure set of the writer's own ([`PROLOG`]) that gives the operators their
|
||||
//! meaning in PostScript, so the file also prints and previews as a PostScript program (its
|
||||
@@ -87,6 +89,8 @@ pub const HIDDEN_LAYER: &str = "hidden objects are moved to a hidden layer above
|
||||
pub const HIDDEN_SHOWN: &str = "hidden objects are written visible (legacy .ai files can hide only layers)";
|
||||
pub const TRANSPARENCY: &str = "transparency (opacity, blending modes, opacity masks) is written opaque: legacy .ai files have none";
|
||||
pub const LAYER_NAME: &str = "characters outside Windows Latin-1 in layer names are written as ? (legacy .ai files can't hold them)";
|
||||
pub const TITLE_AND_COLOR_NAMES: &str =
|
||||
"characters outside Windows Latin-1 in the title and in spot color and gradient names are written as ? (legacy .ai files can't hold them)";
|
||||
pub const NAMES: &str = "object names are left out (legacy .ai files name only layers)";
|
||||
const PATTERNS: &str = "pattern fills are written as their tiles, clipped to the shape";
|
||||
const FREEFORM: &str = "freeform gradients are written as their average colour";
|
||||
@@ -163,11 +167,12 @@ pub fn export_legacy_ai(doc: &Document, o: &LegacyAiOptions) -> Result<LegacyAiO
|
||||
.filter(|b| b.is_finite())
|
||||
.reduce(|a, b| a.union(b))
|
||||
.unwrap_or(Rect::new(0.0, below, width, top));
|
||||
let title = w.latin_name(&o.title, TITLE_AND_COLOR_NAMES);
|
||||
let mut s = String::new();
|
||||
s.push_str(PS_HEADER);
|
||||
s.push('\n');
|
||||
let _ = writeln!(s, "%%Creator: VectorCraft {}", env!("CARGO_PKG_VERSION"));
|
||||
let _ = writeln!(s, "%%Title: {}", ps::string(&o.title));
|
||||
let _ = writeln!(s, "%%Title: {}", latin_string(&title));
|
||||
if let Some(t) = o.created {
|
||||
let [y, mo, d, h, mi, _] = vectorcraft_doc::metadata::civil(t);
|
||||
let _ = writeln!(s, "%%CreationDate: ({mo}/{d}/{y:04}) ({h:02}:{mi:02})");
|
||||
@@ -179,12 +184,12 @@ pub fn export_legacy_ai(doc: &Document, o: &LegacyAiOptions) -> Result<LegacyAiO
|
||||
s.push('\n');
|
||||
s.push_str("%%DocumentProcessColors: Cyan Magenta Yellow Black\n");
|
||||
for (i, (name, _)) in w.spots.iter().enumerate() {
|
||||
let _ = writeln!(s, "{} {}", if i == 0 { "%%DocumentCustomColors:" } else { "%%+" }, ps::string(name));
|
||||
let _ = writeln!(s, "{} {}", if i == 0 { "%%DocumentCustomColors:" } else { "%%+" }, latin_string(name));
|
||||
}
|
||||
for (i, (name, cmyk)) in w.spots.iter().enumerate() {
|
||||
s.push_str(if i == 0 { "%%CMYKCustomColor: " } else { "%%+ " });
|
||||
ps::push_nums(&mut s, &cmyk.map(f64::from));
|
||||
let _ = writeln!(s, "{}", ps::string(name));
|
||||
let _ = writeln!(s, "{}", latin_string(name));
|
||||
}
|
||||
for (i, (name, _)) in w.fonts.iter().enumerate() {
|
||||
let _ = writeln!(s, "{} {name}", if i == 0 { "%%DocumentFonts:" } else { "%%+" });
|
||||
@@ -335,7 +340,7 @@ const PS_HEADER: &str = "%!PS-Adobe-3.0"; // brand-ok: the PostScript header com
|
||||
/// A gradient definition: `%AI5_BeginGradient: (name)`, `(name) type stops Bd`, `[`, the stops
|
||||
/// last first (`colorSpec colorStyle midPoint rampPoint %_Bs`), `BD`, `%AI5_EndGradient`.
|
||||
fn gradient_def(s: &mut String, w: &Writer, name: &str, g: &Gradient) {
|
||||
let name = ps::string(name);
|
||||
let name = latin_string(name);
|
||||
let kind = if g.kind == GradientKind::Radial { 1 } else { 0 };
|
||||
let _ = writeln!(s, "%AI5_BeginGradient: {name}\n{name} {kind} {} Bd\n[", g.stops.len());
|
||||
for stop in g.stops.iter().rev() {
|
||||
@@ -356,7 +361,7 @@ fn gradient_def(s: &mut String, w: &Writer, name: &str, g: &Gradient) {
|
||||
}
|
||||
Ink::Spot { cmyk, name, tint } => {
|
||||
ps::push_nums(s, &cmyk.map(f64::from));
|
||||
let _ = write!(s, "{} ", ps::string(&name));
|
||||
let _ = write!(s, "{} ", latin_string(w.spot_name(&name)));
|
||||
ps::push_num(s, f64::from(1.0 - tint));
|
||||
s.push_str(" 3 ");
|
||||
}
|
||||
@@ -393,6 +398,13 @@ fn invertible(a: Affine) -> bool {
|
||||
det.is_finite() && det.abs() > 1e-12
|
||||
}
|
||||
|
||||
/// Name `name` as a PostScript string of its characters' Windows-1252 codes (a character outside
|
||||
/// Windows-1252 is left out: [`Writer::latin_name`] replaces them first).
|
||||
fn latin_string(name: &str) -> String {
|
||||
let bytes: Vec<u8> = name.chars().filter_map(latin::encode).collect();
|
||||
ps::bytes(&bytes)
|
||||
}
|
||||
|
||||
struct Writer<'a> {
|
||||
doc: &'a Document,
|
||||
/// The document → art space.
|
||||
@@ -401,8 +413,10 @@ struct Writer<'a> {
|
||||
body: String,
|
||||
/// Gradient definitions, by the name objects use.
|
||||
gradients: Vec<(String, Gradient)>,
|
||||
/// Spot colours used: name and full CMYK equivalent.
|
||||
/// Spot colors used: the name written and full CMYK equivalent.
|
||||
spots: Vec<(String, [f32; 4])>,
|
||||
/// The name each spot color (by its swatch's name) is written with ([`Self::spot`]).
|
||||
spot_names: Vec<(String, String)>,
|
||||
warnings: Vec<String>,
|
||||
/// The lock state the last `A` set.
|
||||
locked: bool,
|
||||
@@ -430,6 +444,7 @@ impl<'a> Writer<'a> {
|
||||
body: String::new(),
|
||||
gradients: vec![],
|
||||
spots: vec![],
|
||||
spot_names: vec![],
|
||||
warnings: vec![],
|
||||
locked: false,
|
||||
nest: 0,
|
||||
@@ -454,23 +469,62 @@ impl<'a> Writer<'a> {
|
||||
|
||||
// ---------- layers and objects ----------
|
||||
|
||||
/// A layer's name as a PostScript string in Windows-1252, like the type; a character outside it
|
||||
/// is written as `?`, with a warning.
|
||||
fn layer_name(&mut self, name: &str) -> String {
|
||||
/// `name` with each character outside Windows-1252 (the encoding type and names are written
|
||||
/// in) replaced by `?`; adds the warning `warning` when it replaces one.
|
||||
fn latin_name(&mut self, name: &str, warning: &str) -> String {
|
||||
let mut lost = false;
|
||||
let bytes: Vec<u8> = name
|
||||
let kept = name
|
||||
.chars()
|
||||
.map(|c| {
|
||||
latin::encode(c).unwrap_or_else(|| {
|
||||
if latin::encode(c).is_some() {
|
||||
c
|
||||
} else {
|
||||
lost = true;
|
||||
b'?'
|
||||
})
|
||||
'?'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if lost {
|
||||
self.warn(LAYER_NAME);
|
||||
self.warn(warning);
|
||||
}
|
||||
ps::bytes(&bytes)
|
||||
kept
|
||||
}
|
||||
|
||||
/// A layer's name as a PostScript string in Windows-1252, like the type; a character outside it
|
||||
/// is written as `?`, with a warning.
|
||||
fn layer_name(&mut self, name: &str) -> String {
|
||||
latin_string(&self.latin_name(name, LAYER_NAME))
|
||||
}
|
||||
|
||||
/// Note spot color `name` (its swatch's name), of full CMYK equivalent `cmyk`, as a custom
|
||||
/// color of the document, and give the name it is written with. Its characters outside
|
||||
/// Windows-1252 are written as `?` (with a warning), and it is numbered when another spot color
|
||||
/// is already written with that name; the name is the same each time.
|
||||
fn spot(&mut self, name: &str, cmyk: [f32; 4]) -> String {
|
||||
let written = match self.spot_names.iter().find(|(n, _)| n == name) {
|
||||
Some((_, w)) => w.clone(),
|
||||
None => {
|
||||
let base = self.latin_name(name, TITLE_AND_COLOR_NAMES);
|
||||
let mut written = base.clone();
|
||||
let mut i = 1;
|
||||
while self.spot_names.iter().any(|(_, w)| *w == written) {
|
||||
i += 1;
|
||||
written = format!("{base} {i}");
|
||||
}
|
||||
self.spot_names.push((name.to_string(), written.clone()));
|
||||
written
|
||||
}
|
||||
};
|
||||
if !self.spots.iter().any(|(n, _)| *n == written) {
|
||||
self.spots.push((written.clone(), cmyk));
|
||||
}
|
||||
written
|
||||
}
|
||||
|
||||
/// The name spot color `name` (its swatch's name) is written with, once [`Self::spot`] has
|
||||
/// noted it.
|
||||
fn spot_name<'n>(&'n self, name: &'n str) -> &'n str {
|
||||
self.spot_names.iter().find(|(n, _)| n == name).map_or(name, |(_, w)| w.as_str())
|
||||
}
|
||||
|
||||
/// A top-level layer. False when it isn't a layer.
|
||||
@@ -965,7 +1019,10 @@ impl<'a> Writer<'a> {
|
||||
|
||||
/// Set the fill (or with `stroke`, the stroke) colour to solid paint `p`.
|
||||
fn color(&mut self, p: &Paint, stroke: bool) {
|
||||
let ink = self.ink(p);
|
||||
let ink = match self.ink(p) {
|
||||
Ink::Spot { cmyk, name, tint } => Ink::Spot { name: self.spot(&name, cmyk), cmyk, tint },
|
||||
ink => ink,
|
||||
};
|
||||
let op = |fill: &'static str, line: &'static str| if stroke { line } else { fill };
|
||||
let s = &mut self.body;
|
||||
match ink {
|
||||
@@ -983,13 +1040,10 @@ impl<'a> Writer<'a> {
|
||||
}
|
||||
Ink::Spot { cmyk, name, tint } => {
|
||||
ps::push_nums(s, &cmyk.map(f64::from));
|
||||
let _ = write!(s, "{} ", ps::string(&name));
|
||||
let _ = write!(s, "{} ", latin_string(&name));
|
||||
// The format's tint is how much lighter than the full colour (0: full).
|
||||
ps::push_num(s, f64::from(1.0 - tint));
|
||||
let _ = writeln!(s, " {}", op("x", "X"));
|
||||
if !self.spots.iter().any(|(n, _)| *n == name) {
|
||||
self.spots.push((name, cmyk));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1009,10 +1063,8 @@ impl<'a> Writer<'a> {
|
||||
if let Some(sw) = &stop.swatch {
|
||||
// Spot colours in stops are custom colours too.
|
||||
let p = Paint::Solid { color: stop.color, swatch: Some(sw.clone()), tint: stop.tint };
|
||||
if let Ink::Spot { cmyk, name, .. } = self.ink(&p)
|
||||
&& !self.spots.iter().any(|(n, _)| *n == name)
|
||||
{
|
||||
self.spots.push((name, cmyk));
|
||||
if let Ink::Spot { cmyk, name, .. } = self.ink(&p) {
|
||||
self.spot(&name, cmyk);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1036,21 +1088,21 @@ impl<'a> Writer<'a> {
|
||||
s.push_str("Bh\n");
|
||||
}
|
||||
s.push_str("1 ");
|
||||
s.push_str(&ps::string(&name));
|
||||
s.push_str(&latin_string(&name));
|
||||
s.push(' ');
|
||||
ps::push_nums(&mut s, &[start.x, start.y, angle, length]);
|
||||
s.push_str("1 0 0 1 0 0 Bg\n");
|
||||
s
|
||||
}
|
||||
|
||||
/// The name of the definition of `g` (its swatch's name when it has one), defined the first
|
||||
/// time.
|
||||
/// The name of the definition of `g` (its swatch's name when it has one, with each character
|
||||
/// outside Windows-1252 written as `?` and a warning), defined the first time.
|
||||
fn define(&mut self, g: &Gradient, swatch: Option<&str>) -> String {
|
||||
if let Some((name, _)) = self.gradients.iter().find(|(_, d)| d == g) {
|
||||
return name.clone();
|
||||
}
|
||||
let taken = |n: &str, defs: &[(String, Gradient)]| defs.iter().any(|(m, _)| m == n);
|
||||
let base = swatch.filter(|s| !s.is_empty()).unwrap_or("Gradient").to_string();
|
||||
let base = self.latin_name(swatch.filter(|s| !s.is_empty()).unwrap_or("Gradient"), TITLE_AND_COLOR_NAMES);
|
||||
let mut name = base.clone();
|
||||
let mut i = 1;
|
||||
while taken(&name, &self.gradients) {
|
||||
@@ -1227,7 +1279,9 @@ impl<'a> Writer<'a> {
|
||||
/// An image (§8): `%AI5_File:`, `%AI5_BeginRaster`, its matrix (an art-space matrix applied to
|
||||
/// the pixels with their rows running downwards, `(u, -v)`) as `[ a b c d tx ty ]` and its
|
||||
/// operands with `XI`, its RGB samples in hex on comment lines of [`HEX_LINE`] digits,
|
||||
/// `%AI5_EndRaster`.
|
||||
/// `%AI5_EndRaster`, then `F`. §8.4's examples have no `F`; without it, an app reading the file
|
||||
/// can leave out an image in a clipping group and the art after it. No path is open there, so
|
||||
/// the `F` paints nothing.
|
||||
fn image(&mut self, im: &ImageObject) {
|
||||
let flip = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]);
|
||||
let m = self.to_art * im.xf * flip;
|
||||
@@ -1281,7 +1335,7 @@ impl<'a> Writer<'a> {
|
||||
ps::push_nums(s, &[a, b, c, d, e, f]);
|
||||
let _ = writeln!(s, "] 0 0 {w} {h} {w} {h} 8 3 0 0 0 0 XI");
|
||||
s.push_str(&hex);
|
||||
s.push_str("%AI5_EndRaster\n");
|
||||
s.push_str("%AI5_EndRaster\nF\n");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ mod ps;
|
||||
mod scene;
|
||||
mod tiff;
|
||||
|
||||
pub use import::{Imported, ai_alone, family_style, import, import_with, is_loss, layered_ai};
|
||||
pub use import::{Imported, ai_alone, family_style, import, import_with, is_loss, layered_ai, page};
|
||||
pub use legacy_ai::{HiddenObjects, LegacyAiOptions, LegacyAiOutput, export_legacy_ai};
|
||||
pub use print::{PrintJob, PrintPage, print};
|
||||
|
||||
|
||||
@@ -316,14 +316,15 @@ fn images_below_a_square(images: bool) -> Document {
|
||||
doc
|
||||
}
|
||||
|
||||
/// Each image the file holds (§8.4): the lines from `%AI5_File:` to `%AI5_EndRaster`.
|
||||
/// Each image the file holds (§8.4): the lines from `%AI5_File:` to the line after
|
||||
/// `%AI5_EndRaster`.
|
||||
fn rasters(text: &str) -> Vec<Vec<&str>> {
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let mut out = vec![];
|
||||
for (i, l) in lines.iter().enumerate() {
|
||||
if *l == "%AI5_File:" {
|
||||
let end = lines[i..].iter().position(|l| *l == "%AI5_EndRaster").map(|n| i + n).unwrap();
|
||||
out.push(lines[i..=end].to_vec());
|
||||
out.push(lines[i..=(end + 1).min(lines.len() - 1)].to_vec());
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -332,8 +333,9 @@ fn rasters(text: &str) -> Vec<Vec<&str>> {
|
||||
/// Every image, in a clipping group, a group or neither, is written as §8.1's syntax and §8.4's
|
||||
/// examples write one: `%AI5_File:`, `%AI5_BeginRaster`, the matrix with a space inside each
|
||||
/// bracket and the operands on the `XI` line, the samples in hex on comment lines of 60 digits,
|
||||
/// `%AI5_EndRaster`. The art after the images is written as it is without them, and the file
|
||||
/// reads back with its layers, clipping group, images and square (#2692).
|
||||
/// `%AI5_EndRaster`. A line `F`, which the examples do not show, follows each image. The art
|
||||
/// after the images is written as it is without them, and the file reads back with its layers,
|
||||
/// clipping group, images and square (#2692, #2830).
|
||||
#[test]
|
||||
fn images_are_written_in_the_form_the_specification_shows() {
|
||||
let out = write(&images_below_a_square(true));
|
||||
@@ -341,8 +343,8 @@ fn images_are_written_in_the_form_the_specification_shows() {
|
||||
let images = rasters(&text);
|
||||
assert_eq!(images.len(), 3, "{text}");
|
||||
for lines in &images {
|
||||
let [file, begin, xi, data @ .., end] = lines.as_slice() else { panic!("{lines:?}") };
|
||||
assert_eq!((*file, *begin, *end), ("%AI5_File:", "%AI5_BeginRaster", "%AI5_EndRaster"));
|
||||
let [file, begin, xi, data @ .., end, paint] = lines.as_slice() else { panic!("{lines:?}") };
|
||||
assert_eq!((*file, *begin, *end, *paint), ("%AI5_File:", "%AI5_BeginRaster", "%AI5_EndRaster", "F"));
|
||||
let matrix = xi.strip_prefix("[ ").and_then(|l| l.strip_suffix(" ] 0 0 16 16 16 16 8 3 0 0 0 0 XI")).unwrap_or_else(|| panic!("{xi}"));
|
||||
assert_eq!(matrix.split(' ').filter(|v| v.parse::<f64>().is_ok()).count(), 6, "{xi}");
|
||||
let digits: Vec<&str> = data.iter().map(|l| l.strip_prefix('%').unwrap_or_else(|| panic!("{l}"))).collect();
|
||||
@@ -352,9 +354,10 @@ fn images_are_written_in_the_form_the_specification_shows() {
|
||||
assert_eq!(digits.concat().len(), 16 * 16 * 3 * 2);
|
||||
}
|
||||
// The clipping group: `q`, the clipping path painted `h W n` (§5.6.2), the image, `Q`; the
|
||||
// group: `u`, the image, `U`.
|
||||
assert!(text.contains("\nh\nW\nn\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nQ\n"), "{text}");
|
||||
assert!(text.contains("\nu\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nU\n"), "{text}");
|
||||
// group: `u`, the image, `U`; the loose image, then the layer's end.
|
||||
assert!(text.contains("\nh\nW\nn\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nF\nQ\n"), "{text}");
|
||||
assert!(text.contains("\nu\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nF\nU\n"), "{text}");
|
||||
assert!(text.contains("\n%AI5_EndRaster\nF\nLB\n"), "{text}");
|
||||
let layer = |t: &str| -> String {
|
||||
let at = t.find("(Above) Ln\n").unwrap();
|
||||
t[at..].split("%AI5_EndLayer--").next().unwrap().to_string()
|
||||
@@ -367,8 +370,11 @@ fn images_are_written_in_the_form_the_specification_shows() {
|
||||
let below = children(&doc.layers[0]);
|
||||
assert_eq!(below.len(), 3, "{below:#?}");
|
||||
let NodeKind::Group { children: window, clip: true } = &below[0].kind else { panic!("{:?}", below[0].kind) };
|
||||
assert_eq!(window.len(), 2, "{window:#?}");
|
||||
assert!(matches!(window[0].kind, NodeKind::Path { clipping: true, .. }) && matches!(window[1].kind, NodeKind::Image(_)), "{window:#?}");
|
||||
assert!(matches!(&below[1].kind, NodeKind::Group { children, clip: false } if matches!(children[0].kind, NodeKind::Image(_))));
|
||||
assert!(
|
||||
matches!(&below[1].kind, NodeKind::Group { children, clip: false } if children.len() == 1 && matches!(children[0].kind, NodeKind::Image(_)))
|
||||
);
|
||||
assert!(matches!(below[2].kind, NodeKind::Image(_)));
|
||||
let above = children(&doc.layers[1]);
|
||||
assert_eq!(above.len(), 1);
|
||||
@@ -606,6 +612,127 @@ fn spot_colours_are_custom_colours() {
|
||||
assert!((tint - 0.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// A document with a spot color swatch for each of `spots`, a square filled with each at half
|
||||
/// tint, and a square whose linear gradient fill (the first spot color to white) names the
|
||||
/// gradient swatch `gradient` (the document holds no such swatch; the writer reads only the
|
||||
/// fill's name).
|
||||
fn spots_and_gradient(spots: &[&str], gradient: &str) -> Document {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
for (i, name) in spots.iter().enumerate() {
|
||||
let c = i as f32 * 0.5;
|
||||
doc.swatches.push(vectorcraft_color::Swatch {
|
||||
name: (*name).into(),
|
||||
paint: Paint::solid(Color::cmyk(c, 0.5, 1.0, 0.0)),
|
||||
global: true,
|
||||
spot: true,
|
||||
});
|
||||
let fill = Paint::Solid { color: Color::cmyk(c / 2.0, 0.25, 0.5, 0.0), swatch: Some((*name).into()), tint: 0.5 };
|
||||
let x = 10.0 + 40.0 * i as f64;
|
||||
let n = shape(&mut doc, shapes::rectangle(Rect::new(x, 10.0, x + 30.0, 40.0)), fill, Paint::None);
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
}
|
||||
let mut first = GradientStop::new(0.0, Color::cmyk(0.0, 0.5, 1.0, 0.0));
|
||||
first.swatch = Some(spots[0].into());
|
||||
let g = Gradient { kind: GradientKind::Linear, stops: vec![first, GradientStop::new(1.0, Color::cmyk(0.0, 0.0, 0.0, 0.0))] };
|
||||
let mut paint = GradientPaint::new(g);
|
||||
paint.swatch = Some(gradient.into());
|
||||
let n = shape(&mut doc, shapes::rectangle(Rect::new(10.0, 50.0, 50.0, 90.0)), Paint::Gradient(Box::new(paint)), Paint::None);
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
doc
|
||||
}
|
||||
|
||||
/// `doc` written with the title `title`, and run as a PostScript program ([`prints_as`]).
|
||||
fn write_titled(doc: &Document, title: &str) -> LegacyAiOutput {
|
||||
let o = LegacyAiOptions { title: title.into(), created: Some(0), ..LegacyAiOptions::default() };
|
||||
let out = export_legacy_ai(doc, &o).unwrap();
|
||||
prints_as(&out.bytes, doc, &o);
|
||||
out
|
||||
}
|
||||
|
||||
/// The spot colors and gradients of the objects of `doc`'s first layer, in paint order: each
|
||||
/// solid fill's swatch, and each gradient fill's swatch with its first stop's.
|
||||
fn inks(doc: &Document) -> Vec<Option<String>> {
|
||||
let mut out = vec![];
|
||||
for n in children(&doc.layers[0]) {
|
||||
match n.appearance.fill_paint() {
|
||||
Paint::Solid { swatch, .. } => out.push(swatch.clone()),
|
||||
Paint::Gradient(g) => {
|
||||
out.push(g.swatch.clone());
|
||||
out.push(g.gradient.stops.first().and_then(|s| s.swatch.clone()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The title, spot color names and gradient names are written in Windows-1252, as layer names
|
||||
/// are: é is 351 (octal) in the header's custom colors, each `x`, the gradient's definition, its
|
||||
/// stops and `Bg`. The spot color and gradient names read back as they were.
|
||||
#[test]
|
||||
fn spot_gradient_and_title_names_are_written_in_windows_1252() {
|
||||
let doc = spots_and_gradient(&["Caf\u{e9}"], "D\u{e9}grad\u{e9}");
|
||||
let out = write_titled(&doc, "R\u{e9}sum\u{e9}");
|
||||
let text = String::from_utf8(out.bytes.clone()).unwrap();
|
||||
for needle in [
|
||||
"%%Title: (R\\351sum\\351)\n",
|
||||
"%%DocumentCustomColors: (Caf\\351)\n",
|
||||
"%%CMYKCustomColor: 0 0.5 1 0 (Caf\\351)\n",
|
||||
"0 0.5 1 0 (Caf\\351) 0.5 x\n",
|
||||
"%AI5_BeginGradient: (D\\351grad\\351)\n(D\\351grad\\351) 0 2 Bd\n",
|
||||
"0 0.5 1 0 (Caf\\351) 0 3 50 0 %_Bs\n",
|
||||
"1 (D\\351grad\\351) ",
|
||||
] {
|
||||
assert!(text.contains(needle), "missing {needle:?} in\n{text}");
|
||||
}
|
||||
assert!(!text.contains("\\303"), "{text}");
|
||||
assert!(!out.warnings.iter().any(|w| w == TITLE_AND_COLOR_NAMES), "{:?}", out.warnings);
|
||||
let read = crate::import(&out.bytes).unwrap().document;
|
||||
let names = inks(&read);
|
||||
let want = ["Caf\u{e9}", "D\u{e9}grad\u{e9}", "Caf\u{e9}"].map(|n| Some(n.to_string()));
|
||||
assert_eq!(names, want);
|
||||
}
|
||||
|
||||
/// A character Windows-1252 can't hold is written as `?`, with one warning for all of these
|
||||
/// names. Names that become the same are numbered, so the header, each `x` and each `Bg` still
|
||||
/// tell the colors and gradients apart.
|
||||
#[test]
|
||||
fn names_outside_windows_1252_are_replaced_and_kept_apart() {
|
||||
// 赤 and 青 (spot colors), 渐变 and グラ (gradients), 日本 (the title).
|
||||
let mut doc = spots_and_gradient(&["\u{8d64}", "\u{9752}"], "\u{6e10}\u{53d8}");
|
||||
let g = Gradient {
|
||||
kind: GradientKind::Linear,
|
||||
stops: vec![GradientStop::new(0.0, Color::cmyk(1.0, 0.0, 0.0, 0.0)), GradientStop::new(1.0, Color::cmyk(0.0, 0.0, 0.0, 0.0))],
|
||||
};
|
||||
let mut paint = GradientPaint::new(g);
|
||||
paint.swatch = Some("\u{30b0}\u{30e9}".into());
|
||||
let n = shape(&mut doc, shapes::rectangle(Rect::new(60.0, 50.0, 100.0, 90.0)), Paint::Gradient(Box::new(paint)), Paint::None);
|
||||
let layer = doc.layers[0].id;
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
let out = write_titled(&doc, "\u{65e5}\u{672c}");
|
||||
let text = String::from_utf8(out.bytes.clone()).unwrap();
|
||||
for needle in [
|
||||
"%%Title: (??)\n",
|
||||
"%%DocumentCustomColors: (?)\n%%+ (? 2)\n",
|
||||
"%%CMYKCustomColor: 0 0.5 1 0 (?)\n%%+ 0.5 0.5 1 0 (? 2)\n",
|
||||
"0 0.5 1 0 (?) 0.5 x\n",
|
||||
"0.5 0.5 1 0 (? 2) 0.5 x\n",
|
||||
"(??) 0 2 Bd\n",
|
||||
"(?? 2) 0 2 Bd\n",
|
||||
"0 0.5 1 0 (?) 0 3 50 0 %_Bs\n",
|
||||
"1 (??) ",
|
||||
"1 (?? 2) ",
|
||||
] {
|
||||
assert!(text.contains(needle), "missing {needle:?} in\n{text}");
|
||||
}
|
||||
assert_eq!(out.warnings.iter().filter(|w| *w == TITLE_AND_COLOR_NAMES).count(), 1, "{:?}", out.warnings);
|
||||
assert!(!out.warnings.iter().any(|w| w == LAYER_NAME), "{:?}", out.warnings);
|
||||
let read = crate::import(&out.bytes).unwrap().document;
|
||||
let want = [Some("?"), Some("? 2"), Some("??"), Some("?"), Some("?? 2"), None].map(|n| n.map(String::from));
|
||||
assert_eq!(inks(&read), want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn losses_are_warned() {
|
||||
let mut doc = sample();
|
||||
|
||||
@@ -28,9 +28,11 @@ pub fn polygon_covers_rect(path: &BezPath, r: Rect, tol: f64) -> bool {
|
||||
// Convex: every turn goes the same way (straight runs allowed).
|
||||
let n = pts.len();
|
||||
let mut sign = 0.0f64;
|
||||
let mut turning = 0.0f64;
|
||||
for i in 0..n {
|
||||
let (a, b, c) = (pts[i], pts[(i + 1) % n], pts[(i + 2) % n]);
|
||||
let cross = (b - a).cross(c - b);
|
||||
turning += cross.atan2((b - a).dot(c - b));
|
||||
if cross.abs() <= 1e-9 {
|
||||
continue;
|
||||
}
|
||||
@@ -40,6 +42,10 @@ pub fn polygon_covers_rect(path: &BezPath, r: Rect, tol: f64) -> bool {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// A simple convex polygon turns through exactly one full revolution; a star winds more.
|
||||
if (turning.abs() - std::f64::consts::TAU).abs() > 1e-6 {
|
||||
return false;
|
||||
}
|
||||
let inner = r.inflate(-tol, -tol);
|
||||
sign != 0.0
|
||||
&& [Point::new(inner.x0, inner.y0), Point::new(inner.x1, inner.y0), Point::new(inner.x1, inner.y1), Point::new(inner.x0, inner.y1)]
|
||||
@@ -244,4 +250,20 @@ mod regress_tests {
|
||||
let worst = (0..=200).map(|i| distance_to_outline(&bp, c.eval(i as f64 / 200.0))).fold(0.0, f64::max);
|
||||
assert!(worst < 1e-6, "{worst}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polygon_covers_rect_rejects_self_intersecting_star() {
|
||||
let mut b = BezPath::new();
|
||||
for k in 0..5 {
|
||||
let a = (-90.0 + 144.0 * k as f64).to_radians();
|
||||
let p = Point::new(100.0 * a.cos(), 100.0 * a.sin());
|
||||
if k == 0 {
|
||||
b.move_to(p);
|
||||
} else {
|
||||
b.line_to(p);
|
||||
}
|
||||
}
|
||||
b.close_path();
|
||||
assert!(!polygon_covers_rect(&b, Rect::new(-55.0, -20.0, 55.0, 78.0), 0.01));
|
||||
}
|
||||
}
|
||||
@@ -221,6 +221,19 @@ impl SubPath {
|
||||
let b = &self.anchors[(i + 1) % n];
|
||||
!a.has_out() && !b.has_in()
|
||||
}
|
||||
/// Arc-length at each anchor from the start of this subpath (one entry per anchor).
|
||||
pub fn anchor_arc_positions(&self) -> Vec<f64> {
|
||||
let n = self.anchors.len();
|
||||
if n == 0 {
|
||||
return vec![];
|
||||
}
|
||||
let mut pos = vec![0.0];
|
||||
for i in 0..self.segment_count() {
|
||||
let len = kurbo::ParamCurveArclen::arclen(&self.segment(i), 1e-3);
|
||||
pos.push(pos.last().copied().unwrap_or(0.0) + len);
|
||||
}
|
||||
pos
|
||||
}
|
||||
pub fn to_bezpath_into(&self, out: &mut BezPath) {
|
||||
let Some(first) = self.anchors.first() else { return };
|
||||
out.move_to(first.p);
|
||||
|
||||
@@ -92,6 +92,7 @@ fn export_tool_lists_engine_formats_and_options() {
|
||||
let open = tools.iter().find(|t| t["name"] == "open_file").unwrap();
|
||||
assert!(open["description"].as_str().unwrap().contains(".ait"));
|
||||
assert!(open["description"].as_str().unwrap().contains(".eps, .epsf, .ps, "), "{open}");
|
||||
assert!(open["description"].as_str().unwrap().contains(".bmp, .tga, .vda, .icb, .vst, "), "{open}");
|
||||
}
|
||||
|
||||
/// A fake app that records `engine.execute` calls and answers each with an empty object.
|
||||
@@ -132,6 +133,17 @@ fn export_is_the_same_call_in_the_app_and_headless() {
|
||||
assert_eq!(direct["dataBase64"], r["dataBase64"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_raster_scale_leaves_other_formats_alone() {
|
||||
// DXF's own `scale` (drawing units per mm) comes through `options`; the raster one would resize
|
||||
// the drawing and leave its units unset.
|
||||
let mut h = two_boards();
|
||||
let dxf = |h: &mut Headless, args: Value| text(&call_tool(h, "export", &args))["dataBase64"].clone();
|
||||
let plain = dxf(&mut h, json!({"format": "dxf"}));
|
||||
assert!(dxf(&mut h, json!({"format": "dxf", "scale": 2})) == plain, "the raster scale changed the DXF");
|
||||
assert!(dxf(&mut h, json!({"format": "dxf", "options": {"scale": 2}})) != plain, "the DXF scale was dropped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exporting_native_keeps_the_document_path() {
|
||||
let mut h = two_boards();
|
||||
|
||||
+16
-4
@@ -2,7 +2,7 @@
|
||||
//! [`Backend`].
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_engine::cmd::fileio::{ARTBOARD_PARAMS, FORMATS, OPEN_EXTS, SAVE_FORMATS, format};
|
||||
use vectorcraft_engine::cmd::fileio::{ARTBOARD_PARAMS, FORMATS, OPEN_EXTS, SAVE_FORMATS, format, writable_format};
|
||||
|
||||
use crate::backend::Backend;
|
||||
|
||||
@@ -1003,9 +1003,9 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<ToolResult, Str
|
||||
params.remove(k);
|
||||
}
|
||||
}
|
||||
for k in ["path", "format", "scale", "artboard", "range", "outlineText"] {
|
||||
if let Some(v) = a.get(k).filter(|v| !v.is_null()) {
|
||||
params.insert(k.into(), v.clone());
|
||||
for k in named_export_params(a) {
|
||||
if let Some(v) = a.get(*k).filter(|v| !v.is_null()) {
|
||||
params.insert((*k).into(), v.clone());
|
||||
}
|
||||
}
|
||||
let cmd = if a.get("selection").and_then(Value::as_bool) == Some(true) { "document.exportSelection" } else { "document.export" };
|
||||
@@ -1054,6 +1054,18 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<ToolResult, Str
|
||||
}
|
||||
}
|
||||
|
||||
/// The named `export` arguments passed on to `document.export`. `scale` is raster pixels per
|
||||
/// point, so only a raster format gets it: DXF's own (drawing units per unit, given in `options`)
|
||||
/// would resize the drawing and leave its units unset.
|
||||
fn named_export_params(a: &Args) -> &'static [&'static str] {
|
||||
let format = writable_format(a.get("format").and_then(Value::as_str), a.get("path").and_then(Value::as_str));
|
||||
if format.is_ok_and(|f| f.raster) {
|
||||
&["path", "format", "scale", "artboard", "range", "outlineText"]
|
||||
} else {
|
||||
&["path", "format", "artboard", "range", "outlineText"]
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one tool. Failures (unknown tool, bad arguments, command errors) come back as an
|
||||
/// `isError` result so the model can read and correct them.
|
||||
pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
|
||||
|
||||
@@ -13,6 +13,7 @@ use krilla::metadata::DateTime;
|
||||
|
||||
use crate::PdfError;
|
||||
use crate::import_color::items;
|
||||
use crate::import_scan::text_string;
|
||||
use crate::lab_spot::find;
|
||||
|
||||
/// The embedded file holding the native document (its name among the PDF's attachments).
|
||||
@@ -84,8 +85,9 @@ pub(crate) fn editing_in(pdf: &Pdf) -> Option<Editing> {
|
||||
let files = spec.get::<Dict<'_>>(b"EF")?;
|
||||
let stream = files.get::<Stream<'_>>(b"F").or_else(|| files.get::<Stream<'_>>(b"UF"))?;
|
||||
let data = stream.decoded().map(|d| d.into_owned()).unwrap_or_default();
|
||||
// The hash ends the description; editing data described otherwise is trusted.
|
||||
let desc = spec.get::<PdfString<'_>>(b"Desc").map(|d| String::from_utf8_lossy(d.as_bytes()).into_owned()).unwrap_or_default();
|
||||
// The hash ends the description (a text string, in any of its encodings); editing data
|
||||
// described otherwise is trusted.
|
||||
let desc = spec.get::<PdfString<'_>>(b"Desc").map(|d| text_string(d.as_bytes())).unwrap_or_default();
|
||||
let intact = match desc.rsplit_once("hash ") {
|
||||
Some((_, hash)) => hash.trim() == page_hash(pdf),
|
||||
None => true,
|
||||
@@ -119,11 +121,12 @@ fn editing_spec<'a>(node: &Dict<'a>, depth: usize, seen: &mut HashSet<ObjectIden
|
||||
})
|
||||
}
|
||||
|
||||
/// A file specification naming the editing file (current or legacy name).
|
||||
/// A file specification naming the editing file (current or legacy name), in any text string
|
||||
/// encoding.
|
||||
fn is_editing_file(spec: &Dict<'_>) -> bool {
|
||||
[&b"UF"[..], b"F"].into_iter().filter_map(|k| spec.get::<PdfString<'_>>(k)).any(|name| {
|
||||
let name = name.as_bytes();
|
||||
name == EDITING_FILE.as_bytes() || name == LEGACY_EDITING_FILE.as_bytes()
|
||||
let name = text_string(name.as_bytes());
|
||||
name == EDITING_FILE || name == LEGACY_EDITING_FILE
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha
|
||||
|
||||
let title = crate::pdfx::title(set.standard, opts.title.clone().unwrap_or_else(|| doc.title.clone()));
|
||||
// The time of writing (none on the web) dates the editing data and the page information.
|
||||
let now = vectorcraft_doc::metadata::now_unix();
|
||||
let now = vectorcraft_doc::metadata::output_time();
|
||||
let created_at = opts.created.or(now);
|
||||
crate::pdfx::require_date(set.standard, created_at)?;
|
||||
let mut w = Writer::new(doc, set, &title, created_at, doc.color_mode == vectorcraft_doc::ColorMode::Cmyk)?;
|
||||
|
||||
@@ -149,6 +149,10 @@ pub const PAST_PAGE_NOTE: &str = "art reaching past the page was clipped to it,
|
||||
|
||||
/// Warning: glyphs were kept as outlines rather than read as type.
|
||||
pub const OUTLINED_TEXT_NOTE: &str = "text was converted to outlines";
|
||||
/// Warning: text drawn invisibly (text rendering mode 3, as a scanned page's OCR layer is) was left
|
||||
/// out: VectorCraft has no type that is kept but not drawn, so a PDF saved from it loses that
|
||||
/// searchable text (#2675).
|
||||
pub const INVISIBLE_TEXT_NOTE: &str = "invisible text (such as a scanned page's searchable OCR layer) was left out";
|
||||
/// Warning: glyphs that differ from the installed font of their name were kept as outlines.
|
||||
pub const UNMATCHED_GLYPHS_NOTE: &str = "text whose glyphs differ from the installed font of the same name was kept as outlines";
|
||||
/// The end of the warning that glyphs whose characters the file doesn't name were kept as
|
||||
@@ -1963,7 +1967,10 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
) {
|
||||
self.pending = false;
|
||||
let stroke = match draw_mode {
|
||||
GlyphDrawMode::Invisible => return,
|
||||
GlyphDrawMode::Invisible => {
|
||||
self.warn(INVISIBLE_TEXT_NOTE);
|
||||
return;
|
||||
}
|
||||
GlyphDrawMode::Fill => None,
|
||||
GlyphDrawMode::Stroke(p) => Some(p.clone()),
|
||||
};
|
||||
|
||||
@@ -74,8 +74,8 @@ pub use encrypt::Encryption;
|
||||
pub use export::{export, export_with_report, page_areas};
|
||||
pub use file_info::{FileInfo, file_info};
|
||||
pub use import::{
|
||||
OFF_ARTBOARD_NOTE, OUTLINED_TEXT_NOTE, PAST_PAGE_NOTE, UNMATCHED_GLYPHS_NOTE, UNNAMED_CHARACTERS_NOTE, holds_outlines, import,
|
||||
import_with_report, is_glyph_outlines, is_outlines_note,
|
||||
INVISIBLE_TEXT_NOTE, OFF_ARTBOARD_NOTE, OUTLINED_TEXT_NOTE, PAST_PAGE_NOTE, UNMATCHED_GLYPHS_NOTE, UNNAMED_CHARACTERS_NOTE, holds_outlines,
|
||||
import, import_with_report, is_glyph_outlines, is_outlines_note,
|
||||
};
|
||||
pub use pages::{PageInfo, PdfInfo, has_illustrator_data, illustrator_data, info, is_postscript};
|
||||
pub use post::{THUMBNAIL_SIZE, Thumbnail};
|
||||
|
||||
@@ -355,7 +355,7 @@ pub fn plan<'a>(doc: &'a Document, opts: &PrintOptions) -> Result<PrintPlan<'a>,
|
||||
let set = &opts.settings;
|
||||
let job = Job::new(doc, set, opts.postscript)?;
|
||||
let title = opts.title.clone().unwrap_or_else(|| job.doc.title.clone());
|
||||
let created = opts.created.or_else(vectorcraft_doc::metadata::now_unix);
|
||||
let created = opts.created.or_else(vectorcraft_doc::metadata::output_time);
|
||||
let sep = Separator::new(&job.doc, set);
|
||||
let plates: Vec<Document> = (0..job.plates.len()).filter_map(|p| job.ink(Some(p))).map(|i| sep.plate(&i.name)).collect();
|
||||
let marks = set.marks.printer_marks();
|
||||
|
||||
@@ -53,9 +53,9 @@ fn the_native_document_comes_back_intact() {
|
||||
fn the_native_documents_modification_date_is_the_time_of_writing() {
|
||||
// Created in 1970: the information dictionary's dates say so, the embedded file's doesn't.
|
||||
let year = |t: Option<i64>| vectorcraft_doc::metadata::civil(t.unwrap())[0];
|
||||
let before = year(vectorcraft_doc::metadata::now_unix());
|
||||
let before = year(vectorcraft_doc::metadata::output_time());
|
||||
let text = String::from_utf8_lossy(&pdf_with(Some(b"native"), false).bytes).into_owned();
|
||||
let after = year(vectorcraft_doc::metadata::now_unix());
|
||||
let after = year(vectorcraft_doc::metadata::output_time());
|
||||
assert!(text.contains("/CreationDate(D:1970"), "the file's creation date");
|
||||
assert!([before, after].iter().any(|y| text.contains(&format!("/ModDate(D:{y:04}"))), "the embedded file is dated when it is written");
|
||||
}
|
||||
@@ -72,6 +72,35 @@ fn changed_pages_are_noticed() {
|
||||
assert!(editing(&r.bytes).unwrap().intact);
|
||||
}
|
||||
|
||||
/// `pdf` with its editing file's description rewritten by another app as "hash " and the hash it
|
||||
/// held, in the text string encoding `bom` starts (UTF-16BE or UTF-8), padded with spaces to its
|
||||
/// length.
|
||||
fn described(pdf: &[u8], bom: &[u8]) -> Vec<u8> {
|
||||
let from = b"(VectorCraft editing data; page hash ";
|
||||
let at = pdf.windows(from.len()).position(|w| w == from).unwrap_or_else(|| panic!("no description"));
|
||||
let (hash, len) = (&pdf[at + from.len()..at + from.len() + 16], from.len() + 17);
|
||||
let mut desc = [b"(", bom].concat();
|
||||
for &c in b"hash ".iter().chain(hash) {
|
||||
if bom.len() == 2 {
|
||||
desc.push(0);
|
||||
}
|
||||
desc.push(c);
|
||||
}
|
||||
desc.push(b')');
|
||||
desc.resize(len, b' ');
|
||||
patched(pdf, &pdf[at..at + len], &desc)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hash_is_read_from_a_description_in_any_encoding() {
|
||||
let r = pdf_with(Some(b"native"), false);
|
||||
let resized = patched(&r.bytes, b"/MediaBox[0 0 100 100]", b"/MediaBox[0 0 100 101]");
|
||||
for bom in [&[0xFE, 0xFF][..], &[0xEF, 0xBB, 0xBF]] {
|
||||
assert!(editing(&described(&r.bytes, bom)).unwrap().intact, "{bom:?}: the same pages");
|
||||
assert!(!editing(&described(&resized, bom)).unwrap().intact, "{bom:?}: another page size");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_legacy_name_is_read_and_plain_pdfs_have_no_editing_data() {
|
||||
let r = pdf_with(Some(b"legacy"), false);
|
||||
@@ -153,3 +182,20 @@ fn direct_name_tree_kids_are_all_searched() {
|
||||
let e = editing(&raw_pdf(&objects)).unwrap();
|
||||
assert_eq!(e.data, b"hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_name_written_as_utf16_is_found() {
|
||||
let utf16: String = EDITING_FILE.bytes().map(|c| format!("00{c:02X}")).collect();
|
||||
for key in ["UF", "F"] {
|
||||
let objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 3 0 R >> >>".to_string(),
|
||||
"<< /Type /Pages /Kids [6 0 R] /Count 1 >>".to_string(),
|
||||
"<< /Names [(a) 4 0 R] >>".to_string(),
|
||||
format!("<< /Type /Filespec /{key} <FEFF{utf16}> /EF << /F 5 0 R >> >>"),
|
||||
"<< /Length 5 >>\nstream\nhello\nendstream".to_string(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 10 10] >>".to_string(),
|
||||
];
|
||||
let e = editing(&raw_pdf(&objects)).unwrap_or_else(|| panic!("/{key}: not found"));
|
||||
assert_eq!(e.data, b"hello", "/{key}");
|
||||
}
|
||||
}
|
||||
@@ -229,7 +229,7 @@ fn page_information_prints_the_time_of_the_export_not_the_creation_date() {
|
||||
});
|
||||
out
|
||||
};
|
||||
let now = || vectorcraft_doc::metadata::now_unix().unwrap();
|
||||
let now = || vectorcraft_doc::metadata::output_time().unwrap();
|
||||
// Both print the minute they are written in: try again when the minute turns between them.
|
||||
for _ in 0..3 {
|
||||
let minute = now() / 60;
|
||||
|
||||
@@ -177,3 +177,36 @@ pub(crate) fn transfer_contract() {
|
||||
fn soft_mask_inversion_uses_quantized_transfer_endpoints() {
|
||||
transfer_contract();
|
||||
}
|
||||
|
||||
/// #2675: text in rendering mode 3 (invisible, as a scanned page's OCR layer) isn't kept, and the
|
||||
/// import now says so, for type and outlines alike. Visible text beside it still comes in, and a
|
||||
/// page without invisible text gets no such warning.
|
||||
#[test]
|
||||
fn invisible_text_is_reported_when_left_out() {
|
||||
let open = |content: &str, text_as: TextAs| import_with_report(&text_pdf(content), &ImportOptions { text_as, ..Default::default() }).unwrap();
|
||||
let invisible = "BT /F1 20 Tf 3 Tr 1 0 0 1 10 40 Tm (AB) Tj 0 Tr 1 0 0 1 10 10 Tm (CA) Tj ET";
|
||||
for text_as in [TextAs::Text, TextAs::Outlines] {
|
||||
let r = open(invisible, text_as);
|
||||
assert!(r.warnings.iter().any(|w| w == crate::INVISIBLE_TEXT_NOTE), "{text_as:?}: {:?}", r.warnings);
|
||||
// What came in: the type's text, and where the art lies. On the 60 pt page the visible
|
||||
// line (baseline 10 pt up) lies 35–50 pt down, the invisible one (40 pt up) 5–20 pt down.
|
||||
let (mut text, mut tops) = (String::new(), vec![]);
|
||||
r.document.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
text.push_str(&t.plain_text());
|
||||
}
|
||||
if matches!(n.kind, NodeKind::Text(_) | NodeKind::Path { .. } | NodeKind::Compound { .. })
|
||||
&& let Some(b) = n.visual_bounds()
|
||||
{
|
||||
tops.push(b.y0);
|
||||
}
|
||||
});
|
||||
if text_as == TextAs::Text {
|
||||
assert!(text.contains("CA") && !text.contains("AB"), "{text_as:?}: {text:?}");
|
||||
}
|
||||
assert!(!tops.is_empty(), "{text_as:?}: the visible line still comes in");
|
||||
assert!(tops.iter().all(|y| *y > 25.0), "{text_as:?}: only the visible line's art, tops at {tops:?}");
|
||||
let visible = open("BT /F1 20 Tf 1 0 0 1 10 10 Tm (CA) Tj ET", text_as);
|
||||
assert!(!visible.warnings.iter().any(|w| w == crate::INVISIBLE_TEXT_NOTE), "{text_as:?}: {:?}", visible.warnings);
|
||||
}
|
||||
}
|
||||
+214
-36
@@ -108,6 +108,23 @@ fn evaluates_live(n: &Node) -> bool {
|
||||
is_live(n) || (is_container(n) && to_members && !effects::has_pathfinder(n) && subtree_has_live(n))
|
||||
}
|
||||
|
||||
/// Can the art [`Renderer::fx_art`] makes for `n` change while `n` stays the same, other than
|
||||
/// through live objects it evaluates? It can when `n` holds type (laid out in the fonts installed),
|
||||
/// a symbol instance (made from its symbol's art), or an object with a 3D effect or a color
|
||||
/// adjustment (shaded or recolored through the color settings), in it or in the opacity masks of
|
||||
/// the objects in it.
|
||||
fn reads_outside(n: &Node) -> bool {
|
||||
let mut reads = false;
|
||||
n.walk(&mut |c| {
|
||||
reads |= matches!(c.kind, NodeKind::Text(_) | NodeKind::SymbolInstance { .. })
|
||||
|| effects::has_revolve(c)
|
||||
|| effects::has_solid(c)
|
||||
|| effects::has_adjustment(c)
|
||||
|| c.extras().mask.as_ref().is_some_and(|m| reads_outside(&m.art))
|
||||
});
|
||||
reads
|
||||
}
|
||||
|
||||
/// What [`Renderer::raster_fx`] paints effects around.
|
||||
struct Content<'a> {
|
||||
/// The object the effects belong to (cached shadows are checked against it).
|
||||
@@ -584,7 +601,7 @@ thread_local! {
|
||||
/// Document Raster Effects Settings › Resolution of `doc`, in ppi (1 to 2400; 72 when it isn't a
|
||||
/// number).
|
||||
fn doc_raster_ppi(doc: &Document) -> f64 {
|
||||
Some(doc.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0)
|
||||
doc.raster_ppi()
|
||||
}
|
||||
|
||||
/// The effects [`Renderer::pixel_content`] runs on the content's pixels (those changing the
|
||||
@@ -620,6 +637,11 @@ pub(crate) struct PixelEntry {
|
||||
rel: (f64, f64),
|
||||
k: f64,
|
||||
image: std::sync::Arc<vello_cpu::Pixmap>,
|
||||
/// For a raster of the content's whole reach at full size, `None`: it draws after a pan too.
|
||||
/// For one cropped to the view, or made at a lower resolution, the view and the rect it was
|
||||
/// made for: it draws only there, so dragging other art over the object keeps it (a wide blur,
|
||||
/// or any blur zoomed in, reaches past the pixel budget).
|
||||
view: Option<ViewKey>,
|
||||
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.
|
||||
@@ -648,6 +670,15 @@ fn linear_key(view: Affine) -> [u64; 4] {
|
||||
[c[0].to_bits(), c[1].to_bits(), c[2].to_bits(), c[3].to_bits()]
|
||||
}
|
||||
|
||||
/// A view's six coefficients (its pan included) and the device-pixel rect a frame draws in, as a
|
||||
/// cache key: a raster cropped to that rect, or made at a lower resolution than the view's, draws
|
||||
/// only in that view of that rect ([`PixelEntry::view`], [`ShadowEntry::view`]).
|
||||
type ViewKey = ([u64; 6], [u64; 4]);
|
||||
|
||||
fn view_key(view: Affine, screen: Rect) -> ViewKey {
|
||||
(view.as_coeffs().map(f64::to_bits), [screen.x0, screen.y0, screen.x1, screen.y1].map(f64::to_bits))
|
||||
}
|
||||
|
||||
/// Most pixels a raster of [`Renderer::pixel_content`] or [`Renderer::with_filters`] holds while
|
||||
/// [`crate::RenderOptions::dragging`] (a 1000 × 1000 raster's), when the frame does not draw a raster
|
||||
/// kept from an earlier frame instead (#2558).
|
||||
@@ -732,6 +763,10 @@ pub(crate) struct ShadowEntry {
|
||||
image: std::sync::Arc<vello_cpu::Pixmap>,
|
||||
/// View pixels per raster pixel: more than 1 for a glow made coarser to fit the budget.
|
||||
scale: f64,
|
||||
/// `None` for a raster of the whole silhouette's reach: it draws after a pan too. For one
|
||||
/// cropped to the view, or made over such a raster, the view and the rect it was made for: it
|
||||
/// draws only there ([`PixelEntry::view`]).
|
||||
view: Option<ViewKey>,
|
||||
made: Made,
|
||||
/// What the silhouette read when the raster was made.
|
||||
inputs: Inputs,
|
||||
@@ -749,6 +784,8 @@ struct Shadow {
|
||||
mode: vectorcraft_doc::color::BlendMode,
|
||||
/// Made before its object moved ([`ShadowEntry::moved`]).
|
||||
moved: bool,
|
||||
/// Cropped to the view, or made over a raster that is ([`ShadowEntry::view`]).
|
||||
cropped: bool,
|
||||
}
|
||||
|
||||
impl ShadowEntry {
|
||||
@@ -1012,8 +1049,13 @@ impl Renderer {
|
||||
/// silhouette `paint` draws is rendered once into a small crop, blurred and tinted on the CPU,
|
||||
/// and reused 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`]). `None` when the effect isn't a shadow/glow or the raster would be
|
||||
/// too large (caller falls back to [`Self::draw_shadow_filter`]).
|
||||
/// ([`Self::move_shadow`]). A raster of the whole reach too large for the budget is cropped to
|
||||
/// what can show in the `screen` (device pixels): the view, and around it the blur's spread and
|
||||
/// `around` (document units: how far the shadows and glows stacked over this one look), made
|
||||
/// coarser to fit the budget where the blur hides the coarser grid. Such a raster, and one
|
||||
/// made over it (`over_cropped`), is reused only in the same view. `None` when the effect isn't
|
||||
/// a shadow/glow or the raster would still be too large (caller falls back to
|
||||
/// [`Self::draw_shadow_filter`]).
|
||||
/// While dragging on a multithreaded context, a raster that has to be made again counts as too
|
||||
/// large when it would hold more than [`DRAG_PIXEL_BUDGET`] pixels.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -1025,6 +1067,7 @@ impl Renderer {
|
||||
fx: &RasterFx,
|
||||
reach: Rect,
|
||||
owner: Option<&Arc<Node>>,
|
||||
(screen, around, over_cropped): (Rect, f64, bool),
|
||||
paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame),
|
||||
) -> Option<Shadow> {
|
||||
let (mode, opacity, dx, dy, blur, color, darkness) = match fx {
|
||||
@@ -1039,35 +1082,54 @@ impl Renderer {
|
||||
// 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);
|
||||
let in_view = view_key(f.view, screen);
|
||||
let exact = |e: &ShadowEntry| {
|
||||
e.linear == linear && e.node.is(n) && e.inputs == inputs && (interacting || !e.moved) && e.view.is_none_or(|v| v == in_view)
|
||||
};
|
||||
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, scale, moved) = match hit {
|
||||
let (image, x, y, scale, moved, cropped) = 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(), e.scale, e.moved)
|
||||
(e.image.clone(), (p.x + e.rel.0).round(), (p.y + e.rel.1).round(), e.scale, e.moved, e.view.is_some())
|
||||
}
|
||||
None => {
|
||||
let spread = blur.max(0.0) * 1.5 / f.px + 2.0;
|
||||
// An Outer Glow's silhouette grows by a quarter of a pixel of the document's raster grid first.
|
||||
let grow = if matches!(fx, RasterFx::OuterGlow { .. }) { 18.0 / doc_raster_ppi(f.doc) } else { 0.0 };
|
||||
let spread = (blur.max(0.0) * 1.5 + grow) / f.px + 2.0;
|
||||
let reach = reach + Vec2::new(dx, dy);
|
||||
let r = f.view.transform_rect_bbox(reach).inflate(spread, spread);
|
||||
let (x0, y0) = (r.x0.floor(), r.y0.floor());
|
||||
let (w, h) = (r.x1.ceil() - x0, r.y1.ceil() - y0);
|
||||
let whole = f.view.transform_rect_bbox(reach).inflate(spread, spread);
|
||||
// The raster's region and scale within `budget` pixels: `None` when over the limits.
|
||||
let region = |r: Rect, coarser: bool, budget: f64| {
|
||||
let (x0, y0) = (r.x0.floor(), r.y0.floor());
|
||||
let (w, h) = (r.x1.ceil() - x0, r.y1.ceil() - y0);
|
||||
let scale = if coarser { (w * h / budget).sqrt().ceil().max(1.0) } else { 1.0 };
|
||||
if !scale.is_finite() || scale > 1.0 && blur / 2.0 / f.px / scale < 2.0 {
|
||||
return None;
|
||||
}
|
||||
let (w, h) = ((w / scale).ceil(), (h / scale).ceil());
|
||||
((1.0..=8192.0).contains(&w) && (1.0..=8192.0).contains(&h) && w * h <= budget).then_some((x0, y0, w, h, scale))
|
||||
};
|
||||
// A glow too large for the budget is made coarser, where its blur hides the coarser grid: the filter
|
||||
// layer the caller falls back to can't make its silhouette solid.
|
||||
let scale = if matches!(fx, RasterFx::OuterGlow { .. }) { (w * h / 4.0e6).sqrt().ceil().max(1.0) } else { 1.0 };
|
||||
if scale > 1.0 && blur / 2.0 / f.px / scale < 2.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (w, h) = ((w / scale).ceil(), (h / scale).ceil());
|
||||
if !(1.0..=8192.0).contains(&w) || !(1.0..=8192.0).contains(&h) || w * h > 4.0e6 {
|
||||
return None;
|
||||
}
|
||||
let full = region(whole, matches!(fx, RasterFx::OuterGlow { .. }), 4.0e6).filter(|_| !over_cropped);
|
||||
// Else it is cropped to what can show in the view, within the budget of the filter
|
||||
// layer it would otherwise be drawn through, and made coarser in the same way.
|
||||
let ((x0, y0, w, h, scale), cropped) = match full {
|
||||
Some(r) => (r, false),
|
||||
None => {
|
||||
let m = spread + around / f.px;
|
||||
let r = whole.intersect(screen.inflate(m, m));
|
||||
if r.width() <= 0.0 || r.height() <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
(region(r, true, PIXEL_BUDGET)?, true)
|
||||
}
|
||||
};
|
||||
// During a drag, a larger shadow on a multithreaded context is filtered within the
|
||||
// drag's budget (`with_filters`), and no raster is kept.
|
||||
if f.opts.dragging && f.mt && w * h > DRAG_PIXEL_BUDGET {
|
||||
@@ -1112,6 +1174,7 @@ impl Renderer {
|
||||
// An Outer Glow blurs a solid silhouette: wherever the art has any alpha, as AI files draw a glow round a glow.
|
||||
let solid = matches!(fx, RasterFx::OuterGlow { .. });
|
||||
let mut a: Vec<f32> = pm.data().iter().map(|p| if solid && p.a > 0 { 255.0 } else { p.a as f32 }).collect();
|
||||
effects::pixel::dilate_plane(&mut a, wu, hu, grow / f.px / scale);
|
||||
effects::pixel::blur_plane(&mut a, wu, hu, sigma);
|
||||
let [cr, cg, cb] = f.ink.rgb(&color);
|
||||
let tint = |v: f32, ch: f32| (v * ch).round().clamp(0.0, 255.0) as u8;
|
||||
@@ -1125,12 +1188,13 @@ impl Renderer {
|
||||
let p = f.view * anchor;
|
||||
let node = Drawn::of(n, owner);
|
||||
let rel = (x0 - p.x, y0 - p.y);
|
||||
self.shadows.insert(key, ShadowEntry { node, linear, anchor, rel, image: image.clone(), scale, made, inputs, moved, stamp });
|
||||
let view = cropped.then_some(in_view);
|
||||
self.shadows.insert(key, ShadowEntry { node, linear, anchor, rel, image: image.clone(), scale, view, made, inputs, moved, stamp });
|
||||
self.trim_fx_rasters(FX_RASTER_BUDGET);
|
||||
(image, x0, y0, scale, moved)
|
||||
(image, x0, y0, scale, moved, cropped)
|
||||
}
|
||||
};
|
||||
Some(Shadow { image, at: Affine::translate((x, y)) * Affine::scale(scale), mode, moved })
|
||||
Some(Shadow { image, at: Affine::translate((x, y)) * Affine::scale(scale), mode, moved, cropped })
|
||||
}
|
||||
|
||||
/// Draw shadow raster `s` through `to`, from the view the raster was made for to the one `ctx`
|
||||
@@ -1221,6 +1285,7 @@ impl Renderer {
|
||||
(k.1, k.2) == (key.1, key.2)
|
||||
&& e.stamp < stamp
|
||||
&& e.linear == linear
|
||||
&& e.view.is_none()
|
||||
&& e.node.id() == n.id
|
||||
&& e.inputs == *inputs
|
||||
&& e.made.fits(&[fx], size, None, None)
|
||||
@@ -1355,8 +1420,9 @@ impl Renderer {
|
||||
/// as their evaluated art (groups and layers: [`effects::evaluate_container`], keeping their
|
||||
/// knockout setting), recoloured by the colour adjustments in it ([`Self::adjusted_art`]);
|
||||
/// without the object's transparency and effects (a path keeps its geometry effects). Art that
|
||||
/// is made from live objects it evaluated ([`evaluates_live`]) is kept only while the inputs of
|
||||
/// their evaluation stay the same ([`Self::live_found`]).
|
||||
/// is made from live objects it evaluated ([`evaluates_live`]), or from type, symbol art or
|
||||
/// colors shaded through the color settings ([`reads_outside`]), is kept only while what it
|
||||
/// reads from outside `a` stays the same ([`Self::live_found`]).
|
||||
fn fx_art(&mut self, doc: &vectorcraft_doc::Document, a: &Arc<Node>, cache: bool) -> Arc<Node> {
|
||||
let key = Arc::as_ptr(a) as usize;
|
||||
let mut kept = None;
|
||||
@@ -1369,7 +1435,7 @@ impl Renderer {
|
||||
Some(was) => kept = Some((art.clone(), was.0)),
|
||||
}
|
||||
}
|
||||
let found = (kept.is_some() || (cache && evaluates_live(a))).then(|| self.live_found(doc, a));
|
||||
let found = (kept.is_some() || (cache && (evaluates_live(a) || reads_outside(a)))).then(|| self.live_found(doc, a));
|
||||
if let Some((art, was)) = kept
|
||||
&& found.as_ref().is_some_and(|f| f.0 == was)
|
||||
{
|
||||
@@ -1562,7 +1628,17 @@ impl Renderer {
|
||||
// through a filter layer from the content alone, and goes into the later silhouettes that way.
|
||||
let mut made: Vec<(&RasterFx, Option<Shadow>)> = vec![];
|
||||
let mut stacked = reach;
|
||||
let screen = Rect::new(0.0, 0.0, f64::from(ctx.width()), f64::from(ctx.height()));
|
||||
// How far (document units) a shadow or glow looks from a pixel to the silhouette under it.
|
||||
let looks = |fx: &RasterFx| match fx {
|
||||
RasterFx::DropShadow { dx, dy, blur, .. } => blur.max(0.0) * 1.5 + dx.abs() + dy.abs(),
|
||||
RasterFx::OuterGlow { blur, .. } => blur.max(0.0) * 1.5,
|
||||
_ => 0.0,
|
||||
};
|
||||
for (i, fx) in rfx.iter().enumerate().filter(|(_, x)| x.is_below()) {
|
||||
// A raster cropped to the view covers what the shadows and glows stacked over it read.
|
||||
let around: f64 = rfx.iter().skip(i + 1).filter(|x| x.is_below()).map(looks).sum();
|
||||
let over_cropped = made.iter().any(|(_, s)| s.as_ref().is_some_and(|s| s.cropped));
|
||||
let shadow = {
|
||||
let earlier = &made;
|
||||
let view = f.view;
|
||||
@@ -1579,7 +1655,8 @@ impl Renderer {
|
||||
}
|
||||
paint(r, c, fr);
|
||||
};
|
||||
content.cache.and_then(|slot| self.shadow_raster(f, content.node, slot + i, fx, stacked, owner.as_ref(), &mut art))
|
||||
let view = (screen, around, over_cropped);
|
||||
content.cache.and_then(|slot| self.shadow_raster(f, content.node, slot + i, fx, stacked, owner.as_ref(), view, &mut art))
|
||||
};
|
||||
stacked = match fx {
|
||||
RasterFx::DropShadow { dx, dy, blur, .. } => stacked.union((stacked + Vec2::new(*dx, *dy)).inflate(blur * 1.5, blur * 1.5)),
|
||||
@@ -1649,7 +1726,10 @@ impl Renderer {
|
||||
/// every effect only looks nearby, and content still over either limit is rendered 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`]).
|
||||
/// while interacting after the content only moved ([`Self::move_pixel_content`]). A cropped or
|
||||
/// lower-resolution raster is cached while the object, the view (its pan included) and what the
|
||||
/// content reads stay the same ([`PixelEntry::view`]); a lower-resolution one is kept resampled to
|
||||
/// the view's pixels ([`Self::resampled`]).
|
||||
/// While dragging, content without such a raster to draw renders within [`DRAG_PIXEL_BUDGET`]
|
||||
/// pixels and is not cached.
|
||||
fn pixel_content(
|
||||
@@ -1699,9 +1779,11 @@ impl Renderer {
|
||||
let side = f64::from(crate::MAX_RASTER_SIDE);
|
||||
let k = (PIXEL_BUDGET / (rw * rh)).sqrt().min(side / rw).min(side / rh).min(1.0);
|
||||
let (w, h) = ((rw * k).ceil().clamp(1.0, side), (rh * k).ceil().clamp(1.0, side));
|
||||
// The cached raster, when the whole content was rendered at full size at this zoom.
|
||||
// The cached raster: of the whole content at full size at this zoom, else of this view.
|
||||
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 whole = fits && k >= 1.0;
|
||||
let in_view = (!whole).then(|| view_key(f.view, screen));
|
||||
let key = content.cache.map(|slot| (content.node as *const Node as usize, slot, f.ink));
|
||||
let stamp = self.stamp;
|
||||
let channels = match f.ink {
|
||||
Ink::Cmy => effects::pixel::Channels::CmyPlane,
|
||||
@@ -1719,8 +1801,11 @@ impl Renderer {
|
||||
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);
|
||||
let exact = |e: &PixelEntry| {
|
||||
e.linear == linear && e.view == in_view && e.node.is(content.node) && Some(&e.inputs) == inputs.as_ref() && (interacting || !e.moved)
|
||||
};
|
||||
if interacting
|
||||
&& whole
|
||||
&& let Some(key) = key
|
||||
&& let Some(inputs) = &inputs
|
||||
&& !self.pixel_fx.get(&key).is_some_and(exact)
|
||||
@@ -1794,9 +1879,10 @@ impl Renderer {
|
||||
let failed = crate::paint::decode_failures() - decoded;
|
||||
// Content drawn over moved rasters makes a moved raster.
|
||||
let moved = self.stats.moved > before;
|
||||
// What the raster is made from, for its object once it moves (`move_pixel_content`).
|
||||
// What the raster is made from, for its object once it moves (`move_pixel_content`): a
|
||||
// cropped or coarser raster never moves.
|
||||
let made = key.map(|_| Made {
|
||||
pixels: pm.data_as_u8_slice().to_vec(),
|
||||
pixels: if whole { pm.data_as_u8_slice().to_vec() } else { Vec::new() },
|
||||
fx: chain.iter().map(|fx| (*fx).clone()).collect(),
|
||||
size: content.reach.size(),
|
||||
channels: Some(channels),
|
||||
@@ -1842,6 +1928,14 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
let image = std::sync::Arc::new(pm);
|
||||
// A raster kept for this view alone at a lower resolution than the view's is resampled to the
|
||||
// view's pixels once: each frame that draws it then copies it pixel for pixel, where sampling
|
||||
// it again across the whole view cost several times as much as a plain fill.
|
||||
let view_raster = key.is_some() && in_view.is_some() && k < 1.0 && content.kept.is_none();
|
||||
let (image, x0, y0, k) = match view_raster.then(|| self.resampled(f, image.clone(), (x0, y0), k, screen)).flatten() {
|
||||
Some((image, x, y)) => (image, x, y, 1.0),
|
||||
None => (image, x0, y0, k),
|
||||
};
|
||||
// A cancelled export drew part of the content: that raster isn't kept for later frames.
|
||||
if !vectorcraft_doc::cancel::is_cancelled()
|
||||
&& let Some(((key, made), inputs)) = key.zip(made).zip(inputs)
|
||||
@@ -1851,7 +1945,8 @@ impl Renderer {
|
||||
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(), made, inputs, moved, failed, stamp });
|
||||
let entry = PixelEntry { node, linear, anchor, rel, k, image: image.clone(), view: in_view, made, inputs, moved, failed, stamp };
|
||||
self.pixel_fx.insert(key, entry);
|
||||
self.trim_fx_rasters(FX_RASTER_BUDGET);
|
||||
}
|
||||
// An image over the limits was drawn at a frame's resolution: its raster is kept.
|
||||
@@ -1865,6 +1960,33 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
|
||||
/// `image` as [`draw_scaled`] draws it with its top-left at (`x`, `y`) device pixels, `1 / k`
|
||||
/// device pixels a pixel, resampled to the device pixels it covers of `screen`, with their
|
||||
/// top-left. `None` when it covers none of them.
|
||||
fn resampled(
|
||||
&mut self,
|
||||
f: &Frame,
|
||||
image: std::sync::Arc<vello_cpu::Pixmap>,
|
||||
(x, y): (f64, f64),
|
||||
k: f64,
|
||||
screen: Rect,
|
||||
) -> Option<(std::sync::Arc<vello_cpu::Pixmap>, f64, f64)> {
|
||||
let covered = Rect::new(x, y, x + f64::from(image.width()) / k, y + f64::from(image.height()) / k).intersect(screen);
|
||||
let (x0, y0) = (covered.x0.floor(), covered.y0.floor());
|
||||
let (w, h) = (covered.x1.ceil() - x0, covered.y1.ceil() - y0);
|
||||
let side = f64::from(crate::MAX_RASTER_SIDE);
|
||||
if !((1.0..=side).contains(&w) && (1.0..=side).contains(&h)) {
|
||||
return None;
|
||||
}
|
||||
let (w, h) = (w as u16, h as u16);
|
||||
let mut off = f.offscreen_context(w, h);
|
||||
draw_scaled(&mut off, image, (x - x0, y - y0), k);
|
||||
off.flush();
|
||||
let mut pm = vello_cpu::Pixmap::new(w, h);
|
||||
off.render(&mut pm, &mut self.resources);
|
||||
Some((std::sync::Arc::new(pm), x0, y0))
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -1895,6 +2017,7 @@ impl Renderer {
|
||||
&& e.stamp < stamp
|
||||
&& e.linear == linear
|
||||
&& e.k >= 1.0
|
||||
&& e.view.is_none()
|
||||
&& e.node.id() == content.node.id
|
||||
&& e.inputs == *inputs
|
||||
&& e.made.fits(chain, content.reach.size(), Some(channels), outline.as_ref())
|
||||
@@ -2369,6 +2492,45 @@ mod tests {
|
||||
assert!(r.render(&d, 400, 400, Affine::scale(2.0), &opts).pixels == first.pixels, "made again alike");
|
||||
}
|
||||
|
||||
/// An Outer Glow whose whole reach is over the budget is made coarser over that whole reach and
|
||||
/// kept for any pan: its grid starts at the reach's first whole device pixel, which a pan by whole
|
||||
/// pixels moves with the art, so the raster drawn after the pan is the one a fresh renderer makes,
|
||||
/// also for pans that aren't multiples of the raster's scale. A pan by a fraction of a pixel
|
||||
/// places the raster at the nearest whole pixel, as a full-size raster kept whole is placed, and
|
||||
/// here draws as a fresh renderer draws too. A 100 pt glow at 500% (scale 2) and a 2 pt glow
|
||||
/// round the page at 800% (scale 3, near the narrowest blur made coarser).
|
||||
#[test]
|
||||
fn a_coarser_glow_kept_whole_draws_after_a_pan_as_made_again() {
|
||||
let size = (640, 360);
|
||||
for (rect, blur, zoom, center, scale) in [
|
||||
(Rect::new(200.0, 300.0, 400.0, 500.0), 100.0, 5.0, (440.0, 400.0), 2.0),
|
||||
(Rect::new(0.0, 0.0, 595.0, 842.0), 2.0, 8.0, (595.0, 421.0), 3.0),
|
||||
] {
|
||||
let mut d = Document::new(595.0, 842.0);
|
||||
let layer = d.layers[0].id;
|
||||
let mut n =
|
||||
Node::path(d.alloc_id(), shapes::rectangle(rect), Appearance::basic(Paint::solid(Color::rgb(0.1, 0.3, 0.9)), Paint::None, 0.0));
|
||||
n.appearance.effects = vec![Effect { id: "stylize.outerGlow".into(), params: json!({"blur": blur}), visible: true }];
|
||||
d.insert(Some(layer), 0, n).unwrap();
|
||||
let base = Affine::translate((320.0, 180.0)) * Affine::scale(zoom) * Affine::translate((-center.0, -center.1));
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let mut r = Renderer::new();
|
||||
r.threads = 2;
|
||||
r.render(&d, size.0, size.1, base, &opts);
|
||||
let image = |r: &Renderer| r.shadows.values().map(|e| std::sync::Arc::as_ptr(&e.image)).collect::<Vec<_>>();
|
||||
let kept = image(&r);
|
||||
assert!(r.shadows.values().all(|e| e.scale == scale && e.view.is_none()), "blur {blur}: coarser, over the whole reach");
|
||||
for pan in [(37.0, -21.0), (1.0, 0.0), (-3.0, 5.0), (0.5, 0.0), (37.3, -21.6)] {
|
||||
let xf = Affine::translate(pan) * base;
|
||||
let panned = r.render(&d, size.0, size.1, xf, &opts);
|
||||
assert_eq!(image(&r), kept, "blur {blur}, pan {pan:?}: the raster kept is drawn");
|
||||
let mut fresh = Renderer::new();
|
||||
fresh.threads = 2;
|
||||
assert!(panned.pixels == fresh.render(&d, size.0, size.1, xf, &opts).pixels, "blur {blur}, pan {pan:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The multithreaded rasterizer draws the same pixels with any number of workers, so the
|
||||
/// automatic thread count can follow the machine.
|
||||
#[test]
|
||||
@@ -2589,6 +2751,21 @@ mod tests {
|
||||
assert!(faint[1] < 210, "the glow reaches past the edge: {faint:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_outer_glow_grows_its_silhouette_by_a_quarter_raster_pixel() {
|
||||
// GUARDS: the glows AI files export measure as the solid silhouette grown by a quarter of a pixel
|
||||
// of the document's raster grid, then blurred: a quarter point at 72 ppi, next to nothing at 720.
|
||||
let r = Rect::new(30.0, 30.0, 70.0, 70.0);
|
||||
let green_outside = |ppi: f64| {
|
||||
let mut d =
|
||||
doc_with(r, Color::BLACK, vec![("stylize.outerGlow", json!({"color": "#ff0000", "mode": "normal", "opacity": 100, "blur": 4}))]);
|
||||
d.raster_effects_ppi = ppi;
|
||||
render(&d).pixel(27, 50)[1]
|
||||
};
|
||||
let (coarse, fine) = (green_outside(72.0), green_outside(720.0));
|
||||
assert!(coarse + 3 < fine, "the red glow is stronger at 72 ppi: 72 ppi {coarse}, 720 ppi {fine}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_glow_glows_round_the_glows_before_it_in_the_stack() {
|
||||
// GUARDS: the second glow is of the art with the first glow, not of the bare art, as AI files and the SVG export draw it.
|
||||
@@ -2836,8 +3013,9 @@ mod tests {
|
||||
|
||||
/// While dragging (#2558), filtered content and a drop shadow that the frame does not draw from a
|
||||
/// raster of an earlier frame render within the drag's budget and keep no raster. At 6400% a
|
||||
/// rectangle with Drop Shadow and Texturizer covers more than the raster budget, so no raster is
|
||||
/// kept for either, and a 1600 × 1000 view near its corner needs more than the drag's budget. The
|
||||
/// rectangle with Drop Shadow and Texturizer covers more than the raster budget, so the still
|
||||
/// frame keeps rasters cropped to its view, which the moved rectangle can't take along, and a
|
||||
/// 1600 × 1000 view near its corner needs more than the drag's budget. The
|
||||
/// frame after the drag, of the same document, is what a fresh renderer draws, as is a frame
|
||||
/// rendered while interacting without a drag (a dialog's preview).
|
||||
#[test]
|
||||
@@ -2867,7 +3045,7 @@ mod tests {
|
||||
let dragged = r.render(&d, 1600, 1000, view, &drag).pixels;
|
||||
assert_eq!(r.stats.previewed, 2, "the shadow and the filtered content");
|
||||
assert_eq!(r.stats.moved, 2, "counted as moved: the frame after the drag renders them again");
|
||||
assert!(r.pixel_fx.is_empty() && r.shadows.is_empty(), "no raster kept");
|
||||
assert!(r.pixel_fx.is_empty() && r.shadows.values().all(|e| e.stamp < r.stamp), "no raster kept");
|
||||
assert!(dragged != fresh, "at the drag's lower resolution");
|
||||
let after = r.render(&d, 1600, 1000, view, &still).pixels;
|
||||
assert_eq!((r.stats.previewed, r.stats.moved), (0, 0));
|
||||
|
||||
@@ -410,8 +410,9 @@ 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>>);
|
||||
/// An object with effects (kept alive), the art drawn for it ([`Renderer::fx_art`]), and, when that
|
||||
/// art can be made from evaluated live objects (`fx::evaluates_live`), the inputs of their
|
||||
/// evaluation ([`live::Found`]).
|
||||
/// art can be made from evaluated live objects (`fx::evaluates_live`) or from type, symbol art or
|
||||
/// colors shaded through the color settings (`fx::reads_outside`), the inputs it was made with
|
||||
/// ([`live::Found`]).
|
||||
type FxArtEntry = (Arc<Node>, Arc<Node>, Option<live::Found>);
|
||||
/// 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`]).
|
||||
@@ -1222,7 +1223,9 @@ impl Renderer {
|
||||
/// In isolation mode, while drawing outside the isolated container and the art dimmed around
|
||||
/// it: the isolated container and its layers and groups, from the top layer down to it.
|
||||
fn isolation_path(&mut self, f: &Frame) -> Option<(NodeId, Vec<NodeId>)> {
|
||||
let isolated = f.opts.isolated.filter(|_| !self.isolation_settled)?;
|
||||
// Opacity-mask editing isolates the temporary layer for tools; that layer is not painted on
|
||||
// the page (the mask applies on the object), so canvas dimming would wash out everything.
|
||||
let isolated = f.opts.isolated.filter(|_| f.doc.mask_edit.is_none() && !self.isolation_settled)?;
|
||||
let doc = std::ptr::from_ref(f.doc) as usize;
|
||||
if self.isolation.as_ref().is_none_or(|(stamp, d, id, _)| (*stamp, *d, *id) != (self.stamp, doc, isolated)) {
|
||||
self.isolation = Some((self.stamp, doc, isolated, f.doc.ancestry(isolated).unwrap_or_default()));
|
||||
@@ -2525,3 +2528,5 @@ mod tests_setup;
|
||||
mod tests_strokegradient;
|
||||
#[cfg(test)]
|
||||
mod tests_tileedge;
|
||||
#[cfg(test)]
|
||||
mod tests_viewraster;
|
||||
@@ -68,8 +68,8 @@ pub fn expand_live(n: &Node) -> Vec<Node> {
|
||||
crate::effects::expand_live(None, n)
|
||||
}
|
||||
|
||||
/// What the evaluation of `n` (a live node, or an object whose effect art can be made from
|
||||
/// evaluated live objects, see [`Renderer::live_found`]) reads from the document, placed files,
|
||||
/// What the evaluation of `n` (a live node, or an object whose effect art the renderer keeps with
|
||||
/// its inputs, see [`Renderer::live_found`]) reads from the document, placed files,
|
||||
/// the fonts, the plug-ins and the color settings, hashed: the art of the symbols, the tilings of
|
||||
/// the patterns and the reads of the placed files it shows ([`hash_resources`]), the font
|
||||
/// generation ([`vectorcraft_text::FontDb::generation`]) when it shows type or a placed file, the
|
||||
@@ -343,7 +343,7 @@ fn spread_to_edges(px: &mut [vello_cpu::color::PremulRgba8], filled: Vec<bool>,
|
||||
impl Renderer {
|
||||
/// The [`resources_key`] of `n` in `doc`, with the frame's [`DocInputs`] ([`Found`]): `n` is a
|
||||
/// live node, or an object with effects whose art ([`Renderer::fx_art`]) can be made from
|
||||
/// evaluated live objects.
|
||||
/// evaluated live objects, type, symbol art or colors shaded through the color settings.
|
||||
pub(crate) fn live_found(&mut self, doc: &Document, n: &Node) -> Found {
|
||||
let inputs = self.doc_inputs(doc);
|
||||
let (key, reads) = resources_key(doc, inputs.cms, n);
|
||||
|
||||
@@ -155,6 +155,60 @@ fn symbol_type_redraws_when_its_font_becomes_available() {
|
||||
assert_eq!(after, Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, "as a new renderer draws it");
|
||||
}
|
||||
|
||||
/// Type that a geometry effect outlines redraws in a font that becomes available: the art drawn for
|
||||
/// the effects of the type, of a group that holds it and of a symbol instance whose art holds it
|
||||
/// is made again when the fonts change.
|
||||
#[test]
|
||||
fn type_with_geometry_effects_redraws_when_its_font_becomes_available() {
|
||||
let _adding = adding_fonts();
|
||||
use vectorcraft_doc::Effect;
|
||||
let moved = || Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 0.0}), visible: true };
|
||||
let cases = [
|
||||
("Moving Serif 4", "the type with a Transform effect"),
|
||||
("Shadow Serif 5", "the type with a Transform effect and a drop shadow"),
|
||||
("Massed Serif 4", "the group with a Transform effect"),
|
||||
("Stamps Serif 4", "the symbol instance with a Transform effect"),
|
||||
("Darker Serif 4", "the type with a Transform effect in Multiply"),
|
||||
];
|
||||
for (i, (family, what)) in cases.into_iter().enumerate() {
|
||||
let mut d = Document::new(300.0, 150.0);
|
||||
let style = CharStyle { size: 50.0, font_family: family.into(), ..Default::default() };
|
||||
let at = if i == 3 { Point::new(-140.0, 20.0) } else { Point::new(10.0, 80.0) };
|
||||
let mut object = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(at, "Hamburg", style))));
|
||||
match i {
|
||||
0 => object.appearance.effects.push(moved()),
|
||||
1 => {
|
||||
object.appearance.effects.push(moved());
|
||||
let params = serde_json::json!({"x": 4, "y": 4, "blur": 1, "opacity": 100});
|
||||
object.appearance.effects.push(Effect { id: "stylize.dropShadow".into(), params, visible: true });
|
||||
}
|
||||
2 => {
|
||||
object = Node::group(d.alloc_id(), vec![Arc::new(object)]);
|
||||
object.appearance.effects.push(moved());
|
||||
}
|
||||
3 => {
|
||||
let art = Node::group(d.alloc_id(), vec![Arc::new(object)]);
|
||||
d.symbols.push(vectorcraft_doc::Symbol { name: "Word".into(), art: Arc::new(art) });
|
||||
object = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Word".into(), xf: Affine::translate((150.0, 75.0)) });
|
||||
object.appearance.effects.push(moved());
|
||||
}
|
||||
_ => {
|
||||
object.appearance.effects.push(moved());
|
||||
object.blend = vectorcraft_color::BlendMode::Multiply;
|
||||
}
|
||||
}
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, object).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, "{what}: drawn in the font that arrived");
|
||||
assert_eq!(after, Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, "{what}: as a new renderer draws it");
|
||||
}
|
||||
}
|
||||
|
||||
/// Type that an envelope bends redraws in a font that becomes available, in the envelope and in
|
||||
/// the art of a symbol whose instance an envelope bends: the envelopes are evaluated again when the
|
||||
/// fonts change.
|
||||
|
||||
Loaded 100 of 194 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user